diff --git a/package.json b/package.json index 8e203ce..d06de9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@patchstack/connect", - "version": "0.2.5", + "version": "0.2.6", "description": "Patchstack connector for JavaScript applications. Scans your lockfile and reports installed packages to Patchstack for vulnerability monitoring.", "keywords": [ "patchstack", diff --git a/src/buildFlag.ts b/src/buildFlag.ts new file mode 100644 index 0000000..80df13e --- /dev/null +++ b/src/buildFlag.ts @@ -0,0 +1,123 @@ +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/cli.ts b/src/cli.ts index a339e33..a4db644 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import { scanLockfile } from './parsers/index.js'; import { buildWirePayload } from './normalize.js'; +import { markProductionBuild } from './buildFlag.js'; import { buildClaimUrl, postManifest } from './client.js'; import { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js'; import { PatchstackError } from './types.js'; @@ -13,6 +14,10 @@ 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 help Print this message Options (for scan and status): @@ -20,6 +25,10 @@ Options (for scan and status): --endpoint Override the API endpoint --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) + Environment: PATCHSTACK_SITE_UUID Site UUID PATCHSTACK_ENDPOINT API endpoint (default: https://api.patchstack.com/monitor/pulse/manifest) @@ -34,7 +43,7 @@ Examples: npx @patchstack/connect scan --site-uuid 550e8400-...-446655440000 `; -const VALUE_FLAGS = new Set(['site-uuid', 'endpoint']); +const VALUE_FLAGS = new Set(['site-uuid', 'endpoint', 'dir']); interface ParsedArgs { command: string | null; @@ -184,6 +193,30 @@ async function runStatus(args: ParsedArgs): Promise { return 0; } +async function runMarkBuild(args: ParsedArgs): Promise { + const result = await markProductionBuild(process.cwd(), getStringFlag(args.flags, 'dir')); + + // 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) { + console.warn( + 'patchstack: no build output found (looked for dist/, build/, out/, .output/public). ' + + 'Pass --dir if your build outputs elsewhere. Skipping production flag.', + ); + return 0; + } + if (result.patched.length === 0 && result.skipped.length === 0) { + console.warn(`patchstack: no HTML files found under ${result.dir}; skipping production flag.`); + return 0; + } + + const already = result.skipped.length > 0 ? ` (${result.skipped.length} already marked)` : ''; + console.log( + `patchstack: marked ${result.patched.length} HTML file(s) as a production build in ${result.dir}${already}.`, + ); + return 0; +} + async function main(): Promise { const args = parseArgs(process.argv); @@ -199,6 +232,8 @@ async function main(): Promise { return runScan(args); case 'status': return runStatus(args); + case 'mark-build': + return runMarkBuild(args); default: console.error(`Unknown command: ${args.command}\n`); console.error(HELP);