@patchstack/connect 0.5.12 → 0.5.16

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/dist/cli.js CHANGED
@@ -221,7 +221,7 @@ function unreadableWarning(filename, report) {
221
221
  if (report.unreadable === 0) return null;
222
222
  const shown = report.samples.join(", ");
223
223
  const one = report.unreadable === 1;
224
- return `${filename}: ${report.unreadable} entr${one ? "y was" : "ies were"} not in a form this scanner reads (${shown}${report.unreadable > report.samples.length ? ", \u2026" : ""}), so ${one ? "it is" : "they are"} absent from this manifest. Anything installed only through ${one ? "that entry" : "those entries"} is not being checked.`;
224
+ return `${filename}: ${report.unreadable} entr${one ? "y" : "ies"} could not be read (${shown}${report.unreadable > report.samples.length ? ", \u2026" : ""}), so ${one ? "that package is" : "those packages are"} not being checked.`;
225
225
  }
226
226
 
227
227
  // src/parsers/bun.ts
@@ -1123,7 +1123,7 @@ async function scanLockfile(cwd) {
1123
1123
  warnings.push(staleWarning("node_modules/", missing));
1124
1124
  }
1125
1125
  warnings.push(
1126
- walkWasCandidate ? "Reporting node_modules/, which is what the build loads." : "Scanned node_modules/ instead. Delete the stale lockfile to silence this warning."
1126
+ walkWasCandidate ? "Checked the installed packages in node_modules/, which is what the build loads." : "Checked the installed packages in node_modules/ instead. Delete the out-of-date package list file to stop this warning."
1127
1127
  );
1128
1128
  return manifestWith(walked, warnings);
1129
1129
  }
@@ -1135,7 +1135,7 @@ async function scanLockfile(cwd) {
1135
1135
  );
1136
1136
  }
1137
1137
  warnings.push(
1138
- `No fully-consistent source found; reporting ${firstParsed.filename}. The manifest may understate the real dependency set.`
1138
+ `No package list matches package.json; checked ${firstParsed.filename}, so some packages may be missing. Reinstall your packages to fix this.`
1139
1139
  );
1140
1140
  return manifestWith(firstParsed.packages, warnings);
1141
1141
  }
@@ -1149,12 +1149,19 @@ function otherCompleteSources(all, current, declared) {
1149
1149
  }
1150
1150
  function conflictWarning(filename, conflicts) {
1151
1151
  const shown = conflicts.slice(0, 3).map(({ other, conflict }) => `${conflict.name} ${conflict.version} vs ${conflict.otherVersion} in ${other}`).join("; ");
1152
- return `${filename} and another lockfile disagree about installed versions (${shown}${conflicts.length > 3 ? `, +${conflicts.length - 3} more` : ""}). Scanned node_modules/ instead. Remove whichever lockfile your package manager does not maintain.`;
1153
- }
1152
+ return `${filename} and another package list file give different versions (${shown}${conflicts.length > 3 ? `, +${conflicts.length - 3} more` : ""}). Checked node_modules/ instead. Delete the file your package manager does not use.`;
1153
+ }
1154
+ var INSTALL_BY_SOURCE = {
1155
+ "package-lock.json": "npm install",
1156
+ "bun.lock": "bun install",
1157
+ "bun.lockb": "bun install",
1158
+ "pnpm-lock.yaml": "pnpm install",
1159
+ "yarn.lock": "yarn install"
1160
+ };
1154
1161
  function staleWarning(source, missing) {
1155
1162
  const sample = missing.slice(0, 3).join(", ");
1156
1163
  const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : "";
1157
- return `${source} looks stale: package.json declares ${missing.length} dependenc${missing.length === 1 ? "y" : "ies"} it does not contain (${sample}${suffix}).`;
1164
+ return `${source} is out of date: it is missing ${missing.length} package${missing.length === 1 ? "" : "s"} listed in package.json (${sample}${suffix}). Run ${INSTALL_BY_SOURCE[source] ?? "your install command"} to update it.`;
1158
1165
  }
