2026-08-06 09f01778d5a034e2af50ae05da8908f7b6a871c8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
/**
 * CLI helper: insert/remove the live variant mode script tag in the project's
 * main HTML entry point.
 *
 * On first live run, the agent generates `.impeccable/live/config.json`
 * with the project's insertion target (framework-specific). On
 * every subsequent run, this script handles insert/remove deterministically
 * with zero LLM involvement.
 *
 * Framework knowledge lives in `live/frameworks/` — detection order, adapters,
 * the generic tag strategy, and the per-extension authoring traits live-wrap
 * reads. This file is the CLI around it: resolve config, resolve the
 * framework, heal orphaned artifacts, apply or remove, record the journal.
 *
 * Usage:
 *   node live-inject.mjs --port PORT [--token TOKEN]  # Insert the live script tag
 *   node live-inject.mjs --remove                     # Remove the live script tag
 *   node live-inject.mjs --check                      # Check whether live config exists
 *
 * When --token is supplied, it is appended to the /live.js src as `?token=...`
 * so the server's token-gated /live.js handler will serve the bundle. Omitting
 * the token yields a bare `/live.js` src (legacy behavior; the server returns
 * 401 for it under the current gate).
 */
 
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
  describeInjectArtifacts,
  frameworkIgnorePatterns,
  resolveFramework,
  resolveSourceTraits,
} from './live/frameworks/index.mjs';
import {
  clearInjectJournal,
  healInjectJournal,
  recordInjection,
} from './live/frameworks/journal.mjs';
import {
  buildTagBlock,
  insertTag,
  patchCspMeta,
  removeTag,
  revertCspMeta,
} from './live/frameworks/tag-strategy.mjs';
import { buildLiveScriptSrc } from './live/frameworks/script-src.mjs';
import { enterLiveRoot } from './live/roots.mjs';
 
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Resolved lazily so the enterLiveRoot() chdir in the CLI guard below takes
// effect first; module scope runs before the guard.
let CONFIG_PATH_CACHED = null;
function CONFIG_PATH_GET() {
  if (!CONFIG_PATH_CACHED) {
    CONFIG_PATH_CACHED = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
  }
  return CONFIG_PATH_CACHED;
}
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
 
export const LIVE_IGNORE_PATTERNS = Object.freeze([
  '.impeccable/hook.cache.json',
  '.impeccable/hook.pending.json',
  '.impeccable/config.local.json',
  '.impeccable/live/server.json',
  '.impeccable/live/roots.json',
  '.impeccable/live/app-root.json',
  '.impeccable/live/inject-journal.json',
  '.impeccable/live/sessions/',
  '.impeccable/live/previews/',
  '.impeccable/live/annotations/',
  '.impeccable/live/artifacts/',
  '.impeccable/live/accept-receipts/',
  '.impeccable/live/locks/',
  '.impeccable/live/cache/',
  '.impeccable/live/manual-edit-apply-transaction.json',
  '.impeccable/live/manual-edit-events.jsonl',
  '.impeccable/live/manual-edit-evidence/',
  '.impeccable/live/pending-manual-edits.json',
  '.impeccable/live/deferred-svelte-component-accepts.json',
  '.impeccable-live.json',
  '.impeccable-live/',
  'app/.impeccable-live/',
  'src/.impeccable-live/',
  'node_modules/.impeccable-live/',
  'src/lib/impeccable/ImpeccableLiveRoot.svelte',
  'src/lib/impeccable/__runtime.js',
  'src/lib/impeccable/[0-9a-f]*/',
  'plugins/impeccable-live.client.ts',
  'app/plugins/impeccable-live.client.ts',
  'src/plugins/impeccable-live.client.ts',
]);
 
/**
 * Hard-excluded directory patterns. These are NEVER user-facing pages and
 * matching them would silently inject tracking scripts into third-party
 * code. The user cannot turn these off via config — they are the floor.
 */
const HARD_EXCLUDES = [
  '**/node_modules/**',
  '**/.git/**',
];
 
