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