diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index f3ab3efa..36db5057 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -120,7 +120,7 @@ This is a request, not a mechanism: nothing in the install depends on it. Do it **Bun-managed projects:** `bun run` does not execute npm-style `pre`/`post` scripts, so wire the build script directly instead: `"build": "patchstack-connect scan && && patchstack-connect mark-build"`. -3. **Verify the disclosure widget** — a floating "Report a vulnerability" button. `scan` installs it automatically into a plain HTML shell, and `mark-build` carries it into built HTML. Only when `scan` reported that it found no editable shell (frameworks whose root layout is code, e.g. Next.js/Nuxt/Astro) add the one-liner it printed to the root layout yourself, just before `` (never a JS entry point), reading `siteUuid` from `.patchstackrc.json`. On those same roots the widget also needs the production marker above the tag — `scan` adds it automatically to a JSX root, and prints it to paste when it finds no anchor. A server-rendered site without the marker serves the build-mode claim flow to its visitors: +3. **Verify the disclosure widget** — a floating "Report a vulnerability" button. `scan` installs it automatically into a plain HTML shell **or a JSX root** (Next, Remix, React Router, TanStack Start, Gatsby), and `mark-build` carries it into built HTML. Only when `scan` reported that it found no editable shell at all — a root whose head mechanism is not a plain script tag, e.g. Nuxt's `useHead` or an Astro layout — add the one-liner it printed to the root layout yourself, just before `` (never a JS entry point), reading `siteUuid` from `.patchstackrc.json`. On those same roots the widget also needs the production marker above the tag — `scan` adds it automatically to a JSX root, and prints it to paste when it finds no anchor. A server-rendered site without the marker serves the build-mode claim flow to its visitors: ```html diff --git a/README.md b/README.md index 8921b05a..1a40d0c8 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,7 @@ The widget is a floating "Report a vulnerability" button — a disclosure channe ``` +- **`scan`** installs the widget tag into a plain HTML shell, and — where there is none — into a JSX root (`src/routes/__root.tsx`, `app/layout.tsx`, …), just before ``. The same tag serves both: JSX reads `defer` as a boolean attribute and passes `data-*` through. A server-rendered app has no HTML shell at all, so without this its published site carries no widget and Patchstack never hears from the live page. - **`scan`** also adds the production marker when the root shell is JSX rather than HTML (`src/routes/__root.tsx`, `app/layout.tsx`, …), above the widget tag and guarded by the framework's production expression. A server-rendered app emits no built HTML for `mark-build` to stamp, so without it the widget reads the published site as build mode and shows the claim flow to visitors instead of the report form. Re-runs update the tag in place (the `data-patchstack-connect-widget` attribute marks it as connector-managed); a pre-existing manual widget tag is left untouched. `--dry-run` never edits anything; a failed post still skips the widget tag (it needs the site UUID) but the production marker may already have been written, since it runs before the post. Projects whose root layout is code rather than HTML (Next.js, Nuxt, Astro, …) get the exact snippet and target file printed instead — `guide` shows framework-specific placement. diff --git a/src/cli.ts b/src/cli.ts index 1ba4b1d9..69e3a75b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -715,8 +715,12 @@ async function runScan( // server round-trip, while `scan` is commonly chained as `scan || true`, so a // failed or offline post must not be what decides whether a published build // gets its production flag. --dry-run has already returned by here. + const shellFramework = detectStack(payload.packages).framework; if (config.widget) { - reportSourceMarker(detectStack(payload.packages).framework); + // The checksum of the manifest this run is posting — on a pre-build hook that is the build about + // to be compiled. Without it a server-rendered app's page says it is live but not which build is, + // which is the question the dashboard grades on. + reportSourceMarker(shellFramework, computeManifestChecksum(payload.packages)); } const provisioning = config.siteUuid === null; @@ -789,7 +793,7 @@ async function runScan( // returned above. const effectiveUuid = config.siteUuid ?? response.uuid ?? null; if (config.widget && effectiveUuid !== null && effectiveUuid.length > 0) { - reportSourceWidget(effectiveUuid); + reportSourceWidget(effectiveUuid, shellFramework); } // On the first scan (provisioning), surface the dashboard URL so the user can @@ -840,9 +844,14 @@ async function runScan( * widget management is a convenience layered on top of a successful scan and * must not turn one into a failure. */ -function reportSourceWidget(siteUuid: string): void { +function reportSourceWidget(siteUuid: string, framework: string | null): void { try { - const result = ensureSourceWidget(process.cwd(), siteUuid); + // A server-rendered project has no HTML shell to edit, so the framework's JSX root stands in for + // one. Only where a literal tag is known to belong — the same set the marker will write into. + const hint = hasJsxShell(framework) ? resolveWidgetFileHint(process.cwd(), framework) : null; + const jsxShell = hint !== null && !hint.toLowerCase().endsWith('.html') ? hint : null; + + const result = ensureSourceWidget(process.cwd(), siteUuid, jsxShell); switch (result.action) { case 'added': console.log(`Widget: added the "Report a vulnerability" tag to ${result.shell}. Reload your preview to see it.`); @@ -861,7 +870,7 @@ function reportSourceWidget(siteUuid: string): void { console.log(` ${buildWidgetTag(siteUuid)}`); break; case 'no-shell': - console.log('Widget: no plain HTML shell found (index.html / public/index.html / src/app.html).'); + console.log('Widget: no root shell found to edit (index.html / public/index.html / src/app.html, or a JSX root).'); console.log('Add this tag to your root layout before (run `guide` for framework-specific placement):'); console.log(` ${buildWidgetTag(siteUuid)}`); break; @@ -883,7 +892,7 @@ function reportSourceWidget(siteUuid: string): void { * follows. On a server-rendered root that is the only way the marker reaches * production — `mark-build` runs after the build and has no HTML to stamp. */ -function reportSourceMarker(framework: string | null): void { +function reportSourceMarker(framework: string | null, checksum: string | null = null): void { try { const shell = resolveWidgetFileHint(process.cwd(), framework); if (shell === null || shell.toLowerCase().endsWith('.html')) { @@ -891,7 +900,7 @@ function reportSourceMarker(framework: string | null): void { return; } - const result = ensureSourceMarker(process.cwd(), shell, framework); + const result = ensureSourceMarker(process.cwd(), shell, framework, checksum); switch (result.action) { case 'added': console.log(`Production marker: added to ${shell} (guarded by ${productionGate(framework)}).`); diff --git a/src/mark-build.ts b/src/mark-build.ts index e94a1efd..15fdd939 100644 --- a/src/mark-build.ts +++ b/src/mark-build.ts @@ -269,14 +269,32 @@ export function hasJsxShell(framework: string | null): boolean { * It stays an inline document script rather than a module-level assignment: the * widget tag is `defer`, and only a parser-executed inline script is ordered * ahead of it for certain. + * + * `checksum` carries the build fingerprint, and without it the marker can say a + * site is live but not WHICH build is live — which is the question the dashboard + * actually grades on. A server-rendered app whose page carried only the live flag + * reported as "deploy state unknown" no matter how healthy it was, because the one + * value that answers it is stamped into built HTML that this stack never produces. + * + * `scan` is a pre-build hook, so the value it has is the checksum of the manifest + * it is posting in the same run — which is exactly the build about to be compiled. + * Omitted when there is none to give, and when the snippet is printed for somebody + * to paste, where a pasted fingerprint would go stale the next time deps changed. */ -export function buildSourceMarkerSnippet(framework: string | null): string { +export function buildSourceMarkerSnippet( + framework: string | null, + checksum: string | null = null, +): string { const gate = productionGate(framework); + const statements = ['window.__PATCHSTACK_PROD__=true;']; + if (checksum !== null && checksum !== '') { + statements.push(`window.__PATCHSTACK_BUILD__=${JSON.stringify(checksum)};`); + } return ( `{${gate} && (\n` + ` \n` + `)}` ); @@ -389,7 +407,11 @@ function findJsxShellAnchor(source: string, tagName: 'head' | 'body'): JsxShellA * in source. A marker the developer placed themselves is adopted rather than * duplicated. */ -export function ensureMarkerInJsxShell(source: string, framework: string | null): SourceMarkerResult { +export function ensureMarkerInJsxShell( + source: string, + framework: string | null, + checksum: string | null = null, +): SourceMarkerResult { if (!hasJsxShell(framework)) { return { source, action: 'unsupported' }; } @@ -400,7 +422,7 @@ export function ensureMarkerInJsxShell(source: string, framework: string | null) } const block = (indent: string): string => - [REGION_OPEN, ...buildSourceMarkerSnippet(framework).split('\n'), REGION_CLOSE] + [REGION_OPEN, ...buildSourceMarkerSnippet(framework, checksum).split('\n'), REGION_CLOSE] .map((line) => `${indent}${line}`) .join('\n'); @@ -434,13 +456,14 @@ export function ensureSourceMarker( cwd: string, shell: string | null, framework: string | null, + checksum: string | null = null, ): EnsureSourceMarkerResult { if (shell === null) { return { shell: null, source: '', action: 'no-anchor' }; } const file = path.resolve(cwd, shell); const before = readFileSync(file, 'utf8'); - const result = ensureMarkerInJsxShell(before, framework); + const result = ensureMarkerInJsxShell(before, framework, checksum); if (result.source !== before) { writeFileSync(file, result.source); } diff --git a/src/widget.ts b/src/widget.ts index 2bf668db..e168edb5 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -21,9 +21,9 @@ const WIDGET_NEEDLE = 'patchstack-widget'; /** * Root HTML shells the connector is willing to edit, in priority order: - * Vite/plain SPA, CRA-style, SvelteKit. Framework layouts that are code rather - * than HTML (Next/Nuxt/Astro layouts) are never edited automatically — `guide` - * prints the snippet and the right file for those. + * Vite/plain SPA, CRA-style, SvelteKit. A framework whose root is code rather + * than HTML has no entry here; a JSX one is handled by `ensureSourceWidget`'s + * fallback below, and the rest get the snippet printed by `guide`. */ export const SOURCE_SHELL_CANDIDATES = ['index.html', 'public/index.html', 'src/app.html']; @@ -102,15 +102,38 @@ export interface SourceWidgetResult { } /** - * Ensure the managed widget tag in the project's root HTML shell. Edits at most - * that one file; returns what happened so the caller can report it. + * Ensure the managed widget tag in the project's root shell. Edits at most that one file; returns + * what happened so the caller can report it. + * + * `jsxShell` is the framework's root component, used only when the project has no plain HTML shell — + * a server-rendered app (TanStack Start, Next, Remix) never produces one, and until this fallback + * existed those projects were told to paste the tag themselves. That instruction was reliably missed: + * a hosted builder's agent runs setup, reads "add this yourself", finishes, and the published site + * carries no widget at all. Nothing downstream can tell that apart from a site that was never set up. + * + * The same tag works in both places. JSX reads `')); + expect(ensureSourceWidget(cwd, UUID_A, shell).action).toBe('manual'); + expect(read()).not.toContain(UUID_A); + }); + + it('prefers a real HTML shell, which is the document actually served', () => { + // On a stack that has both, the JSX root only renders into the shell — editing it would put the + // tag one level further from the page than it needs to be. + writeFileSync(path.join(cwd, 'index.html'), SHELL); + const shell = jsxRoot(); + expect(ensureSourceWidget(cwd, UUID_A, shell).shell).toBe('index.html'); + expect(read()).not.toContain('patchstack-widget.js'); + }); + + it('reports no-shell when the hinted root does not exist', () => { + expect(ensureSourceWidget(cwd, UUID_A, 'src/routes/__root.tsx')).toEqual({ + shell: null, + action: 'no-shell', + }); + }); +});