getadvantage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/overviews.mjs ADDED
@@ -0,0 +1,536 @@
1
+ // Ship-Safe v1.1 — the OVERVIEW SCANNERS.
2
+ //
3
+ // Three READ-ONLY repo scans that hand the builder a MAP of what their app
4
+ // actually has. This is the "understand what you built" differentiator: an
5
+ // AI agent can ship a hundred routes in an afternoon, and the human has no
6
+ // idea which ones mutate data, who can reach them, what external services
7
+ // they call, or what runs unattended on a schedule. These scanners read the
8
+ // repo and answer those questions in plain language.
9
+ //
10
+ // 1. API surface map — every Next.js App Router route, its methods, and
11
+ // whether it looks auth-gated. ⚠ on a MUTATING route
12
+ // that looks UNauthenticated (the dangerous ones).
13
+ // 2. Agents & integrations map — external/LLM/3rd-party calls + which env
14
+ // keys back them. ⚠ if a secret looks reachable from
15
+ // the client or an unauthenticated path.
16
+ // 3. Schedules & jobs map — vercel.json crons + app/api/cron/* routes, their
17
+ // schedule, and whether each is gated. ⚠ on an
18
+ // ungated (publicly triggerable) cron.
19
+ //
20
+ // Every scanner returns the same `result(status, label, detail, extra)` shape
21
+ // the runner already renders. Node built-ins only; nothing here mutates the
22
+ // repo (it only READS files). Overall verdict is INFORMATIONAL — these emit
23
+ // `pass`/`warn`, never `fail`, so a surprising map never blocks a ship on its
24
+ // own (the v1 safety checks own the NO-GO).
25
+
26
+ import path from "node:path";
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import { result, walkFiles, readText, relPath } from "./util.mjs";
29
+
30
+ // ===========================================================================
31
+ // shared helpers
32
+ // ===========================================================================
33
+
34
+ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
35
+ const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
36
+
37
+ /** Find every App Router route handler (app/api/**\/route.ts|js). */
38
+ function routeFiles(cwd) {
39
+ const apiDir = path.join(cwd, "app", "api");
40
+ if (!existsSync(apiDir)) return [];
41
+ return walkFiles(apiDir, (abs) => /[\\/]route\.(ts|tsx|js|mjs)$/.test(abs)).sort();
42
+ }
43
+
44
+ /**
45
+ * Turn an absolute app/api/.../route.ts path into its public URL path.
46
+ * app/api/sites/route.ts → /api/sites
47
+ * app/api/team/[memberId]/route.ts → /api/team/[memberId]
48
+ * app/api/[transport]/route.ts → /api/[transport] (=> the MCP server)
49
+ */
50
+ function routeUrlFromFile(abs, cwd) {
51
+ const rel = relPath(abs, cwd); // app/api/.../route.ts
52
+ const segs = rel.split("/");
53
+ // drop leading "app" and the trailing "route.xx"
54
+ const parts = segs.slice(1, -1); // ["api", "sites"] etc.
55
+ return "/" + parts.join("/");
56
+ }
57
+
58
+ /** Which HTTP methods a route file exports (handles `export async function POST`,
59
+ * `export function GET`, and `export { handler as GET, handler as POST }`). */
60
+ function methodsExported(text) {
61
+ const found = new Set();
62
+ for (const m of HTTP_METHODS) {
63
+ // export (async)? function GET(...)
64
+ if (new RegExp(`export\\s+(?:async\\s+)?function\\s+${m}\\b`).test(text)) found.add(m);
65
+ // export const GET = ...
66
+ if (new RegExp(`export\\s+const\\s+${m}\\b`).test(text)) found.add(m);
67
+ // export { handler as GET } / export { x as POST, y as GET }
68
+ if (new RegExp(`\\bas\\s+${m}\\b`).test(text)) found.add(m);
69
+ }
70
+ return [...found].sort((a, b) => HTTP_METHODS.indexOf(a) - HTTP_METHODS.indexOf(b));
71
+ }
72
+
73
+ // Tokens that signal a handler authenticates / authorizes the caller. Drawn
74
+ // from this repo's real conventions (currentAccount, getSession, the API-key
75
+ // resolver, the per-caller rate-limiter) but kept generic enough to catch the
76
+ // usual names an AI agent emits in any Next.js app.
77
+ const AUTH_TOKENS = [
78
+ /\bcurrentAccount\b/,
79
+ /\bgetSession\b/,
80
+ /\brequireAuth\b/,
81
+ /\brequireSession\b/,
82
+ /\bverifySession\b/,
83
+ /\bresolveCaller\b/, // app/lib/server/api-auth (key-or-keyless)
84
+ /\bresolveApiKeySecret\b/, // API-key auth
85
+ /\brateLimitCaller\b/, // per-caller (key/plan) limiter
86
+ /\bgetServerSession\b/, // next-auth idiom
87
+ /\bauth\(\)/, // next-auth v5 idiom
88
+ ];
89
+
90
+ /**
91
+ * Strip line (`// …`) and block (`/* … *\/`) comments from JS/TS source so a
92
+ * commented-out auth/secret token never counts as real gating. This is a
93
+ * heuristic stripper (it does not parse strings/regex literals), which is the
94
+ * right trade-off here: the auth/secret tokens we grep for are identifiers that
95
+ * never legitimately live inside a string literal, but they DO commonly live in
96
+ * commented-out code (e.g. the deferred `withMcpAuth` block in
97
+ * app/api/[transport]/route.ts references `resolveApiKeySecret` only in a
98
+ * comment). Removing comments first keeps that out of the gating decision.
99
+ */
100
+ function stripComments(text) {
101
+ let out = "";
102
+ let i = 0;
103
+ const n = text.length;
104
+ while (i < n) {
105
+ const ch = text[i];
106
+ const next = text[i + 1];
107
+ // Block comment /* … */
108
+ if (ch === "/" && next === "*") {
109
+ const end = text.indexOf("*/", i + 2);
110
+ i = end === -1 ? n : end + 2;
111
+ continue;
112
+ }
113
+ // Line comment // …
114
+ if (ch === "/" && next === "/") {
115
+ const end = text.indexOf("\n", i + 2);
116
+ i = end === -1 ? n : end; // keep the newline so line structure survives
117
+ continue;
118
+ }
119
+ out += ch;
120
+ i++;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** Does the handler reference an auth/session check? Comments are stripped first
126
+ * so commented-out gating never counts (see stripComments). */
127
+ function looksAuthGated(text) {
128
+ const code = stripComments(text);
129
+ return AUTH_TOKENS.some((re) => re.test(code));
130
+ }
131
+
132
+ // Tokens signalling a route authorizes its caller via a CRON / shared secret
133
+ // (Vercel cron sends `Authorization: Bearer <CRON_SECRET>`; this repo also
134
+ // accepts x-cron-secret and compares constant-time). A route gated this way is
135
+ // NOT session-authed but is NOT publicly triggerable either — so the API
136
+ // surface map must not flag it as an "unauthenticated mutator".
137
+ const SECRET_GATE_TOKENS = [
138
+ /\bCRON_SECRET\b/,
139
+ /\btimingSafeEqual\b/,
140
+ /x-cron-secret/i,
141
+ ];
142
+
143
+ /** Does the handler gate on a CRON / shared secret (vs. an auth header it might
144
+ * read for other reasons)? We require a concrete secret token, not just the
145
+ * word "authorization", to avoid false "gated" calls. Comments are stripped
146
+ * first so commented-out gating never counts (see stripComments). */
147
+ function looksSecretGated(text) {
148
+ const code = stripComments(text);
149
+ return SECRET_GATE_TOKENS.some((re) => re.test(code));
150
+ }
151
+
152
+ // A cron route is "gated" when it checks a CRON / shared secret — same precise
153
+ // secret-token detection the API surface map uses, so the two maps agree.
154
+ // (Declared here, next to looksSecretGated, so the shared scanners below — which
155
+ // run before section 3 in source order — can reference it; a `const` alias is
156
+ // not hoisted like a function declaration.)
157
+ const looksCronGated = looksSecretGated;
158
+
159
+ // ===========================================================================
160
+ // SHARED SCANNERS (pure data) — reused by both the `check` overview maps and
161
+ // the `brief` generator (PROJECT-BRIEF.md). These functions do the file
162
+ // reading + classification and return plain data; the `overview*` functions
163
+ // below wrap that data in the runner's `result()` shape, and brief.mjs renders
164
+ // the same data as markdown. ONE scan, two presentations — no duplicated logic.
165
+ // ===========================================================================
166
+
167
+ /**
168
+ * Scan the API surface → structured rows + summary counts.
169
+ * @returns {{ rows: Array<{url,methods:string[],mutates,sessionGated,secretGated,devOnly}>,
170
+ * gatedCount:number, mutatingCount:number, dangerous:any[] }}
171
+ */
172
+ export function scanApiSurface(cwd) {
173
+ const files = routeFiles(cwd);
174
+ const rows = [];
175
+ for (const abs of files) {
176
+ const text = readText(abs);
177
+ const url = routeUrlFromFile(abs, cwd);
178
+ const methods = methodsExported(text);
179
+ const mutates = methods.some((m) => MUTATING.has(m));
180
+ const sessionGated = looksAuthGated(text);
181
+ const secretGated = looksSecretGated(text);
182
+ const devOnly =
183
+ /NODE_ENV\s*===\s*["']production["']/.test(text) &&
184
+ /\b(?:404|"Not found"|Not found)\b/.test(text);
185
+ rows.push({ url, methods, mutates, sessionGated, secretGated, devOnly });
186
+ }
187
+ const gatedCount = rows.filter((r) => r.sessionGated || r.secretGated).length;
188
+ const mutatingCount = rows.filter((r) => r.mutates).length;
189
+ const dangerous = rows.filter(
190
+ (r) => r.mutates && !r.sessionGated && !r.secretGated && !r.devOnly,
191
+ );
192
+ return { rows, gatedCount, mutatingCount, dangerous };
193
+ }
194
+
195
+ /** A short human tag for a single API row (shared by the map + the brief). */
196
+ export function apiRowTag(r) {
197
+ if (r.devOnly) return "dev-only (inert in prod)";
198
+ if (r.sessionGated) return "auth-gated";
199
+ if (r.secretGated) return "secret-gated (cron/shared)";
200
+ if (r.mutates) return "PUBLIC + mutates ⚠";
201
+ return "public (read-only)";
202
+ }
203
+
204
+ /**
205
+ * Scan integrations → label → { files:Set, keys:Set }, plus client-secret hits
206
+ * and whether an MCP server route was found.
207
+ * @returns {{ integrations: Map, clientSecretHits: Array<{file,key}>, mcpRouteFound: boolean }}
208
+ */
209
+ export function scanIntegrations(cwd) {
210
+ const appDir = path.join(cwd, "app");
211
+ const integrations = new Map(); // label -> { files:Set, keys:Set }
212
+ const clientSecretHits = [];
213
+ let mcpRouteFound = false;
214
+ if (!existsSync(appDir)) return { integrations, clientSecretHits, mcpRouteFound };
215
+
216
+ const files = walkFiles(appDir, (abs) => /\.(ts|tsx|js|mjs)$/.test(abs));
217
+ function note(label, keys, file) {
218
+ if (!integrations.has(label)) integrations.set(label, { files: new Set(), keys: new Set() });
219
+ const e = integrations.get(label);
220
+ e.files.add(file);
221
+ for (const k of keys) e.keys.add(k);
222
+ }
223
+
224
+ for (const abs of files) {
225
+ const text = readText(abs);
226
+ if (!text) continue;
227
+ const rel = relPath(abs, cwd);
228
+
229
+ for (const h of INTEGRATION_HOSTS) {
230
+ if (text.includes(h.host)) note(h.label, h.keys, rel);
231
+ }
232
+ for (const s of INTEGRATION_SDKS) {
233
+ if (s.re.test(text)) {
234
+ note(s.label, s.keys, rel);
235
+ if (s.label.startsWith("MCP server")) mcpRouteFound = true;
236
+ }
237
+ }
238
+
239
+ if (isClientFile(text)) {
240
+ let m;
241
+ SECRET_ENV_RE.lastIndex = 0;
242
+ while ((m = SECRET_ENV_RE.exec(text)) !== null) {
243
+ const key = m[1];
244
+ if (key.startsWith("NEXT_PUBLIC_")) continue;
245
+ clientSecretHits.push({ file: rel, key });
246
+ }
247
+ }
248
+ }
249
+ return { integrations, clientSecretHits, mcpRouteFound };
250
+ }
251
+
252
+ /**
253
+ * Scan schedules/jobs → rows + derived buckets.
254
+ * @returns {{ rows: any[], ungated:any[], orphanRoutes:any[], missing:any[], cronCount:number }}
255
+ */
256
+ export function scanSchedules(cwd) {
257
+ const crons = vercelCrons(cwd);
258
+ const allRoutes = routeFiles(cwd);
259
+ const cronRouteFiles = allRoutes.filter((abs) => {
260
+ const url = routeUrlFromFile(abs, cwd);
261
+ return url.startsWith("/api/cron") || crons.has(url);
262
+ });
263
+
264
+ const rows = [];
265
+ const seenUrls = new Set();
266
+ for (const abs of cronRouteFiles) {
267
+ const url = routeUrlFromFile(abs, cwd);
268
+ seenUrls.add(url);
269
+ const text = readText(abs);
270
+ rows.push({
271
+ url,
272
+ schedule: crons.get(url) || null,
273
+ gated: looksCronGated(text),
274
+ scheduled: crons.has(url),
275
+ });
276
+ }
277
+ for (const [p, sched] of crons) {
278
+ if (!seenUrls.has(p)) {
279
+ rows.push({ url: p, schedule: sched, gated: null, scheduled: true, missing: true });
280
+ }
281
+ }
282
+ rows.sort((a, b) => a.url.localeCompare(b.url));
283
+
284
+ const ungated = rows.filter((r) => r.gated === false);
285
+ const orphanRoutes = rows.filter((r) => !r.scheduled && !r.missing);
286
+ const missing = rows.filter((r) => r.missing);
287
+ return { rows, ungated, orphanRoutes, missing, cronCount: crons.size };
288
+ }
289
+
290
+ /** A short human tag for a single schedule row (shared by the map + the brief). */
291
+ export function scheduleRowTag(r) {
292
+ if (r.missing) return "no handler found ⚠";
293
+ if (r.gated === false) return "UNGATED ⚠ (publicly triggerable)";
294
+ if (r.gated) return "gated (CRON_SECRET)";
295
+ return "ungated?";
296
+ }
297
+
298
+ // ===========================================================================
299
+ // 1. API SURFACE MAP
300
+ // ===========================================================================
301
+ // For each App Router route: URL path, exported HTTP methods, and whether it
302
+ // looks auth-gated. The dangerous case we flag is a route that MUTATES
303
+ // (POST/PUT/PATCH/DELETE) yet shows no sign of authenticating the caller —
304
+ // the classic "an AI agent wired up a write endpoint and forgot the gate".
305
+ //
306
+ // NOTE on gating: in this repo `proxy.ts` gates `/app/*` but its matcher
307
+ // EXCLUDES `/api/`, so API routes are NOT covered by the proxy — each handler
308
+ // must authenticate itself. We therefore judge each /api route by its own
309
+ // body. (A route under /app/* would be proxy-gated; we note that too.)
310
+
311
+ export function overviewApiSurface(cwd) {
312
+ const { rows, gatedCount, mutatingCount, dangerous } = scanApiSurface(cwd);
313
+ if (rows.length === 0) {
314
+ return result(
315
+ "pass",
316
+ "API surface map",
317
+ "No app/api/**/route files found — nothing to map.",
318
+ );
319
+ }
320
+
321
+ // Build the listing (capped so a big app stays readable).
322
+ const lines = [];
323
+ const CAP = 60;
324
+ for (const r of rows.slice(0, CAP)) {
325
+ const methodStr = r.methods.length ? r.methods.join(",") : "—";
326
+ lines.push(`${r.url} [${methodStr}] ${apiRowTag(r)}`);
327
+ }
328
+ if (rows.length > CAP) lines.push(`…and ${rows.length - CAP} more route(s)`);
329
+
330
+ const detail =
331
+ `${rows.length} route(s) · ${gatedCount} look gated (session or cron secret) · ` +
332
+ `${mutatingCount} mutate (write) · ${dangerous.length} mutate without any obvious gate.`;
333
+
334
+ if (dangerous.length > 0) {
335
+ // Surface the dangerous ones FIRST, then the full map below them.
336
+ const flagged = dangerous
337
+ .slice(0, 20)
338
+ .map((r) => `⚠ ${r.url} [${r.methods.join(",")}] — mutates but no auth/session check found`);
339
+ return result(
340
+ "warn",
341
+ "API surface map",
342
+ detail,
343
+ [
344
+ ...flagged,
345
+ ...(dangerous.length > 20 ? [`…and ${dangerous.length - 20} more flagged`] : []),
346
+ "Confirm each ⚠ route is meant to be public (some, like a rate-limited lead/contact",
347
+ "endpoint, legitimately are) — and that the others authenticate the caller before writing.",
348
+ "— full map —",
349
+ ...lines,
350
+ ],
351
+ );
352
+ }
353
+
354
+ return result("pass", "API surface map", detail, lines);
355
+ }
356
+
357
+ // ===========================================================================
358
+ // 2. AGENTS & INTEGRATIONS MAP
359
+ // ===========================================================================
360
+ // What external services + LLMs the app talks to, and which env keys back
361
+ // them. We detect via (a) known host strings in fetch()/URL literals,
362
+ // (b) well-known SDK imports, (c) the MCP server route, then map each to the
363
+ // env key(s) it reads. The risk flag: a secret/LLM key that looks reachable
364
+ // from the CLIENT BUNDLE or an UNAUTHENTICATED route.
365
+
366
+ // host substring → { label, keys[] }. Keys are the env vars that typically
367
+ // back that integration in this kind of app (names only — never values).
368
+ const INTEGRATION_HOSTS = [
369
+ { host: "api.openai.com", label: "OpenAI", keys: ["OPENAI_API_KEY"] },
370
+ { host: "api.anthropic.com", label: "Anthropic (Claude)", keys: ["ANTHROPIC_API_KEY"] },
371
+ { host: "api.perplexity.ai", label: "Perplexity", keys: ["PERPLEXITY_API_KEY"] },
372
+ { host: "generativelanguage.googleapis.com", label: "Google Gemini", keys: ["GEMINI_API_KEY"] },
373
+ { host: "api.stripe.com", label: "Stripe (HTTP)", keys: ["STRIPE_SECRET_KEY"] },
374
+ { host: "api.resend.com", label: "Resend (email)", keys: ["RESEND_API_KEY"] },
375
+ { host: "api.improvmx.com", label: "ImprovMX (email aliases)", keys: ["IMPROVMX_API_TOKEN"] },
376
+ { host: "api.vercel.com", label: "Vercel API", keys: ["VERCEL_TOKEN"] },
377
+ { host: "api.cal.com", label: "Cal.com (booking)", keys: ["CAL_API_KEY"] },
378
+ { host: "crt.sh", label: "crt.sh (CT-log harvest)", keys: [] },
379
+ ];
380
+
381
+ // SDK import → { label, keys[] }. Catches integrations that go through an SDK
382
+ // rather than a literal fetch URL (e.g. the Stripe + Resend SDKs).
383
+ const INTEGRATION_SDKS = [
384
+ { re: /from\s+["']stripe["']/, label: "Stripe (SDK)", keys: ["STRIPE_SECRET_KEY"] },
385
+ { re: /from\s+["']openai["']/, label: "OpenAI (SDK)", keys: ["OPENAI_API_KEY"] },
386
+ { re: /from\s+["']@anthropic-ai\/sdk["']/, label: "Anthropic (SDK)", keys: ["ANTHROPIC_API_KEY"] },
387
+ { re: /from\s+["']resend["']/, label: "Resend (SDK)", keys: ["RESEND_API_KEY"] },
388
+ { re: /from\s+["']mcp-handler["']/, label: "MCP server (mcp-handler)", keys: [] },
389
+ ];
390
+
391
+ // Env names that hold a SECRET (vs. public config). A match of one of these in
392
+ // a CLIENT-reachable file is the risk we flag. NEXT_PUBLIC_* is deliberately
393
+ // NOT secret (it's compiled into the browser bundle by design).
394
+ const SECRET_ENV_RE = /\bprocess\.env\.([A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY)[A-Z0-9_]*)\b/g;
395
+
396
+ /** A module is "client-reachable" if it (or the file declaring it) is a Client
397
+ * Component — i.e. carries the "use client" directive. Server Components,
398
+ * route handlers and app/lib/server/* never ship to the browser. */
399
+ function isClientFile(text) {
400
+ // The directive must be at the very top (first non-comment statement). A loose
401
+ // check is fine for a heuristic: look for it in the first ~400 chars.
402
+ return /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*["']use client["']/.test(text.slice(0, 400));
403
+ }
404
+
405
+ export function overviewIntegrations(cwd) {
406
+ const appDir = path.join(cwd, "app");
407
+ if (!existsSync(appDir)) {
408
+ return result("pass", "Agents & integrations map", "No app/ directory found — nothing to map.");
409
+ }
410
+
411
+ const { integrations, clientSecretHits, mcpRouteFound } = scanIntegrations(cwd);
412
+
413
+ if (integrations.size === 0 && clientSecretHits.length === 0) {
414
+ return result(
415
+ "pass",
416
+ "Agents & integrations map",
417
+ "No external LLM / 3rd-party integrations detected in app/.",
418
+ );
419
+ }
420
+
421
+ // Build the integration listing, alphabetical, with backing keys.
422
+ const labels = [...integrations.keys()].sort();
423
+ const lines = [];
424
+ for (const label of labels) {
425
+ const e = integrations.get(label);
426
+ const keys = [...e.keys];
427
+ const keyStr = keys.length ? ` — key: ${keys.join(", ")}` : " — no key (public endpoint)";
428
+ lines.push(`${label}${keyStr} (${e.files.size} file${e.files.size === 1 ? "" : "s"})`);
429
+ }
430
+ if (mcpRouteFound) {
431
+ lines.push("MCP server is EXPOSED at /api/mcp — model-callable tools; confirm its auth posture (keyless vs key-scoped).");
432
+ }
433
+
434
+ const detail = `${labels.length} integration(s) detected${mcpRouteFound ? " (incl. an MCP server)" : ""}.`;
435
+
436
+ // Risk flag: secret key reachable from the client bundle.
437
+ if (clientSecretHits.length > 0) {
438
+ const seen = new Set();
439
+ const flagged = [];
440
+ for (const h of clientSecretHits) {
441
+ const k = `${h.file}::${h.key}`;
442
+ if (seen.has(k)) continue;
443
+ seen.add(k);
444
+ flagged.push(`⚠ ${h.file} reads process.env.${h.key} in a "use client" component — it would ship in the browser bundle.`);
445
+ }
446
+ return result(
447
+ "warn",
448
+ "Agents & integrations map",
449
+ detail,
450
+ [
451
+ ...flagged.slice(0, 15),
452
+ "Move secret reads to a server module (app/lib/server/* or a route handler). Only NEXT_PUBLIC_* belongs client-side.",
453
+ "— integrations —",
454
+ ...lines,
455
+ ],
456
+ );
457
+ }
458
+
459
+ return result("pass", "Agents & integrations map", detail, lines);
460
+ }
461
+
462
+ // ===========================================================================
463
+ // 3. SCHEDULES & JOBS MAP
464
+ // ===========================================================================
465
+ // Cron / scheduled jobs: vercel.json "crons" (path + schedule) plus every
466
+ // app/api/cron/* route. For each: route, schedule (if declared), and whether
467
+ // it's GATED (checks CRON_SECRET / does a secret compare / inspects the
468
+ // authorization header). The flag: a cron route that looks publicly
469
+ // triggerable — anyone who knows the URL could fire it.
470
+
471
+ /** Parse vercel.json crons → Map(path → schedule). Best-effort; tolerates a
472
+ * missing or malformed file. */
473
+ function vercelCrons(cwd) {
474
+ const map = new Map();
475
+ const file = path.join(cwd, "vercel.json");
476
+ if (!existsSync(file)) return map;
477
+ let json;
478
+ try {
479
+ json = JSON.parse(readFileSync(file, "utf8"));
480
+ } catch {
481
+ return map;
482
+ }
483
+ const crons = Array.isArray(json?.crons) ? json.crons : [];
484
+ for (const c of crons) {
485
+ if (c && typeof c.path === "string") map.set(c.path, c.schedule || "(no schedule)");
486
+ }
487
+ return map;
488
+ }
489
+
490
+ export function overviewSchedules(cwd) {
491
+ const { rows, ungated, orphanRoutes, missing, cronCount } = scanSchedules(cwd);
492
+
493
+ if (rows.length === 0) {
494
+ return result("pass", "Schedules & jobs map", "No cron routes or vercel.json crons found.");
495
+ }
496
+
497
+ const lines = [];
498
+ for (const r of rows) {
499
+ const sched = r.schedule ? r.schedule : "not in vercel.json";
500
+ lines.push(`${r.url} [${sched}] ${scheduleRowTag(r)}`);
501
+ }
502
+
503
+ const detail =
504
+ `${rows.length} job(s) · ${cronCount} scheduled in vercel.json · ` +
505
+ `${ungated.length} ungated · ${orphanRoutes.length} cron route(s) not wired to a schedule.`;
506
+
507
+ const warnBits = [];
508
+ if (ungated.length > 0) {
509
+ warnBits.push(
510
+ ...ungated.map((r) => `⚠ ${r.url} — no CRON_SECRET / auth-header check; anyone with the URL could trigger it.`),
511
+ );
512
+ }
513
+ if (missing.length > 0) {
514
+ warnBits.push(
515
+ ...missing.map((r) => `⚠ ${r.url} — scheduled in vercel.json but no matching route handler found.`),
516
+ );
517
+ }
518
+
519
+ if (warnBits.length > 0) {
520
+ return result("warn", "Schedules & jobs map", detail, [
521
+ ...warnBits,
522
+ "Gate each scheduled route on CRON_SECRET (constant-time compare of the Authorization bearer).",
523
+ "— full map —",
524
+ ...lines,
525
+ ]);
526
+ }
527
+
528
+ // Orphan cron routes (handler exists, not scheduled) are informational, not a warn.
529
+ const extra = [...lines];
530
+ if (orphanRoutes.length > 0) {
531
+ extra.push(
532
+ `Note: ${orphanRoutes.length} cron route(s) have a handler but aren't wired into vercel.json — they only run if triggered manually.`,
533
+ );
534
+ }
535
+ return result("pass", "Schedules & jobs map", detail, extra);
536
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "getadvantage",
3
+ "version": "0.1.0",
4
+ "description": "The getAdvantage CLI — a local, dependency-free pre-deploy gate + portable project brain for AI-built apps. Plain-language GO / NO-GO, a repo-resident PROJECT-BRIEF.md any model reads first, and a session handoff so you can switch models or tools without re-explaining your project.",
5
+ "type": "module",
6
+ "bin": {
7
+ "getadvantage": "index.mjs",
8
+ "ship-safe": "index.mjs"
9
+ },
10
+ "files": [
11
+ "*.mjs",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "license": "MIT",
18
+ "keywords": [
19
+ "ai",
20
+ "claude",
21
+ "cursor",
22
+ "llm",
23
+ "vibe-coding",
24
+ "pre-deploy",
25
+ "secrets",
26
+ "secret-scan",
27
+ "project-brief",
28
+ "context",
29
+ "handoff",
30
+ "ship-safe",
31
+ "getadvantage"
32
+ ],
33
+ "homepage": "https://getadvantage.app/ship-safe",
34
+ "author": "getAdvantage (https://getadvantage.app)",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }