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