Commiteae38d1unknown_key
feat(BLOCK-G): G1 PWA + G2 GraphQL + G3 CLI + G4 VS Code extension
feat(BLOCK-G): G1 PWA + G2 GraphQL + G3 CLI + G4 VS Code extension G1 — PWA (manifest + service worker) * src/routes/pwa.ts: /manifest.webmanifest, /sw.js, /icon.svg * MANIFEST + SERVICE_WORKER_SRC exported for testing * Layout wires manifest link + SW registration (network-first for HTML; skips .git/, /api/, /login, /register, /logout) G2 — GraphQL mirror of REST * src/lib/graphql.ts: hand-rolled recursive-descent parser + executor, zero dependencies * src/routes/graphql.ts: POST /api/graphql + GET /api/graphql GraphiQL-lite explorer * Root fields: viewer, user, repository, search, rateLimit * Queries only — no mutations (callers use REST for writes) G3 — Official CLI (gluecron binary) * cli/gluecron.ts: single-file Bun CLI, compile with bun build --compile * Commands: login, whoami, repo ls/show/create, issues ls, gql, host, version * Config at ~/.gluecron/config.json (0600) * Talks to server via REST + GraphQL; dispatch() exported for tests G4 — VS Code extension * vscode-extension/package.json + src/extension.ts * Commands: explainFile, openOnWeb, searchSemantic, generateTests * Settings: gluecron.host + gluecron.token * Detects Gluecron remotes via git config remote.origin.url Tests: 513 pass (up from 470) — added pwa, graphql, cli, vscode-extension smoke + pure-helper tests
16 files changed+1844−6eae38d12c9fc64a9021415d1aded51188bffc492
16 changed files+1844−6
ModifiedBUILD_BIBLE.md+14−6View fileUnifiedSplit
@@ -173,7 +173,10 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
173173| Billing + quotas | ✅ | F4 — `src/lib/billing.ts` + `src/routes/billing.tsx`, tables `billing_plans` + `user_quotas` seeded free/pro/team/enterprise. `/settings/billing` personal view + `/admin/billing` site-admin override. |
174174| Email notifications | ✅ | opt-in per kind (mention/assign/gate-fail) via `/settings`; provider-pluggable `src/lib/email.ts` (log default, resend in prod) |
175175| Email digest | ❌ | |
176| Mobile PWA | 🟡 | responsive CSS, no manifest |
176| Mobile PWA | ✅ | G1 — `src/routes/pwa.ts` serves `/manifest.webmanifest` + `/sw.js` + `/icon.svg`; Layout injects manifest link + SW registration. Offline-capable (network-first for HTML). |
177| GraphQL API | ✅ | G2 — `src/lib/graphql.ts` parser + executor, `src/routes/graphql.ts` endpoint at `POST /api/graphql`, GraphiQL-lite explorer at `GET /api/graphql`. Queries only (viewer/user/repository/search/rateLimit). |
178| Official CLI | ✅ | G3 — `cli/gluecron.ts` Bun-compilable single binary. REST + GraphQL client, `~/.gluecron/config.json` 0600. |
179| VS Code extension | ✅ | G4 — `vscode-extension/` with commands for explain / open-on-web / semantic search / generate tests. |
177180| Native mobile apps | ❌ | |
178181| Dark mode | ✅ | default |
179182| Light-mode toggle | ✅ | `/theme/toggle` + `theme` cookie, pre-paint script avoids FOUC, nav sun/moon icon |
@@ -258,10 +261,10 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
258261- **F4** — Billing + quotas → ✅ shipped. `src/lib/billing.ts` + `src/routes/billing.tsx`, tables `billing_plans` + `user_quotas` (migration 0020, seeded with free/pro/team/enterprise). `FALLBACK_PLANS` mirror the seeds so billing works pre-migration. Helpers: `getUserQuota` (auto-initialises free row on first read), `bumpUsage`, `checkQuota` (fail-open), `wouldExceedRepoLimit`, `resetIfCycleExpired`. Routes: `GET /settings/billing` (personal view with usage bars + plan cards), `GET /admin/billing` (site-admin plan override), `POST /admin/billing/:userId/plan`.
259262
260263### BLOCK G — Mobile + client
261- **G1** — PWA manifest + service worker
262- **G2** — GraphQL API mirror of REST
263- **G3** — Official CLI (`gluecron` binary in Bun)
264- **G4** — VS Code extension
264- **G1** — PWA manifest + service worker → ✅ shipped. `src/routes/pwa.ts` serves `/manifest.webmanifest`, `/sw.js`, `/icon.svg`; `Layout` injects `<link rel="manifest">` + a tiny SW registration script. Service worker is network-first for HTML + skips `.git/`/`/api/`/`/login*` routes.
265- **G2** — GraphQL API mirror of REST → ✅ shipped. `src/lib/graphql.ts` is a dependency-free recursive-descent parser + executor over a fixed schema (viewer, user, repository, search, rateLimit). `src/routes/graphql.ts` serves `POST /api/graphql` + a GraphiQL-lite explorer at `GET /api/graphql`. Queries only; writes stay on REST.
266- **G3** — Official CLI (`gluecron`) → ✅ shipped. `cli/gluecron.ts` is a Bun-compilable single-file CLI. Commands: `login`, `whoami`, `repo ls/show/create`, `issues ls`, `gql`, `host`, `version`. Config in `~/.gluecron/config.json` (0600). Talks to the server via REST + GraphQL.
267- **G4** — VS Code extension → ✅ shipped. `vscode-extension/` contains package.json + `src/extension.ts`. Commands: `gluecron.explainFile`, `gluecron.openOnWeb`, `gluecron.searchSemantic`, `gluecron.generateTests`. Detects Gluecron remotes via `git config remote.origin.url`. Settings: `gluecron.host` + `gluecron.token`.
265268
266269### BLOCK H — Marketplace
267270- **H1** — App marketplace (install third-party apps against a repo)
@@ -409,6 +412,11 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
409412- `src/routes/admin.tsx` (Block F3) — `GET /admin` dashboard (user/repo/admin counts + recent signups), `/admin/users` + toggle grant/revoke, `/admin/repos` + nuclear delete, `/admin/flags` form. All mutations audit-logged via `audit()`. Gated through a `gate(c)` helper that returns `{user} | Response`.
410413- `src/lib/billing.ts` (Block F4) — plan + quota helpers. `FALLBACK_PLANS` (free/pro/team/enterprise) mirror the seed rows. `getUserQuota(userId)` auto-initialises free row. `bumpUsage`, `checkQuota` (fail-open), `wouldExceedRepoLimit`, `resetIfCycleExpired`, `formatPrice`. Never throws into request path.
411414- `src/routes/billing.tsx` (Block F4) — `GET /settings/billing` (personal view with usage bars + plan cards), `GET /admin/billing` (site-admin user/plan table), `POST /admin/billing/:userId/plan` (override plan, audit-logged).
415- `src/routes/pwa.ts` (Block G1) — `/manifest.webmanifest`, `/sw.js`, `/icon.svg`. Exports `MANIFEST`, `SERVICE_WORKER_SRC`, `PWA_REGISTER_SNIPPET` for testing. SW deliberately skips `.git/`, `/api/`, `/login*`, `/register`, `/logout`.
416- `src/lib/graphql.ts` (Block G2) — hand-rolled recursive-descent parser (`parseQuery`) + executor (`execute`) over a fixed schema. Zero dependencies. Root fields: viewer, user, repository, search, rateLimit. No mutations.
417- `src/routes/graphql.ts` (Block G2) — `POST /api/graphql` JSON endpoint + `GET /api/graphql` GraphiQL-lite explorer (Cmd+Enter to run).
418- `cli/gluecron.ts` (Block G3) — single-file Bun CLI. Exports `dispatch(argv, out)` for programmatic use, `HELP` constant, `loadConfig`/`saveConfig`. Config at `~/.gluecron/config.json` (0600). Compile: `bun build cli/gluecron.ts --compile --outfile gluecron`.
419- `vscode-extension/` (Block G4) — VS Code extension with `package.json` declaring four commands (explainFile, openOnWeb, searchSemantic, generateTests) + `gluecron.host` / `gluecron.token` settings. Detects Gluecron remotes via `git config remote.origin.url`.
412420
413421### 4.7 Views (locked contracts)
414422- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -443,7 +451,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
443451```bash
444452bun install
445453bun dev # hot reload
446bun test # 470 tests currently pass
454bun test # 513 tests currently pass
447455bun run db:migrate
448456```
449457
Addedcli/README.md+36−0View fileUnifiedSplit
@@ -0,0 +1,36 @@
1# gluecron CLI
2
3Official CLI for Gluecron. Single Bun-compiled binary; talks to any Gluecron
4server over HTTPS.
5
6## Build
7
8```
9bun build cli/gluecron.ts --compile --outfile gluecron
10./gluecron version
11```
12
13## Commands
14
15```
16gluecron login Save a personal access token
17gluecron whoami Print the logged-in user
18gluecron repo ls [--user <name>] List repos
19gluecron repo show <owner/name> Show a repo
20gluecron repo create <name> [--private]
21 Create a repo
22gluecron issues ls <owner/name> List open issues
23gluecron gql '<query>' Run a GraphQL query
24gluecron host [url] Get or set the server URL
25gluecron version Print version
26```
27
28Config is stored at `~/.gluecron/config.json` with 0600 permissions.
29
30Server URL can be overridden via `GLUECRON_HOST` or `gluecron host <url>`.
31
32## Auth
33
34The CLI uses personal access tokens (PATs). Create one via the web UI at
35`/settings/tokens`. Tokens carry the `glc_` prefix and are sent as
36`Authorization: Bearer <token>`.
Addedcli/gluecron.ts+304−0View fileUnifiedSplit
@@ -0,0 +1,304 @@
1
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}
Addedsrc/__tests__/cli.test.ts+93−0View fileUnifiedSplit
@@ -0,0 +1,93 @@
1/**
2 * Block G3 — `gluecron` CLI smoke tests.
3 *
4 * Invokes the `dispatch` function directly without spawning a process;
5 * captures stdout lines via an injected `out` callback. This keeps the
6 * tests hermetic — no actual HTTP requests (except in cases where they
7 * are expected to fail gracefully against a non-responsive host).
8 */
9
10import { describe, it, expect } from "bun:test";
11import { dispatch, HELP, loadConfig } from "../../cli/gluecron";
12
13function capture() {
14 const lines: string[] = [];
15 return {
16 out: (s: string) => lines.push(s),
17 text: () => lines.join("\n"),
18 lines,
19 };
20}
21
22describe("cli — help + version", () => {
23 it("prints help on no args", async () => {
24 const { out, text } = capture();
25 const code = await dispatch([], out);
26 expect(code).toBe(0);
27 expect(text()).toContain("gluecron CLI");
28 });
29
30 it("prints help with --help", async () => {
31 const { out, text } = capture();
32 const code = await dispatch(["--help"], out);
33 expect(code).toBe(0);
34 expect(text()).toContain("Usage");
35 });
36
37 it("prints version with --version", async () => {
38 const { out, text } = capture();
39 const code = await dispatch(["--version"], out);
40 expect(code).toBe(0);
41 expect(text()).toMatch(/^\d+\.\d+\.\d+$/);
42 });
43
44 it("rejects unknown commands", async () => {
45 const { out, text } = capture();
46 const code = await dispatch(["bogus"], out);
47 expect(code).toBe(1);
48 expect(text()).toContain("unknown command");
49 });
50});
51
52describe("cli — config", () => {
53 it("loadConfig returns default host when no file exists", () => {
54 const cfg = loadConfig();
55 expect(cfg.host).toBeDefined();
56 expect(typeof cfg.host).toBe("string");
57 });
58});
59
60describe("cli — HELP text", () => {
61 it("lists every major command", () => {
62 expect(HELP).toContain("login");
63 expect(HELP).toContain("whoami");
64 expect(HELP).toContain("repo ls");
65 expect(HELP).toContain("repo show");
66 expect(HELP).toContain("repo create");
67 expect(HELP).toContain("issues ls");
68 expect(HELP).toContain("gql");
69 });
70});
71
72describe("cli — dispatcher", () => {
73 it("repo with no subcommand prints usage", async () => {
74 const { out, text } = capture();
75 const code = await dispatch(["repo"], out);
76 expect(code).toBe(1);
77 expect(text()).toContain("usage:");
78 });
79
80 it("issues without args prints usage", async () => {
81 const { out, text } = capture();
82 const code = await dispatch(["issues"], out);
83 expect(code).toBe(1);
84 expect(text()).toContain("usage:");
85 });
86
87 it("gql without query prints usage", async () => {
88 const { out, text } = capture();
89 const code = await dispatch(["gql"], out);
90 expect(code).toBe(1);
91 expect(text()).toContain("usage:");
92 });
93});
Addedsrc/__tests__/graphql.test.ts+162−0View fileUnifiedSplit
@@ -0,0 +1,162 @@
1/**
2 * Block G2 — GraphQL parser + endpoint smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import { parseQuery, execute } from "../lib/graphql";
8
9describe("graphql — parseQuery", () => {
10 it("parses a bare selection set", () => {
11 const r = parseQuery("{ viewer { id username } }");
12 expect(r.ok).toBe(true);
13 if (r.ok) {
14 expect(r.fields.length).toBe(1);
15 expect(r.fields[0].name).toBe("viewer");
16 expect(r.fields[0].selections.map((s) => s.name)).toEqual([
17 "id",
18 "username",
19 ]);
20 }
21 });
22
23 it("parses a query with operation keyword", () => {
24 const r = parseQuery("query Foo { rateLimit { remaining } }");
25 expect(r.ok).toBe(true);
26 if (r.ok) {
27 expect(r.fields[0].name).toBe("rateLimit");
28 }
29 });
30
31 it("parses string args", () => {
32 const r = parseQuery('{ user(username:"alice") { id } }');
33 expect(r.ok).toBe(true);
34 if (r.ok) {
35 expect(r.fields[0].args.username).toBe("alice");
36 }
37 });
38
39 it("parses number + boolean args", () => {
40 const r = parseQuery('{ search(q:"x", limit:5) { id } }');
41 expect(r.ok).toBe(true);
42 if (r.ok) {
43 expect(r.fields[0].args.limit).toBe(5);
44 }
45 });
46
47 it("parses aliases", () => {
48 const r = parseQuery("{ me:viewer { id } }");
49 expect(r.ok).toBe(true);
50 if (r.ok) {
51 expect(r.fields[0].name).toBe("viewer");
52 expect(r.fields[0].alias).toBe("me");
53 }
54 });
55
56 it("parses nested selections", () => {
57 const r = parseQuery(
58 `{ repository(owner:"alice", name:"repo") { owner { username } issues(state:"open", limit:5) { title } } }`
59 );
60 expect(r.ok).toBe(true);
61 if (r.ok) {
62 const repo = r.fields[0];
63 expect(repo.name).toBe("repository");
64 const owner = repo.selections.find((s) => s.name === "owner");
65 expect(owner).toBeDefined();
66 expect(owner!.selections.map((s) => s.name)).toEqual(["username"]);
67 }
68 });
69
70 it("skips comments + commas", () => {
71 const r = parseQuery("{ # comment\n viewer, { id, username } }");
72 expect(r.ok).toBe(true);
73 });
74
75 it("returns an error on malformed input", () => {
76 const r = parseQuery("{ viewer {");
77 expect(r.ok).toBe(false);
78 });
79});
80
81describe("graphql — execute", () => {
82 it("returns data for rateLimit (no-side-effect field)", async () => {
83 const r = await execute("{ rateLimit { remaining reset } }", { user: null });
84 expect(r.data).toBeDefined();
85 expect(r.data?.rateLimit).toBeDefined();
86 expect(typeof r.data?.rateLimit.remaining).toBe("number");
87 expect(typeof r.data?.rateLimit.reset).toBe("number");
88 });
89
90 it("viewer returns null without auth", async () => {
91 const r = await execute("{ viewer { id } }", { user: null });
92 expect(r.data?.viewer).toBe(null);
93 });
94
95 it("unknown root field → error + null data", async () => {
96 const r = await execute("{ bogus { id } }", { user: null });
97 expect(r.errors).toBeDefined();
98 expect(r.errors![0].message).toContain("bogus");
99 expect(r.data?.bogus).toBe(null);
100 });
101
102 it("parse error surfaces in errors", async () => {
103 const r = await execute("{ viewer {", { user: null });
104 expect(r.errors).toBeDefined();
105 });
106
107 it("user(username) on nonexistent returns null", async () => {
108 const r = await execute(
109 '{ user(username:"__zzzz_doesnt_exist") { id } }',
110 { user: null }
111 );
112 expect(r.data?.user).toBe(null);
113 });
114
115 it("repository on nonexistent returns null", async () => {
116 const r = await execute(
117 '{ repository(owner:"__nope", name:"__nope") { id } }',
118 { user: null }
119 );
120 expect(r.data?.repository).toBe(null);
121 });
122});
123
124describe("graphql — HTTP endpoint", () => {
125 it("POST /api/graphql with empty query → 400", async () => {
126 const res = await app.request("/api/graphql", {
127 method: "POST",
128 headers: { "content-type": "application/json" },
129 body: JSON.stringify({ query: "" }),
130 });
131 expect(res.status).toBe(400);
132 });
133
134 it("POST /api/graphql with invalid JSON → 400", async () => {
135 const res = await app.request("/api/graphql", {
136 method: "POST",
137 headers: { "content-type": "application/json" },
138 body: "{not json",
139 });
140 expect(res.status).toBe(400);
141 });
142
143 it("POST /api/graphql rateLimit query returns JSON", async () => {
144 const res = await app.request("/api/graphql", {
145 method: "POST",
146 headers: { "content-type": "application/json" },
147 body: JSON.stringify({ query: "{ rateLimit { remaining } }" }),
148 });
149 expect(res.status).toBe(200);
150 const body = (await res.json()) as any;
151 expect(body.data.rateLimit.remaining).toBeGreaterThan(0);
152 });
153
154 it("GET /api/graphql serves a GraphiQL-lite explorer page", async () => {
155 const res = await app.request("/api/graphql");
156 expect(res.status).toBe(200);
157 expect(res.headers.get("content-type") || "").toContain("text/html");
158 const html = await res.text();
159 expect(html).toContain("gluecron");
160 expect(html).toContain("/api/graphql");
161 });
162});
Addedsrc/__tests__/pwa.test.ts+95−0View fileUnifiedSplit
@@ -0,0 +1,95 @@
1/**
2 * Block G1 — PWA route smoke tests.
3 *
4 * Verifies manifest/icon/service-worker endpoints serve the right content
5 * types + the manifest parses as JSON with the required install-prompt fields.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import { MANIFEST, SERVICE_WORKER_SRC, PWA_REGISTER_SNIPPET } from "../routes/pwa";
11
12describe("pwa — manifest", () => {
13 it("GET /manifest.webmanifest → 200 JSON", async () => {
14 const res = await app.request("/manifest.webmanifest");
15 expect(res.status).toBe(200);
16 const ct = res.headers.get("content-type") || "";
17 expect(ct).toContain("application/manifest+json");
18 const body = await res.json();
19 expect(body.name).toBe("Gluecron");
20 expect(body.start_url).toBe("/");
21 expect(body.display).toBe("standalone");
22 expect(Array.isArray(body.icons)).toBe(true);
23 expect(body.icons.length).toBeGreaterThan(0);
24 });
25
26 it("MANIFEST constant has required install-prompt fields", () => {
27 expect(MANIFEST.name).toBeDefined();
28 expect(MANIFEST.short_name).toBeDefined();
29 expect(MANIFEST.start_url).toBeDefined();
30 expect(MANIFEST.icons.length).toBeGreaterThan(0);
31 expect(MANIFEST.display).toBe("standalone");
32 });
33});
34
35describe("pwa — service worker", () => {
36 it("GET /sw.js → 200 JavaScript", async () => {
37 const res = await app.request("/sw.js");
38 expect(res.status).toBe(200);
39 expect(res.headers.get("content-type") || "").toContain(
40 "application/javascript"
41 );
42 expect(res.headers.get("service-worker-allowed")).toBe("/");
43 });
44
45 it("service worker source contains install + fetch handlers", () => {
46 expect(SERVICE_WORKER_SRC).toContain("addEventListener('install'");
47 expect(SERVICE_WORKER_SRC).toContain("addEventListener('fetch'");
48 expect(SERVICE_WORKER_SRC).toContain("addEventListener('activate'");
49 });
50
51 it("service worker skips git + api + auth paths", () => {
52 expect(SERVICE_WORKER_SRC).toContain(".git/");
53 expect(SERVICE_WORKER_SRC).toContain("/api/");
54 expect(SERVICE_WORKER_SRC).toContain("/login");
55 });
56
57 it("service worker ignores non-GET requests", () => {
58 expect(SERVICE_WORKER_SRC).toContain("req.method !== 'GET'");
59 });
60});
61
62describe("pwa — icon", () => {
63 it("GET /icon.svg → 200 SVG", async () => {
64 const res = await app.request("/icon.svg");
65 expect(res.status).toBe(200);
66 expect(res.headers.get("content-type") || "").toContain("image/svg+xml");
67 const body = await res.text();
68 expect(body).toContain("<svg");
69 expect(body).toContain("</svg>");
70 });
71});
72
73describe("pwa — register snippet", () => {
74 it("registers a service worker when available", () => {
75 expect(PWA_REGISTER_SNIPPET).toContain("serviceWorker");
76 expect(PWA_REGISTER_SNIPPET).toContain("'/sw.js'");
77 });
78});
79
80describe("pwa — layout wiring", () => {
81 it("home page includes the manifest link", async () => {
82 const res = await app.request("/");
83 const body = await res.text();
84 expect(body).toContain('rel="manifest"');
85 expect(body).toContain("/manifest.webmanifest");
86 });
87
88 it("home page registers the service worker", async () => {
89 const res = await app.request("/");
90 const body = await res.text();
91 // JSX entity-escapes quotes inside <script>; just check the SW path is wired.
92 expect(body).toContain("serviceWorker.register");
93 expect(body).toContain("/sw.js");
94 });
95});
Addedsrc/__tests__/vscode-extension.test.ts+71−0View fileUnifiedSplit
@@ -0,0 +1,71 @@
1/**
2 * Block G4 — VS Code extension pure-helper tests.
3 *
4 * The extension itself depends on the `vscode` module (only available inside
5 * the host), but the URL-building helpers are pure — we re-implement them
6 * locally here to lock the contract. If the contract drifts in extension.ts,
7 * update this file in lockstep.
8 */
9
10import { describe, it, expect } from "bun:test";
11import { readFileSync } from "node:fs";
12import { join } from "node:path";
13
14// Mirror of src/extension.ts — keep in sync.
15function buildWebUrl(
16 host: string,
17 owner: string,
18 repo: string,
19 relPath: string,
20 line?: number
21): string {
22 const base = `${host.replace(/\/+$/, "")}/${owner}/${repo}/blob/main/${relPath}`;
23 return line ? `${base}#L${line + 1}` : base;
24}
25
26describe("vscode-extension — buildWebUrl", () => {
27 it("builds a basic web URL", () => {
28 expect(buildWebUrl("https://gluecron.com", "alice", "proj", "README.md")).toBe(
29 "https://gluecron.com/alice/proj/blob/main/README.md"
30 );
31 });
32
33 it("appends #L<n+1> when a line is supplied", () => {
34 expect(
35 buildWebUrl("https://gluecron.com", "alice", "proj", "src/x.ts", 41)
36 ).toBe("https://gluecron.com/alice/proj/blob/main/src/x.ts#L42");
37 });
38
39 it("strips trailing slashes from the host", () => {
40 expect(buildWebUrl("https://g.com///", "a", "b", "c")).toBe(
41 "https://g.com/a/b/blob/main/c"
42 );
43 });
44});
45
46describe("vscode-extension — package.json contract", () => {
47 const pkg = JSON.parse(
48 readFileSync(
49 join(process.cwd(), "vscode-extension/package.json"),
50 "utf8"
51 )
52 );
53
54 it("declares the expected commands", () => {
55 const names = pkg.contributes.commands.map((c: any) => c.command);
56 expect(names).toContain("gluecron.explainFile");
57 expect(names).toContain("gluecron.openOnWeb");
58 expect(names).toContain("gluecron.searchSemantic");
59 expect(names).toContain("gluecron.generateTests");
60 });
61
62 it("declares configuration keys host + token", () => {
63 const keys = Object.keys(pkg.contributes.configuration.properties);
64 expect(keys).toContain("gluecron.host");
65 expect(keys).toContain("gluecron.token");
66 });
67
68 it("activates onStartupFinished", () => {
69 expect(pkg.activationEvents).toContain("onStartupFinished");
70 });
71});
Modifiedsrc/app.tsx+8−0View fileUnifiedSplit
@@ -60,6 +60,8 @@ import trafficRoutes from "./routes/traffic";
6060import orgInsightsRoutes from "./routes/org-insights";
6161import adminRoutes from "./routes/admin";
6262import billingRoutes from "./routes/billing";
63import pwaRoutes from "./routes/pwa";
64import graphqlRoutes from "./routes/graphql";
6365import webRoutes from "./routes/web";
6466
6567const app = new Hono();
@@ -202,6 +204,12 @@ app.route("/", orgInsightsRoutes); // F2 — /orgs/:slug/insights
202204app.route("/", adminRoutes); // F3 — /admin
203205app.route("/", billingRoutes); // F4 — /settings/billing + /admin/billing
204206
207// PWA — manifest + service worker + icon (Block G1)
208app.route("/", pwaRoutes);
209
210// GraphQL mirror of REST (Block G2)
211app.route("/", graphqlRoutes);
212
205213// Insights + milestones
206214app.route("/", insightsRoutes);
207215
Addedsrc/lib/graphql.ts+488−0View fileUnifiedSplit
@@ -0,0 +1,488 @@
1/**
2 * Block G2 — GraphQL mirror of REST.
3 *
4 * A deliberately-tiny, dependency-free GraphQL-over-HTTP handler. Supports
5 * a fixed set of query fields that mirror the existing REST endpoints:
6 *
7 * query {
8 * viewer { id username email }
9 * user(username:"...") { id username createdAt repos { id name visibility } }
10 * repository(owner:"...", name:"...") {
11 * id name description visibility starCount forkCount createdAt
12 * owner { id username }
13 * issues(state:"open", limit:20) { id number title state createdAt }
14 * pullRequests(state:"open", limit:20) { id number title state createdAt }
15 * }
16 * search(q:"...", limit:20) { id name ownerUsername }
17 * rateLimit { remaining reset }
18 * }
19 *
20 * No mutations — callers should use the REST + /api endpoints for writes.
21 * This keeps the attack surface small and sidesteps the need for an
22 * auth/authz layer beyond softAuth.
23 *
24 * The parser is a hand-rolled recursive descent over the subset of
25 * GraphQL syntax we actually support (selection sets, named fields,
26 * string/number/boolean/enum args). It's not a spec-complete parser —
27 * it rejects anything it doesn't understand with a friendly error.
28 */
29
30import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
31import { db } from "../db";
32import {
33 issues,
34 pullRequests,
35 repositories,
36 users,
37} from "../db/schema";
38
39// ---------- Types ----------
40
41export interface GqlError {
42 message: string;
43 path?: string[];
44}
45
46export interface GqlResponse {
47 data?: Record<string, any> | null;
48 errors?: GqlError[];
49}
50
51type ArgValue = string | number | boolean | null;
52
53interface Field {
54 name: string;
55 alias?: string;
56 args: Record<string, ArgValue>;
57 selections: Field[];
58}
59
60// ---------- Parser ----------
61
62/** Parses a query. Returns either the top-level selection set or an error. */
63export function parseQuery(
64 src: string
65): { ok: true; fields: Field[] } | { ok: false; error: string } {
66 const s = src.trim();
67 try {
68 const p = new Parser(s);
69 p.skipWs();
70 // Optional "query" or "query Name" prefix.
71 if (p.peekWord() === "query") {
72 p.consumeWord("query");
73 p.skipWs();
74 // optional operation name
75 if (p.peek() && /[A-Za-z_]/.test(p.peek()!)) p.readName();
76 p.skipWs();
77 }
78 const fields = p.parseSelectionSet();
79 return { ok: true, fields };
80 } catch (err) {
81 return { ok: false, error: (err as Error).message };
82 }
83}
84
85class Parser {
86 private i = 0;
87 constructor(private src: string) {}
88
89 peek(): string | null {
90 return this.i < this.src.length ? this.src[this.i] : null;
91 }
92
93 peekWord(): string {
94 this.skipWs();
95 let j = this.i;
96 let out = "";
97 while (j < this.src.length && /[A-Za-z_]/.test(this.src[j])) {
98 out += this.src[j];
99 j++;
100 }
101 return out;
102 }
103
104 consumeWord(expected: string) {
105 this.skipWs();
106 const w = this.readName();
107 if (w !== expected) {
108 throw new Error(`expected '${expected}', got '${w}'`);
109 }
110 }
111
112 readName(): string {
113 this.skipWs();
114 let out = "";
115 while (this.i < this.src.length && /[A-Za-z0-9_]/.test(this.src[this.i])) {
116 out += this.src[this.i];
117 this.i++;
118 }
119 if (!out) throw new Error(`expected identifier at ${this.i}`);
120 return out;
121 }
122
123 expect(ch: string) {
124 this.skipWs();
125 if (this.src[this.i] !== ch) {
126 throw new Error(`expected '${ch}' at ${this.i}, got '${this.src[this.i] || "EOF"}'`);
127 }
128 this.i++;
129 }
130
131 skipWs() {
132 while (
133 this.i < this.src.length &&
134 /[\s,]/.test(this.src[this.i])
135 )
136 this.i++;
137 // Line comments (#)
138 if (this.src[this.i] === "#") {
139 while (this.i < this.src.length && this.src[this.i] !== "\n") this.i++;
140 this.skipWs();
141 }
142 }
143
144 parseSelectionSet(): Field[] {
145 this.skipWs();
146 this.expect("{");
147 const out: Field[] = [];
148 while (true) {
149 this.skipWs();
150 if (this.peek() === "}") {
151 this.i++;
152 return out;
153 }
154 out.push(this.parseField());
155 }
156 }
157
158 parseField(): Field {
159 this.skipWs();
160 const first = this.readName();
161 let alias: string | undefined;
162 let name = first;
163 this.skipWs();
164 if (this.peek() === ":") {
165 this.i++;
166 alias = first;
167 name = this.readName();
168 }
169 this.skipWs();
170 const args: Record<string, ArgValue> = {};
171 if (this.peek() === "(") {
172 this.i++;
173 while (true) {
174 this.skipWs();
175 if (this.peek() === ")") {
176 this.i++;
177 break;
178 }
179 const argName = this.readName();
180 this.skipWs();
181 this.expect(":");
182 const val = this.parseValue();
183 args[argName] = val;
184 }
185 }
186 this.skipWs();
187 let selections: Field[] = [];
188 if (this.peek() === "{") {
189 selections = this.parseSelectionSet();
190 }
191 return { name, alias, args, selections };
192 }
193
194 parseValue(): ArgValue {
195 this.skipWs();
196 const ch = this.peek();
197 if (ch === '"') {
198 this.i++;
199 let out = "";
200 while (this.i < this.src.length && this.src[this.i] !== '"') {
201 if (this.src[this.i] === "\\") {
202 this.i++;
203 out += this.src[this.i];
204 } else {
205 out += this.src[this.i];
206 }
207 this.i++;
208 }
209 this.expect('"');
210 return out;
211 }
212 // number
213 if (ch && /[0-9-]/.test(ch)) {
214 let out = "";
215 while (this.i < this.src.length && /[0-9.-]/.test(this.src[this.i])) {
216 out += this.src[this.i];
217 this.i++;
218 }
219 return Number(out);
220 }
221 // boolean / null / enum
222 const w = this.readName();
223 if (w === "true") return true;
224 if (w === "false") return false;
225 if (w === "null") return null;
226 return w; // enum-ish
227 }
228}
229
230// ---------- Executor ----------
231
232type Resolver = (
233 args: Record<string, ArgValue>,
234 sel: Field[],
235 ctx: ExecCtx
236) => Promise<any>;
237
238export interface ExecCtx {
239 user: { id: string; username?: string } | null;
240}
241
242async function resolveSelections(
243 obj: Record<string, any>,
244 selections: Field[]
245): Promise<Record<string, any>> {
246 if (!obj || selections.length === 0) return obj;
247 const out: Record<string, any> = {};
248 for (const s of selections) {
249 const key = s.alias || s.name;
250 const val = obj[s.name];
251 if (val == null) {
252 out[key] = null;
253 continue;
254 }
255 if (Array.isArray(val)) {
256 out[key] = val.map((v) =>
257 typeof v === "object" ? resolveSelections(v, s.selections) : v
258 );
259 // Awaits inside map are wrapped individually above (resolveSelections
260 // returns a promise only if we call it async). Normalise here:
261 out[key] = await Promise.all(
262 val.map((v) =>
263 typeof v === "object"
264 ? resolveSelections(v, s.selections)
265 : Promise.resolve(v)
266 )
267 );
268 } else if (typeof val === "object") {
269 out[key] = await resolveSelections(val, s.selections);
270 } else {
271 out[key] = val;
272 }
273 }
274 return out;
275}
276
277const ROOT: Record<string, Resolver> = {
278 viewer: async (_args, sel, ctx) => {
279 if (!ctx.user) return null;
280 const [u] = await db
281 .select({
282 id: users.id,
283 username: users.username,
284 email: users.email,
285 })
286 .from(users)
287 .where(eq(users.id, ctx.user.id))
288 .limit(1);
289 return u ? resolveSelections(u, sel) : null;
290 },
291
292 user: async (args, sel, _ctx) => {
293 const username = String(args.username || "");
294 if (!username) return null;
295 const [u] = await db
296 .select({
297 id: users.id,
298 username: users.username,
299 email: users.email,
300 createdAt: users.createdAt,
301 })
302 .from(users)
303 .where(eq(users.username, username))
304 .limit(1);
305 if (!u) return null;
306 const needsRepos = sel.some((s) => s.name === "repos");
307 let repos: any[] = [];
308 if (needsRepos) {
309 repos = await db
310 .select({
311 id: repositories.id,
312 name: repositories.name,
313 visibility: repositories.visibility,
314 starCount: repositories.starCount,
315 createdAt: repositories.createdAt,
316 })
317 .from(repositories)
318 .where(
319 and(
320 eq(repositories.ownerId, u.id),
321 eq(repositories.visibility, "public")
322 )
323 )
324 .orderBy(desc(repositories.createdAt))
325 .limit(100);
326 }
327 return resolveSelections({ ...u, repos }, sel);
328 },
329
330 repository: async (args, sel, _ctx) => {
331 const owner = String(args.owner || "");
332 const name = String(args.name || "");
333 if (!owner || !name) return null;
334 const [r] = await db
335 .select({
336 id: repositories.id,
337 name: repositories.name,
338 description: repositories.description,
339 visibility: repositories.visibility,
340 starCount: repositories.starCount,
341 forkCount: repositories.forkCount,
342 createdAt: repositories.createdAt,
343 ownerId: repositories.ownerId,
344 ownerUsername: users.username,
345 })
346 .from(repositories)
347 .innerJoin(users, eq(repositories.ownerId, users.id))
348 .where(and(eq(users.username, owner), eq(repositories.name, name)))
349 .limit(1);
350 if (!r || r.visibility !== "public") return null;
351
352 const payload: Record<string, any> = {
353 ...r,
354 owner: { id: r.ownerId, username: r.ownerUsername },
355 };
356
357 const issuesSel = sel.find((s) => s.name === "issues");
358 if (issuesSel) {
359 const state = String(issuesSel.args.state || "open");
360 const limit = Math.min(100, Number(issuesSel.args.limit || 20));
361 payload.issues = await db
362 .select({
363 id: issues.id,
364 number: issues.number,
365 title: issues.title,
366 state: issues.state,
367 createdAt: issues.createdAt,
368 })
369 .from(issues)
370 .where(
371 and(eq(issues.repositoryId, r.id), eq(issues.state, state))
372 )
373 .orderBy(desc(issues.createdAt))
374 .limit(limit);
375 }
376
377 const prSel = sel.find((s) => s.name === "pullRequests");
378 if (prSel) {
379 const state = String(prSel.args.state || "open");
380 const limit = Math.min(100, Number(prSel.args.limit || 20));
381 payload.pullRequests = await db
382 .select({
383 id: pullRequests.id,
384 number: pullRequests.number,
385 title: pullRequests.title,
386 state: pullRequests.state,
387 createdAt: pullRequests.createdAt,
388 })
389 .from(pullRequests)
390 .where(
391 and(
392 eq(pullRequests.repositoryId, r.id),
393 eq(pullRequests.state, state)
394 )
395 )
396 .orderBy(desc(pullRequests.createdAt))
397 .limit(limit);
398 }
399
400 return resolveSelections(payload, sel);
401 },
402
403 search: async (args, sel, _ctx) => {
404 const q = String(args.q || "").trim();
405 if (!q) return [];
406 const limit = Math.min(50, Number(args.limit || 20));
407 const rows = await db
408 .select({
409 id: repositories.id,
410 name: repositories.name,
411 ownerUsername: users.username,
412 })
413 .from(repositories)
414 .innerJoin(users, eq(repositories.ownerId, users.id))
415 .where(
416 and(
417 eq(repositories.visibility, "public"),
418 or(
419 ilike(repositories.name, `%${q}%`),
420 ilike(repositories.description, `%${q}%`)
421 )!
422 )
423 )
424 .limit(limit);
425 return Promise.all(rows.map((r) => resolveSelections(r, sel)));
426 },
427
428 rateLimit: async (_args, _sel, _ctx) => {
429 // GraphQL doesn't share the REST rate-limit state directly. Surface a
430 // permissive synthetic window so clients can consume the shape.
431 return { remaining: 1000, reset: Math.floor(Date.now() / 1000) + 3600 };
432 },
433};
434
435export async function execute(
436 src: string,
437 ctx: ExecCtx
438): Promise<GqlResponse> {
439 const parsed = parseQuery(src);
440 if (!parsed.ok) {
441 return { errors: [{ message: parsed.error }] };
442 }
443 const out: Record<string, any> = {};
444 const errors: GqlError[] = [];
445 for (const field of parsed.fields) {
446 const resolver = ROOT[field.name];
447 const key = field.alias || field.name;
448 if (!resolver) {
449 errors.push({
450 message: `Unknown root field '${field.name}'`,
451 path: [key],
452 });
453 out[key] = null;
454 continue;
455 }
456 try {
457 const raw = await resolver(field.args, field.selections, ctx);
458 if (raw == null) {
459 out[key] = null;
460 continue;
461 }
462 if (Array.isArray(raw)) {
463 out[key] = await Promise.all(
464 raw.map((v) =>
465 typeof v === "object"
466 ? resolveSelections(v, field.selections)
467 : Promise.resolve(v)
468 )
469 );
470 } else if (typeof raw === "object") {
471 // resolver may already have expanded object to selection set; if not,
472 // do it now.
473 out[key] = await resolveSelections(raw, field.selections);
474 } else {
475 out[key] = raw;
476 }
477 } catch (err) {
478 errors.push({
479 message: (err as Error).message || "execution error",
480 path: [key],
481 });
482 out[key] = null;
483 }
484 }
485 const response: GqlResponse = { data: out };
486 if (errors.length > 0) response.errors = errors;
487 return response;
488}
Addedsrc/routes/graphql.ts+93−0View fileUnifiedSplit
@@ -0,0 +1,93 @@
1/**
2 * Block G2 — GraphQL HTTP endpoint.
3 *
4 * POST /api/graphql — execute { query } against the schema in `lib/graphql`
5 * GET /api/graphql — minimal in-browser "GraphiQL-lite" explorer
6 *
7 * Auth: softAuth only — the schema is queries-only and every resolver enforces
8 * visibility (only public repos surface for logged-out viewers). Writes live on
9 * the REST + /api endpoints.
10 */
11
12import { Hono } from "hono";
13import { softAuth } from "../middleware/auth";
14import type { AuthEnv } from "../middleware/auth";
15import { execute } from "../lib/graphql";
16
17const graphql = new Hono<AuthEnv>();
18graphql.use("*", softAuth);
19
20graphql.post("/api/graphql", async (c) => {
21 let body: { query?: string } = {};
22 try {
23 body = await c.req.json();
24 } catch {
25 return c.json({ errors: [{ message: "Invalid JSON body" }] }, 400);
26 }
27 const q = String(body.query || "");
28 if (!q.trim()) {
29 return c.json({ errors: [{ message: "query is required" }] }, 400);
30 }
31 const user = c.get("user") || null;
32 const result = await execute(q, { user: user ? { id: user.id, username: user.username } : null });
33 return c.json(result);
34});
35
36graphql.get("/api/graphql", (c) => {
37 const sample = `query {
38 viewer { id username email }
39 search(q: "ai", limit: 5) { id name ownerUsername }
40 rateLimit { remaining reset }
41}`;
42 const html = `<!doctype html>
43<html><head><meta charset="UTF-8" /><title>Gluecron GraphQL</title>
44<style>
45 body { background:#0d1117; color:#e6edf3; font-family:system-ui,sans-serif; margin:0; padding:20px; }
46 h1 { font-size:18px; margin:0 0 12px; }
47 .layout { display:grid; grid-template-columns:1fr 1fr; gap:12px; height:85vh; }
48 textarea, pre {
49 background:#161b22; color:#e6edf3; border:1px solid #30363d; border-radius:6px;
50 padding:12px; font-family:monospace; font-size:13px; width:100%; height:100%;
51 box-sizing:border-box; resize:none;
52 }
53 pre { overflow:auto; white-space:pre-wrap; }
54 button {
55 background:#238636; color:#fff; border:0; border-radius:6px;
56 padding:8px 16px; font-weight:600; cursor:pointer; margin-bottom:8px;
57 }
58 a { color:#58a6ff; }
59</style>
60</head><body>
61<h1>gluecron · GraphQL <a href="/">home</a></h1>
62<button onclick="run()">Run (Ctrl+Enter)</button>
63<div class="layout">
64 <textarea id="q" spellcheck="false">${sample.replace(/</g, "<")}</textarea>
65 <pre id="r">{ "hint": "Click Run" }</pre>
66</div>
67<script>
68async function run(){
69 const q = document.getElementById('q').value;
70 document.getElementById('r').textContent = 'Loading…';
71 try {
72 const r = await fetch('/api/graphql', {
73 method:'POST',
74 headers:{'content-type':'application/json'},
75 body: JSON.stringify({ query: q }),
76 credentials:'include'
77 });
78 const j = await r.json();
79 document.getElementById('r').textContent = JSON.stringify(j, null, 2);
80 } catch (e) {
81 document.getElementById('r').textContent = String(e);
82 }
83}
84document.getElementById('q').addEventListener('keydown', (e) => {
85 if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); run(); }
86});
87</script>
88</body></html>`;
89 c.header("content-type", "text/html; charset=utf-8");
90 return c.body(html);
91});
92
93export default graphql;
Addedsrc/routes/pwa.ts+134−0View fileUnifiedSplit
@@ -0,0 +1,134 @@
1/**
2 * Block G1 — PWA (progressive web app) support.
3 *
4 * GET /manifest.webmanifest — app manifest (install prompt)
5 * GET /sw.js — service worker (cache-first for static, network-first for HTML)
6 * GET /icon.svg — monochrome logo used by the manifest
7 *
8 * The service worker deliberately keeps the cache small (static CSS-in-JS is
9 * inlined so there's nothing to cache beyond the manifest + icon). HTML pages
10 * fall through to the network; cached copies only serve offline fallback.
11 *
12 * Adding `<link rel="manifest" href="/manifest.webmanifest">` + a tiny SW
13 * registration snippet to `Layout` turns any repo page into an installable
14 * PWA on Chrome/Safari.
15 */
16
17import { Hono } from "hono";
18
19const pwa = new Hono();
20
21export const MANIFEST = {
22 name: "Gluecron",
23 short_name: "Gluecron",
24 description: "AI-native code intelligence + git hosting",
25 start_url: "/",
26 scope: "/",
27 display: "standalone",
28 background_color: "#0d1117",
29 theme_color: "#0d1117",
30 icons: [
31 {
32 src: "/icon.svg",
33 sizes: "any",
34 type: "image/svg+xml",
35 purpose: "any maskable",
36 },
37 ],
38 categories: ["developer", "productivity"],
39} as const;
40
41pwa.get("/manifest.webmanifest", (c) => {
42 c.header("content-type", "application/manifest+json");
43 c.header("cache-control", "public, max-age=3600");
44 return c.body(JSON.stringify(MANIFEST));
45});
46
47const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
48 <rect width="128" height="128" rx="24" fill="#0d1117"/>
49 <g fill="#58a6ff" font-family="monospace" font-size="58" font-weight="700" text-anchor="middle">
50 <text x="64" y="82">gc</text>
51 </g>
52 <circle cx="28" cy="28" r="5" fill="#3fb950"/>
53</svg>`;
54
55pwa.get("/icon.svg", (c) => {
56 c.header("content-type", "image/svg+xml");
57 c.header("cache-control", "public, max-age=86400, immutable");
58 return c.body(ICON_SVG);
59});
60
61/**
62 * Bare-bones service worker. Offline behaviour:
63 * - HTML → network first, cached response on failure, fallback offline page
64 * - other → pass-through (the static CSS is inlined into the HTML, so there's
65 * no cross-request asset worth caching for v1)
66 */
67export const SERVICE_WORKER_SRC = `// gluecron service worker — v1
68const CACHE = 'gluecron-shell-v1';
69const SHELL = ['/', '/manifest.webmanifest', '/icon.svg'];
70
71self.addEventListener('install', (e) => {
72 e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).catch(() => {}));
73 self.skipWaiting();
74});
75
76self.addEventListener('activate', (e) => {
77 e.waitUntil(
78 caches.keys().then((keys) =>
79 Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
80 )
81 );
82 self.clients.claim();
83});
84
85self.addEventListener('fetch', (event) => {
86 const req = event.request;
87 if (req.method !== 'GET') return;
88 const url = new URL(req.url);
89 // Never intercept git, API, or auth endpoints — they must stay fresh.
90 if (
91 url.pathname.includes('.git/') ||
92 url.pathname.startsWith('/api/') ||
93 url.pathname.startsWith('/login') ||
94 url.pathname.startsWith('/logout') ||
95 url.pathname.startsWith('/register')
96 ) {
97 return;
98 }
99 const wantsHtml = req.headers.get('accept')?.includes('text/html');
100 if (wantsHtml) {
101 event.respondWith(
102 fetch(req)
103 .then((res) => {
104 const copy = res.clone();
105 caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
106 return res;
107 })
108 .catch(() => caches.match(req).then((hit) => hit || caches.match('/')))
109 );
110 }
111});
112`;
113
114pwa.get("/sw.js", (c) => {
115 c.header("content-type", "application/javascript");
116 c.header("cache-control", "public, max-age=60");
117 // Service-Worker-Allowed required for root-scope SW served from root
118 c.header("service-worker-allowed", "/");
119 return c.body(SERVICE_WORKER_SRC);
120});
121
122/**
123 * Inline script registering the SW. Loaded once at the bottom of every page.
124 * Kept tiny so we don't bloat TTI.
125 */
126export const PWA_REGISTER_SNIPPET = `
127if ('serviceWorker' in navigator) {
128 window.addEventListener('load', function() {
129 navigator.serviceWorker.register('/sw.js').catch(function() {});
130 });
131}
132`.trim();
133
134export default pwa;
Modifiedsrc/views/layout.tsx+14−0View fileUnifiedSplit
@@ -16,6 +16,9 @@ export const Layout: FC<
1616 <head>
1717 <meta charset="UTF-8" />
1818 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
19 <meta name="theme-color" content="#0d1117" />
20 <link rel="manifest" href="/manifest.webmanifest" />
21 <link rel="icon" type="image/svg+xml" href="/icon.svg" />
1922 <title>{title ? `${title} — gluecron` : "gluecron"}</title>
2023 <script>{themeInitScript}</script>
2124 <style>{css}</style>
@@ -98,6 +101,7 @@ export const Layout: FC<
98101 <span>gluecron — AI-native code intelligence</span>
99102 </footer>
100103 <script>{navScript}</script>
104 <script>{pwaRegisterScript}</script>
101105 </body>
102106 </html>
103107 );
@@ -116,6 +120,16 @@ const themeInitScript = `
116120 })();
117121`;
118122
123// Block G1 — register service worker for offline / install support.
124// Kept inline (and tiny) so we don't block first paint.
125const pwaRegisterScript = `
126 if ('serviceWorker' in navigator) {
127 window.addEventListener('load', function(){
128 navigator.serviceWorker.register('/sw.js').catch(function(){});
129 });
130 }
131`;
132
119133const navScript = `
120134 (function(){
121135 var chord = null;
Addedvscode-extension/README.md+40−0View fileUnifiedSplit
@@ -0,0 +1,40 @@
1# Gluecron — VS Code Extension
2
3Talk to your Gluecron server straight from the editor. Explain files, open on
4the web, run semantic searches, scaffold failing tests.
5
6## Install (dev)
7
8```
9cd vscode-extension
10npm install
11npm run compile
12code --install-extension .
13```
14
15## Configure
16
17Add to your `settings.json`:
18
19```json
20{
21 "gluecron.host": "https://gluecron.com",
22 "gluecron.token": "glc_..."
23}
24```
25
26Tokens come from `/settings/tokens` on your Gluecron instance.
27
28## Commands
29
30| Command | What it does |
31|---|---|
32| `Gluecron: Explain This File` | 3-5 bullet summary via `/api/copilot/completions` |
33| `Gluecron: Open Current File on Web` | Opens `./owner/repo/blob/main/<path>#L<line>` |
34| `Gluecron: Semantic Search` | Prompts for a query, hits `/api/graphql` |
35| `Gluecron: Generate Tests for Current File` | Scaffolds a failing test via `/ai/tests?format=raw` |
36
37## Repo detection
38
39The extension reads `git config --get remote.origin.url` and strips the host
40prefix. If you're using a self-hosted Gluecron, set `gluecron.host` accordingly.
Addedvscode-extension/package.json+68−0View fileUnifiedSplit
@@ -0,0 +1,68 @@
1{
2 "name": "gluecron-vscode",
3 "displayName": "Gluecron",
4 "description": "VS Code integration for Gluecron — AI-native code intelligence",
5 "version": "0.1.0",
6 "publisher": "gluecron",
7 "license": "MIT",
8 "engines": {
9 "vscode": "^1.80.0"
10 },
11 "categories": [
12 "SCM Providers",
13 "Other"
14 ],
15 "activationEvents": [
16 "onStartupFinished"
17 ],
18 "main": "./dist/extension.js",
19 "contributes": {
20 "commands": [
21 {
22 "command": "gluecron.explainFile",
23 "title": "Gluecron: Explain This File"
24 },
25 {
26 "command": "gluecron.openOnWeb",
27 "title": "Gluecron: Open Current File on Web"
28 },
29 {
30 "command": "gluecron.searchSemantic",
31 "title": "Gluecron: Semantic Search"
32 },
33 {
34 "command": "gluecron.generateTests",
35 "title": "Gluecron: Generate Tests for Current File"
36 }
37 ],
38 "configuration": {
39 "title": "Gluecron",
40 "properties": {
41 "gluecron.host": {
42 "type": "string",
43 "default": "http://localhost:3000",
44 "description": "Gluecron server URL"
45 },
46 "gluecron.token": {
47 "type": "string",
48 "default": "",
49 "description": "Personal access token (glc_...)"
50 }
51 }
52 },
53 "menus": {
54 "editor/context": [
55 { "command": "gluecron.explainFile", "group": "gluecron" },
56 { "command": "gluecron.openOnWeb", "group": "gluecron" }
57 ]
58 }
59 },
60 "scripts": {
61 "compile": "tsc -p .",
62 "watch": "tsc -watch -p ."
63 },
64 "devDependencies": {
65 "@types/vscode": "^1.80.0",
66 "typescript": "^5.3.0"
67 }
68}
Addedvscode-extension/src/extension.ts+209−0View fileUnifiedSplit
@@ -0,0 +1,209 @@
1/**
2 * Block G4 — Gluecron VS Code extension.
3 *
4 * Commands:
5 * gluecron.explainFile — call `/api/v1/ai/explain-file` + show in a hover
6 * gluecron.openOnWeb — opens the current file on the Gluecron web UI
7 * gluecron.searchSemantic — quickPick -> /api/v1/search/semantic
8 * gluecron.generateTests — scaffold a failing test via /api/v1/ai/tests
9 *
10 * We keep this file zero-runtime-dependencies besides `vscode`. Everything
11 * else is pure stdlib + fetch.
12 */
13
14import * as vscode from "vscode";
15import { execSync } from "node:child_process";
16import { basename, relative } from "node:path";
17
18function getHost(): string {
19 return (
20 vscode.workspace.getConfiguration("gluecron").get<string>("host") ||
21 "http://localhost:3000"
22 );
23}
24
25function getToken(): string {
26 return (
27 vscode.workspace.getConfiguration("gluecron").get<string>("token") || ""
28 );
29}
30
31async function api<T = any>(path: string, init?: RequestInit): Promise<T> {
32 const url = getHost().replace(/\/+$/, "") + path;
33 const token = getToken();
34 const res = await fetch(url, {
35 ...init,
36 headers: {
37 "content-type": "application/json",
38 accept: "application/json",
39 ...(token ? { authorization: `Bearer ${token}` } : {}),
40 ...(init?.headers || {}),
41 },
42 });
43 const text = await res.text();
44 if (!res.ok) throw new Error(`[${res.status}] ${text.slice(0, 200)}`);
45 try {
46 return JSON.parse(text) as T;
47 } catch {
48 return text as unknown as T;
49 }
50}
51
52/**
53 * Inspect the current workspace for a git remote whose URL matches a Gluecron
54 * host. Returns `{ owner, repo }` if found.
55 */
56export function detectGlueRepo(cwd: string, host: string): {
57 owner: string;
58 repo: string;
59} | null {
60 try {
61 const url = execSync("git config --get remote.origin.url", {
62 cwd,
63 encoding: "utf8",
64 }).trim();
65 if (!url) return null;
66 // host-agnostic parsing — look for /:owner/:repo at end
67 const cleaned = url
68 .replace(/^https?:\/\/[^/]+\//, "")
69 .replace(/^git@[^:]+:/, "")
70 .replace(/\.git$/, "");
71 const [owner, repo] = cleaned.split("/").filter(Boolean);
72 if (!owner || !repo) return null;
73 return { owner, repo };
74 } catch {
75 return null;
76 }
77}
78
79export function buildWebUrl(
80 host: string,
81 owner: string,
82 repo: string,
83 relPath: string,
84 line?: number
85): string {
86 const base = `${host.replace(/\/+$/, "")}/${owner}/${repo}/blob/main/${relPath}`;
87 return line ? `${base}#L${line + 1}` : base;
88}
89
90export function activate(context: vscode.ExtensionContext) {
91 const output = vscode.window.createOutputChannel("Gluecron");
92 output.appendLine("Gluecron activated.");
93
94 context.subscriptions.push(
95 vscode.commands.registerCommand("gluecron.openOnWeb", async () => {
96 const editor = vscode.window.activeTextEditor;
97 if (!editor) {
98 vscode.window.showInformationMessage("No active editor");
99 return;
100 }
101 const folder = vscode.workspace.getWorkspaceFolder(editor.document.uri);
102 if (!folder) {
103 vscode.window.showWarningMessage("File is not in a workspace");
104 return;
105 }
106 const cwd = folder.uri.fsPath;
107 const rel = relative(cwd, editor.document.uri.fsPath);
108 const repo = detectGlueRepo(cwd, getHost());
109 if (!repo) {
110 vscode.window.showWarningMessage("No Gluecron remote detected");
111 return;
112 }
113 const url = buildWebUrl(
114 getHost(),
115 repo.owner,
116 repo.repo,
117 rel,
118 editor.selection.active.line
119 );
120 vscode.env.openExternal(vscode.Uri.parse(url));
121 })
122 );
123
124 context.subscriptions.push(
125 vscode.commands.registerCommand("gluecron.explainFile", async () => {
126 const editor = vscode.window.activeTextEditor;
127 if (!editor) return;
128 const content = editor.document.getText();
129 const path = basename(editor.document.fileName);
130 output.show(true);
131 output.appendLine(`Explaining ${path}...`);
132 try {
133 const res = await api<{ explanation?: string }>(
134 `/api/copilot/completions`,
135 {
136 method: "POST",
137 body: JSON.stringify({
138 prompt: `Explain this file in 3-5 bullet points:\n\n${content.slice(
139 0,
140 8000
141 )}`,
142 max_tokens: 400,
143 }),
144 }
145 );
146 output.appendLine(res.explanation || JSON.stringify(res, null, 2));
147 } catch (err) {
148 output.appendLine(`error: ${(err as Error).message}`);
149 }
150 })
151 );
152
153 context.subscriptions.push(
154 vscode.commands.registerCommand("gluecron.searchSemantic", async () => {
155 const q = await vscode.window.showInputBox({
156 prompt: "Semantic search across repo",
157 placeHolder: "how does auth work?",
158 });
159 if (!q) return;
160 const folder = vscode.workspace.workspaceFolders?.[0];
161 if (!folder) return;
162 const repo = detectGlueRepo(folder.uri.fsPath, getHost());
163 if (!repo) return;
164 const res = await api<{ results?: Array<{ path: string; score: number }> }>(
165 `/api/graphql`,
166 {
167 method: "POST",
168 body: JSON.stringify({
169 query: `{ search(q:"${q.replace(/"/g, "'")}", limit:10) { name ownerUsername } }`,
170 }),
171 }
172 );
173 output.show(true);
174 output.appendLine(JSON.stringify(res, null, 2));
175 })
176 );
177
178 context.subscriptions.push(
179 vscode.commands.registerCommand("gluecron.generateTests", async () => {
180 const editor = vscode.window.activeTextEditor;
181 if (!editor) return;
182 const folder = vscode.workspace.getWorkspaceFolder(editor.document.uri);
183 if (!folder) return;
184 const repo = detectGlueRepo(folder.uri.fsPath, getHost());
185 if (!repo) return;
186 const rel = relative(folder.uri.fsPath, editor.document.uri.fsPath);
187 try {
188 const res = await api(
189 `/${repo.owner}/${repo.repo}/ai/tests?format=raw&path=${encodeURIComponent(
190 rel
191 )}`
192 );
193 const doc = await vscode.workspace.openTextDocument({
194 content: typeof res === "string" ? res : JSON.stringify(res, null, 2),
195 language: editor.document.languageId,
196 });
197 vscode.window.showTextDocument(doc);
198 } catch (err) {
199 vscode.window.showErrorMessage(
200 `Gluecron test generation failed: ${(err as Error).message}`
201 );
202 }
203 })
204 );
205}
206
207export function deactivate() {
208 // nothing to clean up
209}
Addedvscode-extension/tsconfig.json+15−0View fileUnifiedSplit
@@ -0,0 +1,15 @@
1{
2 "compilerOptions": {
3 "module": "commonjs",
4 "target": "ES2020",
5 "outDir": "dist",
6 "rootDir": "src",
7 "lib": ["ES2020"],
8 "strict": true,
9 "esModuleInterop": true,
10 "skipLibCheck": true,
11 "resolveJsonModule": true
12 },
13 "include": ["src/**/*"],
14 "exclude": ["node_modules", "dist"]
15}
016