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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
123 changes: 123 additions & 0 deletions src/buildFlag.ts
Original file line number Diff line number Diff line change
@@ -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:
*
* <script>window.__PATCHSTACK_PROD__=true</script>
*
* 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 = '<script>window.__PATCHSTACK_PROD__=true;/*patchstack:production*/</script>';

/** 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<boolean> {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}

async function resolveBuildDir(cwd: string, override?: string): Promise<string | null> {
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<string[]> {
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 <head> (or <body>), or null if already present. */
function injectFlag(html: string): string | null {
if (html.includes(FLAG_MARKER)) {
return null;
}
const headOpen = /<head[^>]*>/i;
if (headOpen.test(html)) {
return html.replace(headOpen, (match) => `${match}${SNIPPET}`);
}
const bodyOpen = /<body[^>]*>/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<MarkBuildResult> {
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 };
}
37 changes: 36 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,13 +14,21 @@ 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 help Print this message

Options (for scan and status):
--site-uuid <uuid> Override the configured site UUID
--endpoint <url> Override the API endpoint
--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)

Environment:
PATCHSTACK_SITE_UUID Site UUID
PATCHSTACK_ENDPOINT API endpoint (default: https://api.patchstack.com/monitor/pulse/manifest)
Expand All @@ -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;
Expand Down Expand Up @@ -184,6 +193,30 @@ async function runStatus(args: ParsedArgs): Promise<number> {
return 0;
}

async function runMarkBuild(args: ParsedArgs): Promise<number> {
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 <path> 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<number> {
const args = parseArgs(process.argv);

Expand All @@ -199,6 +232,8 @@ async function main(): Promise<number> {
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);
Expand Down
Loading