Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -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…');
Expand Down Expand Up @@ -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 <script>window.__PATCHSTACK_PROD__=true;</script> only when ${productionGate(framework)}.`,
);
}
break;
}
} catch (err) {
console.warn(`Production marker: skipped (${(err as Error).message}).`);
}
}

async function runProtectCommand(args: ParsedArgs): Promise<number> {
// `--check`: verify the guard is wired (for the agent/CI loop). Non-zero exit if not.
if (args.flags.get('check') === true) {
Expand Down Expand Up @@ -932,7 +992,17 @@ async function runMarkBuild(args: ParsedArgs): Promise<number> {

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;
}

Expand Down
80 changes: 78 additions & 2 deletions src/guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -317,6 +346,7 @@ export async function collectGuideState(cwd: string): Promise<GuideState> {
);

const widget = findWidgetMarker(cwd, siteUuid);
const widgetFileHint = resolveWidgetFileHint(cwd, stack.framework);
const protection = runVerify(cwd);

return {
Expand All @@ -341,7 +371,8 @@ export async function collectGuideState(cwd: string): Promise<GuideState> {
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,
Expand All @@ -358,13 +389,26 @@ 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',
state.siteUuid !== null,
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;
}
Expand Down Expand Up @@ -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 <head> 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(` <script>window.__PATCHSTACK_PROD__=true;</script> 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})`));
Expand Down
Loading
Loading