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.
| 4cd6791 | 1 | // 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 | ||
| 24 | const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, ""); | |
| 25 | const PAT = process.env.GLUECRON_PAT?.trim() || ""; | |
| 26 | const SLOW_MS = Number(process.env.NAV_SLOW_MS || 8000); | |
| 27 | ||
| 28 | const argv = process.argv.slice(2); | |
| 29 | const flag = (name: string): string | undefined => { | |
| 30 | const i = argv.indexOf(name); | |
| 31 | return i >= 0 ? argv[i + 1] : undefined; | |
| 32 | }; | |
| 33 | ||
| 34 | type Row = { | |
| 35 | group: string; | |
| 36 | label: string; | |
| 37 | url: string; | |
| 38 | status: number; | |
| 39 | ms: number; | |
| 40 | ok: boolean; | |
| 41 | note: string; | |
| 42 | }; | |
| 43 | ||
| 44 | function 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. | |
| 50 | function 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 | ||
| 61 | async 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 | ||
| 100 | const 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 | ||
| 130 | const 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 | ||
| 144 | const 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 | ||
| 153 | const 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 | ||
| 163 | const GLOBAL_PAGES = [ | |
| 164 | "/", "/explore", "/dashboard", "/notifications", "/activity", "/pricing", | |
| 165 | "/status", "/help", "/docs", "/changelog", | |
| 166 | ]; | |
| 167 | ||
| 168 | type Spec = { group: string; label: string; path: string }; | |
| 169 | ||
| 170 | function 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 | ||
| 178 | function 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. | |
| 187 | async 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 | ||
| 202 | async 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 | ||
| 219 | const DANGER = /(logout|sign-?out|delete|destroy|revoke|remove|\/raw\/|\/git-|uninstall|disable|reset|purge|cancel|deactivate)/i; | |
| 220 | ||
| 221 | async 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 | ||
| 257 | function 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 | ||
| 281 | async 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 | ||
| 300 | main(); |