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.
| eae38d1 | 1 | #!/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 | ||
| 20 | import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; | |
| 21 | import { homedir } from "node:os"; | |
| 22 | import { join } from "node:path"; | |
| 23 | ||
| 24 | const VERSION = "0.1.0"; | |
| 25 | const DEFAULT_HOST = process.env.GLUECRON_HOST || "http://localhost:3000"; | |
| 26 | const CONFIG_DIR = join(homedir(), ".gluecron"); | |
| 27 | const CONFIG_FILE = join(CONFIG_DIR, "config.json"); | |
| 28 | ||
| 29 | // ---------- Config ---------- | |
| 30 | ||
| 31 | interface Config { | |
| 32 | host: string; | |
| 33 | token?: string; | |
| 34 | username?: string; | |
| 35 | } | |
| 36 | ||
| 37 | export 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 | ||
| 54 | export 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 | ||
| 63 | export 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 | ||
| 96 | export const HELP = `gluecron CLI v${VERSION} | |
| 97 | ||
| 98 | Usage: | |
| 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 | ||
| 111 | Env: | |
| 112 | GLUECRON_HOST override the server URL (default: ${DEFAULT_HOST}) | |
| 113 | `; | |
| 114 | ||
| 115 | export 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 | ||
| 131 | export 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 | ||
| 138 | export 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 | ||
| 154 | export 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 | ||
| 169 | export 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 | ||
| 182 | export 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 | ||
| 192 | export async function cmdGql(cfg: Config, query: string): Promise<any> { | |
| 193 | return http(cfg, "POST", "/api/graphql", { query }); | |
| 194 | } | |
| 195 | ||
| 196 | // ---------- Dispatcher ---------- | |
| 197 | ||
| 198 | export 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 | |
| 301 | if (import.meta.main) { | |
| 302 | const code = await dispatch(process.argv.slice(2)); | |
| 303 | process.exit(code); | |
| 304 | } |