1/**
2 * CLI helper: insert/remove the live variant mode script tag in the project's
3 * main HTML entry point.
4 *
5 * On first live run, the agent generates `.impeccable/live/config.json`
6 * with the project's insertion target (framework-specific). On
7 * every subsequent run, this script handles insert/remove deterministically
8 * with zero LLM involvement.
9 *
10 * Usage:
11 * node live-inject.mjs --port PORT # Insert the live script tag
12 * node live-inject.mjs --remove # Remove the live script tag
13 * node live-inject.mjs --check # Check whether live config exists
14 */
15
16import fs from 'node:fs';
17import path from 'node:path';
18import { fileURLToPath } from 'node:url';
19import { resolveLiveConfigPath } from './impeccable-paths.mjs';
20
21const __dirname = path.dirname(fileURLToPath(import.meta.url));
22const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
23const MARKER_OPEN_TEXT = 'impeccable-live-start';
24const MARKER_CLOSE_TEXT = 'impeccable-live-end';
25
26/**
27 * Hard-excluded directory patterns. These are NEVER user-facing pages and
28 * matching them would silently inject tracking scripts into third-party
29 * code. The user cannot turn these off via config — they are the floor.
30 */
31const HARD_EXCLUDES = [
32 '**/node_modules/**',
33 '**/.git/**',
34];
35
36export async function injectCli() {
37 const args = process.argv.slice(2);
38
39 if (args.includes('--help') || args.includes('-h')) {
40 console.log(`Usage: node live-inject.mjs [options]
41
42Insert or remove the live mode script tag in the project's HTML entry point.
43Reads configuration from .impeccable/live/config.json.
44
45Modes:
46 --port PORT Insert script tag pointing at http://localhost:PORT/live.js
47 --remove Remove the script tag (if present)
48 --check Print whether .impeccable/live/config.json exists and its content
49
50Output (JSON):
51 { ok, file, inserted|removed, config? }`);
52 process.exit(0);
53 }
54
55 if (args.includes('--check')) {
56 if (!fs.existsSync(CONFIG_PATH)) {
57 console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
58 process.exit(0);
59 }
60 let cfg;
61 try {
62 cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
63 } catch (err) {
64 console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
65 return;
66 }
67 try {
68 validateConfig(cfg);
69 } catch (err) {
70 console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
71 return;
72 }
73 console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
74 return;
75 }
76
77 // Load config
78 if (!fs.existsSync(CONFIG_PATH)) {
79 console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
80 process.exit(1);
81 }
82 const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
83 validateConfig(config);
84
85 const resolvedFiles = resolveFiles(process.cwd(), config);
86
87 if (args.includes('--remove')) {
88 const results = resolvedFiles.map((relFile) => {
89 const absFile = path.resolve(process.cwd(), relFile);
90 if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
91 const content = fs.readFileSync(absFile, 'utf-8');
92 const detagged = removeTag(content, config.commentSyntax);
93 const updated = revertCspMeta(detagged);
94 if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
95 fs.writeFileSync(absFile, updated, 'utf-8');
96 return {
97 file: relFile,
98 removed: detagged !== content,
99 cspReverted: updated !== detagged,
100 };
101 });
102 console.log(JSON.stringify({ ok: true, results }));
103 return;
104 }
105
106 // Insert mode — need --port
107 const portIdx = args.indexOf('--port');
108 const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN;
109 if (!Number.isFinite(port)) {
110 console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
111 process.exit(1);
112 }
113
114 const results = resolvedFiles.map((relFile) => {
115 const absFile = path.resolve(process.cwd(), relFile);
116 if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
117 const content = fs.readFileSync(absFile, 'utf-8');
118 const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
119 const withTag = insertTag(withoutOld, config, port);
120 if (withTag === withoutOld) {
121 return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
122 }
123 const updated = patchCspMeta(withTag, port);
124 fs.writeFileSync(absFile, updated, 'utf-8');
125 return {
126 file: relFile,
127 inserted: true,
128 cspPatched: updated !== withTag,
129 };
130 });
131 const anyInserted = results.some((r) => r.inserted);
132 console.log(JSON.stringify({ ok: anyInserted, port, results }));
133 if (!anyInserted) process.exit(1);
134}
135
136/**
137 * Expand config.files (which may contain glob patterns) into a literal list
138 * of existing file paths relative to rootDir. Literal entries pass through;
139 * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
140 * are applied as filters. Duplicates are removed. Order is preserved by
141 * first appearance.
142 */
143export function resolveFiles(rootDir, config) {
144 const patterns = config.files;
145 const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
146 const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
147 const excludeRegexes = allExcludes.map(globToRegex);
148
149 const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
150 const isGlob = (s) => /[*?[]/.test(s);
151
152 const seen = new Set();
153 const out = [];
154 for (const pat of patterns) {
155 if (!isGlob(pat)) {
156 // Literal path — include even if it doesn't exist yet; the caller
157 // reports file_not_found per-entry. Exclude list doesn't apply to
158 // explicit literal entries (user named it on purpose).
159 if (!seen.has(pat)) {
160 seen.add(pat);
161 out.push(pat);
162 }
163 continue;
164 }
165 let matches;
166 try {
167 matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
168 } catch {
169 continue;
170 }
171 for (const ent of matches) {
172 if (!ent.isFile || !ent.isFile()) continue;
173 const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
174 const rel = path.relative(rootDir, abs).split(path.sep).join('/');
175 if (isExcluded(rel)) continue;
176 if (seen.has(rel)) continue;
177 seen.add(rel);
178 out.push(rel);
179 }
180 }
181 return out;
182}
183
184/**
185 * Convert a glob pattern to a RegExp. Supports:
186 * ** → any number of path segments (including zero)
187 * * → any chars except `/`
188 * ? → any single char except `/`
189 * Paths are normalized to forward slashes before matching.
190 */
191function globToRegex(pattern) {
192 let re = '';
193 let i = 0;
194 while (i < pattern.length) {
195 const c = pattern[i];
196 if (c === '*') {
197 if (pattern[i + 1] === '*') {
198 // ** — any number of segments, including zero. Handle the common
199 // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
200 if (pattern[i + 2] === '/') {
201 re += '(?:.*/)?';
202 i += 3;
203 } else {
204 re += '.*';
205 i += 2;
206 }
207 } else {
208 re += '[^/]*';
209 i += 1;
210 }
211 } else if (c === '?') {
212 re += '[^/]';
213 i += 1;
214 } else if (/[.+^${}()|[\]\\]/.test(c)) {
215 re += '\\' + c;
216 i += 1;
217 } else {
218 re += c;
219 i += 1;
220 }
221 }
222 return new RegExp('^' + re + '$');
223}
224
225// ---------------------------------------------------------------------------
226// Core operations
227// ---------------------------------------------------------------------------
228
229function validateConfig(cfg) {
230 if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
231 if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
232 throw new Error('config.files (non-empty string array) required');
233 }
234 if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
235 throw new Error('config.files must contain only non-empty strings');
236 }
237 if (cfg.exclude !== undefined) {
238 if (!Array.isArray(cfg.exclude)) {
239 throw new Error('config.exclude, if present, must be a string array');
240 }
241 if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
242 throw new Error('config.exclude must contain only non-empty strings');
243 }
244 }
245 if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
246 throw new Error('config.insertBefore or config.insertAfter (string) required');
247 }
248 if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
249 throw new Error("config.commentSyntax must be 'html' or 'jsx'");
250 }
251 if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
252 throw new Error("config.cspChecked, if present, must be a boolean");
253 }
254}
255
256function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
257function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
258
259function buildTagBlock(syntax, port) {
260 const open = commentOpen(syntax);
261 const close = commentClose(syntax);
262 return (
263 open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
264 '<script src="http://localhost:' + port + '/live.js"></script>\n' +
265 open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
266 );
267}
268
269function insertTag(content, config, port) {
270 const block = buildTagBlock(config.commentSyntax, port);
271 // insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
272 // belong at the end, and the same literal can appear earlier in code blocks
273 // within rendered documentation pages.
274 if (config.insertBefore) {
275 const idx = content.lastIndexOf(config.insertBefore);
276 if (idx === -1) return content;
277 return content.slice(0, idx) + block + content.slice(idx);
278 }
279 // insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
280 // `<body>` open near the top of the document.
281 const idx = content.indexOf(config.insertAfter);
282 if (idx === -1) return content;
283 const after = idx + config.insertAfter.length;
284 // Preserve a single trailing newline if the anchor didn't end with one
285 const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n';
286 return prefix + block + content.slice(prefix.length);
287}
288
289/**
290 * Remove the live script block. Matches either HTML or JSX comment markers
291 * regardless of config (so stale tags from a wrong config can still be cleaned).
292 *
293 * Indent-preserving: captures any whitespace immediately preceding the opener
294 * marker and re-emits it in place of the removed block. `insertTag` inserted
295 * the block *after* the original line's indent and *before* the anchor (e.g.
296 * `</body>`), which moved the indent onto the opener line and left the anchor
297 * unindented. Replacing the whole block (plus its trailing newline) with just
298 * the captured indent hands the indent back to the anchor that follows.
299 */
300function removeTag(content, _syntax) {
301 const patterns = [
302 /([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->[ \t]*\n/,
303 /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/,
304 ];
305 for (const pat of patterns) {
306 const next = content.replace(pat, '$1');
307 if (next !== content) return next;
308 }
309 return content;
310}
311
312// ---------------------------------------------------------------------------
313// Content-Security-Policy meta-tag patcher
314//
315// When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,
316// the cross-origin load of /live.js (and the SSE/POST connection back to
317// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
318//
319// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
320// and stash the original `content` value in a `data-impeccable-csp-original`
321// attribute (base64) so revert is exact.
322//
323// On remove: detect the marker attribute, decode it, restore the original
324// content value verbatim, drop the marker.
325//
326// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
327// shared helpers) is NOT patched here — those need framework-specific config
328// edits and are handled via the existing detect-csp.mjs reference output.
329// Only the in-source meta-tag form gets the auto-patch.
330// ---------------------------------------------------------------------------
331
332const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
333
334function findCspMetaTags(content) {
335 const out = [];
336 const tagRe = /<meta\s+([^>]*?)\/?>/gis;
337 let m;
338 while ((m = tagRe.exec(content)) !== null) {
339 const attrs = m[1];
340 if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
341 out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
342 }
343 return out;
344}
345
346function getAttr(attrs, name) {
347 const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
348 const m = attrs.match(re);
349 return m ? { quote: m[1], value: m[2], full: m[0] } : null;
350}
351
352function appendOriginToDirective(csp, directive, origin) {
353 const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
354 const m = csp.match(re);
355 if (m) {
356 const tokens = m[4].trim().split(/\s+/);
357 if (tokens.includes(origin)) return csp;
358 return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
359 }
360 // Directive missing — add it. Use 'self' + origin so we don't inadvertently
361 // narrow the policy compared to the default-src fallback (most users with
362 // an explicit CSP have 'self' there).
363 return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
364}
365
366export function patchCspMeta(content, port) {
367 const tags = findCspMetaTags(content);
368 if (tags.length === 0) return content;
369 const origin = `http://localhost:${port}`;
370
371 // Walk last-to-first so prior splices don't invalidate later indices.
372 let result = content;
373 for (let i = tags.length - 1; i >= 0; i--) {
374 const tag = tags[i];
375 const attrs = tag.attrs;
376 if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
377 const contentAttr = getAttr(attrs, 'content');
378 if (!contentAttr) continue;
379
380 const original = contentAttr.value;
381 let patched = original;
382 patched = appendOriginToDirective(patched, 'script-src', origin);
383 patched = appendOriginToDirective(patched, 'connect-src', origin);
384 // The shader overlay during 'generating' creates a screenshot via
385 // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
386 // those. Add `blob:` so the overlay doesn't throw a CSP violation.
387 patched = appendOriginToDirective(patched, 'img-src', 'blob:');
388 if (patched === original) continue;
389
390 const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
391 const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
392 // The tagRe captures any whitespace between the last attribute and the
393 // closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
394 // a replace would land it BEFORE that trailing space, leaving a double
395 // space inside attrs and clobbering the space before `/>`. Split off
396 // the trailing whitespace, splice the marker into the attribute body,
397 // and re-append the original trailing whitespace so a self-closing
398 // `<meta … />` round-trips byte-for-byte.
399 const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
400 const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
401 const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
402 const newTag = tag.full.replace(attrs, newAttrs);
403
404 result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
405 }
406 return result;
407}
408
409export function revertCspMeta(content) {
410 const tags = findCspMetaTags(content);
411 if (tags.length === 0) return content;
412
413 let result = content;
414 for (let i = tags.length - 1; i >= 0; i--) {
415 const tag = tags[i];
416 const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
417 if (!origAttr) continue;
418 const contentAttr = getAttr(tag.attrs, 'content');
419 if (!contentAttr) continue;
420
421 let originalValue;
422 try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
423 catch { continue; }
424
425 const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
426 let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
427 // Drop the marker attribute and any single space immediately preceding it.
428 newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
429 const newTag = tag.full.replace(tag.attrs, newAttrs);
430
431 result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
432 }
433 return result;
434}
435
436// ---------------------------------------------------------------------------
437// Auto-execute
438// ---------------------------------------------------------------------------
439
440const _running = process.argv[1];
441if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
442 injectCli();
443}
444
445export { insertTag, removeTag, validateConfig, buildTagBlock };
446// patchCspMeta + revertCspMeta are exported above where they're defined.