Skip to content

Commit ebac6a1

Browse files
authored
Add build flag injector and mark-build CLI (#24)
Add src/buildFlag.ts implementing markProductionBuild which injects a small <script>window.__PATCHSTACK_PROD__=true</script> snippet into built HTML files (auto-detects common build dirs: dist, build, out, .output/public; searches nested HTML up to depth 3; skips files already marked). Add a new CLI command `mark-build` (with --dir option) and runMarkBuild handler to call the injector; the command logs results and intentionally never fails the build if no output or HTML is found. Also update package.json version to 0.2.6.
1 parent ed4e30b commit ebac6a1

3 files changed

Lines changed: 160 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@patchstack/connect",
3-
"version": "0.2.5",
3+
"version": "0.2.6",
44
"description": "Patchstack connector for JavaScript applications. Scans your lockfile and reports installed packages to Patchstack for vulnerability monitoring.",
55
"keywords": [
66
"patchstack",

src/buildFlag.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { promises as fs } from 'node:fs';
2+
import path from 'node:path';
3+
4+
/**
5+
* Marks a production build so the embeddable Patchstack widget can tell it's
6+
* running on the live site (and therefore hide the claim / "connect this site"
7+
* flow — claiming is meant to happen in the builder's edit mode only).
8+
*
9+
* It injects a tiny inline script into the BUILT HTML:
10+
*
11+
* <script>window.__PATCHSTACK_PROD__=true</script>
12+
*
13+
* Crucially this touches the build OUTPUT only, never the source — so dev/edit
14+
* previews (which don't run this step) carry no flag and still show the claim
15+
* flow, while `npm run build` output does.
16+
*/
17+
18+
const FLAG_MARKER = '__PATCHSTACK_PROD__';
19+
const SNIPPET = '<script>window.__PATCHSTACK_PROD__=true;/*patchstack:production*/</script>';
20+
21+
/** Build-output directories checked, in order, when none is given. */
22+
const DEFAULT_DIRS = ['dist', 'build', 'out', '.output/public'];
23+
24+
export interface MarkBuildResult {
25+
/** The build dir that was used, or null if none was found. */
26+
dir: string | null;
27+
/** HTML files that had the flag injected. */
28+
patched: string[];
29+
/** HTML files already carrying the flag (left untouched). */
30+
skipped: string[];
31+
}
32+
33+
async function pathExists(target: string): Promise<boolean> {
34+
try {
35+
await fs.access(target);
36+
return true;
37+
} catch {
38+
return false;
39+
}
40+
}
41+
42+
async function resolveBuildDir(cwd: string, override?: string): Promise<string | null> {
43+
if (override !== undefined && override.length > 0) {
44+
const dir = path.resolve(cwd, override);
45+
return (await pathExists(dir)) ? dir : null;
46+
}
47+
for (const candidate of DEFAULT_DIRS) {
48+
const dir = path.resolve(cwd, candidate);
49+
if (await pathExists(dir)) {
50+
return dir;
51+
}
52+
}
53+
return null;
54+
}
55+
56+
async function findHtmlFiles(dir: string, depth = 3): Promise<string[]> {
57+
const found: string[] = [];
58+
let entries;
59+
try {
60+
entries = await fs.readdir(dir, { withFileTypes: true });
61+
} catch {
62+
return found;
63+
}
64+
for (const entry of entries) {
65+
const full = path.join(dir, entry.name);
66+
if (entry.isDirectory()) {
67+
if (depth > 0) {
68+
found.push(...(await findHtmlFiles(full, depth - 1)));
69+
}
70+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
71+
found.push(full);
72+
}
73+
}
74+
return found;
75+
}
76+
77+
/** Inject the flag at the top of <head> (or <body>), or null if already present. */
78+
function injectFlag(html: string): string | null {
79+
if (html.includes(FLAG_MARKER)) {
80+
return null;
81+
}
82+
const headOpen = /<head[^>]*>/i;
83+
if (headOpen.test(html)) {
84+
return html.replace(headOpen, (match) => `${match}${SNIPPET}`);
85+
}
86+
const bodyOpen = /<body[^>]*>/i;
87+
if (bodyOpen.test(html)) {
88+
return html.replace(bodyOpen, (match) => `${match}${SNIPPET}`);
89+
}
90+
return `${SNIPPET}${html}`;
91+
}
92+
93+
/**
94+
* Inject the production flag into every HTML file of the build output.
95+
* Returns a summary; never throws for "no output" / "no HTML" (the caller
96+
* treats those as a no-op so the build is never blocked).
97+
*/
98+
export async function markProductionBuild(
99+
cwd: string,
100+
override?: string,
101+
): Promise<MarkBuildResult> {
102+
const dir = await resolveBuildDir(cwd, override);
103+
if (dir === null) {
104+
return { dir: null, patched: [], skipped: [] };
105+
}
106+
107+
const files = await findHtmlFiles(dir);
108+
const patched: string[] = [];
109+
const skipped: string[] = [];
110+
111+
for (const file of files) {
112+
const html = await fs.readFile(file, 'utf8');
113+
const next = injectFlag(html);
114+
if (next === null) {
115+
skipped.push(file);
116+
continue;
117+
}
118+
await fs.writeFile(file, next, 'utf8');
119+
patched.push(file);
120+
}
121+
122+
return { dir, patched, skipped };
123+
}

src/cli.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { scanLockfile } from './parsers/index.js';
22
import { buildWirePayload } from './normalize.js';
3+
import { markProductionBuild } from './buildFlag.js';
34
import { buildClaimUrl, postManifest } from './client.js';
45
import { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
56
import { PatchstackError } from './types.js';
@@ -13,13 +14,21 @@ Usage:
1314
patchstack-connect init <site-uuid> Optional: pre-seed .patchstackrc.json
1415
with an existing site UUID
1516
patchstack-connect status [options] Show current configuration
17+
patchstack-connect mark-build [--dir <path>] Inject the production flag into
18+
the built HTML (run as a postbuild
19+
step). Tells the widget it's live so
20+
it hides the claim flow.
1621
patchstack-connect help Print this message
1722
1823
Options (for scan and status):
1924
--site-uuid <uuid> Override the configured site UUID
2025
--endpoint <url> Override the API endpoint
2126
--dry-run (scan only) Show the payload without posting
2227
28+
Options (for mark-build):
29+
--dir <path> Build output dir (default: auto-detect dist/, build/,
30+
out/, .output/public)
31+
2332
Environment:
2433
PATCHSTACK_SITE_UUID Site UUID
2534
PATCHSTACK_ENDPOINT API endpoint (default: https://api.patchstack.com/monitor/pulse/manifest)
@@ -34,7 +43,7 @@ Examples:
3443
npx @patchstack/connect scan --site-uuid 550e8400-...-446655440000
3544
`;
3645

37-
const VALUE_FLAGS = new Set(['site-uuid', 'endpoint']);
46+
const VALUE_FLAGS = new Set(['site-uuid', 'endpoint', 'dir']);
3847

3948
interface ParsedArgs {
4049
command: string | null;
@@ -184,6 +193,30 @@ async function runStatus(args: ParsedArgs): Promise<number> {
184193
return 0;
185194
}
186195

196+
async function runMarkBuild(args: ParsedArgs): Promise<number> {
197+
const result = await markProductionBuild(process.cwd(), getStringFlag(args.flags, 'dir'));
198+
199+
// Never fail the build over this — a missing flag just means the widget falls
200+
// back to showing the claim flow, which is safe.
201+
if (result.dir === null) {
202+
console.warn(
203+
'patchstack: no build output found (looked for dist/, build/, out/, .output/public). ' +
204+
'Pass --dir <path> if your build outputs elsewhere. Skipping production flag.',
205+
);
206+
return 0;
207+
}
208+
if (result.patched.length === 0 && result.skipped.length === 0) {
209+
console.warn(`patchstack: no HTML files found under ${result.dir}; skipping production flag.`);
210+
return 0;
211+
}
212+
213+
const already = result.skipped.length > 0 ? ` (${result.skipped.length} already marked)` : '';
214+
console.log(
215+
`patchstack: marked ${result.patched.length} HTML file(s) as a production build in ${result.dir}${already}.`,
216+
);
217+
return 0;
218+
}
219+
187220
async function main(): Promise<number> {
188221
const args = parseArgs(process.argv);
189222

@@ -199,6 +232,8 @@ async function main(): Promise<number> {
199232
return runScan(args);
200233
case 'status':
201234
return runStatus(args);
235+
case 'mark-build':
236+
return runMarkBuild(args);
202237
default:
203238
console.error(`Unknown command: ${args.command}\n`);
204239
console.error(HELP);

0 commit comments

Comments
 (0)