Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/live-inject.mjs
1/**2* CLI helper: insert/remove the live variant mode script tag in the project's3* 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). On7* every subsequent run, this script handles insert/remove deterministically8* with zero LLM involvement.9*10* Usage:11* node live-inject.mjs --port PORT # Insert the live script tag12* node live-inject.mjs --remove # Remove the live script tag13* node live-inject.mjs --check # Check whether live config exists14*/1516import fs from 'node:fs';17import path from 'node:path';18import { fileURLToPath } from 'node:url';19import { resolveLiveConfigPath } from './impeccable-paths.mjs';2021const __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';2526/**27* Hard-excluded directory patterns. These are NEVER user-facing pages and28* matching them would silently inject tracking scripts into third-party29* code. The user cannot turn these off via config — they are the floor.30*/31const HARD_EXCLUDES = [32'**/node_modules/**',33'**/.git/**',34];3536export async function injectCli() {37const args = process.argv.slice(2);3839if (args.includes('--help') || args.includes('-h')) {40console.log(`Usage: node live-inject.mjs [options]4142Insert or remove the live mode script tag in the project's HTML entry point.43Reads configuration from .impeccable/live/config.json.4445Modes:46--port PORT Insert script tag pointing at http://localhost:PORT/live.js47--remove Remove the script tag (if present)48--check Print whether .impeccable/live/config.json exists and its content4950Output (JSON):51{ ok, file, inserted|removed, config? }`);52process.exit(0);53}5455if (args.includes('--check')) {56if (!fs.existsSync(CONFIG_PATH)) {57console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));58process.exit(0);59}60let cfg;61try {62cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));63} catch (err) {64console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));65return;66}67try {68validateConfig(cfg);69} catch (err) {70console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));71return;72}73console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));74return;75}7677// Load config78if (!fs.existsSync(CONFIG_PATH)) {79console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));80process.exit(1);81}82const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));83validateConfig(config);8485const resolvedFiles = resolveFiles(process.cwd(), config);8687if (args.includes('--remove')) {88const results = resolvedFiles.map((relFile) => {89const absFile = path.resolve(process.cwd(), relFile);90if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };91const content = fs.readFileSync(absFile, 'utf-8');92const detagged = removeTag(content, config.commentSyntax);93const updated = revertCspMeta(detagged);94if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };95fs.writeFileSync(absFile, updated, 'utf-8');96return {97file: relFile,98removed: detagged !== content,99cspReverted: updated !== detagged,100};101});102console.log(JSON.stringify({ ok: true, results }));103return;104}105106// Insert mode — need --port107const portIdx = args.indexOf('--port');108const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN;109if (!Number.isFinite(port)) {110console.error(JSON.stringify({ ok: false, error: 'missing_port' }));111process.exit(1);112}113114const results = resolvedFiles.map((relFile) => {115const absFile = path.resolve(process.cwd(), relFile);116if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };117const content = fs.readFileSync(absFile, 'utf-8');118const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));119const withTag = insertTag(withoutOld, config, port);120if (withTag === withoutOld) {121return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };122}123const updated = patchCspMeta(withTag, port);124fs.writeFileSync(absFile, updated, 'utf-8');125return {126file: relFile,127inserted: true,128cspPatched: updated !== withTag,129};130});131const anyInserted = results.some((r) => r.inserted);132console.log(JSON.stringify({ ok: anyInserted, port, results }));133if (!anyInserted) process.exit(1);134}135136/**137* Expand config.files (which may contain glob patterns) into a literal list138* of existing file paths relative to rootDir. Literal entries pass through;139* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude140* are applied as filters. Duplicates are removed. Order is preserved by141* first appearance.142*/143export function resolveFiles(rootDir, config) {144const patterns = config.files;145const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];146const allExcludes = [...HARD_EXCLUDES, ...userExcludes];147const excludeRegexes = allExcludes.map(globToRegex);148149const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));150const isGlob = (s) => /[*?[]/.test(s);151152const seen = new Set();153const out = [];154for (const pat of patterns) {155if (!isGlob(pat)) {156// Literal path — include even if it doesn't exist yet; the caller157// reports file_not_found per-entry. Exclude list doesn't apply to158// explicit literal entries (user named it on purpose).159if (!seen.has(pat)) {160seen.add(pat);161out.push(pat);162}163continue;164}165let matches;166try {167matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });168} catch {169continue;170}171for (const ent of matches) {172if (!ent.isFile || !ent.isFile()) continue;173const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);174const rel = path.relative(rootDir, abs).split(path.sep).join('/');175if (isExcluded(rel)) continue;176if (seen.has(rel)) continue;177seen.add(rel);178out.push(rel);179}180}181return out;182}183184/**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) {192let re = '';193let i = 0;194while (i < pattern.length) {195const c = pattern[i];196if (c === '*') {197if (pattern[i + 1] === '*') {198// ** — any number of segments, including zero. Handle the common199// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.200if (pattern[i + 2] === '/') {201re += '(?:.*/)?';202i += 3;203} else {204re += '.*';205i += 2;206}207} else {208re += '[^/]*';209i += 1;210}211} else if (c === '?') {212re += '[^/]';213i += 1;214} else if (/[.+^${}()|[\]\\]/.test(c)) {215re += '\\' + c;216i += 1;217} else {218re += c;219i += 1;220}221}222return new RegExp('^' + re + '$');223}224225// ---------------------------------------------------------------------------226// Core operations227// ---------------------------------------------------------------------------228229function validateConfig(cfg) {230if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');231if (!Array.isArray(cfg.files) || cfg.files.length === 0) {232throw new Error('config.files (non-empty string array) required');233}234if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {235throw new Error('config.files must contain only non-empty strings');236}237if (cfg.exclude !== undefined) {238if (!Array.isArray(cfg.exclude)) {239throw new Error('config.exclude, if present, must be a string array');240}241if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {242throw new Error('config.exclude must contain only non-empty strings');243}244}245if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {246throw new Error('config.insertBefore or config.insertAfter (string) required');247}248if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {249throw new Error("config.commentSyntax must be 'html' or 'jsx'");250}251if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {252throw new Error("config.cspChecked, if present, must be a boolean");253}254}255256function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }257function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }258259function buildTagBlock(syntax, port) {260const open = commentOpen(syntax);261const close = commentClose(syntax);262return (263open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +264'<script src="http://localhost:' + port + '/live.js"></script>\n' +265open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'266);267}268269function insertTag(content, config, port) {270const block = buildTagBlock(config.commentSyntax, port);271// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally272// belong at the end, and the same literal can appear earlier in code blocks273// within rendered documentation pages.274if (config.insertBefore) {275const idx = content.lastIndexOf(config.insertBefore);276if (idx === -1) return content;277return content.slice(0, idx) + block + content.slice(idx);278}279// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or280// `<body>` open near the top of the document.281const idx = content.indexOf(config.insertAfter);282if (idx === -1) return content;283const after = idx + config.insertAfter.length;284// Preserve a single trailing newline if the anchor didn't end with one285const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n';286return prefix + block + content.slice(prefix.length);287}288289/**290* Remove the live script block. Matches either HTML or JSX comment markers291* regardless of config (so stale tags from a wrong config can still be cleaned).292*293* Indent-preserving: captures any whitespace immediately preceding the opener294* marker and re-emits it in place of the removed block. `insertTag` inserted295* 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 anchor297* unindented. Replacing the whole block (plus its trailing newline) with just298* the captured indent hands the indent back to the anchor that follows.299*/300function removeTag(content, _syntax) {301const 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];305for (const pat of patterns) {306const next = content.replace(pat, '$1');307if (next !== content) return next;308}309return content;310}311312// ---------------------------------------------------------------------------313// Content-Security-Policy meta-tag patcher314//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 to317// 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 original324// 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 config328// 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// ---------------------------------------------------------------------------331332const CSP_MARKER_ATTR = 'data-impeccable-csp-original';333334function findCspMetaTags(content) {335const out = [];336const tagRe = /<meta\s+([^>]*?)\/?>/gis;337let m;338while ((m = tagRe.exec(content)) !== null) {339const attrs = m[1];340if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;341out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });342}343return out;344}345346function getAttr(attrs, name) {347const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');348const m = attrs.match(re);349return m ? { quote: m[1], value: m[2], full: m[0] } : null;350}351352function appendOriginToDirective(csp, directive, origin) {353const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');354const m = csp.match(re);355if (m) {356const tokens = m[4].trim().split(/\s+/);357if (tokens.includes(origin)) return csp;358return 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 inadvertently361// narrow the policy compared to the default-src fallback (most users with362// an explicit CSP have 'self' there).363return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;364}365366export function patchCspMeta(content, port) {367const tags = findCspMetaTags(content);368if (tags.length === 0) return content;369const origin = `http://localhost:${port}`;370371// Walk last-to-first so prior splices don't invalidate later indices.372let result = content;373for (let i = tags.length - 1; i >= 0; i--) {374const tag = tags[i];375const attrs = tag.attrs;376if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched377const contentAttr = getAttr(attrs, 'content');378if (!contentAttr) continue;379380const original = contentAttr.value;381let patched = original;382patched = appendOriginToDirective(patched, 'script-src', origin);383patched = appendOriginToDirective(patched, 'connect-src', origin);384// The shader overlay during 'generating' creates a screenshot via385// URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects386// those. Add `blob:` so the overlay doesn't throw a CSP violation.387patched = appendOriginToDirective(patched, 'img-src', 'blob:');388if (patched === original) continue;389390const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;391const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;392// The tagRe captures any whitespace between the last attribute and the393// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after394// a replace would land it BEFORE that trailing space, leaving a double395// space inside attrs and clobbering the space before `/>`. Split off396// the trailing whitespace, splice the marker into the attribute body,397// and re-append the original trailing whitespace so a self-closing398// `<meta … />` round-trips byte-for-byte.399const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];400const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);401const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;402const newTag = tag.full.replace(attrs, newAttrs);403404result = result.slice(0, tag.start) + newTag + result.slice(tag.end);405}406return result;407}408409export function revertCspMeta(content) {410const tags = findCspMetaTags(content);411if (tags.length === 0) return content;412413let result = content;414for (let i = tags.length - 1; i >= 0; i--) {415const tag = tags[i];416const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);417if (!origAttr) continue;418const contentAttr = getAttr(tag.attrs, 'content');419if (!contentAttr) continue;420421let originalValue;422try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }423catch { continue; }424425const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;426let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);427// Drop the marker attribute and any single space immediately preceding it.428newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');429const newTag = tag.full.replace(tag.attrs, newAttrs);430431result = result.slice(0, tag.start) + newTag + result.slice(tag.end);432}433return result;434}435436// ---------------------------------------------------------------------------437// Auto-execute438// ---------------------------------------------------------------------------439440const _running = process.argv[1];441if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {442injectCli();443}444445export { insertTag, removeTag, validateConfig, buildTagBlock };446// patchCspMeta + revertCspMeta are exported above where they're defined.447