diff --git a/src/cli.ts b/src/cli.ts index 3b88a050..ae305909 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,8 +25,12 @@ import { import { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js'; import { buildInjectionSnippet, + buildSourceMarkerSnippet, + ensureSourceMarker, findHtmlFiles, + hasJsxShell, injectMarker, + productionGate, resolveBuildDir, } from './mark-build.js'; import { @@ -35,6 +39,7 @@ import { detectPackageManager, installCommand, renderGuideChecklist, + resolveWidgetFileHint, } from './guide.js'; import { login, readPendingLogin, redeemIfApproved, startLogin, waitForApproval } from './login.js'; import { runProtect, runVerify } from './protect/install/index.js'; @@ -468,6 +473,14 @@ async function runScan( return 0; } + // Ahead of the post deliberately. The marker carries no site UUID and needs no + // 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. + if (config.widget) { + reportSourceMarker(detectStack(payload.packages).framework); + } + const provisioning = config.siteUuid === null; if (provisioning) { console.log('No site UUID configured — provisioning a new Patchstack site from this manifest…'); @@ -581,6 +594,53 @@ function reportSourceWidget(siteUuid: string): void { } } +/** + * Run the production-marker pass for `scan` and narrate the outcome. Never + * throws, for the same reason the widget pass doesn't: this is a convenience on + * top of a successful scan and must not turn one into a failure. + * + * `scan` is a pre-build hook, so an edit here is compiled into the build that + * 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 { + try { + const shell = resolveWidgetFileHint(process.cwd(), framework); + if (shell === null || shell.toLowerCase().endsWith('.html')) { + // An HTML shell needs nothing here: mark-build stamps the built copy. + return; + } + + const result = ensureSourceMarker(process.cwd(), shell, framework); + switch (result.action) { + case 'added': + console.log(`Production marker: added to ${shell} (guarded by ${productionGate(framework)}).`); + console.log(' This root is server-rendered, so the marker ships in source rather than built HTML.'); + break; + case 'manual': + console.log(`Production marker: already set in ${shell} — left untouched.`); + break; + case 'no-anchor': + case 'unsupported': + console.log( + `Production marker: ${shell} needs it by hand — without it the widget reads the published site as build mode.`, + ); + if (hasJsxShell(framework)) { + for (const line of buildSourceMarkerSnippet(framework).split('\n')) { + console.log(` ${line}`); + } + } else { + console.log( + ` Emit only when ${productionGate(framework)}.`, + ); + } + break; + } + } catch (err) { + console.warn(`Production marker: skipped (${(err as Error).message}).`); + } +} + async function runProtectCommand(args: ParsedArgs): Promise { // `--check`: verify the guard is wired (for the agent/CI loop). Non-zero exit if not. if (args.flags.get('check') === true) { @@ -932,7 +992,17 @@ async function runMarkBuild(args: ParsedArgs): Promise { const files = findHtmlFiles(dir); if (files.length === 0) { - console.warn(`mark-build: no HTML files found under ${dir}. Nothing to mark.`); + // A build directory with no HTML in it is the signature of a server-rendered + // app: the document is produced per request, so there is no file to stamp and + // the marker only reaches production if it ships in the source shell. Silence + // here reads as success, and the widget then treats the live site as a build. + console.warn(`mark-build: found ${dir} but no HTML files in it.`); + console.warn( + 'mark-build: this build looks server-rendered, so the production flag has nothing to stamp.', + ); + console.warn( + 'mark-build: add the marker to your root shell instead — run `patchstack-connect guide` for the snippet.', + ); return 0; } diff --git a/src/guide.ts b/src/guide.ts index ccecf64d..962e5399 100644 --- a/src/guide.ts +++ b/src/guide.ts @@ -14,9 +14,17 @@ import { DEFAULT_ENDPOINT, buildClaimUrl } from './client.js'; import { resolveConfig } from './config.js'; import { runVerify } from './protect/install/index.js'; import type { VerifyCheck } from './protect/install/types.js'; +import { + buildSourceMarkerSnippet, + hasJsxShell, + productionGate, +} from './mark-build.js'; import { detectStack } from './stack.js'; import { buildWidgetTag } from './widget.js'; +/** Global the widget reads to decide it is running on a published build. */ +const PROD_MARKER_NEEDLE = '__PATCHSTACK_PROD__'; + /** Substring that marks the widget as installed anywhere in the source tree. */ const WIDGET_NEEDLE = 'patchstack-widget'; @@ -46,6 +54,11 @@ export interface GuideState { framework: string | null; /** Existing file the widget snippet belongs in, best-effort. */ widgetFileHint: string | null; + /** + * True when the root shell already sets `__PATCHSTACK_PROD__`. Only meaningful + * for code shells: HTML shells get the marker stamped by `mark-build` instead. + */ + productionMarkerWired: boolean; /** Result of the same local inspection used by `protect --check`. */ protectionWired: boolean; protectionStack: string; @@ -260,7 +273,23 @@ export function findWidgetMarker(cwd: string, siteUuid?: string | null): WidgetS }; } -function resolveWidgetFileHint(cwd: string, framework: string | null): string | null { +/** + * Whether the root shell already carries the production marker. Reads only the + * file the snippet belongs in — the marker has one correct home, so a tree walk + * would cost more and answer no better. + */ +function findProductionMarker(cwd: string, widgetFileHint: string | null): boolean { + if (widgetFileHint === null) { + return false; + } + try { + return readFileSync(path.join(cwd, widgetFileHint), 'utf8').includes(PROD_MARKER_NEEDLE); + } catch { + return false; + } +} + +export function resolveWidgetFileHint(cwd: string, framework: string | null): string | null { const candidates = [ ...(framework !== null ? WIDGET_FILE_CANDIDATES[framework] ?? [] : []), ...GENERIC_WIDGET_FILES, @@ -317,6 +346,7 @@ export async function collectGuideState(cwd: string): Promise { ); const widget = findWidgetMarker(cwd, siteUuid); + const widgetFileHint = resolveWidgetFileHint(cwd, stack.framework); const protection = runVerify(cwd); return { @@ -341,7 +371,8 @@ export async function collectGuideState(cwd: string): Promise { widgetTokenMatches: widget.uuidMatches, widgetOptOut, framework: stack.framework, - widgetFileHint: resolveWidgetFileHint(cwd, stack.framework), + widgetFileHint: widgetFileHint, + productionMarkerWired: findProductionMarker(cwd, widgetFileHint), protectionWired: protection.wired, protectionStack: protection.stack, protectionChecks: protection.checks, @@ -358,6 +389,18 @@ const ANSI = { }; /** Setup steps still missing — 0 means the checklist is fully green. */ +/** + * True when the site's root shell is code rather than an HTML file. Those roots + * are server-rendered, so no built HTML file carries the marker to production and + * `mark-build`'s HTML pass has nothing to stamp. + */ +export function needsSourceProductionMarker(state: GuideState): boolean { + if (state.siteUuid === null || state.widgetOptOut || state.widgetFileHint === null) { + return false; + } + return !state.widgetFileHint.toLowerCase().endsWith('.html'); +} + export function countRemainingSteps(state: GuideState): number { return [ state.installed?.section === 'dependencies', @@ -365,6 +408,7 @@ export function countRemainingSteps(state: GuideState): number { state.installScanWired, !state.hasBuildScript || (state.prebuildWired && state.postbuildWired), state.widgetOptOut || (state.widgetInstalled && state.widgetTokenMatches !== false), + !needsSourceProductionMarker(state) || state.productionMarkerWired, state.protectionWired, ].filter((step) => !step).length; } @@ -474,6 +518,38 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri lines.push(detail('The site UUID is public by design — it ships in client-side HTML.')); } + // 5b. Production marker on server-rendered roots. A code root never emits a + // static HTML file, so `mark-build` has nothing to stamp and the marker only + // reaches production if it ships in the shell alongside the widget tag. + if (needsSourceProductionMarker(state)) { + if (state.productionMarkerWired) { + lines.push(done('Production marker wired (widget switches to report mode on the published site)')); + } else { + lines.push(todo('Add the production marker — this root is server-rendered, so mark-build cannot stamp it')); + lines.push( + detail( + 'Without it the widget treats the published site as build mode and shows the claim flow to visitors.', + ), + ); + const gate = productionGate(state.framework); + if (hasJsxShell(state.framework)) { + lines.push(detail(`Run → npx @patchstack/connect scan (adds it to ${state.widgetFileHint} automatically)`)); + lines.push(detail('Or add it by hand, in above the widget tag:')); + for (const snippetLine of buildSourceMarkerSnippet(state.framework).split('\n')) { + lines.push(detail(` ${snippetLine}`)); + } + } else { + lines.push( + detail( + `Edit ${state.widgetFileHint} → using the framework's head mechanism, emit an inline`, + ), + ); + lines.push(detail(` only when ${gate}.`)); + } + lines.push(detail(`The ${gate} guard is required — an ungated marker also hides the claim flow in preview.`)) + } + } + // 6. Runtime protection if (state.protectionWired) { lines.push(done(`Runtime protection wired (${state.protectionStack})`)); diff --git a/src/mark-build.ts b/src/mark-build.ts index e95b3738..4ef5998f 100644 --- a/src/mark-build.ts +++ b/src/mark-build.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { isEmptyStack, type StackDescriptor } from './stack.js'; @@ -88,3 +88,170 @@ export function injectMarker(html: string, snippet: string): string { } return stripped + snippet; } + +/* ------------------------------------------------------------------ */ +/* Source-shell marking (server-rendered roots) */ +/* ------------------------------------------------------------------ */ + +/** + * Frameworks whose root shell is JSX. These get a literal snippet; every other + * code shell gets the requirement described instead, because its head mechanism + * (`useHead`, ``, Astro frontmatter) is not a plain script tag and + * a wrong snippet costs more than an accurate sentence. + */ +const JSX_SHELL_FRAMEWORKS = new Set([ + 'next', + 'remix', + 'react-router', + 'tanstack-start', + 'gatsby', +]); + +/** + * How each framework spells "this is a production build" in source. Vite-built + * roots use `import.meta.env.PROD`, which the bundler statically replaces; + * bundlers that don't define it fall back to NODE_ENV. + * + * The gate is load-bearing: an ungated marker also fires in the hosted builder's + * dev preview, which is exactly where the owner still needs the claim flow. + */ +const PRODUCTION_GATES: Record = { + next: "process.env.NODE_ENV === 'production'", + gatsby: "process.env.NODE_ENV === 'production'", + nuxt: '!import.meta.dev', +}; + +const DEFAULT_PRODUCTION_GATE = 'import.meta.env.PROD'; + +/** The build-time expression that must guard the marker for this framework. */ +export function productionGate(framework: string | null): string { + const mapped = framework !== null ? PRODUCTION_GATES[framework] : undefined; + return mapped ?? DEFAULT_PRODUCTION_GATE; +} + +/** True when `guide` can print a literal marker snippet for this framework. */ +export function hasJsxShell(framework: string | null): boolean { + return framework !== null && JSX_SHELL_FRAMEWORKS.has(framework); +} + +/** + * The production marker as it belongs in a JSX root shell. + * + * Server-rendered roots never produce a static HTML file for `mark-build` to + * stamp, so on those stacks this is the only path the marker has to production. + * 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. + */ +export function buildSourceMarkerSnippet(framework: string | null): string { + const gate = productionGate(framework); + return ( + `{${gate} && (\n` + + ` \n` + + `)}` + ); +} + +/** Global the widget reads to decide it is running on a published build. */ +const PROD_MARKER_GLOBAL = '__PATCHSTACK_PROD__'; + +/** + * JSX comment fence around the injected marker. Mirrors the `#region` fence the + * protect installer uses on server entries: it makes the block obviously ours, + * and lets a re-run replace it instead of stacking copies. + */ +const REGION_OPEN = '{/* #region patchstack (managed by patchstack-connect — do not edit) */}'; +const REGION_CLOSE = '{/* #endregion patchstack */}'; +const REGION_RE = /[ \t]*\{\/\* #region patchstack[\s\S]*?#endregion patchstack \*\/\}\n?/g; + +export type SourceMarkerAction = + /** The managed block was inserted (or refreshed in place). */ + | 'added' + /** A marker is already present and not ours — adopted, left untouched. */ + | 'manual' + /** No JSX anchor to attach to; the caller falls back to printing the snippet. */ + | 'no-anchor' + /** This framework's root shell is not JSX, so there is no snippet to insert. */ + | 'unsupported'; + +export interface SourceMarkerResult { + source: string; + action: SourceMarkerAction; +} + +/** + * Insert the production marker into a JSX root shell, idempotently. + * + * Anchors on the widget tag when one is present, so the marker is ordered ahead + * of it in the document; otherwise on ``, then ``. A marker the + * developer placed themselves is adopted rather than duplicated. + */ +export function ensureMarkerInJsxShell(source: string, framework: string | null): SourceMarkerResult { + if (!hasJsxShell(framework)) { + return { source, action: 'unsupported' }; + } + + const stripped = source.replace(REGION_RE, ''); + if (stripped.includes(PROD_MARKER_GLOBAL)) { + return { source, action: 'manual' }; + } + + const block = (indent: string): string => + [REGION_OPEN, ...buildSourceMarkerSnippet(framework).split('\n'), REGION_CLOSE] + .map((line) => `${indent}${line}`) + .join('\n'); + + const widget = stripped.match(/^([ \t]*).*patchstack-widget.*$/m); + if (widget?.index !== undefined) { + const indent = widget[1] ?? ''; + return { + source: `${stripped.slice(0, widget.index)}${block(indent)}\n${stripped.slice(widget.index)}`, + action: 'added', + }; + } + + for (const anchor of [/^([ \t]*)[ \t]*$/m, /^([ \t]*)[ \t]*$/m]) { + const match = stripped.match(anchor); + if (match?.index === undefined) { + continue; + } + const end = match.index + match[0].length; + const indent = `${match[1] ?? ''} `; + return { + source: `${stripped.slice(0, end)}\n${block(indent)}${stripped.slice(end)}`, + action: 'added', + }; + } + + return { source, action: 'no-anchor' }; +} + +export interface EnsureSourceMarkerResult extends SourceMarkerResult { + /** Project-relative shell that was inspected, or null when there is none. */ + shell: string | null; +} + +/** + * Ensure the production marker in the project's JSX root shell. Runs during + * `scan`, which is a pre-build hook, so the edit is picked up by the build that + * follows — the marker reaches production without anyone pasting anything. + */ +export function ensureSourceMarker( + cwd: string, + shell: string | null, + framework: string | 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); + if (result.source !== before && result.action === 'added') { + writeFileSync(file, result.source); + } + return { ...result, shell }; +} diff --git a/tests/guide.test.ts b/tests/guide.test.ts index 5f1976dd..d193440c 100644 --- a/tests/guide.test.ts +++ b/tests/guide.test.ts @@ -10,6 +10,7 @@ import { detectPackageManager, findWidgetMarker, installCommand, + needsSourceProductionMarker, renderGuideChecklist, } from '../src/guide.js'; @@ -328,4 +329,64 @@ describe('guide', () => { expect(output).toContain('No package.json found'); }); }); + + describe('production marker on server-rendered roots', () => { + const tanstackProject = (rootContents: string): void => { + writeJson('package.json', { + name: 'ssr-app', + dependencies: { '@tanstack/react-start': '1.0.0', react: '18.0.0' }, + }); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); + mkdirSync(path.join(cwd, 'src', 'routes'), { recursive: true }); + writeFileSync(path.join(cwd, 'src', 'routes', '__root.tsx'), rootContents); + }; + + const widgetTag = ``; + + it('asks for the marker when the root is code and does not have it', async () => { + tanstackProject(`export const Root = () => ${widgetTag};`); + const state = await collectGuideState(cwd); + + expect(needsSourceProductionMarker(state)).toBe(true); + expect(state.productionMarkerWired).toBe(false); + + const output = renderGuideChecklist(state, false); + expect(output).toContain('Add the production marker'); + expect(output).toContain('npx @patchstack/connect scan'); + expect(output).toContain('import.meta.env.PROD &&'); + expect(output).toContain('window.__PATCHSTACK_PROD__=true;'); + }); + + it('counts the missing marker as an outstanding step', async () => { + tanstackProject(`export const Root = () => ${widgetTag};`); + const withoutMarker = countRemainingSteps(await collectGuideState(cwd)); + + tanstackProject( + `export const Root = () => {import.meta.env.PROD && }'); + const { source, action } = ensureMarkerInJsxShell(hand, 'tanstack-start'); + expect(action).toBe('manual'); + expect(source).toBe(hand); + }); + + it('reports no-anchor when the file has no JSX shell to attach to', () => { + const { action } = ensureMarkerInJsxShell('export const x = 1;\n', 'tanstack-start'); + expect(action).toBe('no-anchor'); + }); + + it('declines frameworks whose root shell is not JSX', () => { + expect(ensureMarkerInJsxShell(doc(widgetLine), 'nuxt').action).toBe('unsupported'); + expect(ensureMarkerInJsxShell(doc(widgetLine), 'sveltekit').action).toBe('unsupported'); + }); +}); + +describe('ensureSourceMarker', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), 'psmarker-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('writes the marker into the shell so the next build compiles it in', () => { + mkdirSync(path.join(root, 'src', 'routes'), { recursive: true }); + const shell = path.join('src', 'routes', '__root.tsx'); + writeFileSync( + path.join(root, shell), + 'export const Root = () => (\n \n \n \n \n);\n', + ); + + const result = ensureSourceMarker(root, shell, 'tanstack-start'); + expect(result.action).toBe('added'); + expect(readFileSync(path.join(root, shell), 'utf8')).toContain('window.__PATCHSTACK_PROD__=true;'); + }); + + it('leaves the file untouched when there is no anchor', () => { + const shell = 'root.tsx'; + writeFileSync(path.join(root, shell), 'export const x = 1;\n'); + + expect(ensureSourceMarker(root, shell, 'tanstack-start').action).toBe('no-anchor'); + expect(readFileSync(path.join(root, shell), 'utf8')).toBe('export const x = 1;\n'); + }); +});