Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit2cecc08

feat(ops): doctor + agent-journey — one-shot public-readiness certification

feat(ops): doctor + agent-journey — one-shot public-readiness certification

- scripts/doctor.ts (bun run doctor): endpoint smoke (reuses the
  deploy-gate 15), OAuth provider surface (discovery, DCR round-trip,
  token-endpoint error semantics), MCP anonymous surface + RFC 6750
  invalid_token proof, authenticated MCP reads, --full read-only tool
  matrix. One pass/fail table per section, exit 0 iff all green.
- scripts/agent-journey.ts: simulates an external AI platform end to
  end — PAT identity, scratch repo, git push over smart HTTP with
  token basic-auth, MCP create_branch/write_file/create_pr/comment_pr,
  AI-review poll (warn-only), merge_pr + verify, activity feed, and
  guaranteed cleanup. Hard guard against ever touching the self-host
  repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ccanty labs committed on July 14, 2026Parent: b5a7bb3
3 files changed+49902cecc08b6a3d46b7e95841f90a85a4cfe53dc254
3 changed files+499−0
Modifiedpackage.json+1−0View fileUnifiedSplit
2020 "test": "bun test",
2121 "typecheck": "tsc --noEmit",
2222 "preflight": "bun scripts/preflight.ts",
23 "doctor": "bun scripts/doctor.ts",
2324 "e2e": "playwright test --config=e2e/playwright.config.ts"
2425 },
2526 "dependencies": {
Addedscripts/agent-journey.ts+226−0View fileUnifiedSplit
1// Agent journey — simulate an EXTERNAL AI platform driving a full PR
2// lifecycle against a live Gluecron instance, using exactly the surface
3// such a platform would use: a PAT bearer on MCP tools + git-over-HTTPS
4// with token basic-auth. This is the go/no-go certification for "other
5// platforms can open and merge PRs here".
6//
7// 1. identity GET /api/v2/user
8// 2. create repo POST /api/v2/repos (no MCP create-repo tool exists — platform gap, noted)
9// 3. git push smart-HTTP receive-pack with PAT basic auth
10// 4. branch + edit gluecron_create_branch + gluecron_write_file
11// 5. open PR gluecron_create_pr
12// 6. comment gluecron_comment_pr
13// 7. AI review poll PR comments for the ai-review marker (WARN if absent)
14// 8. merge gluecron_merge_pr → gluecron_get_pr state=merged
15// 9. events GET /api/v2/repos/:o/:r/activity
16// 10. cleanup gluecron_delete_repo (always, unless KEEP_REPO=1)
17//
18// Run: bun run scripts/agent-journey.ts
19// Env: GLUECRON_PAT REQUIRED — admin-scope PAT (merge + delete need it)
20// GLUECRON_HOST target (default https://gluecron.com)
21// KEEP_REPO=1 skip cleanup for debugging
22// Exit: 0 iff all steps pass (step 7 is WARN-only — AI review may be
23// legitimately disabled). Never touches any existing repo.
24
25import { AI_REVIEW_MARKER } from "../src/lib/ai-review";
26import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
27import { tmpdir } from "node:os";
28import { join } from "node:path";
29
30const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
31const PAT = process.env.GLUECRON_PAT?.trim() || "";
32const KEEP = process.env.KEEP_REPO === "1";
33
34if (!PAT) {
35 console.error("GLUECRON_PAT is required (admin scope). Mint one at /settings/tokens or via scripts/emergency-pat.ts.");
36 process.exit(2);
37}
38
39// Hard safety guard: the scratch repo name is generated here and must never
40// collide with the self-host repo (its post-receive hook triggers deploys).
41const REPO = `journey-${new Date().toISOString().slice(0, 19).replace(/[:T-]/g, "")}-${Math.random().toString(36).slice(2, 6)}`;
42const FORBIDDEN = /gluecron\.com/i;
43if (FORBIDDEN.test(REPO)) throw new Error("unreachable: scratch repo name collided with self-host repo");
44
45// ─── plumbing ────────────────────────────────────────────────────────
46
47async function api(method: string, path: string, body?: unknown) {
48 const res = await fetch(HOST + path, {
49 method,
50 headers: {
51 authorization: `Bearer ${PAT}`,
52 ...(body !== undefined ? { "content-type": "application/json" } : {}),
53 },
54 ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
55 });
56 const text = await res.text();
57 let json: any = null;
58 try { json = JSON.parse(text); } catch { /* non-JSON */ }
59 return { status: res.status, json, text };
60}
61
62let rpcId = 0;
63async function mcp(tool: string, args: Record<string, unknown>) {
64 const res = await fetch(HOST + "/mcp", {
65 method: "POST",
66 headers: { authorization: `Bearer ${PAT}`, "content-type": "application/json" },
67 body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: tool, arguments: args } }),
68 });
69 const json: any = await res.json();
70 if (res.status !== 200 || json.error) {
71 throw new Error(`${tool} → HTTP ${res.status} ${JSON.stringify(json.error ?? json).slice(0, 300)}`);
72 }
73 if (json.result?.isError) {
74 throw new Error(`${tool} → tool error: ${JSON.stringify(json.result.content).slice(0, 300)}`);
75 }
76 // MCP text content is JSON-stringified tool output; parse when possible.
77 const textPart = (json.result?.content ?? []).find((p: any) => p.type === "text")?.text;
78 try { return JSON.parse(textPart); } catch { return textPart; }
79}
80
81function git(cwd: string, ...args: string[]): string {
82 const proc = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
83 const out = new TextDecoder().decode(proc.stdout);
84 const err = new TextDecoder().decode(proc.stderr);
85 if (proc.exitCode !== 0) throw new Error(`git ${args[0]} failed (${proc.exitCode}): ${(err || out).slice(0, 300)}`);
86 return out.trim();
87}
88
89interface StepResult { name: string; ok: boolean; warn?: boolean; ms: number; detail?: string }
90const results: StepResult[] = [];
91
92async function step<T>(name: string, fn: () => Promise<T>, opts: { warnOnly?: boolean } = {}): Promise<T | null> {
93 const t0 = Date.now();
94 try {
95 const v = await fn();
96 results.push({ name, ok: true, ms: Date.now() - t0 });
97 console.log(`PASS ${name} (${Date.now() - t0}ms)`);
98 return v;
99 } catch (err) {
100 const detail = (err as Error).message;
101 results.push({ name, ok: !!opts.warnOnly, warn: opts.warnOnly, ms: Date.now() - t0, detail });
102 console.log(`${opts.warnOnly ? "WARN" : "FAIL"} ${name}${detail}`);
103 if (!opts.warnOnly) throw err;
104 return null;
105 }
106}
107
108// ─── the journey ─────────────────────────────────────────────────────
109
110async function main() {
111 console.log(`# agent-journey — ${HOST} — scratch repo "${REPO}" — ${new Date().toISOString()}\n`);
112 let owner = "";
113 let repoCreated = false;
114
115 try {
116 // 1. identity
117 const user = await step("1. PAT identity (GET /api/v2/user)", async () => {
118 const r = await api("GET", "/api/v2/user");
119 if (r.status !== 200 || !r.json?.username) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
120 return r.json;
121 });
122 owner = user!.username;
123 if (`${owner}/${REPO}`.toLowerCase() === "ccantynz/gluecron.com") throw new Error("safety: refusing to touch the self-host repo");
124
125 // 2. create scratch repo
126 await step("2. create scratch repo (POST /api/v2/repos)", async () => {
127 const r = await api("POST", "/api/v2/repos", { name: REPO, description: "agent-journey probe — auto-deleted", private: false });
128 if (r.status !== 200 && r.status !== 201) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
129 repoCreated = true;
130 });
131
132 // 3. git push over smart HTTP with PAT basic auth
133 const pushUrl = `${HOST.replace("://", `://${encodeURIComponent(owner)}:${PAT}@`)}/${owner}/${REPO}.git`;
134 const mainSha = await step("3. git push main over HTTPS (PAT basic auth)", async () => {
135 const dir = mkdtempSync(join(tmpdir(), "gluecron-journey-"));
136 try {
137 git(dir, "init", "-b", "main");
138 git(dir, "config", "user.email", "journey@gluecron.com");
139 git(dir, "config", "user.name", "agent-journey");
140 writeFileSync(join(dir, "README.md"), `# ${REPO}\n\nExternal-platform journey probe.\n`);
141 git(dir, "add", ".");
142 git(dir, "commit", "-m", "chore: journey seed commit");
143 const sha = git(dir, "rev-parse", "HEAD");
144 git(dir, "push", pushUrl, "main");
145 return sha;
146 } finally {
147 rmSync(dir, { recursive: true, force: true });
148 }
149 });
150
151 // 4. branch + file via MCP write tools
152 await step("4a. gluecron_create_branch", () => mcp("gluecron_create_branch", { owner, repo: REPO, branch: "feature/journey", sha: mainSha }));
153 await step("4b. gluecron_write_file on branch", () => mcp("gluecron_write_file", { owner, repo: REPO, path: "probe.md", branch: "feature/journey", message: "feat: journey probe file", content: `probe ${new Date().toISOString()}\n` }));
154
155 // 5. open PR
156 const pr = await step("5. gluecron_create_pr", () => mcp("gluecron_create_pr", { owner, repo: REPO, title: "Journey probe PR", body: "Opened by scripts/agent-journey.ts — will be merged and the repo deleted.", head_branch: "feature/journey", base_branch: "main" }));
157 const prNumber: number = pr!.number;
158
159 // 6. comment
160 await step("6. gluecron_comment_pr", () => mcp("gluecron_comment_pr", { owner, repo: REPO, number: prNumber, body: `Journey probe comment ${new Date().toISOString()}` }));
161
162 // 7. AI review (WARN-only: may be disabled / unfunded)
163 await step("7. AI review fired (poll for marker, ≤90s)", async () => {
164 const deadline = Date.now() + 90_000;
165 while (Date.now() < deadline) {
166 const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/pulls/${prNumber}`);
167 const comments: Array<{ body?: string; isAiReview?: boolean }> = r.json?.comments ?? [];
168 if (comments.some((cm) => cm.isAiReview || cm.body?.includes(AI_REVIEW_MARKER))) return true;
169 await new Promise((res) => setTimeout(res, 5000));
170 }
171 throw new Error("no AI-review comment within 90s (review disabled, or ANTHROPIC_API_KEY unset?)");
172 }, { warnOnly: true });
173
174 // 8. merge + verify
175 await step("8a. gluecron_merge_pr", async () => {
176 const m = await mcp("gluecron_merge_pr", { owner, repo: REPO, number: prNumber });
177 if (!m?.merged) throw new Error(`not merged: ${JSON.stringify(m).slice(0, 200)}`);
178 return m;
179 });
180 await step("8b. PR state is merged + main advanced", async () => {
181 const detail = await mcp("gluecron_get_pr", { owner, repo: REPO, number: prNumber });
182 const state = detail?.state ?? detail?.pr?.state;
183 if (state !== "merged") throw new Error(`PR state: ${state}`);
184 const dir = mkdtempSync(join(tmpdir(), "gluecron-lsr-"));
185 try {
186 git(dir, "init");
187 const head = git(dir, "ls-remote", pushUrl, "refs/heads/main").split(/\s/)[0];
188 if (head === mainSha) throw new Error("main did not advance past the seed commit");
189 } finally {
190 rmSync(dir, { recursive: true, force: true });
191 }
192 });
193
194 // 9. events
195 await step("9. activity feed recorded push + PR events", async () => {
196 const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/activity`);
197 if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
198 const events: Array<{ type?: string }> = r.json?.events ?? r.json?.activity ?? [];
199 if (!Array.isArray(events) || events.length === 0) throw new Error("no activity events");
200 });
201 } finally {
202 // 10. cleanup — always attempt when the repo was created
203 if (repoCreated && !KEEP) {
204 try {
205 await mcp("gluecron_delete_repo", { owner, repo: REPO });
206 console.log(`PASS 10. cleanup — deleted ${owner}/${REPO}`);
207 results.push({ name: "10. cleanup (gluecron_delete_repo)", ok: true, ms: 0 });
208 } catch (err) {
209 console.error(`FAIL 10. cleanup — LEFTOVER REPO ${owner}/${REPO}: ${(err as Error).message}`);
210 results.push({ name: "10. cleanup (gluecron_delete_repo)", ok: false, ms: 0, detail: `LEFTOVER ${owner}/${REPO}` });
211 }
212 } else if (repoCreated) {
213 console.log(`KEEP 10. cleanup skipped (KEEP_REPO=1) — ${owner}/${REPO}`);
214 }
215 }
216
217 const failed = results.filter((r) => !r.ok).length;
218 const warned = results.filter((r) => r.warn).length;
219 console.log(`\n# agent-journey: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${warned ? ` — ${warned} warning(s)` : ""}`);
220 process.exit(failed === 0 ? 0 : 1);
221}
222
223main().catch((err) => {
224 console.error(`\n# agent-journey: FAILED — ${(err as Error).message}`);
225 process.exit(1);
226});
Addedscripts/doctor.ts+272−0View fileUnifiedSplit
1// Doctor — one-shot public-readiness audit of a LIVE Gluecron instance.
2//
3// Certifies the surface an external AI platform needs: endpoint health,
4// the OAuth provider (discovery, DCR, token-endpoint error semantics),
5// and the MCP server (anonymous handshake, auth challenges per RFC 6750,
6// authenticated tool calls, tool-manifest drift vs local code).
7//
8// Run: bun run scripts/doctor.ts [--full] [--md]
9// Env: GLUECRON_HOST target instance (default https://gluecron.com)
10// GLUECRON_PAT optional PAT — enables the authenticated sections
11// (without it they report SKIP, not FAIL)
12// Exit: 0 iff every non-skipped check passes.
13//
14// Companion: scripts/agent-journey.ts exercises the full write path
15// (push → PR → review → merge). Keep doctor read-only + residue-free,
16// except one recognizably-named `doctor-*` OAuth client from the DCR
17// round-trip (RFC 7592 delete isn't implemented).
18
19import {
20 runChecks,
21 formatTable,
22 CHECKS,
23 type CheckResult,
24} from "../src/lib/post-deploy-smoke";
25import { defaultTools } from "../src/lib/mcp-tools";
26
27const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
28const PAT = process.env.GLUECRON_PAT?.trim() || "";
29const FULL = process.argv.includes("--full");
30const MD = process.argv.includes("--md");
31
32interface Row extends CheckResult {
33 skipped?: boolean;
34}
35
36function row(name: string, ok: boolean, opts: Partial<Row> = {}): Row {
37 return { name, url: opts.url ?? "", status: opts.status ?? 0, durationMs: opts.durationMs ?? 0, ok, ...(opts.error ? { error: opts.error } : {}), ...(opts.skipped ? { skipped: true } : {}) };
38}
39
40function skip(name: string, why: string): Row {
41 return { name, url: "", status: 0, durationMs: 0, ok: true, skipped: true, error: why };
42}
43
44async function timed<T>(fn: () => Promise<T>): Promise<[T, number]> {
45 const t0 = Date.now();
46 const v = await fn();
47 return [v, Date.now() - t0];
48}
49
50async function jget(path: string, headers: Record<string, string> = {}) {
51 const res = await fetch(HOST + path, { headers });
52 const text = await res.text();
53 let json: any = null;
54 try { json = JSON.parse(text); } catch { /* non-JSON */ }
55 return { status: res.status, headers: res.headers, text, json };
56}
57
58async function jpost(path: string, body: unknown, headers: Record<string, string> = {}) {
59 const res = await fetch(HOST + path, {
60 method: "POST",
61 headers: { "content-type": "application/json", ...headers },
62 body: JSON.stringify(body),
63 });
64 const text = await res.text();
65 let json: any = null;
66 try { json = JSON.parse(text); } catch { /* non-JSON */ }
67 return { status: res.status, headers: res.headers, text, json };
68}
69
70let rpcId = 0;
71async function mcpRpc(method: string, params?: unknown, bearer?: string) {
72 const headers: Record<string, string> = {};
73 if (bearer) headers.authorization = `Bearer ${bearer}`;
74 return jpost("/mcp", { jsonrpc: "2.0", id: ++rpcId, method, ...(params !== undefined ? { params } : {}) }, headers);
75}
76
77async function mcpCall(tool: string, args: Record<string, unknown>, bearer?: string) {
78 return mcpRpc("tools/call", { name: tool, arguments: args }, bearer);
79}
80
81// ─── Section (a): endpoint smoke — the deploy-gate 15 ───────────────
82
83async function sectionSmoke(): Promise<Row[]> {
84 const summary = await runChecks({ baseUrl: HOST, fetchImpl: fetch as any, checks: CHECKS, log: () => undefined });
85 return summary.results;
86}
87
88// ─── Section (b): OAuth provider surface ────────────────────────────
89
90async function sectionOauth(): Promise<Row[]> {
91 const out: Row[] = [];
92
93 const [as, asMs] = await timed(() => jget("/.well-known/oauth-authorization-server"));
94 const grants: string[] = as.json?.grant_types_supported ?? [];
95 out.push(row("as-metadata advertises refresh_token", as.status === 200 && grants.includes("refresh_token"), {
96 url: "/.well-known/oauth-authorization-server", status: as.status, durationMs: asMs,
97 error: as.status !== 200 ? "not 200" : grants.includes("refresh_token") ? undefined : `grants: ${grants.join(",")}`,
98 }));
99 out.push(row("as-metadata has registration_endpoint", Boolean(as.json?.registration_endpoint), { status: as.status }));
100
101 for (const p of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"]) {
102 const [pr, prMs] = await timed(() => jget(p));
103 out.push(row(`protected-resource ${p.endsWith("mcp") ? "(mcp)" : "(root)"}`, pr.status === 200 && typeof pr.json?.resource === "string", { url: p, status: pr.status, durationMs: prMs }));
104 }
105
106 // DCR round-trip. Residue: one client row named doctor-<ts>.
107 const [reg, regMs] = await timed(() =>
108 jpost("/oauth/register", {
109 client_name: `doctor-${new Date().toISOString().replace(/[:.]/g, "-")}`,
110 redirect_uris: ["https://localhost/doctor-callback"],
111 token_endpoint_auth_method: "none",
112 })
113 );
114 const clientId: string | undefined = reg.json?.client_id;
115 out.push(row("DCR round-trip returns client_id", (reg.status === 200 || reg.status === 201) && Boolean(clientId), {
116 url: "/oauth/register", status: reg.status, durationMs: regMs, error: clientId ? undefined : reg.text.slice(0, 120),
117 }));
118
119 // Token endpoint error semantics (the signals refresh clients key off).
120 if (clientId) {
121 const badCode = await jpost("/oauth/token", { grant_type: "authorization_code", code: "garbage", client_id: clientId, redirect_uri: "https://localhost/doctor-callback", code_verifier: "x".repeat(43) });
122 out.push(row("token: garbage code → invalid_grant", badCode.status === 400 && badCode.json?.error === "invalid_grant", { status: badCode.status, error: badCode.json?.error }));
123
124 const badRefresh = await jpost("/oauth/token", { grant_type: "refresh_token", refresh_token: "glcr_garbage", client_id: clientId });
125 out.push(row("token: garbage refresh → invalid_grant", badRefresh.status === 400 && badRefresh.json?.error === "invalid_grant", { status: badRefresh.status, error: badRefresh.json?.error }));
126
127 const badGrant = await jpost("/oauth/token", { grant_type: "password", client_id: clientId });
128 out.push(row("token: bogus grant_type → unsupported_grant_type", badGrant.status === 400 && badGrant.json?.error === "unsupported_grant_type", { status: badGrant.status, error: badGrant.json?.error }));
129 } else {
130 out.push(skip("token endpoint error semantics", "no client_id from DCR"));
131 }
132
133 return out;
134}
135
136// ─── Section (c): MCP anonymous surface + RFC 6750 challenges ───────
137
138async function sectionMcpAnon(): Promise<Row[]> {
139 const out: Row[] = [];
140
141 const [init, initMs] = await timed(() => mcpRpc("initialize"));
142 out.push(row("anonymous initialize → 200", init.status === 200 && Boolean(init.json?.result?.protocolVersion), { status: init.status, durationMs: initMs }));
143
144 const [list, listMs] = await timed(() => mcpRpc("tools/list"));
145 const liveNames: string[] = (list.json?.result?.tools ?? []).map((t: any) => t.name).sort();
146 const localNames = Object.keys(defaultTools()).sort();
147 const missing = localNames.filter((n) => !liveNames.includes(n));
148 const extra = liveNames.filter((n) => !localNames.includes(n));
149 out.push(row(`tools/list matches local manifest (${localNames.length} tools)`, list.status === 200 && missing.length === 0 && extra.length === 0, {
150 status: list.status, durationMs: listMs,
151 error: missing.length || extra.length ? `live missing: [${missing.join(",")}] live extra: [${extra.join(",")}]` : undefined,
152 }));
153
154 const anon = await mcpCall("gluecron_repo_search", { query: "x" });
155 const anonWww = anon.headers.get("www-authenticate") || "";
156 out.push(row("anonymous tools/call → 401 challenge (no error=)", anon.status === 401 && anonWww.includes("resource_metadata=") && !anonWww.includes("error="), {
157 status: anon.status, error: anonWww ? undefined : "missing WWW-Authenticate",
158 }));
159
160 // Live proof of the 2026-07-14 fix: dead bearer must signal invalid_token
161 // on the HANDSHAKE, not just tools/call — this is what breaks the
162 // reconnect loop.
163 for (const method of ["initialize", "tools/list"]) {
164 const dead = await mcpRpc(method, undefined, "glct_deadbeef");
165 const www = dead.headers.get("www-authenticate") || "";
166 out.push(row(`dead bearer on ${method} → 401 invalid_token`, dead.status === 401 && www.includes('error="invalid_token"'), {
167 status: dead.status, error: dead.status === 401 ? (www.includes('error="invalid_token"') ? undefined : "no invalid_token attr") : "not 401 (fix not deployed?)",
168 }));
169 }
170
171 return out;
172}
173
174// ─── Section (d): MCP authenticated reads (needs GLUECRON_PAT) ──────
175
176async function sectionMcpAuthed(): Promise<Row[]> {
177 if (!PAT) return [skip("authenticated MCP reads", "GLUECRON_PAT not set")];
178 const out: Row[] = [];
179
180 const probes: Array<[string, Record<string, unknown>]> = [
181 ["gluecron_search_repos", { query: "gluecron" }],
182 ["gluecron_repo_health", { owner: "ccantynz", repo: "Gluecron.com" }],
183 ["gluecron_list_prs", { owner: "ccantynz", repo: "Gluecron.com", state: "open" }],
184 ];
185 for (const [tool, args] of probes) {
186 const [res, ms] = await timed(() => mcpCall(tool, args, PAT));
187 const ok = res.status === 200 && !res.json?.error && !res.json?.result?.isError;
188 out.push(row(`authed ${tool}`, ok, { status: res.status, durationMs: ms, error: ok ? undefined : JSON.stringify(res.json?.error ?? res.json?.result)?.slice(0, 140) }));
189 }
190 return out;
191}
192
193// ─── Section (e): --full read-only tool matrix ──────────────────────
194
195// Canned args per read-only tool; write tools belong to agent-journey.ts.
196const READ_MATRIX: Record<string, Record<string, unknown>> = {
197 gluecron_repo_search: { query: "gluecron" },
198 gluecron_search_repos: { query: "gluecron" },
199 gluecron_repo_health: { owner: "ccantynz", repo: "Gluecron.com" },
200 gluecron_list_prs: { owner: "ccantynz", repo: "Gluecron.com", state: "open" },
201 gluecron_search_prs: { owner: "ccantynz", repo: "Gluecron.com", query: "fix" },
202 gluecron_repo_list_issues: { owner: "ccantynz", repo: "Gluecron.com", state: "open" },
203 gluecron_search_issues: { owner: "ccantynz", repo: "Gluecron.com", query: "deploy" },
204 gluecron_list_tree: { owner: "ccantynz", repo: "Gluecron.com", path: "" },
205 gluecron_repo_read_file: { owner: "ccantynz", repo: "Gluecron.com", path: "README.md" },
206 gluecron_read_file: { owner: "ccantynz", repo: "Gluecron.com", path: "README.md" },
207 gluecron_clone_url: { owner: "ccantynz", repo: "Gluecron.com" },
208 gluecron_pr_status_summary: { owner: "ccantynz", repo: "Gluecron.com" },
209};
210
211async function sectionMatrix(): Promise<Row[]> {
212 if (!FULL) return [skip("read-only tool matrix", "pass --full to run")];
213 if (!PAT) return [skip("read-only tool matrix", "GLUECRON_PAT not set")];
214 const out: Row[] = [];
215 for (const [tool, args] of Object.entries(READ_MATRIX)) {
216 const [res, ms] = await timed(() => mcpCall(tool, args, PAT));
217 const ok = res.status === 200 && !res.json?.error && !res.json?.result?.isError;
218 out.push(row(`matrix ${tool}`, ok, { status: res.status, durationMs: ms, error: ok ? undefined : JSON.stringify(res.json?.error ?? res.json?.result)?.slice(0, 140) }));
219 }
220 return out;
221}
222
223// ─── Main ───────────────────────────────────────────────────────────
224
225async function main() {
226 console.log(`# Gluecron doctor — ${HOST}${new Date().toISOString()}`);
227 console.log(`# PAT: ${PAT ? "present" : "absent (authed sections will SKIP)"} mode: ${FULL ? "full" : "standard"}\n`);
228
229 const sections: Array<[string, () => Promise<Row[]>]> = [
230 ["a. Endpoint smoke (deploy-gate 15)", sectionSmoke],
231 ["b. OAuth provider surface", sectionOauth],
232 ["c. MCP anonymous surface + RFC 6750", sectionMcpAnon],
233 ["d. MCP authenticated reads", sectionMcpAuthed],
234 ["e. Read-only tool matrix", sectionMatrix],
235 ];
236
237 let failed = 0;
238 let skipped = 0;
239 const mdLines: string[] = [];
240
241 for (const [title, run] of sections) {
242 let rows: Row[];
243 try {
244 rows = await run();
245 } catch (err) {
246 rows = [row(title, false, { error: `section crashed: ${(err as Error).message}` })];
247 }
248 const sectionFailed = rows.filter((r) => !r.ok && !r.skipped).length;
249 const sectionSkipped = rows.filter((r) => r.skipped).length;
250 failed += sectionFailed;
251 skipped += sectionSkipped;
252
253 console.log(`\n## ${title}${sectionFailed === 0 ? "PASS" : `FAIL (${sectionFailed})`}${sectionSkipped ? ` (${sectionSkipped} skipped)` : ""}\n`);
254 console.log(formatTable(rows.map((r) => (r.skipped ? { ...r, ok: true, error: `SKIP: ${r.error}` } : r))));
255
256 if (MD) {
257 mdLines.push(`### ${title}`, "");
258 for (const r of rows) mdLines.push(`- ${r.skipped ? "⬜" : r.ok ? "✅" : "🚫"} ${r.name}${r.error ? ` — ${r.error}` : ""}`);
259 mdLines.push("");
260 }
261 }
262
263 if (MD) console.log(`\n<!-- markdown -->\n${mdLines.join("\n")}`);
264
265 console.log(`\n# doctor: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${skipped ? ` — ${skipped} skipped` : ""}`);
266 process.exit(failed === 0 ? 0 : 1);
267}
268
269main().catch((err) => {
270 console.error("doctor crashed:", err);
271 process.exit(2);
272});
0273