export async function injectCli() {
  const args = process.argv.slice(2);
 
  if (args.includes('--help') || args.includes('-h')) {
    console.log(`Usage: node live-inject.mjs [options]
 
Insert or remove the live mode script tag in the project's HTML entry point.
Reads configuration from .impeccable/live/config.json.
 
Modes:
  --port PORT   Insert script tag pointing at http://localhost:PORT/live.js
  --remove      Remove the script tag (if present)
  --check       Print whether .impeccable/live/config.json exists and its content
 
Output (JSON):
  { ok, file, inserted|removed, config? }`);
    process.exit(0);
  }
 
  if (args.includes('--check')) {
    // Deliberately read-only: --check runs from status paths and must never
    // mutate the tree. Journal reconciliation happens on the inject run.
    if (!fs.existsSync(CONFIG_PATH_GET())) {
      console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH_GET() }));
      process.exit(0);
    }
    let cfg;
    try {
      cfg = JSON.parse(fs.readFileSync(CONFIG_PATH_GET(), 'utf-8'));
    } catch (err) {
      console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH_GET() }));
      return;
    }
    try {
      validateConfig(cfg);
    } catch (err) {
      console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH_GET() }));
      return;
    }
    console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH_GET() }));
    return;
  }
 
  // Load config
  if (!fs.existsSync(CONFIG_PATH_GET())) {
    console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH_GET() }));
    process.exit(1);
  }
  const config = JSON.parse(fs.readFileSync(CONFIG_PATH_GET(), 'utf-8'));
  validateConfig(config);
 
  const cwd = process.cwd();
  const resolvedFiles = resolveFiles(cwd, config);
  const resolved = resolveFramework(cwd, config);
  const isAdapter = resolved?.framework.inject.kind === 'adapter';
 
  if (args.includes('--remove')) {
    if (isAdapter) {
      const adapterResult = resolved.framework.inject.remove({ cwd, config, project: resolved.project });
      const ok = !(adapterResult && adapterResult.error);
      // Anything the adapter could not reach (its detection may have shifted
      // since the session started) is still on the journal.
      const { healed } = healInjectJournal(cwd);
      clearInjectJournal(cwd);
      console.log(JSON.stringify({
        ok,
        adapter: resolved.framework.name,
        results: [adapterResult],
        healed: healed.length ? healed : undefined,
      }));
      if (!ok) process.exitCode = 1;
      return;
    }
    const results = resolvedFiles.map((relFile) => {
      const absFile = path.resolve(cwd, relFile);
      if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
      const content = fs.readFileSync(absFile, 'utf-8');
      const detagged = removeTag(content, config.commentSyntax);
      const updated = revertCspMeta(detagged);
      if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
      fs.writeFileSync(absFile, updated, 'utf-8');
      return {
        file: relFile,
        removed: detagged !== content,
        cspReverted: updated !== detagged,
      };
    });
    const { healed } = healInjectJournal(cwd);
    clearInjectJournal(cwd);
    console.log(JSON.stringify({ ok: true, results, healed: healed.length ? healed : undefined }));
    return;
  }
 
  // Insert mode — need --port
  const portIdx = args.indexOf('--port');
  const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN;
  if (!Number.isFinite(port)) {
    console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
    process.exit(1);
  }
  // Optional server token: appended to the /live.js src so the token-gated
  // /live.js handler authorizes the browser fetch. `live.mjs` always passes
  // it; a manual `--port`-only invocation reads the running helper's token
  // from server.json instead of writing an unauthenticated URL that 401s.
  const tokenIdx = args.indexOf('--token');
  let token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
  if (!token) {
    try {
      const info = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'live', 'server.json'), 'utf-8'));
      // A record for a DIFFERENT port is a stale or foreign helper; its token
      // would 401 just the same, so only adopt a matching one.
      if (info?.token && Number(info.port) === port) token = info.token;
    } catch { /* no running helper recorded; keep legacy tokenless behavior */ }
  }
 
  // Reconcile before writing anything. Artifacts this run is about to own are
  // kept (so a repeat inject stays byte-idempotent); artifacts left behind by
  // a session that never got to stop are healed.
  const plannedArtifacts = describeInjectArtifacts(resolved, { cwd, files: resolvedFiles });
  const { healed } = healInjectJournal(cwd, { keep: plannedArtifacts.map((a) => a.path) });
 
  const gitIgnore = ensureLiveGitIgnores(cwd, frameworkIgnorePatterns(resolved));
  // In a nested-app repo the roots pointer lives at the REPO root, outside the
  // reach of the appRoot-relative ignore block above; give that directory its
  // own local excludes so the pointer (absolute host paths) never gets staged.
  try {
    const rootsManifest = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'live', 'roots.json'), 'utf-8'));
    if (rootsManifest?.repoRoot && path.resolve(rootsManifest.repoRoot) !== path.resolve(cwd)) {
      ensureLiveGitIgnores(rootsManifest.repoRoot);
    }
  } catch { /* no manifest: single-root project */ }
 
  if (isAdapter) {
    const adapterResult = resolved.framework.inject.apply({
      cwd,
      port,
      token,
      config,
      project: resolved.project,
    });
    const ok = !(adapterResult && adapterResult.error);
    if (ok) recordInjection(cwd, { framework: resolved.framework.name, port, artifacts: plannedArtifacts });
    console.log(JSON.stringify({
      ok,
      port,
      adapter: resolved.framework.name,
      gitIgnore,
      results: [adapterResult],
      healed: healed.length ? healed : undefined,
    }));
    if (!ok) process.exitCode = 1;
    return;
  }
 
  const results = resolvedFiles.map((relFile) => {
    const absFile = path.resolve(cwd, relFile);
    if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
    const content = fs.readFileSync(absFile, 'utf-8');
    const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
    // Per-file, not per-project: a Vite app can hold an .astro partial, and a
    // framework project's entry template is often plain HTML.
    const scriptAttrs = resolveSourceTraits(relFile).injectScriptAttrs;
    const withTag = insertTag(withoutOld, config, port, token, scriptAttrs);
    if (withTag === withoutOld) {
      return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
    }
    const updated = patchCspMeta(withTag, port);
    fs.writeFileSync(absFile, updated, 'utf-8');
    return {
      file: relFile,
      inserted: true,
      cspPatched: updated !== withTag,
    };
  });
  const anyInserted = results.some((r) => r.inserted);
  const writtenFiles = new Set(results.filter((r) => r.inserted).map((r) => r.file));
  recordInjection(cwd, {
    framework: resolved?.framework.name,
    port,
    artifacts: plannedArtifacts.filter((a) => writtenFiles.has(a.path)),
  });
  console.log(JSON.stringify({
    ok: anyInserted,
    port,
    gitIgnore,
    results,
    healed: healed.length ? healed : undefined,
  }));
  if (!anyInserted) process.exit(1);
}
 
