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 when there is no head', () => { + const out = injectMarker('hi', buildInjectionSnippet('c1')); + expect(out.indexOf(MARKER_ATTR)).toBeLessThan(out.indexOf('')); + }); + + it('appends when there is neither head nor body', () => { + const out = injectMarker('
bare
', 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', 'nested', 'about.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(); + }); +});