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