export function ensureLiveGitIgnores(cwd = process.cwd(), extraPatterns = []) {
  const target = resolveIgnoreTarget(cwd);
  const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
  const block = [
    IGNORE_MARKER_OPEN,
    ...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns]),
    IGNORE_MARKER_CLOSE,
  ].join('\n');
  const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
 
  let updated;
  if (markerRe.test(existing)) {
    updated = existing.replace(markerRe, block);
  } else {
    const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';
    updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
  }
 
  if (updated !== existing) {
    fs.mkdirSync(path.dirname(target.path), { recursive: true });
    fs.writeFileSync(target.path, updated, 'utf-8');
  }
 
  return {
    file: path.relative(cwd, target.path).split(path.sep).join('/'),
    mode: target.mode,
    changed: updated !== existing,
    patterns: [...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns])],
  };
}
 
function resolveIgnoreTarget(cwd) {
  const gitExcludePath = resolveGitInfoExcludePath(cwd);
  if (gitExcludePath) {
    return { path: gitExcludePath, mode: 'git-info-exclude' };
  }
  return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
}
 
function resolveGitInfoExcludePath(cwd) {
  const dotGit = path.join(cwd, '.git');
  if (!fs.existsSync(dotGit)) return null;
 
  const stat = fs.statSync(dotGit);
  if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
  if (!stat.isFile()) return null;
 
  const body = fs.readFileSync(dotGit, 'utf-8').trim();
  const match = body.match(/^gitdir:\s*(.+)$/i);
  if (!match) return null;
  const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
  return path.join(gitDir, 'info', 'exclude');
}
 
