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.tsBlame342 lines · 2 contributors
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
3b585e0Dictation App94// ---------- GraphQL Utilities ----------
95
96function sanitizeGraphQLString(input: string): string {
97 return input.replace(/["\\]/g, '\\$&');
98}
99
eae38d1Claude100// ---------- Commands ----------
101
102export const HELP = `gluecron CLI v${VERSION}
103
104Usage:
105 gluecron login Save a personal access token
106 gluecron whoami Print the logged-in user
107 gluecron repo ls [--user <name>] List repos
108 gluecron repo show <owner/name> Show a repo
109 gluecron repo create <name> [--private]
110 Create a repo
111 gluecron issues ls <owner/name> List open issues
112 gluecron gql '<query>' Run a GraphQL query
113 gluecron host [url] Get or set the server URL
114 gluecron version Print version
115 gluecron help Print this help
116
117Env:
118 GLUECRON_HOST override the server URL (default: ${DEFAULT_HOST})
119`;
120
121export async function cmdLogin(
122 cfg: Config,
123 prompt: (q: string) => Promise<string>
124): Promise<Config> {
125 const host =
126 (await prompt(`Server URL [${cfg.host}]: `)) || cfg.host;
127 const token = await prompt("Personal access token (glc_...): ");
128 if (!token) throw new Error("token is required");
129 const next: Config = { host, token };
130 // Probe /api/user/me to confirm
131 const me = await http(next, "GET", "/api/user/me").catch(() => null);
132 if (me?.username) next.username = me.username;
133 saveConfig(next);
134 return next;
135}
136
137export async function cmdWhoami(cfg: Config): Promise<string> {
138 if (!cfg.token) return "(not logged in)";
139 const me = await http(cfg, "GET", "/api/user/me").catch(() => null);
140 if (!me?.username) return cfg.username || "(unknown)";
141 return `${me.username} (${me.email || "no email"})`;
142}
143
144export async function cmdRepoLs(
145 cfg: Config,
146 user?: string
147): Promise<Array<{ owner: string; name: string; visibility: string }>> {
148 const username = user || cfg.username;
149 if (!username) throw new Error("no user context — log in or pass --user");
3b585e0Dictation App150 const q = `{ user(username:"${sanitizeGraphQLString(username)}") { repos { name visibility } } }`;
eae38d1Claude151 const r = await http(cfg, "POST", "/api/graphql", { query: q });
152 const repos = r?.data?.user?.repos || [];
153 return repos.map((x: any) => ({
154 owner: username,
155 name: x.name,
156 visibility: x.visibility,
157 }));
158}
159
160export async function cmdRepoShow(
161 cfg: Config,
162 slug: string
163): Promise<Record<string, any>> {
164 const [owner, name] = slug.split("/");
165 if (!owner || !name) throw new Error("expected owner/name");
3b585e0Dictation App166 const q = `{ repository(owner:"${sanitizeGraphQLString(owner)}", name:"${sanitizeGraphQLString(name)}") {
eae38d1Claude167 name description visibility starCount forkCount
168 owner { username }
169 issues(state:"open", limit:5) { number title }
170 } }`;
171 const r = await http(cfg, "POST", "/api/graphql", { query: q });
172 return r?.data?.repository || null;
173}
174
175export async function cmdRepoCreate(
176 cfg: Config,
177 name: string,
178 isPrivate = false
179): Promise<any> {
180 if (!cfg.username) throw new Error("log in first (gluecron login)");
181 return http(cfg, "POST", "/api/repos", {
182 name,
183 owner: cfg.username,
184 isPrivate,
185 });
186}
187
188export async function cmdIssuesLs(
189 cfg: Config,
190 slug: string
191): Promise<Array<{ number: number; title: string }>> {
192 const [owner, name] = slug.split("/");
3b585e0Dictation App193 const q = `{ repository(owner:"${sanitizeGraphQLString(owner)}", name:"${sanitizeGraphQLString(name)}") { issues(state:"open", limit:50) { number title } } }`;
eae38d1Claude194 const r = await http(cfg, "POST", "/api/graphql", { query: q });
195 return r?.data?.repository?.issues || [];
196}
197
198export async function cmdGql(cfg: Config, query: string): Promise<any> {
199 return http(cfg, "POST", "/api/graphql", { query });
200}
201
3b585e0Dictation App202// ---------- Command Handlers ----------
203
204async function handleHostCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
205 if (rest[0]) {
206 cfg.host = rest[0];
207 saveConfig(cfg);
208 }
209 out(cfg.host);
210 return 0;
211}
212
213async function handleLoginCmd(cfg: Config, out: (msg: string) => void): Promise<number> {
214 const { default: readline } = await import("node:readline/promises");
215 const rl = readline.createInterface({
216 input: process.stdin,
217 output: process.stdout,
218 });
219 try {
220 const next = await cmdLogin(cfg, (q) => rl.question(q));
221 out(`Logged in as ${next.username || "(unknown)"}`);
222 return 0;
223 } finally {
224 rl.close();
225 }
226}
227
228async function handleRepoCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
229 const sub = rest[0];
230 if (sub === "ls") {
231 return handleRepoLsCmd(cfg, rest, out);
232 }
233 if (sub === "show") {
234 return handleRepoShowCmd(cfg, rest, out);
235 }
236 if (sub === "create") {
237 return handleRepoCreateCmd(cfg, rest, out);
238 }
239 out("usage: gluecron repo (ls|show|create)");
240 return 1;
241}
242
243async function handleRepoLsCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
244 const userFlagIdx = rest.indexOf("--user");
245 const user = userFlagIdx >= 0 && userFlagIdx + 1 < rest.length ? rest[userFlagIdx + 1] : undefined;
246 const repos = await cmdRepoLs(cfg, user);
247 for (const r of repos) {
248 out(` ${r.owner}/${r.name} · ${r.visibility}`);
249 }
250 return 0;
251}
252
253async function handleRepoShowCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
254 const repo = await cmdRepoShow(cfg, rest[1]);
255 if (!repo) {
256 out("(not found)");
257 return 1;
258 }
259 out(JSON.stringify(repo, null, 2));
260 return 0;
261}
262
263async function handleRepoCreateCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
264 const isPrivate = rest.includes("--private");
265 const name = rest.find((x, i) => i > 0 && !x.startsWith("--"));
266 if (!name) {
267 out("usage: gluecron repo create <name> [--private]");
268 return 1;
269 }
270 const r = await cmdRepoCreate(cfg, name, isPrivate);
271 out(JSON.stringify(r, null, 2));
272 return 0;
273}
274
275async function handleIssuesCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
276 if (rest[0] !== "ls" || !rest[1]) {
277 out("usage: gluecron issues ls <owner/name>");
278 return 1;
279 }
280 const issues = await cmdIssuesLs(cfg, rest[1]);
281 for (const i of issues) {
282 out(` #${i.number} ${i.title}`);
283 }
284 return 0;
285}
286
287async function handleGqlCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
288 if (!rest[0]) {
289 out("usage: gluecron gql '<query>'");
290 return 1;
291 }
292 const r = await cmdGql(cfg, rest.join(" "));
293 out(JSON.stringify(r, null, 2));
294 return 0;
295}
296
eae38d1Claude297// ---------- Dispatcher ----------
298
299export async function dispatch(argv: string[], out = console.log): Promise<number> {
300 const cfg = loadConfig();
301 const [cmd, ...rest] = argv;
302
303 if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
304 out(HELP);
305 return 0;
306 }
307 if (cmd === "version" || cmd === "--version" || cmd === "-v") {
308 out(VERSION);
309 return 0;
310 }
311
312 try {
313 switch (cmd) {
3b585e0Dictation App314 case "host":
315 return await handleHostCmd(cfg, rest, out);
316 case "login":
317 return await handleLoginCmd(cfg, out);
eae38d1Claude318 case "whoami":
319 out(await cmdWhoami(cfg));
320 return 0;
3b585e0Dictation App321 case "repo":
322 return await handleRepoCmd(cfg, rest, out);
323 case "issues":
324 return await handleIssuesCmd(cfg, rest, out);
325 case "gql":
326 return await handleGqlCmd(cfg, rest, out);
eae38d1Claude327 default:
328 out(`unknown command: ${cmd}\n`);
329 out(HELP);
330 return 1;
331 }
332 } catch (err) {
333 out(`error: ${(err as Error).message}`);
334 return 1;
335 }
336}
337
338// Entry
339if (import.meta.main) {
340 const code = await dispatch(process.argv.slice(2));
341 process.exit(code);
3b585e0Dictation App342}