Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

nav-audit.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.

nav-audit.tsBlame300 lines · 1 contributor
4cd6791ccantynz-alt1// nav-audit — press every tab, on every page, and confirm it actually renders.
2//
3// The gap this closes: prior "audits" checked API endpoints (doctor.ts),
4// the git/PR write flow (agent-journey.ts), or curl'd a few status codes and
5// mis-triaged 404s as "expected". None of them pressed every navigation
6// destination and confirmed the page renders. This does — and it's meant to
7// run before ANY "production ready" claim, and against every repo you import.
8//
9// Two modes:
10// (default) audit the Gluecron self-host repo's every tab
11// --repo owner/name audit one specific repo's tabs
12// --all-repos audit EVERY repo on the instance
13// --crawl <baseUrl> generic same-origin link crawl of ANY site
14// (works on vapron.ai, gluecron.com, any platform)
15//
16// Env:
17// GLUECRON_HOST target instance (default https://gluecron.com)
18// GLUECRON_PAT bearer token — needed to reach private repos / admin
19// NAV_SLOW_MS "slow render" threshold (default 8000)
20//
21// Exit: 0 iff every checked page returns 2xx/3xx and renders without an
22// error body. Non-zero (and a FAIL table) otherwise.
23
24const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
25const PAT = process.env.GLUECRON_PAT?.trim() || "";
26const SLOW_MS = Number(process.env.NAV_SLOW_MS || 8000);
27
28const argv = process.argv.slice(2);
29const flag = (name: string): string | undefined => {
30 const i = argv.indexOf(name);
31 return i >= 0 ? argv[i + 1] : undefined;
32};
33
34type Row = {
35 group: string;
36 label: string;
37 url: string;
38 status: number;
39 ms: number;
40 ok: boolean;
41 note: string;
42};
43
44function authHeaders(): Record<string, string> {
45 return PAT ? { authorization: `Bearer ${PAT}` } : {};
46}
47
48// A 200 that renders an error page still counts as broken. Sniff the title +
49// obvious error markers.
50function errorBody(html: string): string {
51 const title = (html.match(/<title>([^<]*)<\/title>/i)?.[1] || "").trim();
52 if (/(^|\s)(not found|error|forbidden|something went wrong)(\s|$)/i.test(title)) {
53 return `error title: "${title}"`;
54 }
55 if (/>\s*4\d\d\s*<|Page not found|Internal Server Error|Application error/i.test(html)) {
56 return "error content in body";
57 }
58 return "";
59}
60
61async function hit(group: string, label: string, path: string): Promise<Row> {
62 const url = path.startsWith("http") ? path : HOST + path;
63 const t0 = Date.now();
64 try {
65 const res = await fetch(url, { headers: authHeaders(), redirect: "manual" });
66 const ms = Date.now() - t0;
67 let note = "";
68 let ok = res.status >= 200 && res.status < 400;
69 if (res.status >= 200 && res.status < 300) {
70 const body = await res.text();
71 const eb = errorBody(body);
72 if (eb) {
73 ok = false;
74 note = eb;
75 }
76 } else if (res.status >= 400) {
77 ok = false;
78 note = `HTTP ${res.status}`;
79 }
80 if (ok && ms > SLOW_MS) {
81 ok = false;
82 note = `slow render ${ms}ms (> ${SLOW_MS}ms)`;
83 }
84 return { group, label, url, status: res.status, ms, ok, note };
85 } catch (err) {
86 return {
87 group,
88 label,
89 url,
90 status: 0,
91 ms: Date.now() - t0,
92 ok: false,
93 note: `fetch failed: ${(err as Error).message}`,
94 };
95 }
96}
97
98// ── Known Gluecron destinations (repo-scoped templates + global) ─────────────
99
100const REPO_TABS: Array<[string, string]> = [
101 ["Code", ""],
102 ["Issues", "/issues"],
103 ["Pull Requests", "/pulls"],
104 ["Actions", "/actions"],
105 ["Security", "/security/vulnerabilities"],
106 ["Insights", "/insights"],
107 ["Settings", "/settings"],
108 ["AI:Explain", "/explain"],
109 ["AI:Ask", "/ask"],
110 ["AI:Workspace", "/workspace"],
111 ["AI:Spec", "/spec"],
112 ["AI:Tests", "/ai/tests"],
113 ["AI:NL Search", "/search/nl"],
114 ["AI:Debt Map", "/debt-map"],
115 ["AI:Archaeology", "/archaeology"],
116 ["More:Commits", "/commits"],
117 ["More:Discussions", "/discussions"],
118 ["More:Wiki", "/wiki"],
119 ["More:Projects", "/projects"],
120 ["More:Releases", "/releases"],
121 ["More:Contributors", "/contributors"],
122 ["More:Pulse", "/pulse"],
123 ["More:Gates", "/gates"],
124 ["More:Deployments", "/cloud-deployments"],
125 ["More:Pipeline", "/pipeline"],
126 ["More:Agents", "/agents"],
127 ["More:Traffic", "/traffic"],
128];
129
130const INSIGHT_REPORTS: Array<[string, string]> = [
131 ["Insights hub", "/insights"],
132 ["DORA", "/insights/dora"],
133 ["Velocity", "/insights/velocity"],
134 ["Health", "/insights/health"],
135 ["Hot files", "/insights/hotfiles"],
136 ["Bus factor", "/insights/bus-factor"],
137 ["Test gaps", "/insights/test-gaps"],
138 ["Engineering", "/insights/engineering"],
139 ["Coupling", "/coupling"],
140 ["Dependencies", "/dependencies"],
141 ["Repo health", "/health"],
142];
143
144const SETTINGS_PAGES = [
145 "/settings", "/settings/keys", "/settings/signing-keys", "/settings/2fa",
146 "/settings/passkeys", "/settings/sessions", "/settings/tokens",
147 "/settings/notifications", "/settings/replies", "/settings/billing",
148 "/settings/sponsors", "/settings/applications", "/settings/apps",
149 "/settings/authorizations", "/settings/agents", "/settings/deploy-targets",
150 "/settings/integrations", "/settings/incident-hooks", "/settings/audit",
151];
152
153const ADMIN_PAGES = [
154 "/admin", "/admin/command", "/admin/ops", "/admin/health", "/admin/env-health",
155 "/admin/status", "/admin/self-host", "/admin/diagnose", "/admin/users",
156 "/admin/repos", "/admin/flags", "/admin/security", "/admin/soc2",
157 "/admin/deletions", "/admin/stripe", "/admin/google-oauth", "/admin/github-oauth",
158 "/admin/sso", "/admin/ai-costs", "/admin/growth", "/admin/autopilot",
159 "/admin/digests", "/admin/integrations", "/admin/advancement", "/admin/deploys",
160 "/admin/autopilot/health",
161];
162
163const GLOBAL_PAGES = [
164 "/", "/explore", "/dashboard", "/notifications", "/activity", "/pricing",
165 "/status", "/help", "/docs", "/changelog",
166];
167
168type Spec = { group: string; label: string; path: string };
169
170function repoSpecs(ownerRepo: string): Spec[] {
171 const base = `/${ownerRepo}`;
172 return [
173 ...REPO_TABS.map(([label, sub]) => ({ group: `repo:${ownerRepo}`, label, path: base + sub })),
174 ...INSIGHT_REPORTS.map(([label, sub]) => ({ group: `insights:${ownerRepo}`, label, path: base + sub })),
175 ];
176}
177
178function globalSpecs(): Spec[] {
179 return [
180 ...GLOBAL_PAGES.map((p) => ({ group: "global", label: p, path: p })),
181 ...SETTINGS_PAGES.map((p) => ({ group: "settings", label: p, path: p })),
182 ...ADMIN_PAGES.map((p) => ({ group: "admin", label: p, path: p })),
183 ];
184}
185
186// Bounded-concurrency runner so ~90+ pages finish in seconds, not minutes.
187async function runConcurrent(specs: Spec[], concurrency = 10): Promise<Row[]> {
188 const rows: Row[] = new Array(specs.length);
189 let next = 0;
190 async function worker() {
191 while (true) {
192 const i = next++;
193 if (i >= specs.length) return;
194 const s = specs[i];
195 rows[i] = await hit(s.group, s.label, s.path);
196 }
197 }
198 await Promise.all(Array.from({ length: Math.min(concurrency, specs.length) }, worker));
199 return rows;
200}
201
202async function listAllRepos(): Promise<string[]> {
203 // Best-effort: enumerate the authed user's repos, then their org repos.
204 const out = new Set<string>();
205 try {
206 const me = await fetch(HOST + "/api/v2/user", { headers: authHeaders() }).then((r) => r.json());
207 if (me?.username) {
208 const repos = await fetch(HOST + `/api/v2/users/${encodeURIComponent(me.username)}/repos`, {
209 headers: authHeaders(),
210 }).then((r) => r.json());
211 if (Array.isArray(repos)) for (const r of repos) if (r?.name) out.add(`${me.username}/${r.name}`);
212 }
213 } catch { /* fall through */ }
214 return [...out];
215}
216
217// ── Generic same-origin crawler (works on ANY site) ──────────────────────────
218
219const DANGER = /(logout|sign-?out|delete|destroy|revoke|remove|\/raw\/|\/git-|uninstall|disable|reset|purge|cancel|deactivate)/i;
220
221async function crawlSite(startUrl: string, maxPages = 150): Promise<Row[]> {
222 const origin = new URL(startUrl).origin;
223 const seen = new Set<string>();
224 const queue: string[] = [startUrl];
225 const rows: Row[] = [];
226 while (queue.length && seen.size < maxPages) {
227 const url = queue.shift()!;
228 if (seen.has(url)) continue;
229 seen.add(url);
230 const row = await hit("crawl", new URL(url).pathname || "/", url);
231 rows.push(row);
232 // Only expand links from pages that rendered.
233 if (row.status >= 200 && row.status < 300 && !row.note) {
234 try {
235 const html = await fetch(url, { headers: authHeaders() }).then((r) => r.text());
236 const hrefs = [...html.matchAll(/href="([^"#?]+)"/gi)].map((m) => m[1]);
237 for (const href of hrefs) {
238 if (DANGER.test(href)) continue;
239 let abs: string;
240 try {
241 abs = new URL(href, url).toString();
242 } catch {
243 continue;
244 }
245 if (!abs.startsWith(origin)) continue;
246 if (DANGER.test(abs)) continue;
247 if (!seen.has(abs) && !queue.includes(abs)) queue.push(abs);
248 }
249 } catch { /* ignore link-extract errors */ }
250 }
251 }
252 return rows;
253}
254
255// ── Report ───────────────────────────────────────────────────────────────────
256
257function report(rows: Row[]): number {
258 const fails = rows.filter((r) => !r.ok);
259 const slow = rows.filter((r) => r.ok && r.ms > SLOW_MS / 2);
260 console.log(`\n# nav-audit — ${HOST} — ${new Date().toISOString()}`);
261 console.log(`# ${rows.length} pages checked · PAT ${PAT ? "present" : "absent"}\n`);
262 if (fails.length) {
263 console.log(`## FAILURES (${fails.length})\n`);
264 console.log("status | ms | group / label → url | note");
265 console.log("-------+-------+---------------------+-----");
266 for (const r of fails) {
267 console.log(`${String(r.status).padStart(6)} | ${String(r.ms).padStart(5)} | ${r.group} / ${r.label} → ${r.url} | ${r.note}`);
268 }
269 console.log("");
270 }
271 if (slow.length) {
272 console.log(`## SLOW (ok but > ${Math.round(SLOW_MS / 2)}ms) — worth a look\n`);
273 for (const r of slow) console.log(` ${String(r.ms).padStart(6)}ms ${r.group} / ${r.label}`);
274 console.log("");
275 }
276 const passed = rows.length - fails.length;
277 console.log(fails.length === 0 ? `nav-audit: ALL GREEN — ${passed}/${rows.length} pages render` : `nav-audit: ${fails.length} FAILURE(S) — ${passed}/${rows.length} render`);
278 return fails.length === 0 ? 0 : 1;
279}
280
281async function main() {
282 let rows: Row[] = [];
283 const crawlUrl = flag("--crawl");
284 if (crawlUrl) {
285 console.log(`crawling ${crawlUrl} (same-origin, GET-only, safe)…`);
286 rows = await crawlSite(crawlUrl);
287 } else if (argv.includes("--all-repos")) {
288 const repos = await listAllRepos();
289 console.log(`auditing ${repos.length} repos + global/settings/admin…`);
290 const specs = [...globalSpecs(), ...repos.flatMap(repoSpecs)];
291 rows = await runConcurrent(specs);
292 } else {
293 const repo = flag("--repo") || "ccantynz/Gluecron.com";
294 console.log(`auditing repo ${repo} + global/settings/admin…`);
295 rows = await runConcurrent([...globalSpecs(), ...repoSpecs(repo)]);
296 }
297 process.exit(report(rows));
298}
299
300main();