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
123 changes: 0 additions & 123 deletions src/buildFlag.ts

This file was deleted.

38 changes: 38 additions & 0 deletions src/checksum.ts
Original file line number Diff line number Diff line change
@@ -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);
}
64 changes: 47 additions & 17 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -14,10 +22,8 @@ Usage:
patchstack-connect init <site-uuid> Optional: pre-seed .patchstackrc.json
with an existing site UUID
patchstack-connect status [options] Show current configuration
patchstack-connect mark-build [--dir <path>] 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):
Expand All @@ -26,8 +32,8 @@ Options (for scan and status):
--dry-run (scan only) Show the payload without posting

Options (for mark-build):
--dir <path> Build output dir (default: auto-detect dist/, build/,
out/, .output/public)
--dir <path> Build output directory (default: auto-detect
dist/ build/ out/ .output/public)

Environment:
PATCHSTACK_SITE_UUID Site UUID
Expand Down Expand Up @@ -197,25 +203,49 @@ async function runStatus(args: ParsedArgs): Promise<number> {
}

async function runMarkBuild(args: ParsedArgs): Promise<number> {
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 <path> if your build outputs elsewhere. Skipping production flag.',
'mark-build: no build output directory found (looked for dist/, build/, out/, .output/public). Pass --dir <path> 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;
}
Expand Down
81 changes: 81 additions & 0 deletions src/mark-build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { existsSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';

/** Attribute that tags our injected <script> so re-runs replace it instead of stacking. */
export const MARKER_ATTR = 'data-patchstack-build';

/** Build output directories we look for, in priority order (Vite, CRA, Next export, Nuxt). */
export const BUILD_DIR_CANDIDATES = ['dist', 'build', 'out', '.output/public'];

/**
* Resolve the directory holding the built HTML. Honours an explicit `--dir`
* override, otherwise picks the first known build directory that exists.
* Returns null when nothing is found (mark-build then no-ops without failing).
*/
export function resolveBuildDir(cwd: string, override?: string): string | null {
if (override !== undefined && override !== '') {
const abs = path.resolve(cwd, override);
return existsSync(abs) && statSync(abs).isDirectory() ? abs : null;
}

for (const candidate of BUILD_DIR_CANDIDATES) {
const abs = path.resolve(cwd, candidate);
if (existsSync(abs) && statSync(abs).isDirectory()) {
return abs;
}
}

return null;
}

/** Recursively collect every `.html` file under `dir`. */
export function findHtmlFiles(dir: string): string[] {
const out: string[] = [];

const walk = (current: string): void => {
for (const entry of readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
out.push(full);
}
}
};

walk(dir);
return out;
}

/**
* The <script> we inject into built HTML. Always marks the build as production
* (so the widget hides the connect/claim prompt on the published site) and, when
* a fingerprint is available, exposes it for the widget's parity heartbeat.
*/
export function buildInjectionSnippet(checksum: string | null): string {
const statements = ['window.__PATCHSTACK_PROD__=true;'];
if (checksum !== null && checksum !== '') {
statements.push(`window.__PATCHSTACK_BUILD__=${JSON.stringify(checksum)};`);
}
return `<script ${MARKER_ATTR}>${statements.join('')}</script>`;
}

/**
* 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
* `</head>`, falls back to `</body>`, then appends.
*/
export function injectMarker(html: string, snippet: string): string {
const stripped = html.replace(
new RegExp(`\\s*<script ${MARKER_ATTR}[^>]*>[\\s\\S]*?</script>`, 'gi'),
'',
);

if (/<\/head>/i.test(stripped)) {
return stripped.replace(/<\/head>/i, `${snippet}</head>`);
}
if (/<\/body>/i.test(stripped)) {
return stripped.replace(/<\/body>/i, `${snippet}</body>`);
}
return stripped + snippet;
}
42 changes: 42 additions & 0 deletions tests/checksum.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading