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 &&
` (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 `` as an + * element with a boolean `defer`, which is exactly what the HTML form means — and it is already the + * snippet `guide` prints for these roots, so this inserts what a person following the instructions + * would have typed. + * + * Deliberately NOT behind the production gate the marker uses. The marker claims the site is live; + * the widget is how an owner claims the site in the first place, and that happens in the preview. */ -export function ensureSourceWidget(cwd: string, siteUuid: string): SourceWidgetResult { - const shell = findSourceShell(cwd); +export function ensureSourceWidget( + cwd: string, + siteUuid: string, + jsxShell: string | null = null, +): SourceWidgetResult { + // A real HTML shell always wins: it is the document the build actually serves, and on a stack that + // has one the JSX hint would point at a component that merely renders into it. + const shell = findSourceShell(cwd) ?? jsxShell; if (shell === null) { return { shell: null, action: 'no-shell' }; } const file = path.join(cwd, shell); + if (!existsSync(file)) { + return { shell: null, action: 'no-shell' }; + } const before = readFileSync(file, 'utf8'); const { html, action } = ensureWidgetInHtml(before, siteUuid); if (html !== before) { diff --git a/tests/mark-build.test.ts b/tests/mark-build.test.ts index 81a8476e..7dbfaeff 100644 --- a/tests/mark-build.test.ts +++ b/tests/mark-build.test.ts @@ -219,6 +219,57 @@ describe('productionGate', () => { }); }); +describe('buildSourceMarkerSnippet with a build fingerprint', () => { + it('carries the fingerprint, which is what names WHICH build is live', () => { + // Without it the marker says a site is live but not what it is running, and the dashboard grades + // on the second question. A server-rendered app has no built HTML for mark-build to stamp, so + // this snippet is the only path the fingerprint has to production on those stacks. + const snippet = buildSourceMarkerSnippet('tanstack-start', 'ac749db1ff30'); + expect(snippet).toContain('window.__PATCHSTACK_PROD__=true;'); + expect(snippet).toContain('window.__PATCHSTACK_BUILD__="ac749db1ff30";'); + }); + + it('omits it when there is none to give', () => { + const snippet = buildSourceMarkerSnippet('tanstack-start'); + expect(snippet).toContain('__PATCHSTACK_PROD__'); + expect(snippet).not.toContain('__PATCHSTACK_BUILD__'); + }); + + it('keeps the fingerprint behind the production gate too', () => { + const snippet = buildSourceMarkerSnippet('tanstack-start', 'ac749db1ff30'); + expect(snippet.indexOf('import.meta.env.PROD')).toBeLessThan(snippet.indexOf('__PATCHSTACK_BUILD__')); + }); + + it('stays parseable TSX with a fingerprint in it', () => { + const source = `export const Root = () => (\n \n
\n ${buildSourceMarkerSnippet('tanstack-start', 'ac749db1ff30')}\n \n
\n