From 6e9b869c45d2a53b310ce18206ab86715b9e4212 Mon Sep 17 00:00:00 2001
From: Renato Hysa
Date: Wed, 1 Jul 2026 15:25:27 +0200
Subject: [PATCH] Stamp built HTML with a build fingerprint in mark-build
mark-build now computes the manifest checksum from the lockfile and injects it (window.__PATCHSTACK_BUILD__) beside the production flag, so the disclosure widget can report the running build for manifest-parity checks. The fingerprint matches the server's byte-for-byte. Replaces the flag-only injector.
---
src/buildFlag.ts | 123 ---------------------------------------
src/checksum.ts | 38 ++++++++++++
src/cli.ts | 64 ++++++++++++++------
src/mark-build.ts | 81 ++++++++++++++++++++++++++
tests/checksum.test.ts | 42 +++++++++++++
tests/mark-build.test.ts | 92 +++++++++++++++++++++++++++++
6 files changed, 300 insertions(+), 140 deletions(-)
delete mode 100644 src/buildFlag.ts
create mode 100644 src/checksum.ts
create mode 100644 src/mark-build.ts
create mode 100644 tests/checksum.test.ts
create mode 100644 tests/mark-build.test.ts
diff --git a/src/buildFlag.ts b/src/buildFlag.ts
deleted file mode 100644
index 80df13e..0000000
--- a/src/buildFlag.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { promises as fs } from 'node:fs';
-import path from 'node:path';
-
-/**
- * Marks a production build so the embeddable Patchstack widget can tell it's
- * running on the live site (and therefore hide the claim / "connect this site"
- * flow — claiming is meant to happen in the builder's edit mode only).
- *
- * It injects a tiny inline script into the BUILT HTML:
- *
- *
- *
- * Crucially this touches the build OUTPUT only, never the source — so dev/edit
- * previews (which don't run this step) carry no flag and still show the claim
- * flow, while `npm run build` output does.
- */
-
-const FLAG_MARKER = '__PATCHSTACK_PROD__';
-const SNIPPET = '';
-
-/** Build-output directories checked, in order, when none is given. */
-const DEFAULT_DIRS = ['dist', 'build', 'out', '.output/public'];
-
-export interface MarkBuildResult {
- /** The build dir that was used, or null if none was found. */
- dir: string | null;
- /** HTML files that had the flag injected. */
- patched: string[];
- /** HTML files already carrying the flag (left untouched). */
- skipped: string[];
-}
-
-async function pathExists(target: string): Promise {
- try {
- await fs.access(target);
- return true;
- } catch {
- return false;
- }
-}
-
-async function resolveBuildDir(cwd: string, override?: string): Promise {
- if (override !== undefined && override.length > 0) {
- const dir = path.resolve(cwd, override);
- return (await pathExists(dir)) ? dir : null;
- }
- for (const candidate of DEFAULT_DIRS) {
- const dir = path.resolve(cwd, candidate);
- if (await pathExists(dir)) {
- return dir;
- }
- }
- return null;
-}
-
-async function findHtmlFiles(dir: string, depth = 3): Promise {
- const found: string[] = [];
- let entries;
- try {
- entries = await fs.readdir(dir, { withFileTypes: true });
- } catch {
- return found;
- }
- for (const entry of entries) {
- const full = path.join(dir, entry.name);
- if (entry.isDirectory()) {
- if (depth > 0) {
- found.push(...(await findHtmlFiles(full, depth - 1)));
- }
- } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
- found.push(full);
- }
- }
- return found;
-}
-
-/** Inject the flag at the top of (or ), or null if already present. */
-function injectFlag(html: string): string | null {
- if (html.includes(FLAG_MARKER)) {
- return null;
- }
- const headOpen = /]*>/i;
- if (headOpen.test(html)) {
- return html.replace(headOpen, (match) => `${match}${SNIPPET}`);
- }
- const bodyOpen = /]*>/i;
- if (bodyOpen.test(html)) {
- return html.replace(bodyOpen, (match) => `${match}${SNIPPET}`);
- }
- return `${SNIPPET}${html}`;
-}
-
-/**
- * Inject the production flag into every HTML file of the build output.
- * Returns a summary; never throws for "no output" / "no HTML" (the caller
- * treats those as a no-op so the build is never blocked).
- */
-export async function markProductionBuild(
- cwd: string,
- override?: string,
-): Promise {
- const dir = await resolveBuildDir(cwd, override);
- if (dir === null) {
- return { dir: null, patched: [], skipped: [] };
- }
-
- const files = await findHtmlFiles(dir);
- const patched: string[] = [];
- const skipped: string[] = [];
-
- for (const file of files) {
- const html = await fs.readFile(file, 'utf8');
- const next = injectFlag(html);
- if (next === null) {
- skipped.push(file);
- continue;
- }
- await fs.writeFile(file, next, 'utf8');
- patched.push(file);
- }
-
- return { dir, patched, skipped };
-}
diff --git a/src/checksum.ts b/src/checksum.ts
new file mode 100644
index 0000000..2d74b85
--- /dev/null
+++ b/src/checksum.ts
@@ -0,0 +1,38 @@
+import { createHash } from 'node:crypto';
+
+import type { WirePackage } from './normalize.js';
+
+/**
+ * Fingerprint of a manifest's package set, byte-for-byte identical to the
+ * checksum Patchstack stores server-side (PulseController::storeManifest): the
+ * first 12 hex chars of sha256 over the JSON of the packages sorted by
+ * `[name, version]` with plain lexicographic (byte) ordering.
+ *
+ * This mirrors PHP's `usort(..., fn($a,$b) => [$a['name'],$a['version']] <=> ...)`
+ * followed by `json_encode(..., JSON_UNESCAPED_SLASHES)`. Two deliberate details:
+ *
+ * - The sort is lexicographic, NOT the semver-aware ordering `buildWirePayload`
+ * uses for display. The server re-sorts lexicographically before hashing, so
+ * the fingerprint must too (e.g. `1.10.0` sorts before `1.9.0`).
+ * - npm/composer package names and versions are ASCII, so JS `JSON.stringify`
+ * and PHP `json_encode` produce identical bytes (no unicode escaping, and
+ * neither escapes `/`, so scoped names like `@babel/core` match).
+ *
+ * Injected into built HTML by `mark-build` and reported by the disclosure widget
+ * so Patchstack can compare the live build against the last reported manifest.
+ */
+export function computeManifestChecksum(packages: WirePackage[]): string {
+ const canonical = packages
+ .map((pkg) => ({ name: pkg.name, version: pkg.version }))
+ .sort((a, b) => {
+ if (a.name !== b.name) {
+ return a.name < b.name ? -1 : 1;
+ }
+ if (a.version !== b.version) {
+ return a.version < b.version ? -1 : 1;
+ }
+ return 0;
+ });
+
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex').slice(0, 12);
+}
diff --git a/src/cli.ts b/src/cli.ts
index c5526a8..165b47c 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,8 +1,16 @@
+import { readFileSync, writeFileSync } from 'node:fs';
+
import { scanLockfile } from './parsers/index.js';
import { buildWirePayload } from './normalize.js';
-import { markProductionBuild } from './buildFlag.js';
+import { computeManifestChecksum } from './checksum.js';
import { buildClaimUrl, postManifest } from './client.js';
import { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
+import {
+ buildInjectionSnippet,
+ findHtmlFiles,
+ injectMarker,
+ resolveBuildDir,
+} from './mark-build.js';
import { PatchstackError } from './types.js';
const HELP = `@patchstack/connect — scan your lockfile and report packages to Patchstack.
@@ -14,10 +22,8 @@ Usage:
patchstack-connect init Optional: pre-seed .patchstackrc.json
with an existing site UUID
patchstack-connect status [options] Show current configuration
- patchstack-connect mark-build [--dir ] Inject the production flag into
- the built HTML (run as a postbuild
- step). Tells the widget it's live so
- it hides the claim flow.
+ patchstack-connect mark-build [options] Stamp built HTML with a production flag +
+ build fingerprint (run as a postbuild step)
patchstack-connect help Print this message
Options (for scan and status):
@@ -26,8 +32,8 @@ Options (for scan and status):
--dry-run (scan only) Show the payload without posting
Options (for mark-build):
- --dir Build output dir (default: auto-detect dist/, build/,
- out/, .output/public)
+ --dir Build output directory (default: auto-detect
+ dist/ build/ out/ .output/public)
Environment:
PATCHSTACK_SITE_UUID Site UUID
@@ -197,25 +203,49 @@ async function runStatus(args: ParsedArgs): Promise {
}
async function runMarkBuild(args: ParsedArgs): Promise {
- const result = await markProductionBuild(process.cwd(), getStringFlag(args.flags, 'dir'));
+ const cwd = process.cwd();
+
+ // Compute the build fingerprint from the lockfile. Best-effort: mark-build is a
+ // postbuild step and must never fail the build, so a missing/unreadable lockfile
+ // just means we stamp the production flag without a fingerprint.
+ let checksum: string | null = null;
+ try {
+ const manifest = await scanLockfile(cwd);
+ const { payload } = buildWirePayload(manifest);
+ checksum = computeManifestChecksum(payload.packages);
+ } catch (err) {
+ console.warn(
+ `mark-build: could not compute the build fingerprint (${(err as Error).message}). Stamping the production flag only.`,
+ );
+ }
- // Never fail the build over this — a missing flag just means the widget falls
- // back to showing the claim flow, which is safe.
- if (result.dir === null) {
+ const dir = resolveBuildDir(cwd, getStringFlag(args.flags, 'dir'));
+ if (dir === null) {
console.warn(
- 'patchstack: no build output found (looked for dist/, build/, out/, .output/public). ' +
- 'Pass --dir if your build outputs elsewhere. Skipping production flag.',
+ 'mark-build: no build output directory found (looked for dist/, build/, out/, .output/public). Pass --dir if it is elsewhere. Nothing to mark.',
);
return 0;
}
- if (result.patched.length === 0 && result.skipped.length === 0) {
- console.warn(`patchstack: no HTML files found under ${result.dir}; skipping production flag.`);
+
+ const files = findHtmlFiles(dir);
+ if (files.length === 0) {
+ console.warn(`mark-build: no HTML files found under ${dir}. Nothing to mark.`);
return 0;
}
- const already = result.skipped.length > 0 ? ` (${result.skipped.length} already marked)` : '';
+ const snippet = buildInjectionSnippet(checksum);
+ let marked = 0;
+ for (const file of files) {
+ const before = readFileSync(file, 'utf8');
+ const after = injectMarker(before, snippet);
+ if (after !== before) {
+ writeFileSync(file, after);
+ marked += 1;
+ }
+ }
+
console.log(
- `patchstack: marked ${result.patched.length} HTML file(s) as a production build in ${result.dir}${already}.`,
+ `mark-build: marked ${marked} HTML file(s) in ${dir}${checksum !== null ? ` (build ${checksum})` : ''}.`,
);
return 0;
}
diff --git a/src/mark-build.ts b/src/mark-build.ts
new file mode 100644
index 0000000..1e62c98
--- /dev/null
+++ b/src/mark-build.ts
@@ -0,0 +1,81 @@
+import { existsSync, readdirSync, statSync } from 'node:fs';
+import path from 'node:path';
+
+/** Attribute that tags our injected `;
+}
+
+/**
+ * Insert (or replace) the marker script in a single HTML document. Idempotent:
+ * a prior marker is stripped first so repeated builds don't stack tags. Prefers
+ * ``, falls back to ``, then appends.
+ */
+export function injectMarker(html: string, snippet: string): string {
+ const stripped = html.replace(
+ new RegExp(`\\s*`, 'gi'),
+ '',
+ );
+
+ if (/<\/head>/i.test(stripped)) {
+ return stripped.replace(/<\/head>/i, `${snippet}`);
+ }
+ if (/<\/body>/i.test(stripped)) {
+ return stripped.replace(/<\/body>/i, `${snippet}`);
+ }
+ return stripped + snippet;
+}
diff --git a/tests/checksum.test.ts b/tests/checksum.test.ts
new file mode 100644
index 0000000..b573d0c
--- /dev/null
+++ b/tests/checksum.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import { computeManifestChecksum } from '../src/checksum.js';
+
+// The expected values below are cross-checked byte-for-byte against the server
+// algorithm in PulseController::storeManifest:
+// substr(hash('sha256', json_encode($sortedPackages, JSON_UNESCAPED_SLASHES)), 0, 12)
+describe('computeManifestChecksum', () => {
+ it('matches the server checksum for a single package', () => {
+ expect(computeManifestChecksum([{ name: 'lodash', version: '4.17.21' }])).toBe('705680d827bc');
+ });
+
+ it('matches the server for scoped names and multiple packages', () => {
+ expect(
+ computeManifestChecksum([
+ { name: '@babel/core', version: '7.0.0' },
+ { name: 'axios', version: '1.6.0' },
+ ]),
+ ).toBe('a828c5c61d95');
+ });
+
+ it('sorts versions lexicographically like the server (1.10.0 before 1.9.0), not semver', () => {
+ const ascending = computeManifestChecksum([
+ { name: 'foo', version: '1.9.0' },
+ { name: 'foo', version: '1.10.0' },
+ ]);
+ const descending = computeManifestChecksum([
+ { name: 'foo', version: '1.10.0' },
+ { name: 'foo', version: '1.9.0' },
+ ]);
+ expect(ascending).toBe('ba8275c7b0ab');
+ expect(descending).toBe('ba8275c7b0ab');
+ });
+
+ it('is stable regardless of input order', () => {
+ expect(
+ computeManifestChecksum([
+ { name: 'axios', version: '1.6.0' },
+ { name: '@babel/core', version: '7.0.0' },
+ ]),
+ ).toBe('a828c5c61d95');
+ });
+});
diff --git a/tests/mark-build.test.ts b/tests/mark-build.test.ts
new file mode 100644
index 0000000..029c28b
--- /dev/null
+++ b/tests/mark-build.test.ts
@@ -0,0 +1,92 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import {
+ MARKER_ATTR,
+ buildInjectionSnippet,
+ findHtmlFiles,
+ injectMarker,
+ resolveBuildDir,
+} from '../src/mark-build.js';
+
+describe('buildInjectionSnippet', () => {
+ it('always marks production and includes the fingerprint when present', () => {
+ const snippet = buildInjectionSnippet('abc123def456');
+ expect(snippet).toContain('window.__PATCHSTACK_PROD__=true;');
+ expect(snippet).toContain('window.__PATCHSTACK_BUILD__="abc123def456";');
+ expect(snippet).toContain(MARKER_ATTR);
+ });
+
+ it('omits the fingerprint when it is null', () => {
+ const snippet = buildInjectionSnippet(null);
+ expect(snippet).toContain('__PATCHSTACK_PROD__');
+ expect(snippet).not.toContain('__PATCHSTACK_BUILD__');
+ });
+});
+
+describe('injectMarker', () => {
+ it('injects before ', () => {
+ const out = injectMarker(
+ 'x',
+ buildInjectionSnippet('c1'),
+ );
+ expect(out.indexOf(MARKER_ATTR)).toBeLessThan(out.indexOf(''));
+ });
+
+ it('falls back to
hi', buildInjectionSnippet('c1'));
+ expect(out.indexOf(MARKER_ATTR)).toBeLessThan(out.indexOf(''));
+ });
+
+ it('appends when there is neither head nor body', () => {
+ const out = injectMarker('
', buildInjectionSnippet('c1'));
+ expect(out).toContain(MARKER_ATTR);
+ });
+
+ it('is idempotent — re-running replaces the marker instead of stacking', () => {
+ const once = injectMarker('
', buildInjectionSnippet('c1'));
+ const twice = injectMarker(once, buildInjectionSnippet('c2'));
+ const markerCount = (twice.match(new RegExp(MARKER_ATTR, 'g')) ?? []).length;
+ expect(markerCount).toBe(1);
+ expect(twice).toContain('"c2"');
+ expect(twice).not.toContain('"c1"');
+ });
+});
+
+describe('resolveBuildDir + findHtmlFiles', () => {
+ let root: string;
+
+ beforeEach(() => {
+ root = mkdtempSync(path.join(tmpdir(), 'psmb-'));
+ });
+
+ afterEach(() => {
+ rmSync(root, { recursive: true, force: true });
+ });
+
+ it('auto-detects dist/ and finds nested HTML while ignoring non-HTML', () => {
+ mkdirSync(path.join(root, 'dist', 'nested'), { recursive: true });
+ writeFileSync(path.join(root, 'dist', 'index.html'), '
');
+ writeFileSync(path.join(root, 'dist', 'app.js'), 'console.log(1)');
+
+ const dir = resolveBuildDir(root);
+ expect(dir).toBe(path.join(root, 'dist'));
+
+ const files = findHtmlFiles(dir!);
+ expect(files).toHaveLength(2);
+ expect(files.some((f) => f.endsWith('index.html'))).toBe(true);
+ expect(files.some((f) => f.endsWith(path.join('nested', 'about.html')))).toBe(true);
+ });
+
+ it('honours an explicit override directory', () => {
+ mkdirSync(path.join(root, 'public'), { recursive: true });
+ writeFileSync(path.join(root, 'public', 'index.html'), '
');
+ expect(resolveBuildDir(root, 'public')).toBe(path.join(root, 'public'));
+ });
+
+ it('returns null when no build directory exists', () => {
+ expect(resolveBuildDir(root)).toBeNull();
+ });
+});