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 | ||
| 3b585e0 | 94 | // ---------- GraphQL Utilities ---------- |
| 95 | ||
| 96 | function sanitizeGraphQLString(input: string): string { | |
| 97 | return input.replace(/["\\]/g, '\\$&'); | |
| 98 | } | |
| 99 | ||
| eae38d1 | 100 | // ---------- Commands ---------- |
| 101 | ||
| 102 | export const HELP = `gluecron CLI v${VERSION} | |
| 103 | ||
| 104 | Usage: | |
| 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 | ||
| 117 | Env: | |
| 118 | GLUECRON_HOST override the server URL (default: ${DEFAULT_HOST}) | |
| 119 | `; | |
| 120 | ||
| 121 | export 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 | ||
| 137 | export 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 | ||
| 144 | export 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"); | |
| 3b585e0 | 150 | const q = `{ user(username:"${sanitizeGraphQLString(username)}") { repos { name visibility } } }`; |
| eae38d1 | 151 | 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 | ||
| 160 | export 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"); | |
| 3b585e0 | 166 | const q = `{ repository(owner:"${sanitizeGraphQLString(owner)}", name:"${sanitizeGraphQLString(name)}") { |
| eae38d1 | 167 | 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 | ||
| 175 | export 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 | ||
| 188 | export async function cmdIssuesLs( | |
| 189 | cfg: Config, | |
| 190 | slug: string | |
| 191 | ): Promise<Array<{ number: number; title: string }>> { | |
| 192 | const [owner, name] = slug.split("/"); | |
| 3b585e0 | 193 | const q = `{ repository(owner:"${sanitizeGraphQLString(owner)}", name:"${sanitizeGraphQLString(name)}") { issues(state:"open", limit:50) { number title } } }`; |
| eae38d1 | 194 | const r = await http(cfg, "POST", "/api/graphql", { query: q }); |
| 195 | return r?.data?.repository?.issues || []; | |
| 196 | } | |
| 197 | ||
| 198 | export async function cmdGql(cfg: Config, query: string): Promise<any> { | |
| 199 | return http(cfg, "POST", "/api/graphql", { query }); | |
| 200 | } | |
| 201 | ||
| 3b585e0 | 202 | // ---------- Command Handlers ---------- |
| 203 | ||
| 204 | async 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 | ||
| 213 | async 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 | ||
| 228 | async 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 | ||
| 243 | async 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 | ||
| 253 | async 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 | ||
| 263 | async 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 | ||
| 275 | async 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 | ||
| 287 | async 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 | ||
| eae38d1 | 297 | // ---------- Dispatcher ---------- |
| 298 | ||
| 299 | export 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) { | |
| 3b585e0 | 314 | case "host": |
| 315 | return await handleHostCmd(cfg, rest, out); | |
| 316 | case "login": | |
| 317 | return await handleLoginCmd(cfg, out); | |
| eae38d1 | 318 | case "whoami": |
| 319 | out(await cmdWhoami(cfg)); | |
| 320 | return 0; | |
| 3b585e0 | 321 | 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); | |
| eae38d1 | 327 | 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 | |
| 339 | if (import.meta.main) { | |
| 340 | const code = await dispatch(process.argv.slice(2)); | |
| 341 | process.exit(code); | |
| 3b585e0 | 342 | } |