1159
1166
  async function presentLockfiles(cwd) {
1160
1167
  const probed = await Promise.all(
@@ -1622,21 +1629,28 @@ var CLAIM_TOKEN_HEADER = "X-Patchstack-Claim-Token";
1622
1629
  function claimTokenHeader(config) {
1623
1630
  return config.endpointTrusted !== false && isHeaderValue(config.claimToken) ? { [CLAIM_TOKEN_HEADER]: config.claimToken } : {};
1624
1631
  }
1625
- function claimOutcomeLines(claim3, config) {
1626
- if (typeof config.claimToken !== "string" || config.claimToken === "") return [];
1632
+ function claimOutcome(claim3, config) {
1633
+ if (typeof config.claimToken !== "string" || config.claimToken === "") return null;
1627
1634
  if (claim3?.state === "claimed" || claim3?.state === "owned-by-you") {
1628
- const dashboardUrl = safeRemoteUrl(claim3.dashboard_url);
1629
- const dashboard = dashboardUrl === null ? [] : [`Dashboard: ${dashboardUrl}`];
1630
- return [
1631
- claim3.state === "claimed" ? "Connected to your Patchstack account." : "This site is already connected to your Patchstack account.",
1632
- ...dashboard
1633
- ];
1635
+ return {
1636
+ connected: true,
1637
+ summary: claim3.state === "claimed" ? "Connected to your Patchstack account" : "This project is already connected to your Patchstack account",
1638
+ dashboardUrl: safeRemoteUrl(claim3.dashboard_url),
1639
+ hint: []
1640
+ };
1634
1641
  }
1635
- const why = claim3 === void 0 ? "Patchstack did not act on the claim token" : claim3.state === "owned-by-other" ? "this site belongs to a different Patchstack account" : claim3.reason === "expired" ? "the claim token has expired" : "Patchstack did not recognise the claim token";
1636
- return [
1637
- `Not connected to your account: ${why}.`,
1638
- "Open the dashboard link below to connect it, or copy a fresh prompt from the dashboard."
1639
- ];
1642
+ const why = claimFailureReason(claim3);
1643
+ return {
1644
+ connected: false,
1645
+ summary: `Not connected to your account: ${why}`,
1646
+ dashboardUrl: null,
1647
+ hint: ["Open the dashboard link below to connect it, or copy a fresh prompt from the dashboard."]
1648
+ };
1649
+ }
1650
+ function claimFailureReason(claim3) {
1651
+ if (claim3 === void 0) return "Patchstack did not act on the claim token";
1652
+ if (claim3.state === "owned-by-other") return "this project belongs to a different Patchstack account";
1653
+ return claim3.reason === "expired" ? "the claim token has expired" : "Patchstack did not recognise the claim token";
1640
1654
  }
1641
1655
  function buildEndpointUrl(base, siteUuid) {
1642
1656
  const trimmed = base.replace(/\/$/, "");
@@ -1645,13 +1659,13 @@ function buildEndpointUrl(base, siteUuid) {
1645
1659
  function authFailureMessage(status, config) {
1646
1660
  const hasCredential = typeof config.pulseAuth === "string" && config.pulseAuth.length > 0;
1647
1661
  if (status === 401 && !hasCredential) {
1648
- return "Patchstack requires an API credential for this site and none is configured. Run `npx patchstack-connect login`, or set PATCHSTACK_API_KEY.";
1662
+ return "Patchstack needs this project's API key, and none is set here. Run npx @patchstack/connect login, or set PATCHSTACK_API_KEY.";
1649
1663
  }
1650
1664
  if (status === 401) {
1651
- return "Patchstack rejected this API credential. It may have expired, been revoked, or the site may no longer exist. Run `npx patchstack-connect login` to issue a new one.";
1665
+ return "Patchstack did not accept this project's API key: it may have expired or been revoked, or the project may no longer exist. Run npx @patchstack/connect login to get a new one.";
1652
1666
  }
1653
1667
  if (status === 403) {
1654
- return "This API credential is not permitted to act on this site. Check that siteUuid in .patchstackrc.json matches the credential (a credential is issued for one site).";
1668
+ return "This API key belongs to a different project. Check that siteUuid in .patchstackrc.json is the project the key was made for.";
1655
1669
  }
1656
1670
  return null;
1657
1671
  }
@@ -2064,7 +2078,7 @@ function renderDemoGuide(state) {
2064
2078
  `Patchstack demo guide \u2014 ${scenario.name}`,
2065
2079
  "",
2066
2080
  "Goal: prove that a live Patchstack virtual patch blocks the vulnerable payload while normal traffic still succeeds.",
2067
- "Deployment required: no. Keep the app running locally; the connector and generated guard contact Patchstack\u2019s production API.",
2081
+ "Deployment required: no. Keep the app running locally; Connect and the generated guard contact Patchstack\u2019s production API.",
2068
2082
  "",
2069
2083
  `1. ${siteReady ? "\u2713" : "\u25CB"} Connect this project from Bolt\u2019s Host account dropdown`,
2070
2084
  siteReady ? ` Ready \u2014 .patchstackrc.json contains site ${state.siteUuid}.` : " Click \u201CConnect Patchstack\u201D in Bolt. This creates .patchstackrc.json; no separate CLI login is needed.",
@@ -3105,6 +3119,16 @@ function rootIdentifier(node, ts) {
3105
3119
  }
3106
3120
  return void 0;
3107
3121
  }
3122
+ function rootIdentifierNode(node, ts) {
3123
+ let cur = node;
3124
+ while (cur) {
3125
+ if (ts.isIdentifier(cur)) return cur;
3126
+ if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur) || ts.isCallExpression(cur) || ts.isNewExpression(cur) || ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) {
3127
+ cur = cur.expression;
3128
+ } else return void 0;
3129
+ }
3130
+ return void 0;
3131
+ }
3108
3132
  function spanOf(node) {
3109
3133
  const out = { line: lineOf(node) };
3110
3134
  try {
@@ -3209,6 +3233,15 @@ function opCallOf(propAccess, ts) {
3209
3233
  const p = propAccess?.parent;
3210
3234
  return p && ts.isCallExpression(p) && p.expression === propAccess ? p : propAccess;
3211
3235
  }
3236
+ function localCallIdentifiers(node, ts) {
3237
+ const out = [];
3238
+ const visit = (n) => {
3239
+ if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) out.push(n.expression);
3240
+ ts.forEachChild(n, visit);
3241
+ };
3242
+ visit(node);
3243
+ return out;
3244
+ }
3212
3245
  function localCalls(node, ts) {
3213
3246
  const names = [];
3214
3247
  const visit = (n) => {
@@ -3245,8 +3278,9 @@ function routeFromFilePath(relFile) {
3245
3278
  segs = dirs.slice(at("routes") + 1);
3246
3279
  } else if (at("pages") !== -1) {
3247
3280
  segs = [...dirs.slice(at("pages") + 1), ...base === "index" ? [] : [base]];
3248
- } else if (at("server") !== -1) {
3249
- segs = [...dirs.slice(at("server") + 1), ...base === "index" ? [] : [base.replace(/\.(get|post|put|patch|delete|head|options)$/i, "")]];
3281
+ } else if (nuxtRoot(dirs) !== void 0) {
3282
+ const root = nuxtRoot(dirs);
3283
+ segs = [...root, ...base === "index" ? [] : [base.replace(/\.(get|post|put|patch|delete|head|options)$/i, "")]];
3250
3284
  }
3251
3285
  if (!segs) return {};
3252
3286
  segs = segs.filter((s) => !(s.startsWith("(") && s.endsWith(")")) && !s.startsWith("@") && !s.startsWith("_"));
@@ -3262,6 +3296,21 @@ function routeFromFilePath(relFile) {
3262
3296
  const route = "/" + mapped.join("/");
3263
3297
  return { route: route.length > 1 ? route.replace(/\/+$/, "") : "/", dynamic };
3264
3298
  }
3299
+ function isPagesApiFile(relFile) {
3300
+ const dirs = relFile.split(/[\\/]/).filter(Boolean).slice(0, -1);
3301
+ const i = dirs.lastIndexOf("pages");
3302
+ return i !== -1 && dirs[i + 1] === "api";
3303
+ }
3304
+ function nuxtRoot(dirs) {
3305
+ const i = dirs.lastIndexOf("server");
3306
+ if (i === -1) return void 0;
3307
+ if (dirs[i + 1] === "api") return dirs.slice(i + 1);
3308
+ if (dirs[i + 1] === "routes") return dirs.slice(i + 2);
3309
+ return void 0;
3310
+ }
3311
+ function isRoutePath(route) {
3312
+ return route.startsWith("/") || route === "*";
3313
+ }
3265
3314
  function routeFromChain(expr, ts) {
3266
3315
  let cur = expr;
3267
3316
  while (cur && ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
@@ -3322,7 +3371,35 @@ function detectFramework(cwd) {
3322
3371
  }
3323
3372
  return "unknown";
3324
3373
  }
3325
- var isSourceFile = (name) => /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(name) && !name.endsWith(".d.ts");
3374
+ var isSourceFile = (name) => /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(name) && !name.endsWith(".d.ts") || isComponentFile(name);
3375
+ var isComponentFile = (name) => /\.(vue|svelte|astro)$/.test(name);
3376
+ function componentScript(text, file) {
3377
+ const keep = [];
3378
+ if (file.endsWith(".astro")) {
3379
+ const open2 = /^?\s*---[^\S\r\n]*\r?\n/.exec(text);
3380
+ if (open2) {
3381
+ const start = open2[0].length;
3382
+ const close = /\r?\n---[^\S\r\n]*(\r?\n|$)/g;
3383
+ close.lastIndex = start - 1;
3384
+ const end = close.exec(text);
3385
+ if (!end) return null;
3386
+ if (end.index > start) keep.push([start, end.index]);
3387
+ }
3388
+ }
3389
+ const opening = /<script\b[^>]*>/gi;
3390
+ for (let m = opening.exec(text); m; m = opening.exec(text)) {
3391
+ const start = m.index + m[0].length;
3392
+ const closing = /<\/script\s*>/gi;
3393
+ closing.lastIndex = start;
3394
+ const end = closing.exec(text);
3395
+ if (!end) return null;
3396
+ keep.push([start, end.index]);
3397
+ opening.lastIndex = end.index + end[0].length;
3398
+ }
3399
+ let out = text.replace(/[^\r\n]/g, " ");
3400
+ for (const [start, end] of keep) out = out.slice(0, start) + text.slice(start, end) + out.slice(end);
3401
+ return out;
3402
+ }
3326
3403
  var SKIP_DIRS = /* @__PURE__ */ new Set([
3327
3404
  "node_modules",
3328
3405
  "dist",
@@ -3825,7 +3902,7 @@ function classifyArchitecture(cwd) {
3825
3902
  return {
3826
3903
  requestPath: "none",
3827
3904
  evidence: statics.map((label) => `static build: ${label}`),
3828
- note: `This project builds a static site (${statics.join(", ")}) and nothing in it receives a request, so there is no request path for a runtime guard to attach to. Dependency monitoring and the disclosure widget still apply; runtime protection does not.`
3905
+ note: `This project builds a static site (${statics.join(", ")}) and nothing in it receives a request, so there is no request path for a runtime guard to attach to. Dependency monitoring and the Patchstack Connector still apply; runtime protection does not.`
3829
3906
  };
3830
3907
  }
3831
3908
  return {
@@ -3840,7 +3917,21 @@ import { readFileSync as readFileSync7, existsSync as existsSync3 } from "fs";
3840
3917
  import { join as join5, dirname as dirname2 } from "path";
3841
3918
  import { fileURLToPath } from "url";
3842
3919
  var read = (p) => readFileSync7(p, "utf8");
3843
- var log = (msg) => console.log(`patchstack protect: ${msg}`);
3920
+ var logSink = null;
3921
+ var log = (msg) => {
3922
+ if (logSink !== null) logSink(msg);
3923
+ else console.log(`patchstack protect: ${msg}`);
3924
+ };
3925
+ function collectProtectLog(fn) {
3926
+ const lines = [];
3927
+ const previous = logSink;
3928
+ logSink = (msg) => lines.push(...msg.split("\n"));
3929
+ try {
3930
+ return { result: fn(), lines };
3931
+ } finally {
3932
+ logSink = previous;
3933
+ }
3934
+ }
3844
3935
  function hasDependency(cwd, name) {
3845
3936
  try {
3846
3937
  const pkg = JSON.parse(read(join5(cwd, "package.json")));
@@ -5502,6 +5593,147 @@ function ensureSourceWidget(cwd, siteUuid, jsxShell = null) {
5502
5593
  return { shell, action };
5503
5594
  }
5504
5595
 
5596
+ // src/progress.ts
5597
+ var PROGRESS_STEPS = [
5598
+ { step: "installed", label: "Install the Patchstack connector" },
5599
+ { step: "connected", label: "Connect project to Patchstack account" },
5600
+ { step: "synced", label: "Sync and monitor your project" },
5601
+ { step: "deployed", label: "Deploy project to protect live app" }
5602
+ ];
5603
+ function stepLabel(step, environment) {
5604
+ if (step === "synced" && environment) return `Sync and monitor in ${environment} environment`;
5605
+ return PROGRESS_STEPS.find((entry) => entry.step === step).label;
5606
+ }
5607
+ function deployNote(context) {
5608
+ if (context.environment !== "production") return null;
5609
+ return context.environmentSource === "builder" ? "Reported as a publish. Patchstack ticks this once it sees the live site." : "Built for production. Patchstack ticks this once it sees the live site.";
5610
+ }
5611
+ var ANSI = {
5612
+ reset: "\x1B[0m",
5613
+ bold: "\x1B[1m",
5614
+ dim: "\x1B[2m",
5615
+ green: "\x1B[32m",
5616
+ yellow: "\x1B[33m",
5617
+ cyan: "\x1B[36m"
5618
+ };
5619
+ function nextProgressStep(progress) {
5620
+ return PROGRESS_STEPS.find(({ step }) => !progress[step])?.step ?? null;
5621
+ }
5622
+ function nextStepTitle(step, context) {
5623
+ switch (step) {
5624
+ case "installed":
5625
+ return "install the Patchstack connector";
5626
+ case "connected":
5627
+ return "connect this project to your Patchstack account";
5628
+ case "synced":
5629
+ return "sync this project with Patchstack";
5630
+ case "deployed":
5631
+ return context.environment === "production" ? "open your live app so Patchstack can see it" : "deploy your project to protect the live app";
5632
+ }
5633
+ }
5634
+ function nextStepLines(step, context) {
5635
+ switch (step) {
5636
+ case "installed":
5637
+ return [`Run: ${context.installCommand}`, "Then run: npx @patchstack/connect setup"];
5638
+ case "connected":
5639
+ if (context.siteUuid === null) {
5640
+ return [
5641
+ "Run: npx @patchstack/connect setup",
5642
+ "It adds this project to Patchstack and prints the link to connect it.",
5643
+ 'Cannot run commands here? See "When your tool will not run this CLI" in AGENT-INSTALL.md.'
5644
+ ];
5645
+ }
5646
+ return [
5647
+ context.claimUrl !== null ? `Open ${context.claimUrl}` : "Run: npx @patchstack/connect claim",
5648
+ // A production run is the deploy itself, so there is nothing further to point at.
5649
+ ...context.environment === "production" ? [] : ["Already connected? Then commit, set PATCHSTACK_API_KEY on your host, and deploy."]
5650
+ ];
5651
+ case "synced":
5652
+ return ["Run: npx @patchstack/connect scan"];
5653
+ case "deployed":
5654
+ if (context.environment === "production") {
5655
+ return ["Open the live site once so Patchstack can see it.", "Not published yet? Publish or deploy it now."];
5656
+ }
5657
+ return [
5658
+ "Commit your changes. Never commit .patchstackrc.local.json.",
5659
+ "Set PATCHSTACK_API_KEY (from .patchstackrc.local.json) on your hosting platform.",
5660
+ "Deploy or publish. The live site keeps its old version until you do."
5661
+ ];
5662
+ }
5663
+ }
5664
+ function renderProgress(progress, context, options) {
5665
+ const paint = (code, text) => options.useColor ? `${code}${text}${ANSI.reset}` : text;
5666
+ const lines = [];
5667
+ for (const { step } of PROGRESS_STEPS) {
5668
+ const label = stepLabel(step, context.environment);
5669
+ lines.push(progress[step] ? ` ${paint(ANSI.green, "\u2714")} ${label}` : ` ${paint(ANSI.yellow, "\u2718")} ${label}`);
5670
+ const note = step === "deployed" && !progress.deployed ? deployNote(context) : null;
5671
+ if (note !== null) lines.push(` ${paint(ANSI.dim, note)}`);
5672
+ }
5673
+ const next = nextProgressStep(progress);
5674
+ lines.push("");
5675
+ if (next === null) {
5676
+ lines.push(paint(ANSI.bold, "All done."));
5677
+ return lines;
5678
+ }
5679
+ lines.push(`${paint(ANSI.cyan, "\u279C")} ${paint(ANSI.bold, `Next: ${nextStepTitle(next, context)}`)}`);
5680
+ for (const line of nextStepLines(next, context)) {
5681
+ lines.push(` ${line}`);
5682
+ }
5683
+ return lines;
5684
+ }
5685
+
5686
+ // src/report.ts
5687
+ function emptyReport() {
5688
+ return { done: [], missing: [] };
5689
+ }
5690
+ var ANSI2 = {
5691
+ reset: "\x1B[0m",
5692
+ bold: "\x1B[1m",
5693
+ dim: "\x1B[2m",
5694
+ green: "\x1B[32m",
5695
+ yellow: "\x1B[33m"
5696
+ };
5697
+ function renderStatus(title, report, progress, context, options) {
5698
+ const paint = (code, text) => options.useColor ? `${code}${text}${ANSI2.reset}` : text;
5699
+ const lines = [paint(ANSI2.bold, title), ""];
5700
+ if (report.done.length > 0) {
5701
+ lines.push(paint(ANSI2.bold, "Done"));
5702
+ for (const item of report.done) lines.push(` ${paint(ANSI2.green, "\u2714")} ${item}`);
5703
+ lines.push("");
5704
+ }
5705
+ if (report.missing.length > 0) {
5706
+ lines.push(paint(ANSI2.bold, "Missing"));
5707
+ for (const item of report.missing) {
5708
+ lines.push(` ${paint(ANSI2.yellow, "\u2718")} ${item.text}`);
5709
+ for (const hint of item.hint ?? []) lines.push(` ${hint}`);
5710
+ if (options.verbose === true) {
5711
+ for (const detail2 of item.detail ?? []) lines.push(` ${paint(ANSI2.dim, detail2)}`);
5712
+ }
5713
+ }
5714
+ lines.push("");
5715
+ }
5716
+ if (options.withoutProgress === true) {
5717
+ if (lines[lines.length - 1] === "") lines.pop();
5718
+ return lines;
5719
+ }
5720
+ lines.push(...renderProgress(progress, context, { useColor: options.useColor }));
5721
+ return lines;
5722
+ }
5723
+ function renderHookSummary(report, progress, context) {
5724
+ const lowerFirst = (text) => text.charAt(0).toLowerCase() + text.slice(1);
5725
+ const lines = [];
5726
+ if (report.done.length > 0) {
5727
+ lines.push(`Patchstack: ${report.done.map(lowerFirst).join("; ")}.`);
5728
+ }
5729
+ const next = nextProgressStep(progress);
5730
+ if (next !== null) {
5731
+ const first = next === "deployed" && context.environment === "production" ? void 0 : nextStepLines(next, context)[0];
5732
+ lines.push(`Patchstack next step: ${nextStepTitle(next, context)}.${first !== void 0 ? ` ${first}` : ""}`);
5733
+ }
5734
+ return lines;
5735
+ }
5736
+
5505
5737
  // src/guide.ts
5506
5738
  var PROD_MARKER_NEEDLE = "__PATCHSTACK_PROD__";
5507
5739
  var WIDGET_NEEDLE2 = "patchstack-widget";
@@ -5698,8 +5930,12 @@ async function collectGuideState(cwd) {
5698
5930
  let claimUrl = null;
5699
5931
  let endpointOverride = null;
5700
5932
  let widgetOptOut = false;
5933
+ let environment = null;
5934
+ let environmentSource = null;
5701
5935
  try {
5702
5936
  const config = await resolveConfig({ cwd });
5937
+ environment = config.environment;
5938
+ environmentSource = config.environmentSource ?? null;
5703
5939
  siteUuid = config.siteUuid;
5704
5940
  if (siteUuid !== null && config.endpointTrusted !== false) {
5705
5941
  claimUrl = buildClaimUrl(config.endpoint, siteUuid);
@@ -5726,6 +5962,8 @@ async function collectGuideState(cwd) {
5726
5962
  siteUuid,
5727
5963
  claimUrl,
5728
5964
  endpointOverride,
5965
+ environment,
5966
+ environmentSource,
5729
5967
  hasBuildScript: Boolean(pkg?.scripts?.build?.trim()),
5730
5968
  installScanWired: (pkg?.scripts?.postinstall ?? "").includes("patchstack-connect scan"),
5731
5969
  // The scan has to run first: a later prebuild command may upload and stamp the map that the bundle
@@ -5744,7 +5982,7 @@ async function collectGuideState(cwd) {
5744
5982
  protectionApplicable: protection.applicable
5745
5983
  };
5746
5984
  }
5747
- var ANSI = {
5985
+ var ANSI3 = {
5748
5986
  reset: "\x1B[0m",
5749
5987
  bold: "\x1B[1m",
5750
5988
  dim: "\x1B[2m",
@@ -5758,9 +5996,6 @@ function needsSourceProductionMarker(state) {
5758
5996
  }
5759
5997
  return !state.widgetFileHint.toLowerCase().endsWith(".html");
5760
5998
  }
5761
- function widgetTagInPlace(state) {
5762
- return state.siteUuid !== null && !state.widgetOptOut && state.widgetInstalled && state.widgetTokenMatches !== false;
5763
- }
5764
5999
  function countRemainingSteps(state) {
5765
6000
  return [
5766
6001
  state.installed?.section === "dependencies",
@@ -5772,217 +6007,143 @@ function countRemainingSteps(state) {
5772
6007
  !state.protectionApplicable || state.protectionWired
5773
6008
  ].filter((step) => !step).length;
5774
6009
  }
5775
- function renderGuideChecklist(state, useColor) {
5776
- const paint = (code, text) => useColor ? `${code}${text}${ANSI.reset}` : text;
5777
- const done = (text) => ` ${paint(ANSI.green, "\u2714")} ${text}`;
5778
- const todo = (text) => ` ${paint(ANSI.yellow, "\u2716")} ${paint(ANSI.bold, text)}`;
5779
- const detail = (text) => ` ${paint(ANSI.dim, text)}`;
6010
+ function guideProgress(state, known = {}) {
6011
+ return {
6012
+ installed: state.installed !== null,
6013
+ // Claim state lives on the server and nothing on disk records it, so only a caller that has just
6014
+ // heard from the server (a scan's claim outcome) can mark it done.
6015
+ connected: false,
6016
+ // `.patchstackrc.json` only gains a site UUID from a manifest the server stored.
6017
+ synced: state.siteUuid !== null,
6018
+ // Only the dashboard can see the live site, so nothing the CLI runs marks this done.
6019
+ deployed: false,
6020
+ ...known
6021
+ };
6022
+ }
6023
+ function guideNextStepContext(state) {
6024
+ return {
6025
+ installCommand: installCommand(state.packageManager),
6026
+ siteUuid: state.siteUuid,
6027
+ claimUrl: state.claimUrl,
6028
+ environment: state.environment,
6029
+ environmentSource: state.environmentSource
6030
+ };
6031
+ }
6032
+ function notConnectedItem(why = []) {
6033
+ return {
6034
+ text: "Not connected to your Patchstack account",
6035
+ hint: [...why, "Until it is, anyone who opens your app can connect it to their own account."]
6036
+ };
6037
+ }
6038
+ function buildScriptLines(state) {
5780
6039
  const lines = [];
5781
- const headerParts = [state.framework, state.packageManager].filter(
5782
- (part) => part !== null
5783
- );
5784
- const name = state.projectName ?? path12.basename(process.cwd());
5785
- lines.push(paint(ANSI.bold, `Patchstack setup status \u2014 ${name} (${headerParts.join(" \xB7 ")})`));
5786
- if (state.endpointOverride !== null) {
5787
- lines.push(
5788
- detail(
5789
- `endpoint override in effect: ${state.endpointOverride} (set via .patchstackrc.json, PATCHSTACK_ENDPOINT, or --endpoint)`
5790
- )
5791
- );
5792
- }
5793
- lines.push("");
5794
- if (!state.hasPackageJson) {
5795
- lines.push(todo("No package.json found in this directory."));
5796
- lines.push(detail("For a JS/Node app, run the guide from its package directory; check that package.json is readable and valid."));
5797
- lines.push(detail("For a standalone HTML/CSS/browser-JavaScript site, use the disclosure widget directly."));
5798
- lines.push(detail("Do not create a Node project, build hooks, or a server just to install the widget."));
5799
- lines.push(detail("Use the correct site UUID or widget snippet from the Patchstack dashboard; never invent one."));
5800
- lines.push(detail('See "Plain HTML sites" in AGENT-INSTALL.md. Widget-only setup provides no dependency scan or runtime protection.'));
5801
- return lines.join("\n");
5802
- }
5803
- if (countRemainingSteps(state) > 0) {
5804
- lines.push(` ${paint(ANSI.cyan, "\u279C")} ${paint(ANSI.bold, "Tell the user what you are doing as you go.")}`);
5805
- lines.push(" Installs here take minutes and they are watching output they cannot read.");
5806
- lines.push(" Say it in plain words: their site is built out of hundreds of ready-made");
5807
- lines.push(" pieces of code, Patchstack keeps track of which ones it uses and warns them");
5808
- lines.push(" when one turns out to be unsafe, and nothing their visitors see changes.");
5809
- lines.push(" Avoid lockfile / dependency / manifest / npm / UUID when speaking to them.");
5810
- lines.push("");
5811
- }
5812
- if (state.siteUuid === null) {
5813
- lines.push(` ${paint(ANSI.cyan, "\u279C")} ${paint(ANSI.bold, "Nothing is set up yet \u2014 two commands cover the steps below:")}`);
5814
- if (state.installed?.section !== "dependencies") {
5815
- lines.push(` ${installCommand(state.packageManager)}`);
5816
- }
5817
- lines.push(" npx @patchstack/connect setup");
5818
- lines.push(" setup provisions the site, adds the widget, installs and verifies runtime protection,");
5819
- lines.push(" and wires the install and build scans, then prints this checklist again. Anything");
5820
- lines.push(" still listed there is a step setup could not apply safely, and is yours to finish.");
5821
- lines.push("");
5822
- }
5823
- if (state.installed?.section === "dependencies") {
5824
- lines.push(done(`@patchstack/connect installed (${state.installed.version}, ${state.installed.section})`));
5825
- } else if (state.installed !== null) {
5826
- lines.push(todo(`Move @patchstack/connect to runtime dependencies (currently ${state.installed.section})`));
5827
- lines.push(detail(`Run \u2192 ${installCommand(state.packageManager)}`));
5828
- lines.push(detail("The generated guard imports @patchstack/connect/protect at runtime."));
5829
- } else {
5830
- lines.push(todo("Install @patchstack/connect as a runtime dependency"));
5831
- lines.push(detail(`Run \u2192 ${installCommand(state.packageManager)}`));
6040
+ if (!state.installScanWired) lines.push('"postinstall": "patchstack-connect scan"');
6041
+ if (state.hasBuildScript && !(state.prebuildWired && state.postbuildWired)) {
5832
6042
  if (state.packageManager === "bun") {
5833
- lines.push(detail(`(if bun isn't available here, ${INSTALL_COMMANDS.npm} works too)`));
5834
- }
5835
- }
5836
- if (state.siteUuid !== null) {
5837
- lines.push(done(`Site provisioned (${state.siteUuid})`));
5838
- } else {
5839
- lines.push(todo("Provision the site \u2014 run the first scan"));
5840
- lines.push(detail("Run \u2192 npx @patchstack/connect scan"));
5841
- lines.push(detail("Reads the lockfile, registers the project, writes .patchstackrc.json,"));
5842
- lines.push(detail("and prints a dashboard link. The CLI prints the link but never opens it."));
5843
- lines.push(detail("If your tool refuses to run this command, hand it to the person instead of working"));
5844
- lines.push(detail('around it \u2014 see "When your tool will not run this CLI" in the reference guide.'));
5845
- }
5846
- if (state.installScanWired) {
5847
- lines.push(done("Dependency-install scan wired (postinstall)"));
5848
- } else {
5849
- lines.push(todo("Scan again whenever dependencies are installed"));
5850
- lines.push(detail('Edit package.json \u2192 "postinstall": "patchstack-connect scan"'));
5851
- }
5852
- if (!state.hasBuildScript) {
5853
- lines.push(done("No build script to integrate (postinstall covers dependency changes)"));
5854
- } else if (state.prebuildWired && state.postbuildWired) {
5855
- lines.push(done("Build hooks wired (scan before builds, mark-build after)"));
5856
- } else if (state.packageManager === "bun") {
5857
- lines.push(todo('Wire the build hooks yourself \u2014 edit package.json (bun skips pre/post hooks, so chain inside "build")'));
5858
- lines.push(detail('Edit package.json \u2192 "build": "patchstack-connect scan && <existing build command> && patchstack-connect mark-build"'));
5859
- } else {
5860
- lines.push(todo('Wire the build hooks yourself \u2014 edit package.json "scripts" (chain with && if a hook already exists)'));
5861
- if (!state.prebuildWired) {
5862
- lines.push(detail('Edit package.json \u2192 "prebuild": "patchstack-connect scan"'));
5863
- }
5864
- if (!state.postbuildWired) {
5865
- lines.push(detail('Edit package.json \u2192 "postbuild": "patchstack-connect mark-build"'));
5866
- }
5867
- }
5868
- const widgetOk = state.widgetInstalled && state.widgetTokenMatches !== false;
5869
- if (state.widgetOptOut && !widgetOk) {
5870
- lines.push(done('Disclosure widget disabled by config ("widget": false in .patchstackrc.json)'));
5871
- } else if (widgetOk) {
5872
- lines.push(done("Disclosure widget installed"));
5873
- } else if (state.widgetInstalled) {
5874
- lines.push(todo("Fix the disclosure widget yourself \u2014 its site UUID doesn't match this project's"));
5875
- lines.push(detail(`Edit the widget tag \u2192 set data-site-uuid (or userToken) to '${state.siteUuid}' (a wrong UUID makes the widget silently no-op)`));
5876
- } else if (state.siteUuid === null) {
5877
- lines.push(todo('Add the "Report a vulnerability" widget \u2014 the first scan does this for you'));
5878
- lines.push(detail("Run \u2192 npx @patchstack/connect scan (provisions the site and adds the widget tag"));
5879
- lines.push(detail(" to the root HTML shell: index.html / public/index.html / src/app.html)"));
5880
- } else {
5881
- lines.push(todo('Add the "Report a vulnerability" widget yourself \u2014 this root is code, not a plain HTML shell'));
5882
- lines.push(detail("Note \u2192 a normal `scan` adds this tag to a plain HTML shell automatically; add it by hand here:"));
5883
- const placement = state.widgetFileHint !== null ? `Edit ${state.widgetFileHint} \u2192 put it just before </body>:` : "Edit your root layout \u2192 put it just before </body> (the framework's HTML/layout mechanism, never a JS entry point):";
5884
- lines.push(detail(placement));
5885
- lines.push(detail(` ${buildWidgetTag(state.siteUuid)}`));
5886
- lines.push(detail("The site UUID is public by design \u2014 it ships in client-side HTML."));
5887
- }
5888
- if (needsSourceProductionMarker(state)) {
5889
- if (state.productionMarkerWired) {
5890
- lines.push(done("Production marker wired (widget switches to report mode on the published site)"));
6043
+ lines.push('"build": "patchstack-connect scan && <existing build command> && patchstack-connect mark-build"');
5891
6044
  } else {
5892
- lines.push(todo("Add the production marker \u2014 this root is server-rendered, so mark-build cannot stamp it"));
5893
- lines.push(
5894
- detail(
5895
- "Without it the widget treats the published site as build mode and shows the claim flow to visitors."
5896
- )
5897
- );
5898
- const gate = productionGate(state.framework);
5899
- if (hasJsxShell(state.framework)) {
5900
- lines.push(detail(`Run \u2192 npx @patchstack/connect scan (adds it to ${state.widgetFileHint} automatically)`));
5901
- lines.push(detail("Or add it by hand, in <head> above the widget tag:"));
5902
- for (const snippetLine of buildSourceMarkerSnippet(state.framework).split("\n")) {
5903
- lines.push(detail(` ${snippetLine}`));
5904
- }
5905
- } else {
5906
- lines.push(
5907
- detail(
5908
- `Edit ${state.widgetFileHint} \u2192 using the framework's head mechanism, emit an inline`
5909
- )
5910
- );
5911
- lines.push(detail(` <script>window.__PATCHSTACK_PROD__=true;</script> only when ${gate}.`));
5912
- }
5913
- lines.push(detail(`The ${gate} guard is required \u2014 an ungated marker also hides the claim flow in preview.`));
6045
+ if (!state.prebuildWired) lines.push('"prebuild": "patchstack-connect scan"');
6046
+ if (!state.postbuildWired) lines.push('"postbuild": "patchstack-connect mark-build"');
5914
6047
  }
5915
6048
  }
5916
- if (!state.protectionApplicable) {
5917
- lines.push(done("Runtime protection: not applicable \u2014 this project has no request path"));
5918
- for (const check of state.protectionChecks.filter((item) => item.hint !== void 0 && item.group !== "reporting")) {
5919
- lines.push(detail(check.hint ?? ""));
5920
- }
5921
- } else if (state.protectionWired) {
5922
- lines.push(done(`Runtime protection wired (${state.protectionStack})`));
5923
- } else {
5924
- lines.push(todo(`Finish runtime protection (${state.protectionStack})`));
5925
- for (const check of state.protectionChecks.filter((item) => !item.ok)) {
5926
- lines.push(detail(`${check.label}${check.hint ? ` \u2014 ${check.hint}` : ""}`));
5927
- }
5928
- lines.push(detail("Verify \u2192 npx @patchstack/connect protect --check"));
5929
- lines.push(detail("Prove a request reaches the guard (starts your app) \u2192 npx @patchstack/connect protect --check --runtime"));
6049
+ return lines;
6050
+ }
6051
+ function guideMissing(state, known = {}) {
6052
+ const missing = [];
6053
+ if (state.installed?.section === "devDependencies") {
6054
+ missing.push({
6055
+ text: "Patchstack is installed as a development tool only, so your live app cannot load it",
6056
+ hint: [`Run: ${installCommand(state.packageManager)}`]
6057
+ });
5930
6058
  }
5931
- lines.push("");
5932
- if (state.claimUrl !== null) {
5933
- lines.push(` ${paint(ANSI.cyan, "\u279C")} ${paint(ANSI.bold, "Connect this site to your Patchstack account:")}`);
5934
- let route = 1;
5935
- if (widgetTagInPlace(state)) {
5936
- lines.push(` ${route++}. In the preview \u2014 while the site is unclaimed the widget shows a`);
5937
- lines.push(' "Connect this website" panel. Signing in there attaches the site.');
5938
- }
5939
- lines.push(` ${route++}. In a browser \u2014 open the dashboard link (the CLI never opens it):`);
5940
- lines.push(` ${paint(ANSI.cyan, state.claimUrl)}`);
5941
- lines.push(` ${route}. From this terminal \u2192 npx @patchstack/connect claim`);
5942
- lines.push(" (prints a link to sign in with, then attaches the site to that account)");
5943
- lines.push(detail("Reports have no owner to reach until one of these completes. The site UUID ships"));
5944
- lines.push(detail("in the page and claiming is first-come, so an unclaimed site stays claimable by"));
5945
- lines.push(detail("anyone who loads it."));
5946
- if (state.endpointOverride !== null) {
5947
- lines.push(detail("(this URL inherits the endpoint override above)"));
5948
- }
5949
- } else {
5950
- lines.push(detail("The dashboard link appears after the first scan (re-print any time with `status`)."));
6059
+ if (state.siteUuid === null) return missing;
6060
+ if (!state.widgetOptOut && state.widgetInstalled && state.widgetTokenMatches === false) {
6061
+ missing.push({
6062
+ text: "The Patchstack widget on your page belongs to a different project",
6063
+ hint: [`Set data-site-uuid to '${state.siteUuid}' on the widget tag.`]
6064
+ });
6065
+ } else if (!state.widgetOptOut && !state.widgetInstalled) {
6066
+ missing.push({
6067
+ text: "The Patchstack widget is not on your page yet",
6068
+ hint: [
6069
+ state.widgetFileHint !== null ? `Add this to ${state.widgetFileHint}, just before </body>:` : "Add this to your main page layout, just before </body>:",
6070
+ ` ${buildWidgetTag(state.siteUuid)}`
6071
+ ]
6072
+ });
5951
6073
  }
5952
- if (widgetTagInPlace(state)) {
5953
- lines.push("");
5954
- lines.push(` ${paint(ANSI.cyan, "\u279C")} ${paint(ANSI.bold, "Refresh the preview to see the widget:")}`);
5955
- lines.push(" The widget loads with the page, so a preview that was already open still shows");
5956
- lines.push(" the HTML from before this change. Builders that hot reload refresh it themselves;");
5957
- lines.push(" if nothing appears, refresh the preview once.");
5958
- lines.push(' Unclaimed, the widget shows the "Connect this website" panel; the public');
5959
- lines.push(' "Report a vulnerability" button takes its place once the site is claimed.');
5960
- }
5961
- if (state.siteUuid !== null) {
5962
- lines.push("");
5963
- lines.push(` ${paint(ANSI.cyan, "\u279C")} ${paint(ANSI.bold, "Deploy to put this on your live site:")}`);
5964
- lines.push(" These are source changes. Your deployed site keeps serving its previous build,");
5965
- lines.push(" so visitors only get the widget after you deploy (or hit Publish) again.");
6074
+ if (state.protectionApplicable && !state.protectionWired) {
6075
+ const failing = state.protectionChecks.filter((item) => !item.ok && item.group !== "reporting");
6076
+ const generic = state.protectionStack === "generic";
6077
+ missing.push({
6078
+ key: "runtime-protection",
6079
+ text: generic ? "Runtime protection: not added to your server yet" : `Runtime protection: not finished for ${state.protectionStack}`,
6080
+ hint: generic ? ["Add Patchstack where requests enter your app. Run npx @patchstack/connect protect for the steps."] : ["Run: npx @patchstack/connect protect, then npx @patchstack/connect protect --check"],
6081
+ detail: failing.map((check) => `${check.label}${check.hint ? ` \u2014 ${check.hint}` : ""}`)
6082
+ });
5966
6083
  }
5967
- const remaining = countRemainingSteps(state);
5968
- lines.push("");
5969
- if (remaining === 0) {
5970
- lines.push(
5971
- done(
5972
- paint(
5973
- ANSI.bold,
5974
- "Ready to deploy. Everything above is in the working tree only: commit .patchstackrc.json, package.json, the runtime guard changes, and the file carrying the widget snippet; add PATCHSTACK_API_KEY to the hosting platform; then deploy. Never commit .patchstackrc.local.json \u2014 it holds the API key, and setup has already added it to .gitignore."
5975
- )
5976
- )
5977
- );
5978
- if (state.claimUrl !== null) {
5979
- lines.push(detail("Until the site is attached to an account (dashboard link above, or `claim`), nobody can see its reports."));
5980
- }
5981
- } else {
5982
- lines.push(
5983
- ` ${paint(ANSI.yellow, String(remaining))} step(s) remaining \u2014 details in the reference guide below.`
6084
+ const scripts = buildScriptLines(state);
6085
+ if (scripts.length > 0) {
6086
+ missing.push({
6087
+ text: "Patchstack does not check your packages on install and build yet",
6088
+ hint: ["Run: npx @patchstack/connect setup (it adds the build steps to package.json)"],
6089
+ detail: [
6090
+ state.packageManager === "bun" ? "Add to package.json scripts:" : "Add to package.json scripts (chain with && if one exists):",
6091
+ ...scripts.map((line) => ` ${line}`)
6092
+ ]
6093
+ });
6094
+ }
6095
+ if (needsSourceProductionMarker(state) && !state.productionMarkerWired) {
6096
+ const gate = productionGate(state.framework);
6097
+ missing.push(
6098
+ hasJsxShell(state.framework) ? {
6099
+ text: "Your live app does not tell Patchstack it is live yet",
6100
+ hint: [`Run: npx @patchstack/connect scan (it edits ${state.widgetFileHint})`],
6101
+ detail: [`Or add inside <head>:`, ...buildSourceMarkerSnippet(state.framework).split("\n").map((line) => ` ${line}`)]
6102
+ } : {
6103
+ text: "Your live app does not tell Patchstack it is live yet",
6104
+ hint: [
6105
+ `Add this inside <head> in ${state.widgetFileHint}, only when ${gate}:`,
6106
+ " <script>window.__PATCHSTACK_PROD__=true;</script>"
6107
+ ]
6108
+ }
5984
6109
  );
5985
6110
  }
6111
+ if (known.connected !== true) missing.push(notConnectedItem());
6112
+ return missing;
6113
+ }
6114
+ function renderGuideChecklist(state, useColor2, known = {}, options = {}) {
6115
+ const paint = (code, text) => useColor2 ? `${code}${text}${ANSI3.reset}` : text;
6116
+ const name = state.projectName ?? path12.basename(process.cwd());
6117
+ const title = `Patchstack status for ${name}`;
6118
+ if (!state.hasPackageJson) {
6119
+ return [
6120
+ paint(ANSI3.bold, title),
6121
+ "",
6122
+ paint(ANSI3.bold, "Missing"),
6123
+ ` ${paint(ANSI3.yellow, "\u2718")} No package.json here.`,
6124
+ " For a JS/Node app, run this from its package directory.",
6125
+ ' For a plain HTML site, follow "Plain HTML sites" in AGENT-INSTALL.md. It gets the widget only: no dependency scan or runtime protection.',
6126
+ " Do not create a Node project just to add the widget.",
6127
+ " Use the site UUID or widget snippet from the Patchstack dashboard. Never invent one."
6128
+ ].join("\n");
6129
+ }
6130
+ const lines = renderStatus(
6131
+ title,
6132
+ { done: [], missing: guideMissing(state, known) },
6133
+ guideProgress(state, known),
6134
+ guideNextStepContext(state),
6135
+ { useColor: useColor2, verbose: options.verbose }
6136
+ );
6137
+ if (options.verbose === true) {
6138
+ const verbose2 = [
6139
+ `Project: ${[state.framework, state.packageManager].filter((part) => part !== null).join(" \xB7 ")}`,
6140
+ ...state.siteUuid !== null ? [`Site UUID: ${state.siteUuid}`] : [],
6141
+ ...state.environment !== null ? [`Environment: ${state.environment}${state.environmentSource !== null ? ` (${state.environmentSource})` : ""}`] : [],
6142
+ ...state.endpointOverride !== null ? [`Endpoint override: ${state.endpointOverride}`] : [],
6143
+ ...state.widgetOptOut ? ['Widget is off ("widget": false in .patchstackrc.json).'] : []
6144
+ ];
6145
+ lines.splice(1, 0, ...verbose2.map((line) => paint(ANSI3.dim, line)));
6146
+ }
5986
6147
  return lines.join("\n");
5987
6148
  }
5988
6149
 
@@ -6919,6 +7080,97 @@ function npmPackageOf(spec) {
6919
7080
 
6920
7081
  // src/map/sinks.ts
6921
7082
  import { createHash as createHash4 } from "crypto";
7083
+
7084
+ // src/map/scope.ts
7085
+ var scopeCache = /* @__PURE__ */ new WeakMap();
7086
+ function declarationOf(id, ts) {
7087
+ if (!id || !ts.isIdentifier(id)) return void 0;
7088
+ const name = id.text;
7089
+ for (let cur = id.parent; cur; cur = cur.parent) {
7090
+ const declared = declaredIn(cur, ts);
7091
+ if (declared?.has(name)) return declared.get(name);
7092
+ }
7093
+ return void 0;
7094
+ }
7095
+ function isGlobal(node, name, ts) {
7096
+ if (ts.isIdentifier(node)) return node.text === name && declarationOf(node, ts) === void 0;
7097
+ return ts.isPropertyAccessExpression(node) && node.name.text === name && ts.isIdentifier(node.expression) && node.expression.text === "globalThis" && declarationOf(node.expression, ts) === void 0;
7098
+ }
7099
+ function boundIdentifiers(name, ts) {
7100
+ if (!name) return [];
7101
+ if (ts.isIdentifier(name)) return [name];
7102
+ if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
7103
+ return name.elements.flatMap((el) => ts.isBindingElement(el) ? boundIdentifiers(el.name, ts) : []);
7104
+ }
7105
+ return [];
7106
+ }
7107
+ function isFunctionScope(n, ts) {
7108
+ return ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n) || ts.isArrowFunction(n) || ts.isMethodDeclaration(n) || ts.isConstructorDeclaration(n) || ts.isGetAccessorDeclaration(n) || ts.isSetAccessorDeclaration(n) || ts.isSourceFile(n);
7109
+ }
7110
+ function declaredIn(n, ts) {
7111
+ const cached2 = scopeCache.get(n);
7112
+ if (cached2) return cached2;
7113
+ const out = /* @__PURE__ */ new Map();
7114
+ const add = (ids) => {
7115
+ for (const i of ids) if (!out.has(i.text)) out.set(i.text, i);
7116
+ };
7117
+ if (isFunctionScope(n, ts)) {
7118
+ if ((ts.isFunctionExpression(n) || ts.isClassExpression?.(n)) && n.name) add([n.name]);
7119
+ for (const p of n.parameters ?? []) add(boundIdentifiers(p.name, ts));
7120
+ collectVarScoped(ts.isSourceFile(n) ? n : n.body, ts, add);
7121
+ if (ts.isSourceFile(n)) collectLexical(n.statements, ts, add, true);
7122
+ } else if (ts.isBlock(n) || ts.isModuleBlock(n) || ts.isCaseBlock(n)) {
7123
+ const statements = ts.isCaseBlock(n) ? n.clauses.flatMap((c) => [...c.statements]) : n.statements;
7124
+ collectLexical(statements, ts, add, false);
7125
+ } else if (ts.isForStatement(n) || ts.isForInStatement(n) || ts.isForOfStatement(n)) {
7126
+ const init = n.initializer;
7127
+ if (init && ts.isVariableDeclarationList(init) && isLexicalList(init, ts)) {
7128
+ for (const d of init.declarations) add(boundIdentifiers(d.name, ts));
7129
+ }
7130
+ } else if (ts.isCatchClause(n)) {
7131
+ if (n.variableDeclaration) add(boundIdentifiers(n.variableDeclaration.name, ts));
7132
+ } else if (ts.isClassExpression?.(n) && n.name) {
7133
+ add([n.name]);
7134
+ } else {
7135
+ return void 0;
7136
+ }
7137
+ scopeCache.set(n, out);
7138
+ return out;
7139
+ }
7140
+ function isLexicalList(list, ts) {
7141
+ return (list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0;
7142
+ }
7143
+ function collectLexical(statements, ts, add, topLevel) {
7144
+ for (const s of statements ?? []) {
7145
+ if (ts.isVariableStatement(s) && isLexicalList(s.declarationList, ts)) {
7146
+ for (const d of s.declarationList.declarations) add(boundIdentifiers(d.name, ts));
7147
+ } else if ((ts.isClassDeclaration(s) || ts.isFunctionDeclaration(s) || ts.isEnumDeclaration(s)) && s.name) {
7148
+ add([s.name]);
7149
+ } else if (topLevel && ts.isImportDeclaration(s) && s.importClause) {
7150
+ const clause = s.importClause;
7151
+ if (clause.name) add([clause.name]);
7152
+ const bindings = clause.namedBindings;
7153
+ if (bindings && ts.isNamespaceImport(bindings)) add([bindings.name]);
7154
+ if (bindings && ts.isNamedImports(bindings)) add(bindings.elements.map((e) => e.name));
7155
+ } else if (topLevel && ts.isImportEqualsDeclaration(s)) {
7156
+ add([s.name]);
7157
+ }
7158
+ }
7159
+ }
7160
+ function collectVarScoped(node, ts, add) {
7161
+ const visit = (n) => {
7162
+ if (!n) return;
7163
+ if (ts.isVariableDeclarationList(n) && !isLexicalList(n, ts)) {
7164
+ for (const d of n.declarations) add(boundIdentifiers(d.name, ts));
7165
+ }
7166
+ if (n !== node && isFunctionScope(n, ts)) return;
7167
+ if (ts.isClassDeclaration(n) || ts.isClassExpression?.(n)) return;
7168
+ ts.forEachChild(n, visit);
7169
+ };
7170
+ visit(node);
7171
+ }
7172
+
7173
+ // src/map/sinks.ts
6922
7174
  var DB_OPS = /* @__PURE__ */ new Set(["insert", "update", "delete", "select", "upsert", "rpc"]);
6923
7175
  var PRISMA_OPS = /* @__PURE__ */ new Set(["create", "createMany", "update", "updateMany", "delete", "deleteMany", "upsert", "findFirst", "findUnique", "findMany", "count", "aggregate"]);
6924
7176
  var FS_CALLS = /^(readFile|writeFile|readFileSync|writeFileSync|appendFile|createReadStream|createWriteStream|unlink|rm|rmSync|mkdir|readdir|stat|open)$/;
@@ -6961,29 +7213,39 @@ function recognizedSinkKinds(pkg) {
6961
7213
  return kinds;
6962
7214
  }
6963
7215
  function collectLocalSinks(sf, ts, bindings, ctx) {
6964
- const map = /* @__PURE__ */ new Map();
6965
- const visit = (node) => {
6966
- if (ts.isFunctionDeclaration(node) && node.name && node.body) map.set(node.name.text, directSinks(node.body, ts, bindings, ctx));
7216
+ const byDeclaration = /* @__PURE__ */ new Map();
7217
+ const byName = /* @__PURE__ */ new Map();
7218
+ const record = (name, body, topLevel) => {
7219
+ const sinks = directSinks(body, ts, bindings, ctx);
7220
+ byDeclaration.set(name, sinks);
7221
+ if (topLevel || !byName.has(name.text)) byName.set(name.text, sinks);
7222
+ };
7223
+ const visit = (node, depth) => {
7224
+ const topLevel = depth === 0;
7225
+ if (ts.isFunctionDeclaration(node) && node.name && node.body) record(node.name, node.body, topLevel);
6967
7226
  else if (ts.isVariableStatement(node)) {
6968
7227
  for (const decl of node.declarationList.declarations) {
6969
7228
  if (ts.isIdentifier(decl.name) && decl.initializer && isFnLike(decl.initializer, ts)) {
6970
- map.set(decl.name.text, directSinks(decl.initializer.body, ts, bindings, ctx));
7229
+ record(decl.name, decl.initializer.body, topLevel);
6971
7230
  }
6972
7231
  }
6973
7232
  }
6974
- ts.forEachChild(node, visit);
7233
+ ts.forEachChild(node, (child) => visit(child, depth + (ts.isSourceFile(node) ? 0 : 1)));
6975
7234
  };
6976
- visit(sf);
6977
- return map;
7235
+ visit(sf, 0);
7236
+ return { byDeclaration, byName };
6978
7237
  }
6979
7238
  function sinksFrom(arrowOrNode, ts, localSinks, bindings, ctx) {
6980
7239
  if (!arrowOrNode) return [];
6981
7240
  const body = arrowOrNode.isSyntheticBody ? arrowOrNode.body : isFnLike(arrowOrNode, ts) ? arrowOrNode.body : arrowOrNode;
6982
7241
  if (!body) return [];
6983
7242
  const sinks = directSinks(body, ts, bindings, ctx);
6984
- for (const called of localCalls(body, ts)) {
6985
- for (const s of localSinks.get(called) ?? []) sinks.push(s);
6986
- if (ctx) {
7243
+ for (const callee of localCallIdentifiers(body, ts)) {
7244
+ const called = callee.text;
7245
+ const declaration = declarationOf(callee, ts);
7246
+ const helper = declaration === void 0 ? void 0 : localSinks.byDeclaration.get(declaration);
7247
+ for (const s of helper ?? []) sinks.push(s);
7248
+ if (ctx && (declaration === void 0 || isModuleLevel(declaration, ts))) {
6987
7249
  const spec = bindings.resolve(called);
6988
7250
  if (spec && spec.startsWith(".")) {
6989
7251
  for (const s of ctx.graph.importedSinks(ctx.file, spec, bindings.exportNameOf(called) ?? called)) sinks.push(s);
@@ -7000,6 +7262,22 @@ function sinksFrom(arrowOrNode, ts, localSinks, bindings, ctx) {
7000
7262
  }
7001
7263
  return dedupeSinks(sinks, ctx?.owner ?? "");
7002
7264
  }
7265
+ function isContextRequest(node, ts) {
7266
+ if (!ts.isPropertyAccessExpression(node) || node.name.text !== "req" || !ts.isIdentifier(node.expression)) return false;
7267
+ const declaration = declarationOf(node.expression, ts);
7268
+ for (let cur = declaration?.parent; cur; cur = cur.parent) {
7269
+ if (ts.isParameter(cur)) return true;
7270
+ if (!ts.isBindingElement(cur) && !ts.isObjectBindingPattern(cur) && !ts.isArrayBindingPattern(cur)) return false;
7271
+ }
7272
+ return false;
7273
+ }
7274
+ function isModuleLevel(declaration, ts) {
7275
+ for (let cur = declaration?.parent; cur; cur = cur.parent) {
7276
+ if (ts.isSourceFile(cur)) return true;
7277
+ if (ts.isFunctionLike(cur) || ts.isBlock(cur) || ts.isClassLike(cur)) return false;
7278
+ }
7279
+ return false;
7280
+ }
7003
7281
  function namespaceMemberCalls(node, ts) {
7004
7282
  const out = [];
7005
7283
  const visit = (n) => {
@@ -7060,7 +7338,7 @@ function directSinks(node, ts, bindings, ctx) {
7060
7338
  const prismaLikely = b.pkg === "@prisma/client" || !b.pkg && (bindings.imports.has("@prisma/client") || /prisma/i.test(b.root ?? ""));
7061
7339
  if (prismaLikely) push({ kind: "db", ...b.pkg ? { provider: "prisma" } : {}, package: "@prisma/client", table: callee.expression.name.text, op: method, attribution: b.pkg ? "import" : "inferred", ...spanOf(n) });
7062
7340
  }
7063
- if (method === "query" || method === "execute") {
7341
+ if ((method === "query" || method === "execute") && !isContextRequest(callee.expression, ts)) {
7064
7342
  const pkg = b.pkg ?? infer("db");
7065
7343
  const attribution = attributionOf(b, pkg);
7066
7344
  push({ kind: "db", ...dbApi(pkg, attribution), package: pkg, op: method, attribution, ...spanOf(n) });
@@ -7200,114 +7478,6 @@ function dedupeSinks(sinks, owner) {
7200
7478
  return out;
7201
7479
  }
7202
7480
 
7203
- // src/map/module-graph.ts
7204
- import { readFileSync as readFileSync14, realpathSync as realpathSync5, statSync as statSync7 } from "fs";
7205
- import { dirname as dirname8, join as join16, relative as relative5, resolve as resolvePath } from "path";
7206
- var RESOLVE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
7207
- function createModuleGraph(ts, opts) {
7208
- const cache = /* @__PURE__ */ new Map();
7209
- const load = (file) => {
7210
- if (cache.has(file)) return cache.get(file) ?? null;
7211
- let entry = null;
7212
- try {
7213
- const text = readFileSync14(file, "utf8");
7214
- const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file));
7215
- const bindings = buildModuleBindings(sf, ts);
7216
- entry = { fnSinks: collectLocalSinks(sf, ts, bindings), calleesOf: collectCallees(sf, ts), bindings };
7217
- } catch {
7218
- entry = null;
7219
- }
7220
- cache.set(file, entry);
7221
- return entry;
7222
- };
7223
- const resolveInProject = (fromFile, specifier) => {
7224
- const target = resolveRelativeModule(fromFile, specifier);
7225
- if (!target) return void 0;
7226
- if (!opts.followOutside) {
7227
- let real = target;
7228
- try {
7229
- real = realpathSync5(target);
7230
- } catch {
7231
- }
7232
- if (!isInside(real, opts.boundary)) return void 0;
7233
- }
7234
- return target;
7235
- };
7236
- return {
7237
- resolveLocal: resolveInProject,
7238
- importedSinks(fromFile, specifier, exportName) {
7239
- const target = resolveInProject(fromFile, specifier);
7240
- if (!target) return [];
7241
- const mod = load(target);
7242
- if (!mod) return [];
7243
- const collected = [...mod.fnSinks.get(exportName) ?? []];
7244
- for (const callee of mod.calleesOf.get(exportName) ?? []) {
7245
- for (const s of mod.fnSinks.get(callee) ?? []) collected.push(s);
7246
- }
7247
- const rel = relative5(opts.cwd, target);
7248
- return collected.map((s) => ({ ...s, file: rel }));
7249
- },
7250
- // A different question about the same module: not "what sinks are in there" but "what does this
7251
- // export TRACE TO". The common AI-built layout puts the client in a lib file —
7252
- // `export const db = createClient(...)` in `lib/db.ts`, imported everywhere — so the receiver of
7253
- // `db.from('orders').insert(...)` resolves to a relative specifier and nothing else. Refusing it (as
7254
- // an unattributable receiver) is right for app code but wrong here: one hop away it is a real
7255
- // dependency, and that chain is import-to-import, fully static — evidence, not inference.
7256
- importedBinding(fromFile, specifier, exportName) {
7257
- const target = resolveInProject(fromFile, specifier);
7258
- if (!target) return void 0;
7259
- const mod = load(target);
7260
- if (!mod) return void 0;
7261
- const pkg = npmPackageOf(mod.bindings.resolve(exportName));
7262
- if (pkg === void 0) return void 0;
7263
- return {
7264
- package: pkg,
7265
- origin: mod.bindings.originOf(exportName) ?? "direct",
7266
- name: mod.bindings.exportNameOf(exportName) ?? exportName
7267
- };
7268
- }
7269
- };
7270
- }
7271
- function resolveRelativeModule(fromFile, spec) {
7272
- if (!spec.startsWith(".")) return void 0;
7273
- const dir = dirname8(fromFile);
7274
- const base = resolvePath(dir, spec);
7275
- const candidates = [];
7276
- const jsLike = /\.(js|jsx|mjs|cjs)$/.exec(base);
7277
- if (jsLike) {
7278
- const stem = base.slice(0, -jsLike[0].length);
7279
- for (const e of RESOLVE_EXTS) candidates.push(stem + e);
7280
- }
7281
- candidates.push(base);
7282
- for (const e of RESOLVE_EXTS) candidates.push(base + e);
7283
- for (const e of RESOLVE_EXTS) candidates.push(join16(base, "index" + e));
7284
- for (const c of candidates) {
7285
- try {
7286
- if (statSync7(c).isFile()) return c;
7287
- } catch {
7288
- }
7289
- }
7290
- return void 0;
7291
- }
7292
- function collectCallees(sf, ts) {
7293
- const map = /* @__PURE__ */ new Map();
7294
- const visit = (node) => {
7295
- let name;
7296
- let body;
7297
- if (ts.isFunctionDeclaration(node) && node.name && node.body) {
7298
- name = node.name.text;
7299
- body = node.body;
7300
- } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isFnLike(node.initializer, ts)) {
7301
- name = node.name.text;
7302
- body = node.initializer.body;
7303
- }
7304
- if (name && body) map.set(name, localCalls(body, ts));
7305
- ts.forEachChild(node, visit);
7306
- };
7307
- visit(sf);
7308
- return map;
7309
- }
7310
-
7311
7481
  // src/map/coordinates.ts
7312
7482
  function runtimeCoordinate(source, path17) {
7313
7483
  if (/\[\d*\]/.test(path17)) {
@@ -7379,7 +7549,9 @@ function inputsFromValidator(validatorCall, ts, bindings) {
7379
7549
  }
7380
7550
  function inputsFromHandler(params, body, ts, bindings, opts = {}) {
7381
7551
  const schemaSource = opts.validatorSource ?? "json-body";
7382
- const schemaFields = zodObjectFields(body, ts, bindings);
7552
+ const inline = zodObjectFields(body, ts, bindings);
7553
+ const referenced = inline.length > 0 ? { fields: [], unresolved: false } : referencedSchemaFields(params, body, ts, bindings);
7554
+ const schemaFields = inline.length > 0 ? inline : referenced.fields;
7383
7555
  const reads = requestMemberAccesses(params, body, ts, opts);
7384
7556
  const byId = /* @__PURE__ */ new Map();
7385
7557
  const put = (name, source, extra = {}) => {
@@ -7392,7 +7564,58 @@ function inputsFromHandler(params, body, ts, bindings, opts = {}) {
7392
7564
  put(name, source ?? schemaSource, shape);
7393
7565
  }
7394
7566
  for (const { name, sources } of reads) for (const source of sources) put(name, source);
7395
- return [...byId.values()];
7567
+ return { inputs: [...byId.values()], schemaUnresolved: referenced.unresolved };
7568
+ }
7569
+ var SCHEMA_METHODS = /* @__PURE__ */ new Set(["parse", "safeParse", "parseAsync", "safeParseAsync"]);
7570
+ function referencedSchemaFields(params, body, ts, bindings) {
7571
+ if (!body) return { fields: [], unresolved: false };
7572
+ const paramDecls = /* @__PURE__ */ new Set();
7573
+ for (const p of params ?? []) {
7574
+ if (!p?.name) continue;
7575
+ if (ts.isIdentifier(p.name)) paramDecls.add(p.name);
7576
+ else if (ts.isObjectBindingPattern(p.name)) {
7577
+ for (const el of p.name.elements) if (ts.isIdentifier(el.name)) paramDecls.add(el.name);
7578
+ }
7579
+ }
7580
+ const fromParams = (e) => {
7581
+ const root = rootIdentifierNode(e, ts);
7582
+ const declaration = root ? declarationOf(root, ts) : void 0;
7583
+ if (declaration === void 0) return false;
7584
+ if (paramDecls.has(declaration)) return true;
7585
+ const owner = declaration.parent;
7586
+ if (owner && ts.isVariableDeclaration(owner) && owner.name === declaration && owner.initializer) {
7587
+ const inner = rootIdentifierNode(owner.initializer, ts);
7588
+ const innerDeclaration = inner ? declarationOf(inner, ts) : void 0;
7589
+ return innerDeclaration !== void 0 && paramDecls.has(innerDeclaration);
7590
+ }
7591
+ return false;
7592
+ };
7593
+ let fields = [];
7594
+ let unresolved = false;
7595
+ const visit = (n) => {
7596
+ if (fields.length > 0) return;
7597
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && SCHEMA_METHODS.has(n.expression.name.text) && ts.isIdentifier(n.expression.expression) && n.arguments.some((a) => fromParams(a))) {
7598
+ const declaration = declarationOf(n.expression.expression, ts);
7599
+ const owner = declaration?.parent;
7600
+ if (owner && ts.isVariableDeclaration(owner) && owner.name === declaration && owner.initializer) {
7601
+ const literal = findValidatorObject(owner.initializer, ts, bindings);
7602
+ if (literal) fields = fieldsOfObject(literal, ts, bindings, "");
7603
+ else unresolved = true;
7604
+ } else if (declaration !== void 0 && isImported(declaration, ts)) {
7605
+ unresolved = true;
7606
+ }
7607
+ }
7608
+ ts.forEachChild(n, visit);
7609
+ };
7610
+ visit(body);
7611
+ return { fields, unresolved: fields.length === 0 && unresolved };
7612
+ }
7613
+ function isImported(declaration, ts) {
7614
+ for (let cur = declaration?.parent; cur; cur = cur.parent) {
7615
+ if (ts.isImportDeclaration(cur)) return true;
7616
+ if (ts.isSourceFile(cur) || ts.isStatement(cur)) return false;
7617
+ }
7618
+ return false;
7396
7619
  }
7397
7620
  function findValidatorObject(node, ts, bindings) {
7398
7621
  let found = null;
@@ -7471,16 +7694,29 @@ function requestMemberAccesses(params, body, ts, opts = {}) {
7471
7694
  out.set(name, list);
7472
7695
  };
7473
7696
  const p0 = params?.[0];
7474
- const reqName = p0 && ts.isIdentifier(p0.name) ? p0.name.text : void 0;
7697
+ const reqDecl = p0 && ts.isIdentifier(p0.name) ? p0.name : void 0;
7698
+ const requestDecls = new Set(reqDecl ? [reqDecl] : []);
7699
+ const isRequest = (e) => ts.isIdentifier(e) && requestDecls.has(declarationOf(e, ts));
7700
+ const isRequestObject = (e) => isRequest(e) || ts.isPropertyAccessExpression(e) && e.name.text === "req" && isRequest(e.expression);
7701
+ const urlDecls = /* @__PURE__ */ new Set();
7702
+ const searchParamDecls = /* @__PURE__ */ new Set();
7475
7703
  const sourceNames = /* @__PURE__ */ new Map();
7476
7704
  const payloadNames = /* @__PURE__ */ new Set();
7477
- if (opts.payloadParam && p0 && ts.isIdentifier(p0.name)) payloadNames.add(p0.name.text);
7478
- if (p0 && !reqName && ts.isObjectBindingPattern(p0.name)) {
7705
+ const sourceOf = (e) => ts.isIdentifier(e) ? sourceNames.get(declarationOf(e, ts)) : void 0;
7706
+ if (opts.payloadParam && p0 && ts.isIdentifier(p0.name)) payloadNames.add(p0.name);
7707
+ if (p0 && !reqDecl && ts.isObjectBindingPattern(p0.name)) {
7479
7708
  for (const el of p0.name.elements) {
7480
7709
  const key = bindingKey(el, ts);
7481
- if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) {
7482
- sourceNames.set(el.name.text, namespaceSource(key));
7483
- }
7710
+ if (!key || !ts.isIdentifier(el.name)) continue;
7711
+ if (REQ_SOURCES.includes(key)) sourceNames.set(el.name, namespaceSource(key));
7712
+ else if (key === "request") requestDecls.add(el.name);
7713
+ else if (key === "url") urlDecls.add(el.name);
7714
+ }
7715
+ }
7716
+ for (const p of (params ?? []).slice(1)) {
7717
+ if (!p?.name || !ts.isObjectBindingPattern(p.name)) continue;
7718
+ for (const el of p.name.elements) {
7719
+ if (bindingKey(el, ts) === "params" && ts.isIdentifier(el.name)) sourceNames.set(el.name, "route-param");
7484
7720
  }
7485
7721
  }
7486
7722
  const unwrap = (e) => {
@@ -7488,29 +7724,74 @@ function requestMemberAccesses(params, body, ts, opts = {}) {
7488
7724
  while (cur && (ts.isAwaitExpression(cur) || ts.isAsExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression;
7489
7725
  return cur;
7490
7726
  };
7491
- const isPayloadExpr = (e) => ts.isIdentifier(e) && payloadNames.has(e.text);
7492
- const isReqSourceExpr = (e) => isPayloadExpr(e) || ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === reqName && REQ_SOURCES.includes(e.name.text) || ts.isIdentifier(e) && sourceNames.has(e.text);
7727
+ const isPayloadExpr = (e) => ts.isIdentifier(e) && payloadNames.has(declarationOf(e, ts));
7728
+ const isReqSourceExpr = (e) => isPayloadExpr(e) || ts.isPropertyAccessExpression(e) && isRequest(e.expression) && REQ_SOURCES.includes(e.name.text) || sourceOf(e) !== void 0;
7493
7729
  const isBodyReadCall = (e) => {
7494
7730
  const inner = unwrap(e);
7495
- return Boolean(inner && ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression) && ["json", "formData"].includes(inner.expression.name.text) && ts.isIdentifier(inner.expression.expression) && inner.expression.expression.text === reqName);
7731
+ return Boolean(inner && ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression) && ["json", "formData"].includes(inner.expression.name.text) && isRequestObject(inner.expression.expression));
7732
+ };
7733
+ const isRequestUrl = (e) => {
7734
+ const cur = unwrap(e);
7735
+ if (!cur) return false;
7736
+ if (ts.isIdentifier(cur)) return urlDecls.has(declarationOf(cur, ts));
7737
+ if (ts.isPropertyAccessExpression(cur) && cur.name.text === "nextUrl") return isRequest(cur.expression);
7738
+ if (ts.isNewExpression(cur) && isGlobal(cur.expression, "URL", ts)) {
7739
+ const [href] = cur.arguments ?? [];
7740
+ return Boolean(href && ts.isPropertyAccessExpression(href) && href.name.text === "url" && isRequestObject(href.expression));
7741
+ }
7742
+ return false;
7743
+ };
7744
+ const isSearchParams = (e) => {
7745
+ const cur = unwrap(e);
7746
+ if (!cur) return false;
7747
+ if (ts.isIdentifier(cur)) return searchParamDecls.has(declarationOf(cur, ts));
7748
+ return ts.isPropertyAccessExpression(cur) && cur.name.text === "searchParams" && isRequestUrl(cur.expression);
7749
+ };
7750
+ const literalArgument = (call) => {
7751
+ const [arg] = call.arguments ?? [];
7752
+ return call.arguments?.length === 1 && arg && ts.isStringLiteral(arg) ? arg.text : void 0;
7496
7753
  };
7754
+ const isCallee = (n) => Boolean(n.parent && (ts.isCallExpression(n.parent) || ts.isNewExpression(n.parent)) && n.parent.expression === n);
7755
+ const ACCESSOR_SOURCES = /* @__PURE__ */ new Set(["header", "cookie", "form-body"]);
7756
+ const HONO_ACCESSORS = { query: "query", param: "route-param", header: "header" };
7497
7757
  const visit = (n) => {
7498
- if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression)) {
7758
+ if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression) && !isCallee(n)) {
7499
7759
  record(n.name.text, sourceOfExpr(n.expression));
7500
7760
  }
7501
7761
  if (ts.isElementAccessExpression(n) && isReqSourceExpr(n.expression) && n.argumentExpression && ts.isStringLiteral(n.argumentExpression)) {
7502
7762
  record(n.argumentExpression.text, sourceOfExpr(n.expression));
7503
7763
  }
7504
- if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === "get" && isReqSourceExpr(n.expression.expression) && n.arguments.length === 1 && n.arguments[0] && ts.isStringLiteral(n.arguments[0])) {
7764
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === "get" && isReqSourceExpr(n.expression.expression) && n.arguments.length === 1 && n.arguments[0] && ts.isStringLiteral(n.arguments[0]) && ACCESSOR_SOURCES.has(sourceOfExpr(n.expression.expression))) {
7505
7765
  record(n.arguments[0].text, sourceOfExpr(n.expression.expression));
7506
7766
  }
7767
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === "get" && isSearchParams(n.expression.expression)) {
7768
+ const name = literalArgument(n);
7769
+ if (name !== void 0) record(name, "query");
7770
+ }
7771
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && ts.isPropertyAccessExpression(n.expression.expression) && n.expression.expression.name.text === "req" && isRequest(n.expression.expression.expression)) {
7772
+ const source = HONO_ACCESSORS[n.expression.name.text];
7773
+ const name = literalArgument(n);
7774
+ if (source && name !== void 0) record(name, source);
7775
+ }
7507
7776
  if (ts.isVariableDeclaration(n) && n.initializer) {
7508
7777
  const init = unwrap(n.initializer);
7509
- if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.set(n.name.text, bodyReadSource(n.initializer));
7510
- if (ts.isObjectBindingPattern(n.name) && ts.isIdentifier(init) && reqName && init.text === reqName) {
7778
+ if (ts.isIdentifier(n.name)) {
7779
+ if (isRequest(init)) requestDecls.add(n.name);
7780
+ else if (ts.isPropertyAccessExpression(init) && isRequest(init.expression) && REQ_SOURCES.includes(init.name.text)) {
7781
+ sourceNames.set(n.name, namespaceSource(init.name.text));
7782
+ } else if (isRequestUrl(init)) urlDecls.add(n.name);
7783
+ else if (isSearchParams(init)) searchParamDecls.add(n.name);
7784
+ }
7785
+ if (ts.isObjectBindingPattern(n.name) && isRequestUrl(init)) {
7786
+ for (const el of n.name.elements) {
7787
+ if (bindingKey(el, ts) === "searchParams" && ts.isIdentifier(el.name)) searchParamDecls.add(el.name);
7788
+ }
7789
+ }
7790
+ if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.set(n.name, bodyReadSource(n.initializer));
7791
+ if (ts.isObjectBindingPattern(n.name) && isRequest(init)) {
7511
7792
  for (const el of n.name.elements) {
7512
7793
  const key = bindingKey(el, ts);
7513
- if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) sourceNames.set(el.name.text, namespaceSource(key));
7794
+ if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) sourceNames.set(el.name, namespaceSource(key));
7514
7795
  }
7515
7796
  }
7516
7797
  if (ts.isObjectBindingPattern(n.name) && (isReqSourceExpr(init) || isBodyReadCall(n.initializer))) {
@@ -7532,7 +7813,7 @@ function requestMemberAccesses(params, body, ts, opts = {}) {
7532
7813
  if (REQ_SOURCES.includes(e.name.text)) return named;
7533
7814
  }
7534
7815
  if (ts.isIdentifier(e)) {
7535
- const recorded = sourceNames.get(e.text);
7816
+ const recorded = sourceOf(e);
7536
7817
  if (recorded) return recorded;
7537
7818
  }
7538
7819
  return "body";
@@ -7553,6 +7834,75 @@ function requestMemberAccesses(params, body, ts, opts = {}) {
7553
7834
 
7554
7835
  // src/map/flows.ts
7555
7836
  var MAX_DEPENDENCY_INPUT_FLOWS_PER_ENDPOINT = 100;
7837
+ var MAX_UNPROVEN_FLOWS_PER_ENDPOINT = 200;
7838
+ function boundUnprovenFlows(flows, limit) {
7839
+ let unproven = 0;
7840
+ let truncated = false;
7841
+ const kept = flows.filter((f) => {
7842
+ if (isProvenFlow(f.confidence)) return true;
7843
+ if (unproven >= limit) {
7844
+ truncated = true;
7845
+ return false;
7846
+ }
7847
+ unproven++;
7848
+ return true;
7849
+ });
7850
+ return { flows: kept, truncated };
7851
+ }
7852
+ var Roots = class {
7853
+ constructor(ts) {
7854
+ this.ts = ts;
7855
+ }
7856
+ ts;
7857
+ byDeclaration = /* @__PURE__ */ new Map();
7858
+ add(declaration, root) {
7859
+ if (!this.byDeclaration.has(declaration)) this.byDeclaration.set(declaration, root);
7860
+ }
7861
+ /** The root an identifier occurrence refers to, when its binding is tainted. */
7862
+ get(id) {
7863
+ const declaration = declarationOf(id, this.ts);
7864
+ return declaration === void 0 ? void 0 : this.byDeclaration.get(declaration);
7865
+ }
7866
+ };
7867
+ function reassignedDeclarations(body, ts) {
7868
+ const out = /* @__PURE__ */ new Set();
7869
+ const target = (n) => {
7870
+ if (!n) return;
7871
+ if (ts.isParenthesizedExpression(n)) return target(n.expression);
7872
+ if (ts.isIdentifier(n)) {
7873
+ const declaration = declarationOf(n, ts);
7874
+ if (declaration !== void 0) out.add(declaration);
7875
+ return;
7876
+ }
7877
+ if (ts.isObjectLiteralExpression(n)) {
7878
+ for (const p of n.properties) {
7879
+ if (ts.isShorthandPropertyAssignment(p)) target(p.name);
7880
+ else if (ts.isPropertyAssignment(p)) target(p.initializer);
7881
+ else if (ts.isSpreadAssignment(p)) target(p.expression);
7882
+ }
7883
+ return;
7884
+ }
7885
+ if (ts.isArrayLiteralExpression(n)) {
7886
+ for (const e of n.elements) target(ts.isSpreadElement(e) ? e.expression : e);
7887
+ return;
7888
+ }
7889
+ if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken) target(n.left);
7890
+ };
7891
+ const visit = (n) => {
7892
+ if (!n) return;
7893
+ if (ts.isBinaryExpression(n) && n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && n.operatorToken.kind <= ts.SyntaxKind.LastAssignment) {
7894
+ target(n.left);
7895
+ }
7896
+ if ((ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && (n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken)) {
7897
+ target(n.operand);
7898
+ }
7899
+ if ((ts.isForOfStatement(n) || ts.isForInStatement(n)) && !ts.isVariableDeclarationList(n.initializer)) target(n.initializer);
7900
+ ts.forEachChild(n, visit);
7901
+ };
7902
+ visit(body);
7903
+ return out;
7904
+ }
7905
+ var ACCESSOR_NAMESPACES = /* @__PURE__ */ new Set(["headers", "cookies"]);
7556
7906
  function spaceOfKey(key) {
7557
7907
  if (key === "query") return "get";
7558
7908
  if (key === "params") return "route-param";
@@ -7564,9 +7914,11 @@ function spaceOfKey(key) {
7564
7914
  return void 0;
7565
7915
  }
7566
7916
  function linkedFlows(body, params, inputs, sinks, ts, invocations = []) {
7567
- const { flows, limitations, dependencyInputFlows, dependencyInputFlowsTruncated } = linkFlows(body, params, inputs, sinks, ts, invocations);
7917
+ const { flows: linked, limitations, dependencyInputFlows, dependencyInputFlowsTruncated } = linkFlows(body, params, inputs, sinks, ts, invocations);
7918
+ const { flows, truncated } = boundUnprovenFlows(linked, MAX_UNPROVEN_FLOWS_PER_ENDPOINT);
7568
7919
  return {
7569
7920
  flows,
7921
+ ...truncated ? { flowsTruncated: true } : {},
7570
7922
  ...limitations.length > 0 ? { limitations } : {},
7571
7923
  ...dependencyInputFlows.length > 0 ? { dependencyInputFlows } : {},
7572
7924
  ...dependencyInputFlowsTruncated ? { dependencyInputFlowsTruncated: true } : {}
@@ -7577,19 +7929,29 @@ function linkFlows(bodyNode, params, inputs, sinks, ts, invocations) {
7577
7929
  return { flows: [], limitations: [], dependencyInputFlows: [], dependencyInputFlowsTruncated: false };
7578
7930
  }
7579
7931
  const CONTAINER_KEYS = /* @__PURE__ */ new Set([...REQ_SOURCES, "data", "input", "payload"]);
7580
- const rootPath = /* @__PURE__ */ new Map();
7581
- const addRoot = (name, path17, space) => {
7582
- if (!rootPath.has(name)) rootPath.set(name, { path: path17, space });
7932
+ const rootPath = new Roots(ts);
7933
+ const reassigned = reassignedDeclarations(bodyNode, ts);
7934
+ const addRoot = (declaration, path17, space, accessor = false, request = false, inherited = false, url = false) => {
7935
+ const changed = inherited || reassigned.has(declaration);
7936
+ rootPath.add(declaration, { path: path17, space, ...accessor ? { accessor } : {}, ...request ? { request } : {}, ...changed ? { reassigned: true } : {}, ...url ? { url } : {} });
7583
7937
  };
7584
- for (const p of params ?? []) {
7938
+ for (const [index, p] of (params ?? []).entries()) {
7585
7939
  if (!p?.name) continue;
7586
- if (ts.isIdentifier(p.name)) addRoot(p.name.text, "");
7940
+ if (ts.isIdentifier(p.name)) addRoot(p.name, "", void 0, false, index === 0);
7587
7941
  else if (ts.isObjectBindingPattern(p.name)) {
7588
7942
  for (const el of p.name.elements) {
7589
7943
  if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue;
7590
7944
  const key = bindingKey(el, ts);
7945
+ if (index === 0 && key === "request") {
7946
+ addRoot(el.name, "", void 0, false, true);
7947
+ continue;
7948
+ }
7949
+ if (index === 0 && key === "url") {
7950
+ addRoot(el.name, "", void 0, false, false, false, true);
7951
+ continue;
7952
+ }
7591
7953
  const container = key !== void 0 && CONTAINER_KEYS.has(key);
7592
- addRoot(el.name.text, container ? "" : key ?? el.name.text, container ? spaceOfKey(key) : void 0);
7954
+ addRoot(el.name, container ? "" : key ?? el.name.text, container ? spaceOfKey(key) : void 0, container && ACCESSOR_NAMESPACES.has(key));
7593
7955
  }
7594
7956
  }
7595
7957
  }
@@ -7597,11 +7959,17 @@ function linkFlows(bodyNode, params, inputs, sinks, ts, invocations) {
7597
7959
  let cur = init;
7598
7960
  while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression;
7599
7961
  if (!cur) return void 0;
7962
+ const params2 = searchParamsOrigin(cur, ts, rootPath);
7963
+ if (params2) return { path: "", space: "get", accessor: true, ...carried(params2) };
7964
+ const url = requestUrlOrigin(cur, ts, rootPath);
7965
+ if (url) return { path: "", url: true, ...carried(url) };
7600
7966
  if (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
7601
7967
  const m = cur.expression.name.text;
7602
7968
  if (["json", "formData", "text"].includes(m)) {
7603
- const root = rootIdentifier(cur.expression.expression, ts);
7604
- return root && rootPath.has(root) ? { path: "", space: "post" } : void 0;
7969
+ const root = rootIdentifierNode(cur.expression.expression, ts);
7970
+ const owner = root ? rootPath.get(root) : void 0;
7971
+ if (!owner) return void 0;
7972
+ return m === "formData" ? { path: "", space: "post", accessor: true, ...carried(owner) } : { path: "", space: "post", ...carried(owner) };
7605
7973
  }
7606
7974
  if (["parse", "safeParse", "validate", "cast"].includes(m)) {
7607
7975
  for (const a of cur.arguments) {
@@ -7611,21 +7979,25 @@ function linkFlows(bodyNode, params, inputs, sinks, ts, invocations) {
7611
7979
  return void 0;
7612
7980
  }
7613
7981
  }
7614
- const p = pathFromTainted(cur, ts, rootPath);
7615
- return p;
7982
+ return pathFromTainted(cur, ts, rootPath) ?? accessorRead(cur, ts, rootPath);
7616
7983
  };
7617
7984
  const aliasVisit = (n) => {
7618
7985
  if (ts.isVariableDeclaration(n) && n.initializer) {
7619
7986
  const base = requestReadPath(n.initializer);
7620
7987
  if (base !== void 0) {
7621
- if (ts.isIdentifier(n.name)) addRoot(n.name.text, base.path, base.space);
7988
+ if (ts.isIdentifier(n.name)) addRoot(n.name, base.path, base.space, base.accessor === true, base.request === true, base.reassigned === true, base.url === true);
7622
7989
  else if (ts.isObjectBindingPattern(n.name)) {
7623
7990
  for (const el of n.name.elements) {
7624
7991
  if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue;
7625
7992
  const key = bindingKey(el, ts);
7626
- const space = base.space ?? (base.path === "" ? spaceOfKey(key) : void 0);
7627
- const container = base.path === "" && key !== void 0 && CONTAINER_KEYS.has(key);
7628
- addRoot(el.name.text, container ? "" : join22(base.path, key ?? el.name.text), space);
7993
+ if (base.url === true) {
7994
+ if (key === "searchParams") addRoot(el.name, "", "get", true, false, base.reassigned === true);
7995
+ continue;
7996
+ }
7997
+ const namespace = base.request === true && key !== void 0 && REQ_SOURCES.includes(key);
7998
+ const container = base.path === "" && key !== void 0 && CONTAINER_KEYS.has(key) && (namespace || !REQ_SOURCES.includes(key));
7999
+ const space = base.space ?? (namespace ? spaceOfKey(key) : void 0);
8000
+ addRoot(el.name, container ? "" : join22(base.path, key ?? el.name.text), space, namespace && ACCESSOR_NAMESPACES.has(key), false, base.reassigned === true);
7629
8001
  }
7630
8002
  }
7631
8003
  }
@@ -7657,10 +8029,10 @@ function linkFlows(bodyNode, params, inputs, sinks, ts, invocations) {
7657
8029
  for (const a of args) for (const l of sinkArgumentLimitations(a, ts, rootPath)) sinkLimits.push(l);
7658
8030
  for (let i = 0; i < args.length; i++) {
7659
8031
  const role = argumentRoleOf(sink.kind, method, i, args.length);
7660
- const whole = pathFromTainted(args[i], ts, rootPath);
8032
+ const whole = pathFromTainted(args[i], ts, rootPath) ?? accessorRead(args[i], ts, rootPath);
7661
8033
  for (const read2 of taintedReadPaths(args[i], ts, rootPath)) {
7662
8034
  const key = `${read2.space ?? "*"}:${read2.path}`;
7663
- const exact = whole !== void 0 && whole.path === read2.path && whole.space === read2.space;
8035
+ const exact = whole !== void 0 && whole.path === read2.path && whole.space === read2.space && whole.reassigned !== true;
7664
8036
  const entry = reads.get(key) ?? { read: read2, roles: /* @__PURE__ */ new Set(), exact: false };
7665
8037
  entry.roles.add(role);
7666
8038
  entry.exact = entry.exact || exact;
@@ -7750,7 +8122,7 @@ function linkFlows(bodyNode, params, inputs, sinks, ts, invocations) {
7750
8122
  kind: invocation.kind,
7751
8123
  resolution: invocation.resolution,
7752
8124
  argumentIndex,
7753
- argumentUse: whole?.path === read2.path && whole.space === read2.space ? "direct" : "within-expression",
8125
+ argumentUse: whole?.path === read2.path && whole.space === read2.space && whole.reassigned !== true ? "direct" : "within-expression",
7754
8126
  line: site.line,
7755
8127
  start: site.start,
7756
8128
  end: site.end
@@ -7819,6 +8191,18 @@ function taintedReadPaths(node, ts, rootPath, includeDeferredBodies = true) {
7819
8191
  const visit = (n) => {
7820
8192
  if (!n) return;
7821
8193
  if (!includeDeferredBodies && (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n))) return;
8194
+ if (ts.isCallExpression(n)) {
8195
+ const accessed = accessorRead(n, ts, rootPath);
8196
+ if (accessed !== void 0) {
8197
+ add(accessed);
8198
+ return;
8199
+ }
8200
+ if (ts.isPropertyAccessExpression(n.expression)) {
8201
+ visit(n.expression.expression);
8202
+ for (const arg of n.arguments ?? []) visit(arg);
8203
+ return;
8204
+ }
8205
+ }
7822
8206
  if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) {
7823
8207
  const read2 = pathFromTainted(n, ts, rootPath);
7824
8208
  if (read2 !== void 0) {
@@ -7826,9 +8210,9 @@ function taintedReadPaths(node, ts, rootPath, includeDeferredBodies = true) {
7826
8210
  return;
7827
8211
  }
7828
8212
  }
7829
- if (ts.isIdentifier(n) && rootPath.has(n.text) && isValueRead(n, ts)) {
7830
- const r = rootPath.get(n.text);
7831
- if (r.path) add({ path: normalizePath(r.path), space: r.space });
8213
+ const bound = ts.isIdentifier(n) && isValueRead(n, ts) ? rootPath.get(n) : void 0;
8214
+ if (bound) {
8215
+ if (bound.path) add({ path: normalizePath(bound.path), space: bound.space });
7832
8216
  }
7833
8217
  ts.forEachChild(n, visit);
7834
8218
  };
@@ -7838,11 +8222,11 @@ function taintedReadPaths(node, ts, rootPath, includeDeferredBodies = true) {
7838
8222
  function sinkArgumentLimitations(node, ts, rootPath) {
7839
8223
  const out = [];
7840
8224
  const seen = /* @__PURE__ */ new Set();
7841
- const add = (kind, detail, n) => {
7842
- const key = `${kind}:${detail}`;
8225
+ const add = (kind, detail2, n) => {
8226
+ const key = `${kind}:${detail2}`;
7843
8227
  if (seen.has(key)) return;
7844
8228
  seen.add(key);
7845
- out.push({ kind, detail, line: lineOf(n) });
8229
+ out.push({ kind, detail: detail2, line: lineOf(n) });
7846
8230
  };
7847
8231
  const text = (n) => {
7848
8232
  try {
@@ -7854,22 +8238,90 @@ function sinkArgumentLimitations(node, ts, rootPath) {
7854
8238
  const visit = (n) => {
7855
8239
  if (!n) return;
7856
8240
  if (ts.isElementAccessExpression(n)) {
7857
- const root = rootIdentifier(n.expression, ts);
8241
+ const root = rootIdentifierNode(n.expression, ts);
7858
8242
  const arg = n.argumentExpression;
7859
- if (root && rootPath.has(root) && arg && !ts.isStringLiteralLike(arg) && !ts.isNumericLiteral(arg)) {
8243
+ if (root && rootPath.get(root) && arg && !ts.isStringLiteralLike(arg) && !ts.isNumericLiteral(arg)) {
7860
8244
  add("dynamic-key", text(n), n);
7861
8245
  }
7862
8246
  }
7863
8247
  if ((ts.isSpreadAssignment?.(n) || ts.isSpreadElement(n)) && n.expression) {
7864
- const root = rootIdentifier(n.expression, ts);
7865
- if (root && rootPath.has(root)) add("spread-into-sink", text(n.parent ?? n), n);
8248
+ const root = rootIdentifierNode(n.expression, ts);
8249
+ if (root && rootPath.get(root)) add("spread-into-sink", text(n.parent ?? n), n);
7866
8250
  }
7867
8251
  ts.forEachChild(n, visit);
7868
8252
  };
7869
8253
  visit(node);
7870
8254
  return out;
7871
8255
  }
7872
- function pathFromTainted(node, ts, rootPath) {
8256
+ function accessorRead(node, ts, rootPath) {
8257
+ let cur = node;
8258
+ while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression;
8259
+ if (!cur || !ts.isCallExpression(cur) || !ts.isPropertyAccessExpression(cur.expression)) return void 0;
8260
+ const [name] = cur.arguments;
8261
+ if (cur.arguments.length !== 1 || !name || !ts.isStringLiteralLike(name)) return void 0;
8262
+ const method = cur.expression.name.text;
8263
+ const receiver = cur.expression.expression;
8264
+ const hono = HONO_ACCESSOR_SPACES[method];
8265
+ if (hono && ts.isPropertyAccessExpression(receiver) && receiver.name.text === "req" && ts.isIdentifier(receiver.expression)) {
8266
+ const context = rootPath.get(receiver.expression);
8267
+ if (context?.request === true) return { path: normalizePath(name.text), space: hono, ...carried(context) };
8268
+ }
8269
+ if (method !== "get") return void 0;
8270
+ const params = ts.isIdentifier(receiver) ? void 0 : searchParamsOrigin(receiver, ts, rootPath);
8271
+ if (params) return { path: normalizePath(name.text), space: "get", ...carried(params) };
8272
+ if (!isAccessor(receiver, ts, rootPath)) return void 0;
8273
+ return pathFromTainted(receiver, ts, rootPath, [name.text]);
8274
+ }
8275
+ var HONO_ACCESSOR_SPACES = { query: "get", param: "route-param", header: "server" };
8276
+ function carried(origin) {
8277
+ return origin.reassigned === true ? { reassigned: true } : {};
8278
+ }
8279
+ function requestUrlOrigin(node, ts, rootPath) {
8280
+ let cur = node;
8281
+ while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur) || ts.isAwaitExpression(cur) || ts.isAsExpression(cur))) cur = cur.expression;
8282
+ if (!cur) return void 0;
8283
+ if (ts.isIdentifier(cur)) {
8284
+ const root = rootPath.get(cur);
8285
+ return root?.url === true ? root : void 0;
8286
+ }
8287
+ const request = (e) => {
8288
+ let inner = e;
8289
+ while (inner && (ts.isParenthesizedExpression(inner) || ts.isNonNullExpression(inner) || ts.isAsExpression(inner))) inner = inner.expression;
8290
+ if (!inner || !ts.isIdentifier(inner)) return void 0;
8291
+ const root = rootPath.get(inner);
8292
+ return root?.request === true ? root : void 0;
8293
+ };
8294
+ if (ts.isPropertyAccessExpression(cur) && cur.name.text === "nextUrl") return request(cur.expression);
8295
+ if (ts.isNewExpression(cur) && isGlobal(cur.expression, "URL", ts)) {
8296
+ const [href] = cur.arguments ?? [];
8297
+ if (!href || !ts.isPropertyAccessExpression(href) || href.name.text !== "url") return void 0;
8298
+ const owner = href.expression;
8299
+ if (ts.isPropertyAccessExpression(owner) && owner.name.text === "req") return request(owner.expression) ?? request(owner);
8300
+ return request(owner);
8301
+ }
8302
+ return void 0;
8303
+ }
8304
+ function searchParamsOrigin(node, ts, rootPath) {
8305
+ let cur = node;
8306
+ while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur) || ts.isAsExpression(cur))) cur = cur.expression;
8307
+ if (!cur) return void 0;
8308
+ if (ts.isIdentifier(cur)) {
8309
+ const root = rootPath.get(cur);
8310
+ return root?.accessor === true && root.space === "get" ? root : void 0;
8311
+ }
8312
+ if (!ts.isPropertyAccessExpression(cur) || cur.name.text !== "searchParams") return void 0;
8313
+ return requestUrlOrigin(cur.expression, ts, rootPath);
8314
+ }
8315
+ function isAccessor(node, ts, rootPath) {
8316
+ let cur = node;
8317
+ while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression;
8318
+ if (ts.isIdentifier(cur)) return rootPath.get(cur)?.accessor === true;
8319
+ if (ts.isPropertyAccessExpression(cur) && ACCESSOR_NAMESPACES.has(cur.name.text) && ts.isIdentifier(cur.expression)) {
8320
+ return rootPath.get(cur.expression)?.request === true;
8321
+ }
8322
+ return false;
8323
+ }
8324
+ function pathFromTainted(node, ts, rootPath, trailing = []) {
7873
8325
  const segs = [];
7874
8326
  let cur = node;
7875
8327
  for (; ; ) {
@@ -7891,14 +8343,128 @@ function pathFromTainted(node, ts, rootPath) {
7891
8343
  break;
7892
8344
  }
7893
8345
  if (!cur || !ts.isIdentifier(cur)) return void 0;
7894
- const base = rootPath.get(cur.text);
7895
- if (base === void 0) return void 0;
8346
+ const base = rootPath.get(cur);
8347
+ if (base === void 0 || base.url === true) return void 0;
8348
+ segs.push(...trailing);
7896
8349
  let space = base.space;
7897
- if (base.path === "" && segs.length > 1 && REQ_SOURCES.includes(segs[0])) {
7898
- space = spaceOfKey(segs[0]) ?? space;
8350
+ let namespace;
8351
+ if (base.request === true && base.path === "" && segs.length > 0 && REQ_SOURCES.includes(segs[0])) {
8352
+ namespace = segs[0];
8353
+ space = spaceOfKey(namespace) ?? space;
7899
8354
  segs.shift();
7900
8355
  }
7901
- return { path: normalizePath([base.path, ...segs].filter(Boolean).join(".")), space };
8356
+ const request = base.request === true && namespace === void 0 && segs.length === 0 ? { request: true } : {};
8357
+ const accessor = namespace !== void 0 && segs.length === 0 && ACCESSOR_NAMESPACES.has(namespace) ? { accessor: true } : {};
8358
+ const reassigned = base.reassigned === true ? { reassigned: true } : {};
8359
+ return { path: normalizePath([base.path, ...segs].filter(Boolean).join(".")), space, ...request, ...accessor, ...reassigned };
8360
+ }
8361
+
8362
+ // src/map/module-graph.ts
8363
+ import { readFileSync as readFileSync14, realpathSync as realpathSync5, statSync as statSync7 } from "fs";
8364
+ import { dirname as dirname8, join as join16, relative as relative5, resolve as resolvePath } from "path";
8365
+ var RESOLVE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
8366
+ function createModuleGraph(ts, opts) {
8367
+ const cache = /* @__PURE__ */ new Map();
8368
+ const load = (file) => {
8369
+ if (cache.has(file)) return cache.get(file) ?? null;
8370
+ let entry = null;
8371
+ try {
8372
+ const text = readFileSync14(file, "utf8");
8373
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file));
8374
+ const bindings = buildModuleBindings(sf, ts);
8375
+ entry = { fnSinks: collectLocalSinks(sf, ts, bindings).byName, calleesOf: collectCallees(sf, ts), bindings };
8376
+ } catch {
8377
+ entry = null;
8378
+ }
8379
+ cache.set(file, entry);
8380
+ return entry;
8381
+ };
8382
+ const resolveInProject = (fromFile, specifier) => {
8383
+ const target = resolveRelativeModule(fromFile, specifier);
8384
+ if (!target) return void 0;
8385
+ if (!opts.followOutside) {
8386
+ let real = target;
8387
+ try {
8388
+ real = realpathSync5(target);
8389
+ } catch {
8390
+ }
8391
+ if (!isInside(real, opts.boundary)) return void 0;
8392
+ }
8393
+ return target;
8394
+ };
8395
+ return {
8396
+ resolveLocal: resolveInProject,
8397
+ importedSinks(fromFile, specifier, exportName) {
8398
+ const target = resolveInProject(fromFile, specifier);
8399
+ if (!target) return [];
8400
+ const mod = load(target);
8401
+ if (!mod) return [];
8402
+ const collected = [...mod.fnSinks.get(exportName) ?? []];
8403
+ for (const callee of mod.calleesOf.get(exportName) ?? []) {
8404
+ for (const s of mod.fnSinks.get(callee) ?? []) collected.push(s);
8405
+ }
8406
+ const rel = relative5(opts.cwd, target);
8407
+ return collected.map((s) => ({ ...s, file: rel }));
8408
+ },
8409
+ // A different question about the same module: not "what sinks are in there" but "what does this
8410
+ // export TRACE TO". The common AI-built layout puts the client in a lib file —
8411
+ // `export const db = createClient(...)` in `lib/db.ts`, imported everywhere — so the receiver of
8412
+ // `db.from('orders').insert(...)` resolves to a relative specifier and nothing else. Refusing it (as
8413
+ // an unattributable receiver) is right for app code but wrong here: one hop away it is a real
8414
+ // dependency, and that chain is import-to-import, fully static — evidence, not inference.
8415
+ importedBinding(fromFile, specifier, exportName) {
8416
+ const target = resolveInProject(fromFile, specifier);
8417
+ if (!target) return void 0;
8418
+ const mod = load(target);
8419
+ if (!mod) return void 0;
8420
+ const pkg = npmPackageOf(mod.bindings.resolve(exportName));
8421
+ if (pkg === void 0) return void 0;
8422
+ return {
8423
+ package: pkg,
8424
+ origin: mod.bindings.originOf(exportName) ?? "direct",
8425
+ name: mod.bindings.exportNameOf(exportName) ?? exportName
8426
+ };
8427
+ }
8428
+ };
8429
+ }
8430
+ function resolveRelativeModule(fromFile, spec) {
8431
+ if (!spec.startsWith(".")) return void 0;
8432
+ const dir = dirname8(fromFile);
8433
+ const base = resolvePath(dir, spec);
8434
+ const candidates = [];
8435
+ const jsLike = /\.(js|jsx|mjs|cjs)$/.exec(base);
8436
+ if (jsLike) {
8437
+ const stem = base.slice(0, -jsLike[0].length);
8438
+ for (const e of RESOLVE_EXTS) candidates.push(stem + e);
8439
+ }
8440
+ candidates.push(base);
8441
+ for (const e of RESOLVE_EXTS) candidates.push(base + e);
8442
+ for (const e of RESOLVE_EXTS) candidates.push(join16(base, "index" + e));
8443
+ for (const c of candidates) {
8444
+ try {
8445
+ if (statSync7(c).isFile()) return c;
8446
+ } catch {
8447
+ }
8448
+ }
8449
+ return void 0;
8450
+ }
8451
+ function collectCallees(sf, ts) {
8452
+ const map = /* @__PURE__ */ new Map();
8453
+ const visit = (node) => {
8454
+ let name;
8455
+ let body;
8456
+ if (ts.isFunctionDeclaration(node) && node.name && node.body) {
8457
+ name = node.name.text;
8458
+ body = node.body;
8459
+ } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isFnLike(node.initializer, ts)) {
8460
+ name = node.name.text;
8461
+ body = node.initializer.body;
8462
+ }
8463
+ if (name && body) map.set(name, localCalls(body, ts));
8464
+ ts.forEachChild(node, visit);
8465
+ };
8466
+ visit(sf);
8467
+ return map;
7902
8468
  }
7903
8469
 
7904
8470
  // src/map/invocations.ts
@@ -8020,6 +8586,7 @@ var HTTP_METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELE
8020
8586
  function extractFromFile(sf, ts, localSinks, bindings, ctx) {
8021
8587
  const out = [];
8022
8588
  const isServerActionsFile = fileHasUseServer(sf, ts);
8589
+ const pagesApi = isPagesApiFile(ctx.owner);
8023
8590
  const visit = (node) => {
8024
8591
  if (ts.isVariableStatement(node) && hasExport(node, ts)) {
8025
8592
  for (const decl of node.declarationList.declarations) {
@@ -8061,6 +8628,12 @@ function extractFromFile(sf, ts, localSinks, bindings, ctx) {
8061
8628
  out.push(handlerEntry(node.name.text, "server-action", node.parameters, node.body, ts, localSinks, bindings, ctx, spanOf(node)));
8062
8629
  }
8063
8630
  }
8631
+ if (pagesApi) {
8632
+ const handler = defaultExportedHandler(node, ts);
8633
+ if (handler) {
8634
+ out.push(handlerEntry(handler.name ?? "default", "default-export", handler.fn.parameters, handler.fn.body, ts, localSinks, bindings, ctx, spanOf(handler.fn)));
8635
+ }
8636
+ }
8064
8637
  if (ts.isCallExpression(node)) {
8065
8638
  const c = node.expression;
8066
8639
  const denoServe = ts.isPropertyAccessExpression(c) && c.name.text === "serve" && ts.isIdentifier(c.expression) && c.expression.text === "Deno";
@@ -8079,7 +8652,7 @@ function extractFromFile(sf, ts, localSinks, bindings, ctx) {
8079
8652
  const first = args[0];
8080
8653
  const route = first && ts.isStringLiteralLike(first) ? first.text : routeFromChain(node.expression.expression, ts);
8081
8654
  const handler = args[args.length - 1];
8082
- if (route !== void 0 && handler && isFnLike(handler, ts)) {
8655
+ if (route !== void 0 && isRoutePath(route) && handler && isFnLike(handler, ts)) {
8083
8656
  out.push(handlerEntry(route, "route-registration", handler.parameters, handler.body, ts, localSinks, bindings, ctx, {
8084
8657
  // `use`/`all` register handlers but are not HTTP methods — leave method undefined.
8085
8658
  method: HTTP_METHODS.has(mname.toUpperCase()) ? mname.toUpperCase() : void 0,
@@ -8092,7 +8665,7 @@ function extractFromFile(sf, ts, localSinks, bindings, ctx) {
8092
8665
  const arg = node.arguments[0];
8093
8666
  if (arg && ts.isObjectLiteralExpression(arg)) {
8094
8667
  const reg = routeObject(arg, ts);
8095
- if (reg.url && reg.handler) {
8668
+ if (reg.url && isRoutePath(reg.url) && reg.handler) {
8096
8669
  for (const m of reg.methods.length ? reg.methods : [void 0]) {
8097
8670
  out.push(handlerEntry(reg.url, "route-registration", reg.handler.parameters, reg.handler.body, ts, localSinks, bindings, ctx, { method: m, route: reg.url, ...spanOf(node) }));
8098
8671
  }
@@ -8105,6 +8678,19 @@ function extractFromFile(sf, ts, localSinks, bindings, ctx) {
8105
8678
  visit(sf);
8106
8679
  return out;
8107
8680
  }
8681
+ function defaultExportedHandler(node, ts) {
8682
+ const isDefault = (n) => Boolean(n.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword));
8683
+ if (ts.isFunctionDeclaration(node) && hasExport(node, ts) && isDefault(node) && node.body) return { name: node.name?.text, fn: node };
8684
+ if (!ts.isExportAssignment(node) || node.isExportEquals) return void 0;
8685
+ const target = node.expression;
8686
+ if (isFnLike(target, ts)) return { fn: target };
8687
+ if (!ts.isIdentifier(target)) return void 0;
8688
+ const declaration = declarationOf(target, ts);
8689
+ const owner = declaration?.parent;
8690
+ if (owner && ts.isFunctionDeclaration(owner) && owner.body) return { name: target.text, fn: owner };
8691
+ if (owner && ts.isVariableDeclaration(owner) && owner.initializer && isFnLike(owner.initializer, ts)) return { name: target.text, fn: owner.initializer };
8692
+ return void 0;
8693
+ }
8108
8694
  function fileHasUseServer(sf, ts) {
8109
8695
  const first = sf.statements?.[0];
8110
8696
  return Boolean(first && ts.isExpressionStatement(first) && ts.isStringLiteralLike(first.expression) && first.expression.text === "use server");
@@ -8120,7 +8706,7 @@ function handlerInvocations(body, ts, bindings, ctx) {
8120
8706
  function handlerEntry(name, kindLabel, params, body, ts, localSinks, bindings, ctx, extra = {}) {
8121
8707
  const entryKind = kindLabel === "route-registration" || kindLabel === "server-action" || kindLabel === "edge-function" ? kindLabel : "route-handler";
8122
8708
  const payloadStyle = kindLabel === "server-action";
8123
- const inputs = inputsFromHandler(params, body, ts, bindings, {
8709
+ const { inputs, schemaUnresolved } = inputsFromHandler(params, body, ts, bindings, {
8124
8710
  payloadParam: payloadStyle,
8125
8711
  validatorSource: payloadStyle ? "server-fn-data" : "json-body"
8126
8712
  });
@@ -8135,7 +8721,9 @@ function handlerEntry(name, kindLabel, params, body, ts, localSinks, bindings, c
8135
8721
  end: extra.end,
8136
8722
  inputs,
8137
8723
  sinks,
8138
- ...linkedFlows(body, params, inputs, sinks, ts, handlerInvocations(body, ts, bindings, ctx))
8724
+ ...linkedFlows(body, params, inputs, sinks, ts, handlerInvocations(body, ts, bindings, ctx)),
8725
+ // The handler validates its request with a schema declared in another module: its fields are unknown.
8726
+ ...schemaUnresolved ? { inputsResolved: false } : {}
8139
8727
  };
8140
8728
  }
8141
8729
 
@@ -8394,6 +8982,7 @@ function lineAt(starts, pos) {
8394
8982
 
8395
8983
  // src/map/extract.ts
8396
8984
  var MAX_DEPENDENCY_INPUT_FLOWS_PER_MAP = 500;
8985
+ var MAX_UNPROVEN_FLOWS_PER_MAP = 1e3;
8397
8986
  async function extractInputMap(cwd, ts, options = {}) {
8398
8987
  const notes = [];
8399
8988
  const endpoints = [];
@@ -8409,9 +8998,11 @@ async function extractInputMap(cwd, ts, options = {}) {
8409
8998
  const imports = createImportInventory(readPathAliases(cwd));
8410
8999
  const invocations = createInvocationInventory();
8411
9000
  let dependencyInputFlowCount = 0;
9001
+ let unprovenFlowCount = 0;
8412
9002
  let parsed = 0;
8413
9003
  let preFiltered = 0;
8414
9004
  let importScanFailures = 0;
9005
+ let componentFiles = 0;
8415
9006
  let unresolvableImports = 0;
8416
9007
  let sourceBytes = 0;
8417
9008
  const calls = { total: 0, dependency: 0, local: 0, ambiguous: 0 };
@@ -8423,7 +9014,17 @@ async function extractInputMap(cwd, ts, options = {}) {
8423
9014
  const text = readFileSync16(file, "utf8");
8424
9015
  const relFile = relative6(cwd, file);
8425
9016
  sourceBytes += text.length;
8426
- if (!hasEntrySignal(text)) {
9017
+ if (isComponentFile(file)) {
9018
+ componentFiles++;
9019
+ preFiltered++;
9020
+ const script = componentScript(text, file);
9021
+ const scanned = script === null ? null : scanFileImports(script, ts);
9022
+ if (scanned === null) importScanFailures++;
9023
+ else imports.add(relFile, scanned, false);
9024
+ if (script !== null) unresolvableImports += countUnresolvableImports(script, ts);
9025
+ continue;
9026
+ }
9027
+ if (!hasEntrySignal(text) && !isPagesApiFile(relFile)) {
8427
9028
  preFiltered++;
8428
9029
  const scanned = scanFileImports(text, ts);
8429
9030
  if (scanned === null) importScanFailures++;
@@ -8459,11 +9060,17 @@ async function extractInputMap(cwd, ts, options = {}) {
8459
9060
  ep.dependencyInputFlowsTruncated = true;
8460
9061
  }
8461
9062
  dependencyInputFlowCount += ep.dependencyInputFlows?.length ?? 0;
9063
+ const bounded = boundUnprovenFlows(ep.flows, Math.max(0, MAX_UNPROVEN_FLOWS_PER_MAP - unprovenFlowCount));
9064
+ if (bounded.truncated) {
9065
+ ep.flows = bounded.flows;
9066
+ ep.flowsTruncated = true;
9067
+ }
9068
+ unprovenFlowCount += ep.flows.filter((f) => !isProvenFlow(f.confidence)).length;
8462
9069
  if (ep.route === void 0 && ep.entryKind === "edge-function") {
8463
9070
  const fn = functionNameFromPath(relFile);
8464
9071
  if (fn) ep.route = "/" + fn;
8465
9072
  }
8466
- if (ep.route === void 0 && (ep.entryKind === "route-handler" || ep.entryKind === "server-action")) {
9073
+ if (ep.route === void 0 && ep.entryKind === "route-handler") {
8467
9074
  const derived = routeFromFilePath(relFile);
8468
9075
  if (derived.route) {
8469
9076
  ep.route = derived.route;
@@ -8514,6 +9121,10 @@ async function extractInputMap(cwd, ts, options = {}) {
8514
9121
  if (truncatedDependencyFlows > 0) {
8515
9122
  notes.push(`${truncatedDependencyFlows} endpoint(s) had more dependency-input links than the bounded map carries; those endpoint records are marked dependencyInputFlowsTruncated.`);
8516
9123
  }
9124
+ const truncatedFlows = endpoints.filter((e) => e.flowsTruncated).length;
9125
+ if (truncatedFlows > 0) {
9126
+ notes.push(`${truncatedFlows} endpoint(s) had more unproven flows than the bounded map carries; those endpoint records are marked flowsTruncated. Every proven flow is kept.`);
9127
+ }
8517
9128
  if (unresolved > 0) {
8518
9129
  notes.push(`${unresolved} endpoint(s) declare an input validator that could not be statically parsed \u2014 their inputs are UNKNOWN, not empty (marked inputsResolved: false).`);
8519
9130
  }
@@ -8521,6 +9132,9 @@ async function extractInputMap(cwd, ts, options = {}) {
8521
9132
  if (heuristicOnly > 0) {
8522
9133
  notes.push(`${heuristicOnly} endpoint(s) have inputs and sinks but no proven data link \u2014 their flows say "may reach", not "does reach" (see each flow's confidence).`);
8523
9134
  }
9135
+ if (componentFiles > 0) {
9136
+ notes.push(`${componentFiles} single-file component(s) (.vue, .svelte, .astro) were scanned for imports only. Code in their script blocks and Astro frontmatter can run on the server, but it is not analyzed for endpoints, inputs or sinks.`);
9137
+ }
8524
9138
  if (endpoints.length === 0) notes.push("No recognized server-side entry points found under the analyzed roots.");
8525
9139
  const importList = imports.list();
8526
9140
  const unmodelled = importList.filter((d) => d.recognizedSinkKinds.length === 0).length;
@@ -8798,7 +9412,7 @@ function isPreBundleBuildHook(env = process.env) {
8798
9412
  return /^\s*patchstack-connect\s+scan(?:\s*(?:&&|;)|\s*$)/.test(env.npm_lifecycle_script ?? "");
8799
9413
  }
8800
9414
  function undeliveredReportLines(err, config, cwd) {
8801
- const lines = [`patchstack: manifest not reported \u2014 ${err.message}`];
9415
+ const lines = [`patchstack: could not send the package list to Patchstack \u2014 ${err.message}`];
8802
9416
  if (err.code === "UNAUTHORIZED") {
8803
9417
  const hasCredential = typeof config.pulseAuth === "string" && config.pulseAuth.length > 0;
8804
9418
  if (hasCredential) {
@@ -8813,7 +9427,7 @@ function undeliveredReportLines(err, config, cwd) {
8813
9427
  }
8814
9428
  }
8815
9429
  lines.push(
8816
- "patchstack: continuing the build. Patchstack keeps the last manifest it accepted for this site until a scan that can report."
9430
+ "patchstack: continuing the build. Patchstack keeps the last package list it received until a check gets through."
8817
9431
  );
8818
9432
  return lines;
8819
9433
  }
@@ -8964,9 +9578,9 @@ import path15 from "path";
8964
9578
  var SCAN_COMMAND = "patchstack-connect scan";
8965
9579
  var MARK_BUILD_COMMAND = "patchstack-connect mark-build";
8966
9580
  function setupProtection(cwd) {
8967
- const install = runProtect(cwd);
9581
+ const { result: install, lines: log2 } = collectProtectLog(() => runProtect(cwd));
8968
9582
  const verification = runVerify(cwd);
8969
- return { install, verification };
9583
+ return { install, verification, log: log2 };
8970
9584
  }
8971
9585
  function appendHook(existing, command) {
8972
9586
  if (existing === void 0 || existing.trim().length === 0) {
@@ -9059,7 +9673,7 @@ Usage:
9059
9673
  If no UUID is configured, the server
9060
9674
  provisions one and we persist it. After a
9061
9675
  successful post it also adds/updates the
9062
- disclosure-widget <script> tag in the root
9676
+ Patchstack Connector <script> tag in the root
9063
9677
  HTML shell (index.html, public/index.html,
9064
9678
  or src/app.html) \u2014 opt out with
9065
9679
  "widget": false in .patchstackrc.json.
@@ -9168,6 +9782,8 @@ Usage:
9168
9782
  Global:
9169
9783
  --version Print the installed version of this package and exit
9170
9784
  --help Print this help and exit
9785
+ --verbose Also print the technical detail: site ID, endpoint, checksum,
9786
+ environment and why, files written, installer steps
9171
9787
 
9172
9788
  Options (for scan, setup, status, and uninstall):
9173
9789
  --site-uuid <uuid> Override the configured site UUID
@@ -9223,6 +9839,13 @@ Examples:
9223
9839
  npx @patchstack/connect demo-guide node-serialize
9224
9840
  `;
9225
9841
  var VALUE_FLAGS = /* @__PURE__ */ new Set(["site-uuid", "endpoint", "dir", "url", "out", "claim-token"]);
9842
+ var verbose = false;
9843
+ function detail(line) {
9844
+ if (verbose) console.log(line);
9845
+ }
9846
+ function useColor() {
9847
+ return process.stdout.isTTY === true && process.env.NO_COLOR === void 0;
9848
+ }
9226
9849
  function parseArgs(argv) {
9227
9850
  const args = argv.slice(2);
9228
9851
  const positional = [];
@@ -9281,7 +9904,7 @@ async function runInit(args) {
9281
9904
  const target = await writeConfigFile(process.cwd(), { siteUuid: uuid });
9282
9905
  console.log(`Wrote ${target}`);
9283
9906
  console.log("");
9284
- console.log("Next: run `npx @patchstack/connect scan` to send your first manifest.");
9907
+ console.log("Next: run `npx @patchstack/connect scan`.");
9285
9908
  return 0;
9286
9909
  }
9287
9910
  async function runClaim(args) {
@@ -9303,7 +9926,7 @@ async function runClaim(args) {
9303
9926
  console.log(` ${result2.dashboardUrl ?? buildClaimUrl(config.endpoint, siteUuid)}`);
9304
9927
  if (result2.credentialSaved === true) {
9305
9928
  const ignore = await secretFileIgnored(process.cwd());
9306
- console.log(` A credential for this site was issued and saved to ${SECRET_CONFIG_FILENAME}.`);
9929
+ console.log(` Saved a new credential to ${SECRET_CONFIG_FILENAME}.`);
9307
9930
  console.log(gitignoreOutcomeLine(ignore));
9308
9931
  }
9309
9932
  console.log("");
@@ -9314,8 +9937,7 @@ async function runClaim(args) {
9314
9937
  Your code: ${userCode}`);
9315
9938
  console.log(` Claim at: ${verificationUri}
9316
9939
  `);
9317
- console.log(" Open that link and sign in to Patchstack \u2014 or create an account \u2014 to attach");
9318
- console.log(" this site to it. Whoever approves becomes the site's owner.\n");
9940
+ console.log(" Open the link and sign in (or sign up) to connect this site. Whoever approves owns it.\n");
9319
9941
  };
9320
9942
  if (args.flags.has("wait")) {
9321
9943
  const pending = readPendingClaim(siteUuid);
@@ -9359,8 +9981,7 @@ async function runClaim(args) {
9359
9981
  return 1;
9360
9982
  }
9361
9983
  prompt(started.pending.userCode, started.pending.verificationUri);
9362
- console.log(" Give that link to the user. When they confirm they have claimed the site, run");
9363
- console.log(" this same command again (or `claim --wait` to block until they do).\n");
9984
+ console.log(" Give the link to the user. Once they approve, run this command again (or `claim --wait`).\n");
9364
9985
  return 0;
9365
9986
  }
9366
9987
  const result = await claim2(config, (userCode, verificationUri) => {
@@ -9390,8 +10011,7 @@ async function runLogin(args) {
9390
10011
  console.log(`
9391
10012
  \u2713 Credential restored and saved to ${SECRET_CONFIG_FILENAME}.`);
9392
10013
  console.log(gitignoreOutcomeLine(ignore));
9393
- console.log(" The previous credential no longer works. Update it anywhere else it was set:");
9394
- console.log(" CI secrets, hosting env vars, preview environments, other checkouts.\n");
10014
+ console.log(" The old credential no longer works. Update it in CI, hosting and other checkouts.\n");
9395
10015
  return 0;
9396
10016
  };
9397
10017
  if (args.flags.has("wait")) {
@@ -9412,9 +10032,8 @@ async function runLogin(args) {
9412
10032
  Your code: ${userCode}`);
9413
10033
  console.log(` Approve at: ${verificationUri}
9414
10034
  `);
9415
- console.log(" Open that link and approve it as the site's owner. Approving issues a new");
9416
- console.log(" credential and stops the current one working \u2014 CI, deploys and any other");
9417
- console.log(" machine using it will need the new value.\n");
10035
+ console.log(" Open the link and approve as the site's owner. This replaces the current credential,");
10036
+ console.log(" so CI, deploys and other machines will need the new one.\n");
9418
10037
  };
9419
10038
  if (process.stdout.isTTY !== true) {
9420
10039
  const existing = config.siteUuid === null ? null : readPendingLogin(config.siteUuid);
@@ -9439,8 +10058,7 @@ async function runLogin(args) {
9439
10058
  return 1;
9440
10059
  }
9441
10060
  prompt(started.pending.userCode, started.pending.verificationUri);
9442
- console.log(" Give that link to the user. When they confirm they have approved it, run");
9443
- console.log(" this same command again (or `login --wait` to block until they do).\n");
10061
+ console.log(" Give the link to the user. Once they approve, run this command again (or `login --wait`).\n");
9444
10062
  return 0;
9445
10063
  }
9446
10064
  const result = await login(config, (userCode, verificationUri) => {
@@ -9458,8 +10076,8 @@ var MAX_RETRY_TIMEOUT_MS = 18e4;
9458
10076
  async function postManifestAccepted(config, payload) {
9459
10077
  const { response, environmentUsed } = await postManifestWithEnvironmentFallback(config, payload);
9460
10078
  if (environmentUsed !== config.environment) {
9461
- console.warn(
9462
- `patchstack: this Patchstack API does not know the ${config.environment} label yet; the report was accepted as ${environmentUsed}, which also keeps it apart from production.`
10079
+ detail(
10080
+ `This Patchstack API does not know the ${config.environment} label yet; the report was accepted as ${environmentUsed}, which also keeps it apart from production.`
9463
10081
  );
9464
10082
  }
9465
10083
  return response;
@@ -9475,23 +10093,27 @@ async function postManifestWithPatience(config, payload) {
9475
10093
  if (!(err instanceof PatchstackError) || err.code !== "NETWORK_TIMEOUT") throw err;
9476
10094
  const timeoutMs = Math.min(config.timeoutMs * RETRY_TIMEOUT_FACTOR, MAX_RETRY_TIMEOUT_MS);
9477
10095
  if (timeoutMs <= config.timeoutMs) throw err;
9478
- console.warn(
9479
- `patchstack: the report timed out after ${config.timeoutMs}ms; trying once more with ${timeoutMs}ms.`
9480
- );
10096
+ console.warn("Patchstack is slow to answer. Trying once more\u2026");
10097
+ detail(`The report timed out after ${config.timeoutMs}ms; trying once more with ${timeoutMs}ms.`);
9481
10098
  const response = await postManifestAccepted({ ...config, timeoutMs }, payload);
9482
10099
  try {
9483
10100
  const target = await persistTimeout(process.cwd(), timeoutMs);
9484
- console.log(`Saved a ${timeoutMs}ms request timeout to ${target}, so builds inherit it.`);
10101
+ detail(`Saved a ${timeoutMs}ms request timeout to ${target}, so builds inherit it.`);
9485
10102
  } catch {
9486
- console.warn(
9487
- `patchstack: could not save the timeout; set PATCHSTACK_TIMEOUT_MS=${timeoutMs} where builds run.`
9488
- );
10103
+ console.warn(`Could not save the longer wait. Set PATCHSTACK_TIMEOUT_MS=${timeoutMs} where your builds run.`);
9489
10104
  }
9490
10105
  return response;
9491
10106
  }
9492
10107
  }
10108
+ function plural(count, one, many) {
10109
+ return `${count} ${count === 1 ? one : many}`;
10110
+ }
9493
10111
  async function runScan(args, options = {}) {
9494
10112
  const dryRun = args.flags.get("dry-run") === true;
10113
+ const say = (line) => {
10114
+ if (verbose || dryRun) console.log(line);
10115
+ };
10116
+ const report = options.report ?? emptyReport();
9495
10117
  const config = await resolveCliConfig(args, {
9496
10118
  cliClaimToken: getStringFlag(args.flags, "claim-token"),
9497
10119
  // The one command that reports them, so the one command that resolves them.
@@ -9499,48 +10121,41 @@ async function runScan(args, options = {}) {
9499
10121
  });
9500
10122
  const manifest = await scanLockfile(process.cwd());
9501
10123
  for (const warning of manifest.warnings ?? []) {
9502
- console.warn(`patchstack: ${warning}`);
10124
+ if (dryRun) console.warn(`patchstack: ${warning}`);
10125
+ else report.missing.push({ text: warning });
9503
10126
  }
9504
10127
  const installPaths = args.flags.get("install-paths") === true;
9505
10128
  const { payload, stats } = buildWirePayload(manifest, { installPaths });
9506
- console.log(
9507
- `Found ${payload.packages.length} unique package versions across ${stats.uniqueNames} package names (${manifest.ecosystem} ecosystem).`
9508
- );
10129
+ say(`Found ${payload.packages.length} package versions across ${stats.uniqueNames} packages (${manifest.ecosystem}).`);
9509
10130
  if (installPaths) {
9510
10131
  const located = payload.packages.filter((pkg) => pkg.paths !== void 0).length;
9511
10132
  console.log(
9512
- payload.installPathsComplete ? `Including each package's install location in the dependency tree (--install-paths), for all ${located}.` : `Including install locations (--install-paths), for ${located} of ${payload.packages.length} \u2014 this lockfile format does not record them for the rest, which will be reported as "not recorded" rather than "not installed there".`
10133
+ payload.installPathsComplete ? `Including install locations (--install-paths) for all ${located}.` : `Including install locations (--install-paths) for ${located} of ${payload.packages.length}. This lockfile does not record the rest; they are sent as "not recorded".`
9513
10134
  );
9514
10135
  }
9515
10136
  if (config.environment === "local") {
9516
- console.log(
9517
- "Reporting from this machine as the local environment: the dashboard will show the app as configured, not deployed. A build on a platform this recognises reports production or sandbox on its own; on a platform it does not know, set PATCHSTACK_ENVIRONMENT=production for the build that goes live."
9518
- );
10137
+ say("Environment: local (this machine). Set PATCHSTACK_ENVIRONMENT=production on a live build your host does not identify.");
9519
10138
  } else {
9520
10139
  const because = (config.environmentEvidence ?? []).length > 0 ? ` (${config.environmentEvidence.join("; ")})` : "";
9521
- console.log(`Reporting under the ${config.environment} environment${because}. Override with PATCHSTACK_ENVIRONMENT.`);
10140
+ say(`Environment: ${config.environment}${because}.`);
9522
10141
  }
9523
10142
  if (config.endpoint !== DEFAULT_ENDPOINT) {
9524
- console.log(
9525
- `Using endpoint override: ${config.endpoint} (set via --endpoint, PATCHSTACK_ENDPOINT, or .patchstackrc.json).`
9526
- );
10143
+ say(`Endpoint override: ${config.endpoint}`);
9527
10144
  }
9528
10145
  if (stats.duplicateNames.length > 0) {
9529
10146
  const sample = stats.duplicateNames.slice(0, 10).join(", ");
9530
10147
  const more = stats.duplicateNames.length > 10 ? `, +${stats.duplicateNames.length - 10} more` : "";
9531
- console.log(
9532
- `${stats.duplicateNames.length} package(s) appear at multiple versions: ${sample}${more}`
9533
- );
10148
+ say(`${stats.duplicateNames.length} package(s) at more than one version: ${sample}${more}`);
9534
10149
  }
9535
10150
  const body = buildManifestBody(config, payload);
9536
10151
  if (typeof body.url === "string") {
9537
- console.log(`Reporting this app's address as ${body.url}.`);
10152
+ say(`Reporting app address: ${body.url}`);
9538
10153
  }
9539
10154
  if (typeof body.name === "string") {
9540
- console.log(`Reporting this app's name as "${body.name}".`);
10155
+ say(`Reporting app name: "${body.name}"`);
9541
10156
  }
9542
10157
  if (typeof config.claimToken === "string" && config.claimToken !== "") {
9543
- console.log("A claim token is set: the site will be connected to the Patchstack account that issued it.");
10158
+ say("Claim token set. The site will join the Patchstack account that issued it.");
9544
10159
  }
9545
10160
  if (dryRun) {
9546
10161
  console.log("");
@@ -9559,15 +10174,15 @@ async function runScan(args, options = {}) {
9559
10174
  }
9560
10175
  if (isPreBundleBuildHook()) {
9561
10176
  const cleared = applyBuildStamp(process.cwd(), null);
9562
- if (cleared.kind === "cleared") console.log(`Removed the previous map binding from ${cleared.file}.`);
10177
+ if (cleared.kind === "cleared") say(`Removed the previous map binding from ${cleared.file}.`);
9563
10178
  }
9564
10179
  const shellFramework = detectStack(payload.packages).framework;
9565
10180
  if (config.widget) {
9566
- reportSourceMarker(shellFramework, computeManifestChecksum(payload.packages));
10181
+ reportSourceMarker(shellFramework, computeManifestChecksum(payload.packages), report);
9567
10182
  }
9568
10183
  const provisioning = config.siteUuid === null;
9569
10184
  if (provisioning) {
9570
- console.log("No site UUID configured \u2014 provisioning a new Patchstack site from this manifest\u2026");
10185
+ say("No site yet. Creating one on Patchstack\u2026");
9571
10186
  }
9572
10187
  let response;
9573
10188
  try {
@@ -9577,113 +10192,147 @@ async function runScan(args, options = {}) {
9577
10192
  for (const line of undeliveredReportLines(err, config, process.cwd())) console.error(line);
9578
10193
  return 0;
9579
10194
  }
10195
+ const checked = `Checked ${plural(stats.uniqueNames, "package", "packages")}`;
10196
+ if (response.stored) {
10197
+ report.done.unshift(checked);
10198
+ say(`Stored manifest #${response.manifest_id} (checksum ${response.checksum}).`);
10199
+ } else if (response.reason === "duplicate") {
10200
+ report.done.unshift(checked, "No changes since the last check");
10201
+ } else {
10202
+ report.missing.push({
10203
+ text: "Patchstack did not save this check",
10204
+ hint: [response.message ?? "Run this again in a few minutes."],
10205
+ detail: [`Server response: ${JSON.stringify(response)}`]
10206
+ });
10207
+ }
9580
10208
  if (provisioning && response.uuid !== void 0 && response.uuid.length > 0) {
9581
10209
  const target = await persistSiteUuid(process.cwd(), response.uuid);
9582
- console.log(`Provisioned site ${response.uuid}. Saved UUID to ${target}.`);
10210
+ report.done.push("Added this project to Patchstack");
10211
+ say(`Created site ${response.uuid}. Saved to ${target}.`);
9583
10212
  }
9584
10213
  if (typeof response.api_key === "string" && response.api_key.length > 0) {
9585
10214
  const hadCredentialInConfig = await credentialInCommittedConfig(process.cwd());
9586
10215
  const saved = await persistApiKey(process.cwd(), response.api_key);
9587
- console.log(`Saved API key to ${saved.path}. Do not commit it.`);
9588
- console.log(
9589
- saved.ignored ? " Added to .gitignore." : ` NOT ignored by git \u2014 ${saved.reason ?? "unknown reason"}. Add \`${SECRET_CONFIG_FILENAME}\` to .gitignore yourself before committing.`
9590
- );
10216
+ if (saved.ignored) {
10217
+ report.done.push(`Saved the project's API key in ${SECRET_CONFIG_FILENAME}. Never commit this file`);
10218
+ say(`Saved API key to ${saved.path} (git-ignored).`);
10219
+ } else {
10220
+ report.missing.push({
10221
+ text: `${SECRET_CONFIG_FILENAME} holds your API key, and git does not ignore it`,
10222
+ hint: [`Add ${SECRET_CONFIG_FILENAME} to .gitignore before you commit. Never commit this file.`],
10223
+ detail: [`Saved to ${saved.path}. Not ignored: ${saved.reason ?? "unknown reason"}.`]
10224
+ });
10225
+ }
9591
10226
  if (hadCredentialInConfig) {
9592
- console.log(
9593
- "A credential was also present in .patchstackrc.json and has been removed from it. If that file was ever committed, rotate the credential from the dashboard."
9594
- );
10227
+ report.missing.push({
10228
+ text: "Your API key was in .patchstackrc.json",
10229
+ hint: [
10230
+ `It is now in ${SECRET_CONFIG_FILENAME}. If .patchstackrc.json was ever committed, replace the key in the Patchstack dashboard.`
10231
+ ]
10232
+ });
9595
10233
  }
9596
10234
  }
9597
- if (response.stored) {
9598
- console.log(`Stored manifest #${response.manifest_id} (checksum ${response.checksum}).`);
9599
- } else if (response.reason === "duplicate") {
9600
- console.log("Manifest unchanged since last scan \u2014 nothing to store.");
9601
- } else {
9602
- console.log(`Server response: ${response.message ?? JSON.stringify(response)}`);
9603
- }
9604
- const claimLines = claimOutcomeLines(response.claim, config);
9605
- if (claimLines.length > 0) {
9606
- console.log("");
9607
- for (const line of claimLines) console.log(line);
10235
+ const claim3 = claimOutcome(response.claim, config);
10236
+ if (claim3 !== null && claim3.connected) {
10237
+ report.done.push(claim3.dashboardUrl !== null ? `${claim3.summary}: ${claim3.dashboardUrl}` : claim3.summary);
10238
+ } else if (claim3 !== null) {
10239
+ report.missing.push(notConnectedItem([`${claim3.summary}.`, ...claim3.hint]));
9608
10240
  }
9609
10241
  const connected = response.claim?.state === "claimed" || response.claim?.state === "owned-by-you";
9610
10242
  const effectiveUuid = config.siteUuid ?? response.uuid ?? null;
9611
10243
  if (config.widget && effectiveUuid !== null && effectiveUuid.length > 0) {
9612
- reportSourceWidget(effectiveUuid, shellFramework);
9613
- }
9614
- const linkUuid = response.uuid ?? config.siteUuid;
9615
- if (!connected && (provisioning || claimLines.length > 0) && linkUuid !== null && linkUuid !== void 0 && linkUuid.length > 0) {
9616
- console.log("");
9617
- console.log("Connect this site to your Patchstack account \u2014 any one of these:");
9618
- if (config.widget) {
9619
- console.log(' 1. In the preview \u2014 the widget shows a "Connect this website" panel while the');
9620
- console.log(" site is unclaimed. Signing in there attaches it.");
9621
- console.log(" 2. Open this dashboard link in a browser:");
9622
- console.log(` ${buildClaimUrl(config.endpoint, linkUuid)}`);
9623
- console.log(" 3. From this terminal: npx @patchstack/connect claim");
9624
- } else {
9625
- console.log(" 1. Open this dashboard link in a browser:");
9626
- console.log(` ${buildClaimUrl(config.endpoint, linkUuid)}`);
9627
- console.log(" 2. From this terminal: npx @patchstack/connect claim");
9628
- }
9629
- if (config.endpoint !== DEFAULT_ENDPOINT) {
9630
- console.log(" (this URL inherits the endpoint override above)");
9631
- }
9632
- }
9633
- if (options.showRemainingSetup !== false) {
10244
+ reportSourceWidget(effectiveUuid, shellFramework, report);
10245
+ }
10246
+ const synced = effectiveUuid !== null && effectiveUuid.length > 0 && (response.stored || response.reason === "duplicate");
10247
+ const outcome = {
10248
+ connected,
10249
+ synced,
10250
+ // Only the dashboard can see the live site; a production scan is reported, not confirmed.
10251
+ deployed: false
10252
+ };
10253
+ options.onReported?.(outcome);
10254
+ if (options.report === void 0) {
9634
10255
  try {
9635
- const state = await collectGuideState(process.cwd());
9636
- const remaining = countRemainingSteps(state);
9637
- if (remaining > 0) {
9638
- const hooksMissing = !(state.prebuildWired && state.postbuildWired);
9639
- console.log("");
9640
- console.log(
9641
- `Setup not complete \u2014 ${remaining} step(s) remaining${hooksMissing ? ", including the package.json build hooks (which scan can't wire)" : ""}.`
9642
- );
9643
- console.log("Run `npx @patchstack/connect guide` for the exact steps to finish for this project.");
9644
- }
10256
+ await printScanReport(config, effectiveUuid, outcome, report, options.showRemainingSetup !== false);
9645
10257
  } catch {
9646
10258
  }
9647
10259
  }
9648
10260
  return 0;
9649
10261
  }
9650
- function reportSourceWidget(siteUuid, framework) {
10262
+ function mergeMissing(...lists) {
10263
+ const merged = [];
10264
+ for (const item of lists.flat()) {
10265
+ const key = item.key ?? item.text;
10266
+ if (!merged.some((existing) => (existing.key ?? existing.text) === key)) merged.push(item);
10267
+ }
10268
+ return merged;
10269
+ }
10270
+ function scanNextStepContext(config, state, siteUuid) {
10271
+ const uuid = siteUuid !== null && siteUuid.length > 0 ? siteUuid : state.siteUuid;
10272
+ return {
10273
+ ...guideNextStepContext(state),
10274
+ environment: config.environment,
10275
+ environmentSource: config.environmentSource,
10276
+ siteUuid: uuid,
10277
+ claimUrl: uuid !== null && config.endpointTrusted !== false ? buildClaimUrl(config.endpoint, uuid) : null
10278
+ };
10279
+ }
10280
+ async function printScanReport(config, siteUuid, outcome, report, withProgress) {
10281
+ const state = await collectGuideState(process.cwd());
10282
+ const context = scanNextStepContext(config, state, siteUuid);
10283
+ const progress = guideProgress(state, outcome);
10284
+ if ((isInstallOrBuildHook() || runningInCi()) && !verbose) {
10285
+ for (const item of report.missing) console.warn(`Patchstack: ${[item.text + ".", ...item.hint ?? []].join(" ")}`);
10286
+ for (const line of renderHookSummary(report, progress, context)) console.log(line);
10287
+ return;
10288
+ }
10289
+ if (verbose) console.log("");
10290
+ const missing = mergeMissing(report.missing, withProgress ? guideMissing(state, outcome) : []);
10291
+ for (const line of renderStatus("Patchstack scan", { done: report.done, missing }, progress, context, {
10292
+ useColor: useColor(),
10293
+ verbose,
10294
+ withoutProgress: !withProgress
10295
+ })) {
10296
+ console.log(line);
10297
+ }
10298
+ }
10299
+ function reportSourceWidget(siteUuid, framework, report) {
10300
+ const handAdd = (why) => {
10301
+ report.missing.push({
10302
+ text: "The Patchstack widget is not on your page yet",
10303
+ hint: [why, ` ${buildWidgetTag(siteUuid)}`]
10304
+ });
10305
+ };
9651
10306
  try {
9652
10307
  const hint = hasJsxShell(framework) ? resolveWidgetFileHint(process.cwd(), framework) : null;
9653
10308
  const jsxShell = hint !== null && !hint.toLowerCase().endsWith(".html") ? hint : null;
9654
10309
  const result = ensureSourceWidget(process.cwd(), siteUuid, jsxShell);
9655
10310
  switch (result.action) {
9656
10311
  case "added":
9657
- console.log(`Widget: added the disclosure widget tag to ${result.shell}. Reload your preview to see it.`);
9658
- console.log(' Unclaimed, it shows a "Connect this website" panel; the "Report a vulnerability" button replaces it once the site is claimed.');
10312
+ report.done.push(`Added the Patchstack widget to ${result.shell}`);
9659
10313
  break;
9660
10314
  case "updated":
9661
- console.log(`Widget: updated the managed tag in ${result.shell} to site ${siteUuid}.`);
10315
+ report.done.push(`Updated the Patchstack widget in ${result.shell}`);
9662
10316
  break;
9663
10317
  case "unchanged":
9664
- console.log(`Widget: already installed in ${result.shell}.`);
10318
+ report.done.push(`The Patchstack widget is already in ${result.shell}`);
9665
10319
  break;
9666
10320
  case "manual":
9667
- console.log(`Widget: found an existing (manual) install in ${result.shell} \u2014 left untouched.`);
10321
+ report.done.push(`Left your own Patchstack widget in ${result.shell} as it is`);
9668
10322
  break;
9669
10323
  case "no-body":
9670
- console.log(`Widget: ${result.shell} has no </body> tag to anchor on. Add this tag to your root layout manually:`);
9671
- console.log(` ${buildWidgetTag(siteUuid)}`);
10324
+ handAdd(`${result.shell} has no </body>. Add this to your main page layout, just before </body>:`);
9672
10325
  break;
9673
10326
  case "no-shell":
9674
- console.log("Widget: no root shell found to edit (index.html / public/index.html / src/app.html, or a JSX root).");
9675
- console.log("Add this tag to your root layout before </body> (run `guide` for framework-specific placement):");
9676
- console.log(` ${buildWidgetTag(siteUuid)}`);
10327
+ handAdd("Add this to your main page layout, just before </body>:");
9677
10328
  break;
9678
10329
  }
9679
- if (result.action === "added" || result.action === "updated") {
9680
- console.log(' (opt out any time with "widget": false in .patchstackrc.json)');
9681
- }
9682
10330
  } catch (err) {
9683
- console.warn(`Widget: skipped (${err.message}).`);
10331
+ handAdd("Add this to your main page layout, just before </body>:");
10332
+ detail(`Widget: skipped (${err.message}).`);
9684
10333
  }
9685
10334
  }
9686
- function reportSourceMarker(framework, checksum = null) {
10335
+ function reportSourceMarker(framework, checksum, report) {
9687
10336
  try {
9688
10337
  const shell = resolveWidgetFileHint(process.cwd(), framework);
9689
10338
  if (shell === null || shell.toLowerCase().endsWith(".html")) {
@@ -9692,30 +10341,25 @@ function reportSourceMarker(framework, checksum = null) {
9692
10341
  const result = ensureSourceMarker(process.cwd(), shell, framework, checksum);
9693
10342
  switch (result.action) {
9694
10343
  case "added":
9695
- console.log(`Production marker: added to ${shell} (guarded by ${productionGate(framework)}).`);
9696
- console.log(" This root is server-rendered, so the marker ships in source rather than built HTML.");
10344
+ report.done.push(`Set up ${shell} to tell Patchstack when it runs as your live app`);
10345
+ detail(`Production marker: added to ${shell} (only set when ${productionGate(framework)}).`);
9697
10346
  break;
9698
10347
  case "manual":
9699
- console.log(`Production marker: already set in ${shell} \u2014 left untouched.`);
10348
+ detail(`Production marker: already in ${shell}.`);
9700
10349
  break;
9701
10350
  case "no-anchor":
9702
10351
  case "unsupported":
9703
- console.log(
9704
- `Production marker: ${shell} needs it by hand \u2014 without it the widget reads the published site as build mode.`
9705
- );
9706
- if (hasJsxShell(framework)) {
9707
- for (const line of buildSourceMarkerSnippet(framework).split("\n")) {
9708
- console.log(` ${line}`);
9709
- }
9710
- } else {
9711
- console.log(
9712
- ` Emit <script>window.__PATCHSTACK_PROD__=true;</script> only when ${productionGate(framework)}.`
9713
- );
9714
- }
10352
+ report.missing.push({
10353
+ text: "Your live app does not tell Patchstack it is live yet",
10354
+ hint: hasJsxShell(framework) ? [`Add this inside <head> in ${shell}:`, ...buildSourceMarkerSnippet(framework).split("\n").map((line) => ` ${line}`)] : [
10355
+ `Add this inside <head> in ${shell}, only when ${productionGate(framework)}:`,
10356
+ " <script>window.__PATCHSTACK_PROD__=true;</script>"
10357
+ ]
10358
+ });
9715
10359
  break;
9716
10360
  }
9717
10361
  } catch (err) {
9718
- console.warn(`Production marker: skipped (${err.message}).`);
10362
+ detail(`Production marker: skipped (${err.message}).`);
9719
10363
  }
9720
10364
  }
9721
10365
  async function runProtectCommand(args) {
@@ -9855,8 +10499,7 @@ async function runGuide(args) {
9855
10499
  let allDone = false;
9856
10500
  try {
9857
10501
  const state = await collectGuideState(process.cwd());
9858
- const useColor = process.stdout.isTTY === true && process.env.NO_COLOR === void 0;
9859
- console.log(renderGuideChecklist(state, useColor));
10502
+ console.log(renderGuideChecklist(state, useColor(), {}, { verbose }));
9860
10503
  allDone = countRemainingSteps(state) === 0;
9861
10504
  } catch {
9862
10505
  }
@@ -9874,96 +10517,85 @@ async function runGuide(args) {
9874
10517
  console.log(readFileSync19(guidePath, "utf8"));
9875
10518
  return 0;
9876
10519
  }
10520
+ function reportProtection(protection, report) {
10521
+ for (const line of protection.log) detail(`patchstack protect: ${line}`);
10522
+ if (protection.install.status === "not-applicable") {
10523
+ report.done.push("Runtime protection is not needed: this project has no server");
10524
+ if (protection.install.leftovers.length > 0) {
10525
+ report.missing.push({
10526
+ text: "Files from an earlier Patchstack protection setup are not used here",
10527
+ hint: [`You can delete: ${protection.install.leftovers.join(", ")}`]
10528
+ });
10529
+ }
10530
+ return;
10531
+ }
10532
+ if (protection.verification.wired) {
10533
+ const changed = protection.install.status === "wired" && protection.install.changed.length > 0;
10534
+ report.done.push(
10535
+ changed ? `Added runtime protection (${protection.verification.stack})` : `Runtime protection is already set up (${protection.verification.stack})`
10536
+ );
10537
+ return;
10538
+ }
10539
+ if (protection.install.status === "scaffolded" && protection.install.plan.includes("Could not locate a server entry")) {
10540
+ report.missing.push({
10541
+ key: "runtime-protection",
10542
+ text: "Runtime protection: no server file found",
10543
+ hint: ["Add Patchstack where requests enter your app. Run npx @patchstack/connect protect for the steps."]
10544
+ });
10545
+ }
10546
+ }
10547
+ function buildStepsLine(wired) {
10548
+ if (wired.strategy === "postinstall-only") {
10549
+ return wired.changed ? "Added a package check after each install to package.json" : "The package check after each install is already in package.json";
10550
+ }
10551
+ return wired.changed ? "Added the build steps to package.json" : "The build steps are already in package.json";
10552
+ }
9877
10553
  async function runSetup(args) {
9878
10554
  if (args.flags.get("dry-run") === true) {
9879
- console.error("Error: setup does not support --dry-run. Use `scan --dry-run` to preview the manifest.");
10555
+ console.error("Setup cannot run as a preview. To see what would be sent, run: npx @patchstack/connect scan --dry-run");
9880
10556
  return 1;
9881
10557
  }
9882
10558
  const before = await collectGuideState(process.cwd());
9883
10559
  if (!before.hasPackageJson) {
9884
- console.error("Error: no package.json found. Run setup from the project root.");
10560
+ console.error("No package.json here. Run setup from the folder that has your package.json.");
9885
10561
  console.error("For a standalone HTML site, use the widget-only instructions in AGENT-INSTALL.md; do not create a Node project just to run setup.");
9886
10562
  return 1;
9887
10563
  }
9888
10564
  if (before.installed === null) {
9889
10565
  console.error(
9890
- `Error: @patchstack/connect is not declared in package.json.
10566
+ `The Patchstack connector is not installed in this project yet.
9891
10567
  Run: ${installCommand(before.packageManager)}`
9892
10568
  );
9893
10569
  return 1;
9894
10570
  }
9895
- console.log("Patchstack setup \u2014 applying bounded project changes");
9896
- console.log(" 1. Scan dependencies, provision/reuse the site, and manage the source widget");
9897
- const scanCode = await runScan(args, { showRemainingSetup: false });
10571
+ const report = emptyReport();
10572
+ let reported = {};
10573
+ const scanCode = await runScan(args, {
10574
+ report,
10575
+ onReported: (outcome) => {
10576
+ reported = outcome;
10577
+ }
10578
+ });
9898
10579
  if (scanCode !== 0) {
9899
10580
  return scanCode;
9900
10581
  }
9901
- console.log("");
9902
- console.log(" 2. Install and verify runtime protection");
9903
- const protection = setupProtection(process.cwd());
9904
- if (protection.install.status === "not-applicable") {
9905
- console.log("Runtime protection: not applicable to this project \u2014 nothing installed.");
9906
- console.log(` ${protection.install.reason}`);
9907
- if (protection.install.leftovers.length > 0) {
9908
- console.log(" An earlier run scaffolded a guard here before that was established. These files do");
9909
- console.log(" nothing on this project and can be deleted:");
9910
- for (const file of protection.install.leftovers) console.log(` ${file}`);
9911
- }
9912
- } else if (protection.verification.wired) {
9913
- console.log(`Runtime protection: wired (${protection.verification.stack}).`);
9914
- } else {
9915
- console.log(`Runtime protection: manual wiring remains (${protection.verification.stack}):`);
9916
- for (const check of protection.verification.checks) {
9917
- console.log(` ${check.ok ? "\u2713" : "\u2717"} ${check.label}${!check.ok && check.hint ? ` \u2014 ${check.hint}` : ""}`);
9918
- }
9919
- console.log("Run `npx @patchstack/connect protect --check` after completing the failed checks.");
9920
- }
9921
- console.log("");
9922
- console.log(" 3. Wire dependency-install and production-build scans into package.json");
10582
+ reportProtection(setupProtection(process.cwd()), report);
9923
10583
  const wired = wireBuildScripts(process.cwd(), before.packageManager);
9924
- console.log(`Build integration: ${wired.detail}`);
9925
- console.log("");
9926
- console.log(" 4. Verify setup status");
10584
+ report.done.push(buildStepsLine(wired));
10585
+ detail(`Build hooks: ${wired.detail}`);
10586
+ const config = await resolveCliConfig(args);
9927
10587
  const after = await collectGuideState(process.cwd());
9928
- const useColor = process.stdout.isTTY === true && process.env.NO_COLOR === void 0;
9929
- console.log(renderGuideChecklist(after, useColor));
9930
- const remaining = countRemainingSteps(after);
9931
- if (remaining > 0) {
9932
- console.log("");
9933
- console.log(`Setup applied its bounded changes; ${remaining} manual step(s) remain above.`);
10588
+ const missing = mergeMissing(report.missing, guideMissing(after, reported));
10589
+ const context = scanNextStepContext(config, after, after.siteUuid);
10590
+ if (verbose) console.log("");
10591
+ for (const line of renderStatus("Patchstack setup", { done: report.done, missing }, guideProgress(after, reported), context, {
10592
+ useColor: useColor(),
10593
+ verbose
10594
+ })) {
10595
+ console.log(line);
9934
10596
  }
9935
- const outcome = setupOutcome(after, protection, wired);
9936
- console.log("");
9937
- console.log("Outcome \u2014 relay this to the user as it is:");
9938
- for (const [label, value] of outcome) console.log(` ${label}: ${value}`);
9939
10597
  return 0;
9940
10598
  }
9941
- function setupOutcome(state, protection, wired) {
9942
- const remaining = countRemainingSteps(state);
9943
- const lines = [];
9944
- lines.push(["Status", remaining === 0 ? "Ready to deploy (configured locally; nothing is live yet)" : `Configured locally; ${remaining} step(s) still to finish (see the checklist above)`]);
9945
- lines.push(["Monitoring", "starts with the first build that runs on your hosting platform"]);
9946
- if (protection.install.status === "not-applicable") {
9947
- lines.push(["Runtime protection", "not applicable \u2014 this project has no request path (static build)"]);
9948
- } else if (protection.verification.wired) {
9949
- lines.push(["Runtime protection", `wired (${protection.verification.stack}) \u2014 local wiring verified; verify against the live site after deploy with \`protect --check --runtime\``]);
9950
- } else {
9951
- lines.push(["Runtime protection", `not wired yet (${protection.verification.stack}) \u2014 finish the checks above, then \`npx @patchstack/connect protect --check\``]);
9952
- }
9953
- lines.push(["Next steps", [
9954
- "add PATCHSTACK_API_KEY (from .patchstackrc.local.json) to your hosting platform's environment variables",
9955
- "commit .patchstackrc.json, package.json and the widget change (never .patchstackrc.local.json)",
9956
- 'deploy, then check the site in the Patchstack dashboard \u2014 it reads "Deployed" once the live site is seen'
9957
- ].join("; ")]);
9958
- const warnings = [];
9959
- if (!wired.changed && wired.strategy === "postinstall-only") warnings.push("no build script, so only dependency installs are scanned");
9960
- if (protection.install.status === "not-applicable" && protection.install.leftovers.length > 0) {
9961
- warnings.push(`earlier guard scaffold does nothing here and can be deleted: ${protection.install.leftovers.join(", ")}`);
9962
- }
9963
- if (state.claimUrl !== null) warnings.push('the site is not attached to an account until someone signs in through the widget\'s "Connect this website" panel, the dashboard link is opened, or `npx @patchstack/connect claim` completes');
9964
- if (warnings.length > 0) lines.push(["Warnings", warnings.join("; ")]);
9965
- return lines;
9966
- }
9967
10599
  async function runStatus(args) {
9968
10600
  const config = await resolveCliConfig(args);
9969
10601
  console.log(`Site UUID: ${config.siteUuid ?? "(none yet \u2014 the next `scan` will provision one)"}`);
@@ -9974,24 +10606,14 @@ async function runStatus(args) {
9974
10606
  console.log(`Environment: ${config.environment}`);
9975
10607
  if (config.siteUuid !== null) {
9976
10608
  console.log(`Dashboard URL: ${buildClaimUrl(config.endpoint, config.siteUuid)}`);
9977
- console.log(` Not attached to an account yet? Sign in through the widget's "Connect this`);
9978
- console.log(' website" panel on the preview, open the link above, or run');
9979
- console.log(" `npx @patchstack/connect claim`.");
10609
+ console.log(" Not connected yet? Open the link above or run `npx @patchstack/connect claim`.");
9980
10610
  switch (await fetchSiteStatus(config)) {
9981
10611
  case "active":
9982
10612
  console.log("Site status: active on Patchstack");
9983
10613
  break;
9984
10614
  case "removed":
9985
10615
  console.log("Site status: removed from Patchstack");
9986
- console.log(
9987
- " The site record no longer exists (deleted from the dashboard or via the"
9988
- );
9989
- console.log(
9990
- " widget uninstall flow). The local integration files are still in this"
9991
- );
9992
- console.log(
9993
- ' project \u2014 see "Uninstalling" in AGENT-INSTALL.md to remove them.'
9994
- );
10616
+ console.log(' The local files are still here. See "Uninstalling" in AGENT-INSTALL.md to remove them.');
9995
10617
  break;
9996
10618
  case "unknown":
9997
10619
  console.log("Site status: could not be verified (Patchstack unreachable)");
@@ -10026,8 +10648,7 @@ async function runUninstall(args) {
10026
10648
  break;
10027
10649
  }
10028
10650
  console.log("");
10029
- console.log("This command only signals Patchstack. The local integration files must still be");
10030
- console.log('removed \u2014 follow the "Uninstalling" steps in AGENT-INSTALL.md.');
10651
+ console.log('Local files are not touched. Remove them with the "Uninstalling" steps in AGENT-INSTALL.md.');
10031
10652
  return 0;
10032
10653
  }
10033
10654
  function describeStack(stack) {
@@ -10060,7 +10681,7 @@ async function reportBuildStamp(config, payload, marker) {
10060
10681
  await postManifestWithEnvironmentFallback(bounded, payload, marker);
10061
10682
  } catch (err) {
10062
10683
  console.warn(
10063
- `mark-build: this build was not reported to Patchstack (${err.message}). The pages were still marked as described above.`
10684
+ `mark-build: could not tell Patchstack about this build (${err.message}). The pages were still marked.`
10064
10685
  );
10065
10686
  }
10066
10687
  }
@@ -10122,22 +10743,14 @@ async function runMarkBuild(args) {
10122
10743
  const files = findHtmlFiles(dir);
10123
10744
  if (files.length === 0) {
10124
10745
  console.warn(`mark-build: found ${dir} but no HTML files in it.`);
10125
- console.warn(
10126
- "mark-build: this build looks server-rendered, so the production flag has nothing to stamp."
10127
- );
10128
- console.warn(
10129
- "mark-build: add the marker to your root shell instead \u2014 run `patchstack-connect guide` for the snippet."
10130
- );
10746
+ console.warn("mark-build: this looks like a server-rendered app. Run `npx @patchstack/connect guide` to see what your main layout needs.");
10131
10747
  await reportBuildStamp(reported, wirePayload, "no-pages");
10132
10748
  return 0;
10133
10749
  }
10134
10750
  const staleBy = staleBuildAge(files, startedAt);
10135
10751
  if (staleBy !== null) {
10136
10752
  console.warn(
10137
- `mark-build: nothing in ${dir} has been written in the last ${Math.round(staleBy / 6e4)} minute(s), so this looks like output from an earlier build.`
10138
- );
10139
- console.warn(
10140
- "mark-build: run it as a postbuild hook (or straight after the build) so what is stamped is what was just produced."
10753
+ `mark-build: nothing in ${dir} changed in the last ${Math.round(staleBy / 6e4)} minute(s). Run it right after the build (as postbuild).`
10141
10754
  );
10142
10755
  }
10143
10756
  const snippet = published ? buildInjectionSnippet(checksum, stack) : "";
@@ -10160,7 +10773,8 @@ async function runMarkBuild(args) {
10160
10773
  }
10161
10774
  const stackSummary = stack !== null ? describeStack(stack) : null;
10162
10775
  if (published) {
10163
- console.log(
10776
+ console.log(`Patchstack: marked ${plural(files.length, "page", "pages")} as your live app.`);
10777
+ detail(
10164
10778
  `mark-build: marked ${marked} HTML file(s) in ${dir}${checksum !== null ? ` (build ${checksum})` : ""}${widgetTouched > 0 ? `, widget tag ensured in ${widgetTouched}` : ""}${stackSummary !== null ? ` [${stackSummary}]` : ""}.`
10165
10779
  );
10166
10780
  await reportBuildStamp(reported, wirePayload, "stamped");
@@ -10168,13 +10782,10 @@ async function runMarkBuild(args) {
10168
10782
  }
10169
10783
  const because = environmentEvidence.length > 0 ? ` (${environmentEvidence.join("; ")})` : "";
10170
10784
  console.log(
10171
- `mark-build: ${environment} build${because} \u2014 production marker withheld from ${files.length} HTML file(s) in ${dir}${widgetTouched > 0 ? `, widget tag ensured in ${widgetTouched}` : ""}${stackSummary !== null ? ` [${stackSummary}]` : ""}.`
10785
+ `Patchstack: ${environment} build, so ${plural(files.length, "page is", "pages are")} not marked as your live app. Publishing this folder by hand? Run again with --production.`
10172
10786
  );
10173
- console.log(
10174
- "mark-build: the marker tells Patchstack a page is the live site, so only the build that deploys carries it. Your hosting platform's build stamps it automatically."
10175
- );
10176
- console.log(
10177
- "mark-build: publishing this directory by hand? Re-run with --production (or set PATCHSTACK_ENVIRONMENT=production) so the deployed pages carry it."
10787
+ detail(
10788
+ `mark-build: ${environment} build${because}, so no production marker in ${files.length} HTML file(s) in ${dir}${widgetTouched > 0 ? `, widget tag ensured in ${widgetTouched}` : ""}${stackSummary !== null ? ` [${stackSummary}]` : ""}.`
10178
10789
  );
10179
10790
  await reportBuildStamp(reported, wirePayload, "withheld");
10180
10791
  return 0;
@@ -10188,8 +10799,28 @@ function packageVersion() {
10188
10799
  return "unknown";
10189
10800
  }
10190
10801
  }
10802
+ function plainErrorMessage(err) {
10803
+ switch (err.code) {
10804
+ case "NETWORK_ERROR":
10805
+ return "Could not reach Patchstack. Check your internet connection and try again.";
10806
+ case "NETWORK_TIMEOUT":
10807
+ return "Patchstack took too long to answer. Try again in a minute.";
10808
+ case "SERVER_ERROR":
10809
+ return "Patchstack could not handle the request right now. Try again in a few minutes.";
10810
+ case "LOCKFILE_NOT_FOUND":
10811
+ return "Could not find the list of installed packages. Install your packages first (for example npm install), then try again.";
10812
+ case "LOCKFILE_PARSE_ERROR":
10813
+ case "LOCKFILE_UNSUPPORTED":
10814
+ return "Could not read the list of installed packages. Reinstall your packages (for example npm install), then try again.";
10815
+ case "CONFIG_MISSING":
10816
+ return "This project is not set up with Patchstack yet. Run npx @patchstack/connect setup first.";
10817
+ default:
10818
+ return null;
10819
+ }
10820
+ }
10191
10821
  async function main() {
10192
10822
  const args = parseArgs(process.argv);
10823
+ verbose = args.flags.get("verbose") === true;
10193
10824
  if (args.flags.has("version") || args.command === "version") {
10194
10825
  console.log(packageVersion());
10195
10826
  return 0;
@@ -10234,7 +10865,9 @@ async function main() {
10234
10865
  }
10235
10866
  main().then((code) => process.exit(code)).catch((err) => {
10236
10867
  if (err instanceof PatchstackError) {
10237
- console.error(`Error (${err.code}): ${err.message}`);
10868
+ const plain = plainErrorMessage(err);
10869
+ console.error(`${plain ?? err.message} (${err.code})`);
10870
+ if (plain !== null && verbose) console.error(`Detail: ${err.message}`);
10238
10871
  process.exit(1);
10239
10872
  }
10240
10873
  console.error("Unexpected error:", err);