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 './lib/impeccable-paths.mjs';20import {21applySvelteKitLiveAdapter,22detectSvelteKitProject,23removeSvelteKitLiveAdapter,24} from './live/sveltekit-adapter.mjs';2526const __dirname = path.dirname(fileURLToPath(import.meta.url));27const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });28const MARKER_OPEN_TEXT = 'impeccable-live-start';29const MARKER_CLOSE_TEXT = 'impeccable-live-end';30const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';31const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';3233export const LIVE_IGNORE_PATTERNS = Object.freeze([34'.impeccable/hook.cache.json',35'.impeccable/hook.pending.json',36'.impeccable/config.local.json',37'.impeccable/live/server.json',38'.impeccable/live/sessions/',39'.impeccable/live/previews/',40'.impeccable/live/annotations/',41'.impeccable/live/cache/',42'.impeccable/live/manual-edit-apply-transaction.json',43'.impeccable/live/manual-edit-events.jsonl',44'.impeccable/live/manual-edit-evidence/',45'.impeccable/live/pending-manual-edits.json',46'.impeccable/live/deferred-svelte-component-accepts.json',47'.impeccable-live.json',48'.impeccable-live/',49'node_modules/.impeccable-live/',50'src/lib/impeccable/ImpeccableLiveRoot.svelte',51'src/lib/impeccable/__runtime.js',52'src/lib/impeccable/[0-9a-f]*/',53]);5455/**56* Hard-excluded directory patterns. These are NEVER user-facing pages and57* matching them would silently inject tracking scripts into third-party58* code. The user cannot turn these off via config — they are the floor.59*/60const HARD_EXCLUDES = [61'**/node_modules/**',62'**/.git/**',63];6465export async function injectCli() {66const args = process.argv.slice(2);6768if (args.includes('--help') || args.includes('-h')) {69console.log(`Usage: node live-inject.mjs [options]7071Insert or remove the live mode script tag in the project's HTML entry point.72Reads configuration from .impeccable/live/config.json.7374Modes:75--port PORT Insert script tag pointing at http://localhost:PORT/live.js76--remove Remove the script tag (if present)77--check Print whether .impeccable/live/config.json exists and its content7879Output (JSON):80{ ok, file, inserted|removed, config? }`);81process.exit(0);82}8384if (args.includes('--check')) {85if (!fs.existsSync(CONFIG_PATH)) {86console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));87process.exit(0);88}89let cfg;90try {91cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));92} catch (err) {93console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));94return;95}96try {97validateConfig(cfg);98} catch (err) {99console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));100return;101}102console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));103return;104}105106// Load config107if (!fs.existsSync(CONFIG_PATH)) {108console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));109process.exit(1);110}111const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));112validateConfig(config);113114const resolvedFiles = resolveFiles(process.cwd(), config);115const svelteKit = detectSvelteKitProject(process.cwd(), config);116117if (args.includes('--remove')) {118if (svelteKit) {119const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });120console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));121return;122}123const results = resolvedFiles.map((relFile) => {124const absFile = path.resolve(process.cwd(), relFile);125if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };126const content = fs.readFileSync(absFile, 'utf-8');127const detagged = removeTag(content, config.commentSyntax);128const updated = revertCspMeta(detagged);129if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };130fs.writeFileSync(absFile, updated, 'utf-8');131return {132file: relFile,133removed: detagged !== content,134cspReverted: updated !== detagged,135};136});137console.log(JSON.stringify({ ok: true, results }));138return;139}140141// Insert mode — need --port142const portIdx = args.indexOf('--port');143const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN;144if (!Number.isFinite(port)) {145console.error(JSON.stringify({ ok: false, error: 'missing_port' }));146process.exit(1);147}148const gitIgnore = ensureLiveGitIgnores(process.cwd());149150if (svelteKit) {151const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });152console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));153return;154}155156const results = resolvedFiles.map((relFile) => {157const absFile = path.resolve(process.cwd(), relFile);158if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };159const content = fs.readFileSync(absFile, 'utf-8');160const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));161const withTag = insertTag(withoutOld, config, port, relFile);162if (withTag === withoutOld) {163return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };164}165const updated = patchCspMeta(withTag, port);166fs.writeFileSync(absFile, updated, 'utf-8');167return {168file: relFile,169inserted: true,170cspPatched: updated !== withTag,171};172});173const anyInserted = results.some((r) => r.inserted);174console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));175if (!anyInserted) process.exit(1);176}177178export function ensureLiveGitIgnores(cwd = process.cwd()) {179const target = resolveIgnoreTarget(cwd);180const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';181const block = [182IGNORE_MARKER_OPEN,183...LIVE_IGNORE_PATTERNS,184IGNORE_MARKER_CLOSE,185].join('\n');186const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);187188let updated;189if (markerRe.test(existing)) {190updated = existing.replace(markerRe, block);191} else {192const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';193updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;194}195196if (updated !== existing) {197fs.mkdirSync(path.dirname(target.path), { recursive: true });198fs.writeFileSync(target.path, updated, 'utf-8');199}200201return {202file: path.relative(cwd, target.path).split(path.sep).join('/'),203mode: target.mode,204changed: updated !== existing,205patterns: [...LIVE_IGNORE_PATTERNS],206};207}208209function resolveIgnoreTarget(cwd) {210const gitExcludePath = resolveGitInfoExcludePath(cwd);211if (gitExcludePath) {212return { path: gitExcludePath, mode: 'git-info-exclude' };213}214return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };215}216217function resolveGitInfoExcludePath(cwd) {218const dotGit = path.join(cwd, '.git');219if (!fs.existsSync(dotGit)) return null;220221const stat = fs.statSync(dotGit);222if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');223if (!stat.isFile()) return null;224225const body = fs.readFileSync(dotGit, 'utf-8').trim();226const match = body.match(/^gitdir:\s*(.+)$/i);227if (!match) return null;228const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);229return path.join(gitDir, 'info', 'exclude');230}231232function escapeRegExp(value) {233return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');234}235236/**237* Expand config.files (which may contain glob patterns) into a literal list238* of existing file paths relative to rootDir. Literal entries pass through;239* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude240* are applied as filters. Duplicates are removed. Order is preserved by241* first appearance.242*/243export function resolveFiles(rootDir, config) {244const patterns = config.files;245const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];246const allExcludes = [...HARD_EXCLUDES, ...userExcludes];247const excludeRegexes = allExcludes.map(globToRegex);248249const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));250const isGlob = (s) => /[*?[]/.test(s);251252const seen = new Set();253const out = [];254for (const pat of patterns) {255if (!isGlob(pat)) {256// Literal path — include even if it doesn't exist yet; the caller257// reports file_not_found per-entry. Exclude list doesn't apply to258// explicit literal entries (user named it on purpose).259if (!seen.has(pat)) {260seen.add(pat);261out.push(pat);262}263continue;264}265let matches;266try {267matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });268} catch {269continue;270}271for (const ent of matches) {272if (!ent.isFile || !ent.isFile()) continue;273const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);274const rel = path.relative(rootDir, abs).split(path.sep).join('/');275if (isExcluded(rel)) continue;276if (seen.has(rel)) continue;277seen.add(rel);278out.push(rel);279}280}281return out;282}283284/**285* Convert a glob pattern to a RegExp. Supports:286* ** → any number of path segments (including zero)287* * → any chars except `/`288* ? → any single char except `/`289* Paths are normalized to forward slashes before matching.290*/291function globToRegex(pattern) {292let re = '';293let i = 0;294while (i < pattern.length) {295const c = pattern[i];296if (c === '*') {297if (pattern[i + 1] === '*') {298// ** — any number of segments, including zero. Handle the common299// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.300if (pattern[i + 2] === '/') {301re += '(?:.*/)?';302i += 3;303} else {304re += '.*';305i += 2;306}307} else {308re += '[^/]*';309i += 1;310}311} else if (c === '?') {312re += '[^/]';313i += 1;314} else if (/[.+^${}()|[\]\\]/.test(c)) {315re += '\\' + c;316i += 1;317} else {318re += c;319i += 1;320}321}322return new RegExp('^' + re + '$');323}324325// ---------------------------------------------------------------------------326// Core operations327// ---------------------------------------------------------------------------328329function validateConfig(cfg) {330if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');331if (!Array.isArray(cfg.files) || cfg.files.length === 0) {332throw new Error('config.files (non-empty string array) required');333}334if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {335throw new Error('config.files must contain only non-empty strings');336}337if (cfg.exclude !== undefined) {338if (!Array.isArray(cfg.exclude)) {339throw new Error('config.exclude, if present, must be a string array');340}341if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {342throw new Error('config.exclude must contain only non-empty strings');343}344}345if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {346throw new Error('config.insertBefore or config.insertAfter (string) required');347}348if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {349throw new Error("config.commentSyntax must be 'html' or 'jsx'");350}351if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {352throw new Error("config.cspChecked, if present, must be a boolean");353}354}355356function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }357function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }358359function buildTagBlock(syntax, port, filePath) {360const open = commentOpen(syntax);361const close = commentClose(syntax);362// Astro processes <script> tags by default and rewrites src to its own363// bundled URL. is:inline opts out so the literal external src survives.364const isAstro = typeof filePath === 'string' && filePath.endsWith('.astro');365const scriptAttrs = isAstro ? 'is:inline ' : '';366return (367open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +368'<script ' + scriptAttrs + 'src="http://localhost:' + port + '/live.js"></script>\n' +369open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'370);371}372373function detectLineEnding(content) {374if (content.includes('\r\n')) return '\r\n';375if (content.includes('\r')) return '\r';376return '\n';377}378379function normalizeLineEndings(content, lineEnding) {380return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);381}382383function readLineEndingAt(content, index) {384if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';385if (content[index] === '\n') return '\n';386if (content[index] === '\r') return '\r';387return '';388}389390function insertTag(content, config, port, filePath) {391const lineEnding = detectLineEnding(content);392const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding);393// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally394// belong at the end, and the same literal can appear earlier in code blocks395// within rendered documentation pages.396if (config.insertBefore) {397const idx = content.lastIndexOf(config.insertBefore);398if (idx === -1) return content;399return content.slice(0, idx) + block + content.slice(idx);400}401// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or402// `<body>` open near the top of the document.403const idx = content.indexOf(config.insertAfter);404if (idx === -1) return content;405const after = idx + config.insertAfter.length;406// Preserve an existing trailing newline if the anchor already has one.407// Slice the remainder from the original anchor offset, not prefix.length:408// in the no-newline case prefix is one char longer than the anchor (the409// appended '\n'), so slicing by prefix.length would drop the first real410// character after the anchor (#227).411const existingNewline = readLineEndingAt(content, after);412const prefix = content.slice(0, after) + (existingNewline || lineEnding);413const rest = content.slice(after + existingNewline.length);414return prefix + block + rest;415}416417/**418* Remove the live script block. Matches either HTML or JSX comment markers419* regardless of config (so stale tags from a wrong config can still be cleaned).420*421* Indent-preserving: captures any whitespace immediately preceding the opener422* marker and re-emits it in place of the removed block. `insertTag` inserted423* the block *after* the original line's indent and *before* the anchor (e.g.424* `</body>`), which moved the indent onto the opener line and left the anchor425* unindented. Replacing the whole block (plus its trailing newline) with just426* the captured indent hands the indent back to the anchor that follows.427*/428function removeTag(content, _syntax) {429const patterns = [430/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,431/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,432];433for (const pat of patterns) {434let changed = false;435let next = content;436do {437content = next;438next = content.replace(pat, (_match, leadingIndent, trailing = '') => {439if (/[\r\n]/.test(trailing)) return leadingIndent;440return leadingIndent || trailing || '';441});442if (next !== content) changed = true;443} while (next !== content);444if (changed) return next;445}446return content;447}448449// ---------------------------------------------------------------------------450// Content-Security-Policy meta-tag patcher451//452// When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,453// the cross-origin load of /live.js (and the SSE/POST connection back to454// localhost:PORT) is blocked unless the CSP explicitly allows that origin.455//456// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,457// and stash the original `content` value in a `data-impeccable-csp-original`458// attribute (base64) so revert is exact.459//460// On remove: detect the marker attribute, decode it, restore the original461// content value verbatim, drop the marker.462//463// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,464// shared helpers) is NOT patched here — those need framework-specific config465// edits and are handled via the existing detect-csp.mjs reference output.466// Only the in-source meta-tag form gets the auto-patch.467// ---------------------------------------------------------------------------468469const CSP_MARKER_ATTR = 'data-impeccable-csp-original';470471function findCspMetaTags(content) {472const out = [];473const tagRe = /<meta\s+([^>]*?)\/?>/gis;474let m;475while ((m = tagRe.exec(content)) !== null) {476const attrs = m[1];477if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;478out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });479}480return out;481}482483function getAttr(attrs, name) {484const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');485const m = attrs.match(re);486return m ? { quote: m[1], value: m[2], full: m[0] } : null;487}488489function appendOriginToDirective(csp, directive, origin) {490const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');491const m = csp.match(re);492if (m) {493const tokens = m[4].trim().split(/\s+/);494if (tokens.includes(origin)) return csp;495return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);496}497// Directive missing — add it. Use 'self' + origin so we don't inadvertently498// narrow the policy compared to the default-src fallback (most users with499// an explicit CSP have 'self' there).500return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;501}502503export function patchCspMeta(content, port) {504const tags = findCspMetaTags(content);505if (tags.length === 0) return content;506const origin = `http://localhost:${port}`;507508// Walk last-to-first so prior splices don't invalidate later indices.509let result = content;510for (let i = tags.length - 1; i >= 0; i--) {511const tag = tags[i];512const attrs = tag.attrs;513if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched514const contentAttr = getAttr(attrs, 'content');515if (!contentAttr) continue;516517const original = contentAttr.value;518let patched = original;519patched = appendOriginToDirective(patched, 'script-src', origin);520patched = appendOriginToDirective(patched, 'connect-src', origin);521// The shader overlay during 'generating' creates a screenshot via522// URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects523// those. Add `blob:` so the overlay doesn't throw a CSP violation.524patched = appendOriginToDirective(patched, 'img-src', 'blob:');525if (patched === original) continue;526527const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;528const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;529// The tagRe captures any whitespace between the last attribute and the530// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after531// a replace would land it BEFORE that trailing space, leaving a double532// space inside attrs and clobbering the space before `/>`. Split off533// the trailing whitespace, splice the marker into the attribute body,534// and re-append the original trailing whitespace so a self-closing535// `<meta … />` round-trips byte-for-byte.536const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];537const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);538const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;539const newTag = tag.full.replace(attrs, newAttrs);540541result = result.slice(0, tag.start) + newTag + result.slice(tag.end);542}543return result;544}545546export function revertCspMeta(content) {547const tags = findCspMetaTags(content);548if (tags.length === 0) return content;549550let result = content;551for (let i = tags.length - 1; i >= 0; i--) {552const tag = tags[i];553const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);554if (!origAttr) continue;555const contentAttr = getAttr(tag.attrs, 'content');556if (!contentAttr) continue;557558let originalValue;559try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }560catch { continue; }561562const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;563let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);564// Drop the marker attribute and any single space immediately preceding it.565newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');566const newTag = tag.full.replace(tag.attrs, newAttrs);567568result = result.slice(0, tag.start) + newTag + result.slice(tag.end);569}570return result;571}572573// ---------------------------------------------------------------------------574// Auto-execute575// ---------------------------------------------------------------------------576577const _running = process.argv[1];578if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {579injectCli();580}581582export { insertTag, removeTag, validateConfig, buildTagBlock };583// patchCspMeta + revertCspMeta are exported above where they're defined.584