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; | |
| 1d74c37 | 42 | body?: string; // captured for 2xx so --crawl can extract links without re-fetching |
| 4cd6791 | 43 | }; |
| 44 | ||
| 1d74c37 | 45 | // SECURITY: only ever send the Gluecron PAT to the Gluecron origin. In |
| 46 | // --crawl mode against another platform (vapron.ai, etc.) we must NOT leak | |
| 47 | // the token to a different host. | |
| 48 | let HOST_ORIGIN = ""; | |
| 49 | try { | |
| 50 | HOST_ORIGIN = new URL(HOST).origin; | |
| 51 | } catch { | |
| 52 | /* HOST malformed — no auth will be attached */ | |
| 53 | } | |
| 54 | function authHeaders(url: string): Record<string, string> { | |
| 55 | if (!PAT) return {}; | |
| 56 | try { | |
| 57 | if (new URL(url).origin === HOST_ORIGIN) return { authorization: `Bearer ${PAT}` }; | |
| 58 | } catch { | |
| 59 | /* unparseable URL — send nothing */ | |
| 60 | } | |
| 61 | return {}; | |
| 4cd6791 | 62 | } |
| 63 | ||
| 64 | // A 200 that renders an error page still counts as broken. Sniff the title + | |
| 65 | // obvious error markers. | |
| 66 | function errorBody(html: string): string { | |
| 67 | const title = (html.match(/<title>([^<]*)<\/title>/i)?.[1] || "").trim(); | |
| 68 | if (/(^|\s)(not found|error|forbidden|something went wrong)(\s|$)/i.test(title)) { | |
| 69 | return `error title: "${title}"`; | |
| 70 | } | |
| 71 | if (/>\s*4\d\d\s*<|Page not found|Internal Server Error|Application error/i.test(html)) { | |
| 72 | return "error content in body"; | |
| 73 | } | |
| 74 | return ""; | |
| 75 | } | |
| 76 | ||
| 77 | async function hit(group: string, label: string, path: string): Promise<Row> { | |
| 78 | const url = path.startsWith("http") ? path : HOST + path; | |
| 79 | const t0 = Date.now(); | |
| 80 | try { | |
| 1d74c37 | 81 | const res = await fetch(url, { headers: authHeaders(url), redirect: "manual" }); |
| 4cd6791 | 82 | const ms = Date.now() - t0; |
| 83 | let note = ""; | |
| 84 | let ok = res.status >= 200 && res.status < 400; | |
| 1d74c37 | 85 | let body: string | undefined; |
| 4cd6791 | 86 | if (res.status >= 200 && res.status < 300) { |
| 1d74c37 | 87 | body = await res.text(); |
| 4cd6791 | 88 | const eb = errorBody(body); |
| 89 | if (eb) { | |
| 90 | ok = false; | |
| 91 | note = eb; | |
| 92 | } | |
| 93 | } else if (res.status >= 400) { | |
| 94 | ok = false; | |
| 95 | note = `HTTP ${res.status}`; | |
| 96 | } | |
| 97 | if (ok && ms > SLOW_MS) { | |
| 98 | ok = false; | |
| 99 | note = `slow render ${ms}ms (> ${SLOW_MS}ms)`; | |
| 100 | } | |
| 1d74c37 | 101 | return { group, label, url, status: res.status, ms, ok, note, body }; |
| 4cd6791 | 102 | } catch (err) { |
| 103 | return { | |
| 104 | group, | |
| 105 | label, | |
| 106 | url, | |
| 107 | status: 0, | |
| 108 | ms: Date.now() - t0, | |
| 109 | ok: false, | |
| 110 | note: `fetch failed: ${(err as Error).message}`, | |
| 111 | }; | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | // ── Known Gluecron destinations (repo-scoped templates + global) ───────────── | |
| 116 | ||
| 117 | const REPO_TABS: Array<[string, string]> = [ | |
| 118 | ["Code", ""], | |
| 119 | ["Issues", "/issues"], | |
| 120 | ["Pull Requests", "/pulls"], | |
| 121 | ["Actions", "/actions"], | |
| 122 | ["Security", "/security/vulnerabilities"], | |
| 123 | ["Insights", "/insights"], | |
| 124 | ["Settings", "/settings"], | |
| 125 | ["AI:Explain", "/explain"], | |
| 126 | ["AI:Ask", "/ask"], | |
| 127 | ["AI:Workspace", "/workspace"], | |
| 128 | ["AI:Spec", "/spec"], | |
| 129 | ["AI:Tests", "/ai/tests"], | |
| 130 | ["AI:NL Search", "/search/nl"], | |
| 131 | ["AI:Debt Map", "/debt-map"], | |
| 132 | ["AI:Archaeology", "/archaeology"], | |
| 133 | ["More:Commits", "/commits"], | |
| 134 | ["More:Discussions", "/discussions"], | |
| 135 | ["More:Wiki", "/wiki"], | |
| 136 | ["More:Projects", "/projects"], | |
| 137 | ["More:Releases", "/releases"], | |
| 138 | ["More:Contributors", "/contributors"], | |
| 139 | ["More:Pulse", "/pulse"], | |
| 140 | ["More:Gates", "/gates"], | |
| 141 | ["More:Deployments", "/cloud-deployments"], | |
| 142 | ["More:Pipeline", "/pipeline"], | |
| 143 | ["More:Agents", "/agents"], | |
| 144 | ["More:Traffic", "/traffic"], | |
| 145 | ]; | |
| 146 | ||
| 147 | const INSIGHT_REPORTS: Array<[string, string]> = [ | |
| 148 | ["Insights hub", "/insights"], | |
| 149 | ["DORA", "/insights/dora"], | |
| 150 | ["Velocity", "/insights/velocity"], | |
| 151 | ["Health", "/insights/health"], | |
| 152 | ["Hot files", "/insights/hotfiles"], | |
| 153 | ["Bus factor", "/insights/bus-factor"], | |
| 154 | ["Test gaps", "/insights/test-gaps"], | |
| 155 | ["Engineering", "/insights/engineering"], | |
| 156 | ["Coupling", "/coupling"], | |
| 157 | ["Dependencies", "/dependencies"], | |
| 158 | ["Repo health", "/health"], | |
| 159 | ]; | |
| 160 | ||
| 161 | const SETTINGS_PAGES = [ | |
| 162 | "/settings", "/settings/keys", "/settings/signing-keys", "/settings/2fa", | |
| 163 | "/settings/passkeys", "/settings/sessions", "/settings/tokens", | |
| 164 | "/settings/notifications", "/settings/replies", "/settings/billing", | |
| 165 | "/settings/sponsors", "/settings/applications", "/settings/apps", | |
| 166 | "/settings/authorizations", "/settings/agents", "/settings/deploy-targets", | |
| 167 | "/settings/integrations", "/settings/incident-hooks", "/settings/audit", | |
| 168 | ]; | |
| 169 | ||
| 170 | const ADMIN_PAGES = [ | |
| 171 | "/admin", "/admin/command", "/admin/ops", "/admin/health", "/admin/env-health", | |
| 172 | "/admin/status", "/admin/self-host", "/admin/diagnose", "/admin/users", | |
| 173 | "/admin/repos", "/admin/flags", "/admin/security", "/admin/soc2", | |
| 174 | "/admin/deletions", "/admin/stripe", "/admin/google-oauth", "/admin/github-oauth", | |
| 175 | "/admin/sso", "/admin/ai-costs", "/admin/growth", "/admin/autopilot", | |
| 176 | "/admin/digests", "/admin/integrations", "/admin/advancement", "/admin/deploys", | |
| 177 | "/admin/autopilot/health", | |
| 178 | ]; | |
| 179 | ||
| 180 | const GLOBAL_PAGES = [ | |
| 181 | "/", "/explore", "/dashboard", "/notifications", "/activity", "/pricing", | |
| 182 | "/status", "/help", "/docs", "/changelog", | |
| 183 | ]; | |
| 184 | ||
| 185 | type Spec = { group: string; label: string; path: string }; | |
| 186 | ||
| 187 | function repoSpecs(ownerRepo: string): Spec[] { | |
| 188 | const base = `/${ownerRepo}`; | |
| 189 | return [ | |
| 190 | ...REPO_TABS.map(([label, sub]) => ({ group: `repo:${ownerRepo}`, label, path: base + sub })), | |
| 191 | ...INSIGHT_REPORTS.map(([label, sub]) => ({ group: `insights:${ownerRepo}`, label, path: base + sub })), | |
| 192 | ]; | |
| 193 | } | |
| 194 | ||
| 195 | function globalSpecs(): Spec[] { | |
| 196 | return [ | |
| 197 | ...GLOBAL_PAGES.map((p) => ({ group: "global", label: p, path: p })), | |
| 198 | ...SETTINGS_PAGES.map((p) => ({ group: "settings", label: p, path: p })), | |
| 199 | ...ADMIN_PAGES.map((p) => ({ group: "admin", label: p, path: p })), | |
| 200 | ]; | |
| 201 | } | |
| 202 | ||
| 203 | // Bounded-concurrency runner so ~90+ pages finish in seconds, not minutes. | |
| 204 | async function runConcurrent(specs: Spec[], concurrency = 10): Promise<Row[]> { | |
| 205 | const rows: Row[] = new Array(specs.length); | |
| 206 | let next = 0; | |
| 207 | async function worker() { | |
| 208 | while (true) { | |
| 209 | const i = next++; | |
| 210 | if (i >= specs.length) return; | |
| 211 | const s = specs[i]; | |
| 212 | rows[i] = await hit(s.group, s.label, s.path); | |
| 213 | } | |
| 214 | } | |
| 215 | await Promise.all(Array.from({ length: Math.min(concurrency, specs.length) }, worker)); | |
| 216 | return rows; | |
| 217 | } | |
| 218 | ||
| 219 | async function listAllRepos(): Promise<string[]> { | |
| 220 | // Best-effort: enumerate the authed user's repos, then their org repos. | |
| 221 | const out = new Set<string>(); | |
| 222 | try { | |
| 1d74c37 | 223 | const me = await fetch(HOST + "/api/v2/user", { headers: authHeaders(HOST + "/api/v2/user") }).then((r) => r.json()); |
| 4cd6791 | 224 | if (me?.username) { |
| 1d74c37 | 225 | const reposUrl = HOST + `/api/v2/users/${encodeURIComponent(me.username)}/repos`; |
| 226 | const repos = await fetch(reposUrl, { headers: authHeaders(reposUrl) }).then((r) => r.json()); | |
| 4cd6791 | 227 | if (Array.isArray(repos)) for (const r of repos) if (r?.name) out.add(`${me.username}/${r.name}`); |
| 228 | } | |
| 229 | } catch { /* fall through */ } | |
| 230 | return [...out]; | |
| 231 | } | |
| 232 | ||
| 233 | // ── Generic same-origin crawler (works on ANY site) ────────────────────────── | |
| 234 | ||
| 235 | const DANGER = /(logout|sign-?out|delete|destroy|revoke|remove|\/raw\/|\/git-|uninstall|disable|reset|purge|cancel|deactivate)/i; | |
| 236 | ||
| 237 | async function crawlSite(startUrl: string, maxPages = 150): Promise<Row[]> { | |
| 238 | const origin = new URL(startUrl).origin; | |
| 239 | const seen = new Set<string>(); | |
| 240 | const queue: string[] = [startUrl]; | |
| 241 | const rows: Row[] = []; | |
| 242 | while (queue.length && seen.size < maxPages) { | |
| 243 | const url = queue.shift()!; | |
| 244 | if (seen.has(url)) continue; | |
| 245 | seen.add(url); | |
| 246 | const row = await hit("crawl", new URL(url).pathname || "/", url); | |
| 247 | rows.push(row); | |
| 1d74c37 | 248 | // Only expand links from pages that rendered. Reuse the body hit() |
| 249 | // already captured — no second fetch of a (possibly slow) page. | |
| 250 | if (row.status >= 200 && row.status < 300 && !row.note && row.body) { | |
| 4cd6791 | 251 | try { |
| 1d74c37 | 252 | const html = row.body; |
| 4cd6791 | 253 | const hrefs = [...html.matchAll(/href="([^"#?]+)"/gi)].map((m) => m[1]); |
| 254 | for (const href of hrefs) { | |
| 255 | if (DANGER.test(href)) continue; | |
| 256 | let abs: string; | |
| 257 | try { | |
| 258 | abs = new URL(href, url).toString(); | |
| 259 | } catch { | |
| 260 | continue; | |
| 261 | } | |
| 262 | if (!abs.startsWith(origin)) continue; | |
| 263 | if (DANGER.test(abs)) continue; | |
| 264 | if (!seen.has(abs) && !queue.includes(abs)) queue.push(abs); | |
| 265 | } | |
| 266 | } catch { /* ignore link-extract errors */ } | |
| 267 | } | |
| 268 | } | |
| 269 | return rows; | |
| 270 | } | |
| 271 | ||
| 272 | // ── Report ─────────────────────────────────────────────────────────────────── | |
| 273 | ||
| 274 | function report(rows: Row[]): number { | |
| 275 | const fails = rows.filter((r) => !r.ok); | |
| 276 | const slow = rows.filter((r) => r.ok && r.ms > SLOW_MS / 2); | |
| 277 | console.log(`\n# nav-audit — ${HOST} — ${new Date().toISOString()}`); | |
| 278 | console.log(`# ${rows.length} pages checked · PAT ${PAT ? "present" : "absent"}\n`); | |
| 279 | if (fails.length) { | |
| 280 | console.log(`## FAILURES (${fails.length})\n`); | |
| 281 | console.log("status | ms | group / label → url | note"); | |
| 282 | console.log("-------+-------+---------------------+-----"); | |
| 283 | for (const r of fails) { | |
| 284 | console.log(`${String(r.status).padStart(6)} | ${String(r.ms).padStart(5)} | ${r.group} / ${r.label} → ${r.url} | ${r.note}`); | |
| 285 | } | |
| 286 | console.log(""); | |
| 287 | } | |
| 288 | if (slow.length) { | |
| 289 | console.log(`## SLOW (ok but > ${Math.round(SLOW_MS / 2)}ms) — worth a look\n`); | |
| 290 | for (const r of slow) console.log(` ${String(r.ms).padStart(6)}ms ${r.group} / ${r.label}`); | |
| 291 | console.log(""); | |
| 292 | } | |
| 293 | const passed = rows.length - fails.length; | |
| 294 | console.log(fails.length === 0 ? `nav-audit: ALL GREEN — ${passed}/${rows.length} pages render` : `nav-audit: ${fails.length} FAILURE(S) — ${passed}/${rows.length} render`); | |
| 295 | return fails.length === 0 ? 0 : 1; | |
| 296 | } | |
| 297 | ||
| 298 | async function main() { | |
| 299 | let rows: Row[] = []; | |
| 300 | const crawlUrl = flag("--crawl"); | |
| 301 | if (crawlUrl) { | |
| 302 | console.log(`crawling ${crawlUrl} (same-origin, GET-only, safe)…`); | |
| 303 | rows = await crawlSite(crawlUrl); | |
| 304 | } else if (argv.includes("--all-repos")) { | |
| 305 | const repos = await listAllRepos(); | |
| 306 | console.log(`auditing ${repos.length} repos + global/settings/admin…`); | |
| 307 | const specs = [...globalSpecs(), ...repos.flatMap(repoSpecs)]; | |
| 308 | rows = await runConcurrent(specs); | |
| 309 | } else { | |
| 310 | const repo = flag("--repo") || "ccantynz/Gluecron.com"; | |
| 311 | console.log(`auditing repo ${repo} + global/settings/admin…`); | |
| 312 | rows = await runConcurrent([...globalSpecs(), ...repoSpecs(repo)]); | |
| 313 | } | |
| 314 | process.exit(report(rows)); | |
| 315 | } | |
| 316 | ||
| 317 | main(); |