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.tsBlame724 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";
9018b1fClaude23import { runCommit, runAiCommitMsg } from "./commands/commit";
24import { runHook } from "./install-hook";
eae38d1Claude25
26const VERSION = "0.1.0";
27const DEFAULT_HOST = process.env.GLUECRON_HOST || "http://localhost:3000";
28const CONFIG_DIR = join(homedir(), ".gluecron");
29const CONFIG_FILE = join(CONFIG_DIR, "config.json");
30
31// ---------- Config ----------
32
33interface Config {
34 host: string;
35 token?: string;
36 username?: string;
f764c07Claude37 githubToken?: string;
38 defaultRepo?: string;
eae38d1Claude39}
40
41export function loadConfig(): Config {
42 try {
43 if (existsSync(CONFIG_FILE)) {
44 const raw = readFileSync(CONFIG_FILE, "utf8");
45 const parsed = JSON.parse(raw) as Partial<Config>;
46 return {
47 host: parsed.host || DEFAULT_HOST,
48 token: parsed.token,
49 username: parsed.username,
f764c07Claude50 githubToken: parsed.githubToken,
51 defaultRepo: parsed.defaultRepo,
eae38d1Claude52 };
53 }
54 } catch {
55 // fall through
56 }
57 return { host: DEFAULT_HOST };
58}
59
60export function saveConfig(cfg: Config) {
61 mkdirSync(CONFIG_DIR, { recursive: true });
62 writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), {
63 mode: 0o600,
64 });
65}
66
67// ---------- HTTP ----------
68
69export async function http(
70 cfg: Config,
71 method: string,
72 path: string,
73 body?: unknown
74): Promise<any> {
75 const url = cfg.host.replace(/\/+$/, "") + path;
76 const headers: Record<string, string> = {
77 "content-type": "application/json",
78 accept: "application/json",
79 };
80 if (cfg.token) headers.authorization = `Bearer ${cfg.token}`;
81 const res = await fetch(url, {
82 method,
83 headers,
84 body: body ? JSON.stringify(body) : undefined,
85 });
86 const text = await res.text();
87 let json: any;
88 try {
89 json = text ? JSON.parse(text) : {};
90 } catch {
91 json = { _raw: text };
92 }
93 if (!res.ok) {
94 const msg = json?.error || res.statusText || "request failed";
95 throw new Error(`[${res.status}] ${msg}`);
96 }
97 return json;
98}
99
3b585e0Dictation App100// ---------- GraphQL Utilities ----------
101
102function sanitizeGraphQLString(input: string): string {
103 return input.replace(/["\\]/g, '\\$&');
104}
105
eae38d1Claude106// ---------- Commands ----------
107
108export const HELP = `gluecron CLI v${VERSION}
109
110Usage:
111 gluecron login Save a personal access token
112 gluecron whoami Print the logged-in user
113 gluecron repo ls [--user <name>] List repos
114 gluecron repo show <owner/name> Show a repo
115 gluecron repo create <name> [--private]
116 Create a repo
117 gluecron issues ls <owner/name> List open issues
118 gluecron gql '<query>' Run a GraphQL query
119 gluecron host [url] Get or set the server URL
f764c07Claude120 gluecron deploy [--repo owner/name] [--workflow id] [--ref branch] [--no-watch]
121 Trigger a Hetzner deploy via GitHub Actions
9018b1fClaude122 gluecron commit [-a] [-m <msg>] [-y] [--plain]
123 Commit with an AI-drafted message
124 gluecron ai commit-msg Print an AI commit draft (used by hooks)
125 gluecron hook install commit-msg Install the prepare-commit-msg hook
126 gluecron hook uninstall commit-msg Remove the hook
f764c07Claude127 gluecron config set <key> <value> Set a config value (e.g. github-token)
eae38d1Claude128 gluecron version Print version
129 gluecron help Print this help
130
131Env:
f764c07Claude132 GLUECRON_HOST override the server URL (default: ${DEFAULT_HOST})
133 GLUECRON_GITHUB_TOKEN GitHub PAT (repo+workflow scopes) for \`deploy\`
eae38d1Claude134`;
135
136export async function cmdLogin(
137 cfg: Config,
138 prompt: (q: string) => Promise<string>
139): Promise<Config> {
140 const host =
141 (await prompt(`Server URL [${cfg.host}]: `)) || cfg.host;
142 const token = await prompt("Personal access token (glc_...): ");
143 if (!token) throw new Error("token is required");
144 const next: Config = { host, token };
145 // Probe /api/user/me to confirm
146 const me = await http(next, "GET", "/api/user/me").catch(() => null);
147 if (me?.username) next.username = me.username;
148 saveConfig(next);
149 return next;
150}
151
152export async function cmdWhoami(cfg: Config): Promise<string> {
153 if (!cfg.token) return "(not logged in)";
154 const me = await http(cfg, "GET", "/api/user/me").catch(() => null);
155 if (!me?.username) return cfg.username || "(unknown)";
156 return `${me.username} (${me.email || "no email"})`;
157}
158
159export async function cmdRepoLs(
160 cfg: Config,
161 user?: string
162): Promise<Array<{ owner: string; name: string; visibility: string }>> {
163 const username = user || cfg.username;
164 if (!username) throw new Error("no user context — log in or pass --user");
3b585e0Dictation App165 const q = `{ user(username:"${sanitizeGraphQLString(username)}") { repos { name visibility } } }`;
eae38d1Claude166 const r = await http(cfg, "POST", "/api/graphql", { query: q });
167 const repos = r?.data?.user?.repos || [];
168 return repos.map((x: any) => ({
169 owner: username,
170 name: x.name,
171 visibility: x.visibility,
172 }));
173}
174
175export async function cmdRepoShow(
176 cfg: Config,
177 slug: string
178): Promise<Record<string, any>> {
179 const [owner, name] = slug.split("/");
180 if (!owner || !name) throw new Error("expected owner/name");
3b585e0Dictation App181 const q = `{ repository(owner:"${sanitizeGraphQLString(owner)}", name:"${sanitizeGraphQLString(name)}") {
eae38d1Claude182 name description visibility starCount forkCount
183 owner { username }
184 issues(state:"open", limit:5) { number title }
185 } }`;
186 const r = await http(cfg, "POST", "/api/graphql", { query: q });
187 return r?.data?.repository || null;
188}
189
190export async function cmdRepoCreate(
191 cfg: Config,
192 name: string,
193 isPrivate = false
194): Promise<any> {
195 if (!cfg.username) throw new Error("log in first (gluecron login)");
196 return http(cfg, "POST", "/api/repos", {
197 name,
198 owner: cfg.username,
199 isPrivate,
200 });
201}
202
203export async function cmdIssuesLs(
204 cfg: Config,
205 slug: string
206): Promise<Array<{ number: number; title: string }>> {
207 const [owner, name] = slug.split("/");
3b585e0Dictation App208 const q = `{ repository(owner:"${sanitizeGraphQLString(owner)}", name:"${sanitizeGraphQLString(name)}") { issues(state:"open", limit:50) { number title } } }`;
eae38d1Claude209 const r = await http(cfg, "POST", "/api/graphql", { query: q });
210 return r?.data?.repository?.issues || [];
211}
212
213export async function cmdGql(cfg: Config, query: string): Promise<any> {
214 return http(cfg, "POST", "/api/graphql", { query });
215}
216
f764c07Claude217// ---------- Deploy (GitHub Actions workflow_dispatch) ----------
218
219export interface DeployArgs {
220 repo: string; // "owner/name"
221 workflow: string; // file name (e.g. "hetzner-deploy.yml") OR numeric id
222 ref: string; // "main"
223 githubToken: string;
224}
225
226export interface DeployDispatchResult {
227 runId: number;
228 runUrl: string;
229 htmlUrl: string;
230}
231
232export type FetchLike = (
233 url: string,
234 init?: { method?: string; headers?: Record<string, string>; body?: string }
235) => Promise<{
236 status: number;
237 ok: boolean;
238 text: () => Promise<string>;
239 json?: () => Promise<any>;
240}>;
241
242const GITHUB_API = "https://api.github.com";
243
244function ghHeaders(token: string): Record<string, string> {
245 return {
246 accept: "application/vnd.github+json",
247 authorization: `Bearer ${token}`,
248 "x-github-api-version": "2022-11-28",
249 "user-agent": "gluecron-cli",
250 };
251}
252
253async function parseBody(res: { text: () => Promise<string> }): Promise<any> {
254 const t = await res.text();
255 if (!t) return {};
256 try {
257 return JSON.parse(t);
258 } catch {
259 return { _raw: t };
260 }
261}
262
263function friendlyGhError(status: number, body: any): string {
264 const msg = (body && (body.message || body.error)) || "";
265 if (status === 401) {
266 return "GitHub auth failed (401). Check your token has `repo` and `workflow` scopes.";
267 }
268 if (status === 403) {
269 return `GitHub forbade the request (403). ${msg || "Token may lack `workflow` scope or you don't have write access."}`;
270 }
271 if (status === 404) {
272 return `Workflow or repo not found (404). ${msg || "Check --repo, --workflow, and that the token can see this repo."}`;
273 }
274 if (status === 422) {
275 return `GitHub rejected the dispatch (422). ${msg || "Branch may not exist, or the workflow has no workflow_dispatch trigger on that ref."}`;
276 }
277 return `GitHub error [${status}]: ${msg || "request failed"}`;
278}
279
280/**
281 * Trigger a `workflow_dispatch` on GitHub Actions and resolve the run id.
282 *
283 * Step 1: POST /repos/:o/:r/actions/workflows/:wf/dispatches (expects 204)
284 * Step 2: GET /repos/:o/:r/actions/workflows/:wf/runs?event=workflow_dispatch&branch=:ref
285 * and pick the newest run created within the last 60s.
286 */
287export async function triggerWorkflowDispatch(
288 args: DeployArgs,
289 opts: { fetchImpl?: FetchLike; now?: () => number } = {}
290): Promise<DeployDispatchResult> {
291 const f: FetchLike = opts.fetchImpl ?? (fetch as unknown as FetchLike);
292 const [owner, name] = args.repo.split("/");
293 if (!owner || !name) throw new Error("expected --repo owner/name");
294 if (!args.githubToken) {
295 throw new Error(
296 "no GitHub token — set GLUECRON_GITHUB_TOKEN or run `gluecron config set github-token <token>`"
297 );
298 }
299
300 const dispatchedAt = (opts.now ?? Date.now)();
301 const dispatchUrl = `${GITHUB_API}/repos/${owner}/${name}/actions/workflows/${encodeURIComponent(args.workflow)}/dispatches`;
302 const dispatchRes = await f(dispatchUrl, {
303 method: "POST",
304 headers: { ...ghHeaders(args.githubToken), "content-type": "application/json" },
305 body: JSON.stringify({ ref: args.ref }),
306 });
307 if (dispatchRes.status !== 204) {
308 const body = await parseBody(dispatchRes);
309 throw new Error(friendlyGhError(dispatchRes.status, body));
310 }
311
312 // GitHub does not return the run id on dispatch — query for the latest run.
313 const runsUrl =
314 `${GITHUB_API}/repos/${owner}/${name}/actions/workflows/${encodeURIComponent(args.workflow)}/runs` +
315 `?event=workflow_dispatch&branch=${encodeURIComponent(args.ref)}&per_page=5`;
316 // Try a handful of times — the run may take a moment to register.
317 let lastErr: string | null = null;
318 for (let i = 0; i < 6; i++) {
319 const r = await f(runsUrl, { method: "GET", headers: ghHeaders(args.githubToken) });
320 if (!r.ok) {
321 const body = await parseBody(r);
322 lastErr = friendlyGhError(r.status, body);
323 break;
324 }
325 const body = await parseBody(r);
326 const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [];
327 // Pick the newest run created at/after the dispatch moment (minus 5s slack).
328 const slack = dispatchedAt - 5_000;
329 const candidate = runs.find((rn: any) => {
330 const t = Date.parse(rn?.created_at || "");
331 return Number.isFinite(t) && t >= slack;
332 }) ?? runs[0];
333 if (candidate?.id) {
334 return {
335 runId: Number(candidate.id),
336 runUrl: candidate.url,
337 htmlUrl: candidate.html_url ||
338 `https://github.com/${owner}/${name}/actions/runs/${candidate.id}`,
339 };
340 }
341 // Wait a beat and retry; the test path injects a synchronous fetchImpl so
342 // this loop completes immediately when the run is registered on first try.
343 if (i < 5) await new Promise((res) => setTimeout(res, 1000));
344 }
345 throw new Error(lastErr || "workflow dispatched but could not locate the run id");
346}
347
348export interface DeployJobStep {
349 name: string;
350 status: string; // queued | in_progress | completed
351 conclusion: string | null;
352 startedAt: string | null;
353 completedAt: string | null;
354}
355
356export interface DeployRunStatus {
357 status: string; // queued | in_progress | completed
358 conclusion: string | null;
359 steps: DeployJobStep[];
360}
361
362export async function fetchRunStatus(
363 args: { repo: string; runId: number; githubToken: string },
364 opts: { fetchImpl?: FetchLike } = {}
365): Promise<DeployRunStatus> {
366 const f: FetchLike = opts.fetchImpl ?? (fetch as unknown as FetchLike);
367 const [owner, name] = args.repo.split("/");
368 const r = await f(
369 `${GITHUB_API}/repos/${owner}/${name}/actions/runs/${args.runId}/jobs`,
370 { method: "GET", headers: ghHeaders(args.githubToken) }
371 );
372 if (!r.ok) {
373 const body = await parseBody(r);
374 throw new Error(friendlyGhError(r.status, body));
375 }
376 const body = await parseBody(r);
377 const jobs = Array.isArray(body?.jobs) ? body.jobs : [];
378 // Flatten across jobs — for the hetzner-deploy workflow there's just one.
379 const steps: DeployJobStep[] = [];
380 let status = "queued";
381 let conclusion: string | null = null;
382 for (const j of jobs) {
383 status = j.status || status;
384 conclusion = j.conclusion ?? conclusion;
385 for (const s of j.steps || []) {
386 steps.push({
387 name: s.name,
388 status: s.status,
389 conclusion: s.conclusion ?? null,
390 startedAt: s.started_at ?? null,
391 completedAt: s.completed_at ?? null,
392 });
393 }
394 }
395 return { status, conclusion, steps };
396}
397
398function fmtClock(ms: number): string {
399 const total = Math.max(0, Math.floor(ms / 1000));
400 const m = Math.floor(total / 60);
401 const s = total % 60;
402 return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
403}
404
405function durationSec(a: string | null, b: string | null): number | null {
406 if (!a || !b) return null;
407 const ta = Date.parse(a);
408 const tb = Date.parse(b);
409 if (!Number.isFinite(ta) || !Number.isFinite(tb)) return null;
410 return Math.max(0, Math.round((tb - ta) / 1000));
411}
412
413export async function watchDeploy(
414 args: { repo: string; runId: number; githubToken: string; startedAt: number },
415 out: (msg: string) => void,
416 opts: {
417 fetchImpl?: FetchLike;
418 pollMs?: number;
419 maxPolls?: number;
420 sleep?: (ms: number) => Promise<void>;
421 now?: () => number;
422 } = {}
423): Promise<{ ok: boolean; conclusion: string | null }> {
424 const pollMs = opts.pollMs ?? 3_000;
425 const maxPolls = opts.maxPolls ?? 240; // ~12min default
426 const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
427 const now = opts.now ?? Date.now;
428 const seen = new Map<string, { startedLogged: boolean; completedLogged: boolean }>();
429
430 for (let i = 0; i < maxPolls; i++) {
431 const st = await fetchRunStatus(args, { fetchImpl: opts.fetchImpl });
432 for (const step of st.steps) {
433 // Skip GitHub's implicit "Set up job"/"Complete job" noise if you like;
434 // for now we surface everything.
435 const key = step.name;
436 const prev = seen.get(key) ?? { startedLogged: false, completedLogged: false };
437 const clock = fmtClock(now() - args.startedAt);
438 if (!prev.startedLogged && (step.status === "in_progress" || step.status === "completed")) {
439 out(` ${clock} ${step.name} (in progress)`);
440 prev.startedLogged = true;
441 }
442 if (!prev.completedLogged && step.status === "completed") {
443 const d = durationSec(step.startedAt, step.completedAt);
444 const tail = d != null ? `completed in ${d}s` : `completed`;
445 out(` ${clock} ${step.name} (${tail})`);
446 prev.completedLogged = true;
447 }
448 seen.set(key, prev);
449 }
450 if (st.status === "completed") {
451 return { ok: st.conclusion === "success", conclusion: st.conclusion };
452 }
453 await sleep(pollMs);
454 }
455 return { ok: false, conclusion: "timeout" };
456}
457
3b585e0Dictation App458// ---------- Command Handlers ----------
459
460async function handleHostCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
461 if (rest[0]) {
462 cfg.host = rest[0];
463 saveConfig(cfg);
464 }
465 out(cfg.host);
466 return 0;
467}
468
469async function handleLoginCmd(cfg: Config, out: (msg: string) => void): Promise<number> {
470 const { default: readline } = await import("node:readline/promises");
471 const rl = readline.createInterface({
472 input: process.stdin,
473 output: process.stdout,
474 });
475 try {
476 const next = await cmdLogin(cfg, (q) => rl.question(q));
477 out(`Logged in as ${next.username || "(unknown)"}`);
478 return 0;
479 } finally {
480 rl.close();
481 }
482}
483
484async function handleRepoCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
485 const sub = rest[0];
486 if (sub === "ls") {
487 return handleRepoLsCmd(cfg, rest, out);
488 }
489 if (sub === "show") {
490 return handleRepoShowCmd(cfg, rest, out);
491 }
492 if (sub === "create") {
493 return handleRepoCreateCmd(cfg, rest, out);
494 }
495 out("usage: gluecron repo (ls|show|create)");
496 return 1;
497}
498
499async function handleRepoLsCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
500 const userFlagIdx = rest.indexOf("--user");
501 const user = userFlagIdx >= 0 && userFlagIdx + 1 < rest.length ? rest[userFlagIdx + 1] : undefined;
502 const repos = await cmdRepoLs(cfg, user);
503 for (const r of repos) {
504 out(` ${r.owner}/${r.name} · ${r.visibility}`);
505 }
506 return 0;
507}
508
509async function handleRepoShowCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
510 const repo = await cmdRepoShow(cfg, rest[1]);
511 if (!repo) {
512 out("(not found)");
513 return 1;
514 }
515 out(JSON.stringify(repo, null, 2));
516 return 0;
517}
518
519async function handleRepoCreateCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
520 const isPrivate = rest.includes("--private");
521 const name = rest.find((x, i) => i > 0 && !x.startsWith("--"));
522 if (!name) {
523 out("usage: gluecron repo create <name> [--private]");
524 return 1;
525 }
526 const r = await cmdRepoCreate(cfg, name, isPrivate);
527 out(JSON.stringify(r, null, 2));
528 return 0;
529}
530
531async function handleIssuesCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
532 if (rest[0] !== "ls" || !rest[1]) {
533 out("usage: gluecron issues ls <owner/name>");
534 return 1;
535 }
536 const issues = await cmdIssuesLs(cfg, rest[1]);
537 for (const i of issues) {
538 out(` #${i.number} ${i.title}`);
539 }
540 return 0;
541}
542
543async function handleGqlCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
544 if (!rest[0]) {
545 out("usage: gluecron gql '<query>'");
546 return 1;
547 }
548 const r = await cmdGql(cfg, rest.join(" "));
549 out(JSON.stringify(r, null, 2));
550 return 0;
551}
552
f764c07Claude553function readFlag(rest: string[], name: string): string | undefined {
554 const i = rest.indexOf(name);
555 if (i >= 0 && i + 1 < rest.length) return rest[i + 1];
556 return undefined;
557}
558
559export async function handleConfigCmd(
560 cfg: Config,
561 rest: string[],
562 out: (msg: string) => void
563): Promise<number> {
564 if (rest[0] !== "set" || !rest[1]) {
565 out("usage: gluecron config set <key> <value>");
566 return 1;
567 }
568 const key = rest[1];
569 const value = rest[2];
570 if (value === undefined) {
571 out("usage: gluecron config set <key> <value>");
572 return 1;
573 }
574 switch (key) {
575 case "github-token":
576 cfg.githubToken = value;
577 break;
578 case "default-repo":
579 cfg.defaultRepo = value;
580 break;
581 case "host":
582 cfg.host = value;
583 break;
584 case "token":
585 cfg.token = value;
586 break;
587 default:
588 out(`unknown config key: ${key}`);
589 return 1;
590 }
591 saveConfig(cfg);
592 out(`ok: ${key} saved`);
593 return 0;
594}
595
596export async function handleDeployCmd(
597 cfg: Config,
598 rest: string[],
599 out: (msg: string) => void,
600 opts: {
601 fetchImpl?: FetchLike;
602 sleep?: (ms: number) => Promise<void>;
603 pollMs?: number;
604 maxPolls?: number;
605 now?: () => number;
606 } = {}
607): Promise<number> {
608 const repo = readFlag(rest, "--repo") || cfg.defaultRepo || "ccantynz/Gluecron.com";
609 const workflow = readFlag(rest, "--workflow") || "hetzner-deploy.yml";
610 const ref = readFlag(rest, "--ref") || "main";
611 const noWatch = rest.includes("--no-watch");
612 const githubToken =
613 readFlag(rest, "--gh-token") ||
614 process.env.GLUECRON_GITHUB_TOKEN ||
615 cfg.githubToken ||
616 "";
617
618 if (!githubToken) {
619 out(
620 "error: no GitHub token — set GLUECRON_GITHUB_TOKEN or run `gluecron config set github-token <token>`"
621 );
622 return 1;
623 }
624
625 const startedAt = (opts.now ?? Date.now)();
626 out(`> Triggering ${workflow} on ${repo}@${ref}`);
627 let result: DeployDispatchResult;
628 try {
629 result = await triggerWorkflowDispatch(
630 { repo, workflow, ref, githubToken },
631 { fetchImpl: opts.fetchImpl, now: opts.now }
632 );
633 } catch (err) {
634 out(`error: ${(err as Error).message}`);
635 return 1;
636 }
637 out(`> Workflow run dispatched: ${result.htmlUrl}`);
638
639 if (noWatch) return 0;
640
641 out("> Watching deploy status...");
642 const watchResult = await watchDeploy(
643 { repo, runId: result.runId, githubToken, startedAt },
644 out,
645 {
646 fetchImpl: opts.fetchImpl,
647 sleep: opts.sleep,
648 pollMs: opts.pollMs,
649 maxPolls: opts.maxPolls,
650 now: opts.now,
651 }
652 );
653 const elapsed = Math.round(((opts.now ?? Date.now)() - startedAt) / 1000);
654 if (watchResult.ok) {
655 out(`> Deploy succeeded in ${elapsed}s.`);
656 return 0;
657 }
658 out(`! Deploy ${watchResult.conclusion || "failed"} after ${elapsed}s.`);
659 return 1;
660}
661
eae38d1Claude662// ---------- Dispatcher ----------
663
664export async function dispatch(argv: string[], out = console.log): Promise<number> {
665 const cfg = loadConfig();
666 const [cmd, ...rest] = argv;
667
668 if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
669 out(HELP);
670 return 0;
671 }
672 if (cmd === "version" || cmd === "--version" || cmd === "-v") {
673 out(VERSION);
674 return 0;
675 }
676
677 try {
678 switch (cmd) {
3b585e0Dictation App679 case "host":
680 return await handleHostCmd(cfg, rest, out);
681 case "login":
682 return await handleLoginCmd(cfg, out);
eae38d1Claude683 case "whoami":
684 out(await cmdWhoami(cfg));
685 return 0;
3b585e0Dictation App686 case "repo":
687 return await handleRepoCmd(cfg, rest, out);
688 case "issues":
689 return await handleIssuesCmd(cfg, rest, out);
690 case "gql":
691 return await handleGqlCmd(cfg, rest, out);
f764c07Claude692 case "deploy":
693 return await handleDeployCmd(cfg, rest, out);
9018b1fClaude694 case "commit":
695 return await runCommit(rest, { host: cfg.host, token: cfg.token }, { out });
696 case "ai":
697 if (rest[0] === "commit-msg") {
698 return await runAiCommitMsg(
699 { host: cfg.host, token: cfg.token },
700 { out }
701 );
702 }
703 out("usage: gluecron ai commit-msg");
704 return 1;
705 case "hook":
706 return await runHook(rest, { out });
f764c07Claude707 case "config":
708 return await handleConfigCmd(cfg, rest, out);
eae38d1Claude709 default:
710 out(`unknown command: ${cmd}\n`);
711 out(HELP);
712 return 1;
713 }
714 } catch (err) {
715 out(`error: ${(err as Error).message}`);
716 return 1;
717 }
718}
719
720// Entry
721if (import.meta.main) {
722 const code = await dispatch(process.argv.slice(2));
723 process.exit(code);
3b585e0Dictation App724}