function escapeRegExp(value) {
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
 
/**
 * Expand config.files (which may contain glob patterns) into a literal list
 * of existing file paths relative to rootDir. Literal entries pass through;
 * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
 * are applied as filters. Duplicates are removed. Order is preserved by
 * first appearance.
 */
export function resolveFiles(rootDir, config) {
  const patterns = config.files;
  const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
  const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
  const excludeRegexes = allExcludes.map(globToRegex);
 
  const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
  const isGlob = (s) => /[*?[]/.test(s);
 
  const seen = new Set();
  const out = [];
  for (const pat of patterns) {
    if (!isGlob(pat)) {
      // Literal path — include even if it doesn't exist yet; the caller
      // reports file_not_found per-entry. Exclude list doesn't apply to
      // explicit literal entries (user named it on purpose).
      if (!seen.has(pat)) {
        seen.add(pat);
        out.push(pat);
      }
      continue;
    }
    let matches;
    try {
      matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
    } catch {
      continue;
    }
    for (const ent of matches) {
      if (!ent.isFile || !ent.isFile()) continue;
      const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
      const rel = path.relative(rootDir, abs).split(path.sep).join('/');
      if (isExcluded(rel)) continue;
      if (seen.has(rel)) continue;
      seen.add(rel);
      out.push(rel);
    }
  }
  return out;
}
 
/**
 * Convert a glob pattern to a RegExp. Supports:
 *   **  → any number of path segments (including zero)
 *   *   → any chars except `/`
 *   ?   → any single char except `/`
 * Paths are normalized to forward slashes before matching.
 */
function globToRegex(pattern) {
  let re = '';
  let i = 0;
  while (i < pattern.length) {
    const c = pattern[i];
    if (c === '*') {
      if (pattern[i + 1] === '*') {
        // ** — any number of segments, including zero. Handle the common
        // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
        if (pattern[i + 2] === '/') {
          re += '(?:.*/)?';
          i += 3;
        } else {
          re += '.*';
          i += 2;
        }
      } else {
        re += '[^/]*';
        i += 1;
      }
    } else if (c === '?') {
      re += '[^/]';
      i += 1;
    } else if (/[.+^${}()|[\]\\]/.test(c)) {
      re += '\\' + c;
      i += 1;
    } else {
      re += c;
      i += 1;
    }
  }
  return new RegExp('^' + re + '$');
}
 
// ---------------------------------------------------------------------------
// Core operations
// ---------------------------------------------------------------------------
 
function validateConfig(cfg) {
  if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
  if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
    throw new Error('config.files (non-empty string array) required');
  }
  if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
    throw new Error('config.files must contain only non-empty strings');
  }
  if (cfg.exclude !== undefined) {
    if (!Array.isArray(cfg.exclude)) {
      throw new Error('config.exclude, if present, must be a string array');
    }
    if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
      throw new Error('config.exclude must contain only non-empty strings');
    }
  }
  if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
    throw new Error('config.insertBefore or config.insertAfter (string) required');
  }
  if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
    throw new Error("config.commentSyntax must be 'html' or 'jsx'");
  }
  if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
    throw new Error("config.cspChecked, if present, must be a boolean");
  }
}
 
// ---------------------------------------------------------------------------
// Auto-execute
// ---------------------------------------------------------------------------
 
const _running = process.argv[1];
if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
  enterLiveRoot();
  injectCli();
}
 
// Re-exported so long-standing importers (live.mjs, the adapter modules, the
// test suites) keep their entry points while the implementations live in
// live/frameworks/.
export {
  buildLiveScriptSrc,
  buildTagBlock,
  insertTag,
  patchCspMeta,
  removeTag,
  revertCspMeta,
  validateConfig,
};
export {
  applyNuxtLiveAdapter,
  buildNuxtPlugin,
  detectNuxtProject,
  removeNuxtLiveAdapter,
} from './live/frameworks/nuxt.mjs';