Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

gluecron.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

gluecron.tsBlame304 lines · 1 contributor
eae38d1Claude1#!/usr/bin/env bun
2/**
3 * Block G3 — `gluecron` CLI.
4 *
5 * A dependency-free Bun executable that talks to a Gluecron server using
6 * either the REST `/api/*` endpoints or the GraphQL endpoint at `/api/graphql`.
7 *
8 * gluecron login — store a PAT in ~/.gluecron/config.json
9 * gluecron whoami — print the logged-in user
10 * gluecron repo ls — list repos for the logged-in user
11 * gluecron repo show <owner/name> — pretty-print a repo
12 * gluecron repo create <name> — create a repo for the logged-in user
13 * gluecron issues ls <owner/name> — list open issues
14 * gluecron gql '<query>' — run a GraphQL query verbatim
15 *
16 * Build: bun build cli/gluecron.ts --compile --outfile gluecron
17 * Install: cp gluecron /usr/local/bin/
18 */
19
20import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
21import { homedir } from "node:os";
22import { join } from "node:path";
23
24const VERSION = "0.1.0";
25const DEFAULT_HOST = process.env.GLUECRON_HOST || "http://localhost:3000";
26const CONFIG_DIR = join(homedir(), ".gluecron");
27const CONFIG_FILE = join(CONFIG_DIR, "config.json");
28
29// ---------- Config ----------
30
31interface Config {
32 host: string;
33 token?: string;
34 username?: string;
35}
36
37export function loadConfig(): Config {
38 try {
39 if (existsSync(CONFIG_FILE)) {
40 const raw = readFileSync(CONFIG_FILE, "utf8");
41 const parsed = JSON.parse(raw) as Partial<Config>;
42 return {
43 host: parsed.host || DEFAULT_HOST,
44 token: parsed.token,
45 username: parsed.username,
46 };
47 }
48 } catch {
49 // fall through
50 }
51 return { host: DEFAULT_HOST };
52}
53
54export function saveConfig(cfg: Config) {
55 mkdirSync(CONFIG_DIR, { recursive: true });
56 writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), {
57 mode: 0o600,
58 });
59}
60
61// ---------- HTTP ----------
62
63export async function http(
64 cfg: Config,
65 method: string,
66 path: string,
67 body?: unknown
68): Promise<any> {
69 const url = cfg.host.replace(/\/+$/, "") + path;
70 const headers: Record<string, string> = {
71 "content-type": "application/json",
72 accept: "application/json",
73 };
74 if (cfg.token) headers.authorization = `Bearer ${cfg.token}`;
75 const res = await fetch(url, {
76 method,
77 headers,
78 body: body ? JSON.stringify(body) : undefined,
79 });
80 const text = await res.text();
81 let json: any;
82 try {
83 json = text ? JSON.parse(text) : {};
84 } catch {
85 json = { _raw: text };
86 }
87 if (!res.ok) {
88 const msg = json?.error || res.statusText || "request failed";
89 throw new Error(`[${res.status}] ${msg}`);
90 }
91 return json;
92}
93
94// ---------- Commands ----------
95
96export const HELP = `gluecron CLI v${VERSION}
97
98Usage:
99 gluecron login Save a personal access token
100 gluecron whoami Print the logged-in user
101 gluecron repo ls [--user <name>] List repos
102 gluecron repo show <owner/name> Show a repo
103 gluecron repo create <name> [--private]
104 Create a repo
105 gluecron issues ls <owner/name> List open issues
106 gluecron gql '<query>' Run a GraphQL query
107 gluecron host [url] Get or set the server URL
108 gluecron version Print version
109 gluecron help Print this help
110
111Env:
112 GLUECRON_HOST override the server URL (default: ${DEFAULT_HOST})
113`;
114
115export async function cmdLogin(
116 cfg: Config,
117 prompt: (q: string) => Promise<string>
118): Promise<Config> {
119 const host =
120 (await prompt(`Server URL [${cfg.host}]: `)) || cfg.host;
121 const token = await prompt("Personal access token (glc_...): ");
122 if (!token) throw new Error("token is required");
123 const next: Config = { host, token };
124 // Probe /api/user/me to confirm
125 const me = await http(next, "GET", "/api/user/me").catch(() => null);
126 if (me?.username) next.username = me.username;
127 saveConfig(next);
128 return next;
129}
130
131export async function cmdWhoami(cfg: Config): Promise<string> {
132 if (!cfg.token) return "(not logged in)";
133 const me = await http(cfg, "GET", "/api/user/me").catch(() => null);
134 if (!me?.username) return cfg.username || "(unknown)";
135 return `${me.username} (${me.email || "no email"})`;
136}
137
138export async function cmdRepoLs(
139 cfg: Config,
140 user?: string
141): Promise<Array<{ owner: string; name: string; visibility: string }>> {
142 const username = user || cfg.username;
143 if (!username) throw new Error("no user context — log in or pass --user");
144 const q = `{ user(username:"${username}") { repos { name visibility } } }`;
145 const r = await http(cfg, "POST", "/api/graphql", { query: q });
146 const repos = r?.data?.user?.repos || [];
147 return repos.map((x: any) => ({
148 owner: username,
149 name: x.name,
150 visibility: x.visibility,
151 }));
152}
153
154export async function cmdRepoShow(
155 cfg: Config,
156 slug: string
157): Promise<Record<string, any>> {
158 const [owner, name] = slug.split("/");
159 if (!owner || !name) throw new Error("expected owner/name");
160 const q = `{ repository(owner:"${owner}", name:"${name}") {
161 name description visibility starCount forkCount
162 owner { username }
163 issues(state:"open", limit:5) { number title }
164 } }`;
165 const r = await http(cfg, "POST", "/api/graphql", { query: q });
166 return r?.data?.repository || null;
167}
168
169export async function cmdRepoCreate(
170 cfg: Config,
171 name: string,
172 isPrivate = false
173): Promise<any> {
174 if (!cfg.username) throw new Error("log in first (gluecron login)");
175 return http(cfg, "POST", "/api/repos", {
176 name,
177 owner: cfg.username,
178 isPrivate,
179 });
180}
181
182export async function cmdIssuesLs(
183 cfg: Config,
184 slug: string
185): Promise<Array<{ number: number; title: string }>> {
186 const [owner, name] = slug.split("/");
187 const q = `{ repository(owner:"${owner}", name:"${name}") { issues(state:"open", limit:50) { number title } } }`;
188 const r = await http(cfg, "POST", "/api/graphql", { query: q });
189 return r?.data?.repository?.issues || [];
190}
191
192export async function cmdGql(cfg: Config, query: string): Promise<any> {
193 return http(cfg, "POST", "/api/graphql", { query });
194}
195
196// ---------- Dispatcher ----------
197
198export async function dispatch(argv: string[], out = console.log): Promise<number> {
199 const cfg = loadConfig();
200 const [cmd, ...rest] = argv;
201
202 if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
203 out(HELP);
204 return 0;
205 }
206 if (cmd === "version" || cmd === "--version" || cmd === "-v") {
207 out(VERSION);
208 return 0;
209 }
210
211 try {
212 switch (cmd) {
213 case "host": {
214 if (rest[0]) {
215 cfg.host = rest[0];
216 saveConfig(cfg);
217 }
218 out(cfg.host);
219 return 0;
220 }
221 case "login": {
222 const { default: readline } = await import("node:readline/promises");
223 const rl = readline.createInterface({
224 input: process.stdin,
225 output: process.stdout,
226 });
227 const next = await cmdLogin(cfg, (q) => rl.question(q));
228 rl.close();
229 out(`Logged in as ${next.username || "(unknown)"}`);
230 return 0;
231 }
232 case "whoami":
233 out(await cmdWhoami(cfg));
234 return 0;
235 case "repo": {
236 const sub = rest[0];
237 if (sub === "ls") {
238 const userFlagIdx = rest.indexOf("--user");
239 const user = userFlagIdx >= 0 ? rest[userFlagIdx + 1] : undefined;
240 const repos = await cmdRepoLs(cfg, user);
241 for (const r of repos) {
242 out(` ${r.owner}/${r.name} · ${r.visibility}`);
243 }
244 return 0;
245 }
246 if (sub === "show") {
247 const repo = await cmdRepoShow(cfg, rest[1]);
248 if (!repo) {
249 out("(not found)");
250 return 1;
251 }
252 out(JSON.stringify(repo, null, 2));
253 return 0;
254 }
255 if (sub === "create") {
256 const isPrivate = rest.includes("--private");
257 const name = rest.find((x, i) => i > 0 && !x.startsWith("--"));
258 if (!name) {
259 out("usage: gluecron repo create <name> [--private]");
260 return 1;
261 }
262 const r = await cmdRepoCreate(cfg, name, isPrivate);
263 out(JSON.stringify(r, null, 2));
264 return 0;
265 }
266 out("usage: gluecron repo (ls|show|create)");
267 return 1;
268 }
269 case "issues": {
270 if (rest[0] !== "ls" || !rest[1]) {
271 out("usage: gluecron issues ls <owner/name>");
272 return 1;
273 }
274 const issues = await cmdIssuesLs(cfg, rest[1]);
275 for (const i of issues) {
276 out(` #${i.number} ${i.title}`);
277 }
278 return 0;
279 }
280 case "gql": {
281 if (!rest[0]) {
282 out("usage: gluecron gql '<query>'");
283 return 1;
284 }
285 const r = await cmdGql(cfg, rest.join(" "));
286 out(JSON.stringify(r, null, 2));
287 return 0;
288 }
289 default:
290 out(`unknown command: ${cmd}\n`);
291 out(HELP);
292 return 1;
293 }
294 } catch (err) {
295 out(`error: ${(err as Error).message}`);
296 return 1;
297 }
298}
299
300// Entry
301if (import.meta.main) {
302 const code = await dispatch(process.argv.slice(2));
303 process.exit(code);
304}