live.mjs

  1/**
  2 * CLI entry point: prepare everything needed to enter the live variant poll loop.
  3 *
  4 * Does (all in one command):
  5 *   1. Check .impeccable/live/config.json (returns config_missing if first-ever run)
  6 *   2. Start the live server in the background (or reuse a running one)
  7 *   3. Inject the browser script tag into the project's entry file
  8 *   4. Read PRODUCT.md / DESIGN.md for project context
  9 *   5. Print a single JSON blob with everything the agent needs
 10 *
 11 * After this, the agent's only remaining steps are:
 12 *   - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll
 13 *   - Enter the poll loop: `node live-poll.mjs`
 14 *
 15 * Usage:
 16 *   node live.mjs                   # Prepare everything, print JSON, exit
 17 *   node live.mjs --help
 18 */
 19
 20import { execSync } from 'node:child_process';
 21import fs from 'node:fs';
 22import path from 'node:path';
 23import { fileURLToPath } from 'node:url';
 24import { loadContext } from './load-context.mjs';
 25import { resolveFiles } from './live-inject.mjs';
 26import { readLiveServerInfo } from './impeccable-paths.mjs';
 27
 28const __dirname = path.dirname(fileURLToPath(import.meta.url));
 29
 30async function liveCli() {
 31  const args = process.argv.slice(2);
 32
 33  if (args.includes('--help') || args.includes('-h')) {
 34    console.log(`Usage: node live.mjs
 35
 36Prepare everything for live variant mode in a single command:
 37  - Checks .impeccable/live/config.json (required, created once per project)
 38  - Starts (or reuses) the live server in the background
 39  - Injects the browser script tag
 40  - Reads PRODUCT.md / DESIGN.md for project context
 41
 42On success, prints a JSON blob with:
 43  { ok, serverPort, serverToken, pageFile, hasContext, context }
 44
 45On config_missing, prints:
 46  { ok: false, error: "config_missing", configPath, hint }
 47
 48The agent should then:
 49  1. If config_missing, create the config and re-run this script
 50  2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
 51  3. Enter the poll loop: node live-poll.mjs`);
 52    process.exit(0);
 53  }
 54
 55  // 1. Check config (fail fast if missing — no point starting anything else)
 56  const checkOut = runScript('live-inject.mjs', ['--check']);
 57  const checkResult = safeParse(checkOut);
 58  if (!checkResult || !checkResult.ok) {
 59    console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
 60    process.exit(0);
 61  }
 62
 63  // 2. Start server (or reuse existing)
 64  const serverInfo = ensureServerRunning();
 65  if (!serverInfo) {
 66    console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
 67    process.exit(1);
 68  }
 69
 70  // 3. Inject the script tag at the current port
 71  const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
 72  const injectResult = safeParse(injectOut);
 73  if (!injectResult || !injectResult.ok) {
 74    console.log(JSON.stringify({
 75      ok: false,
 76      error: 'inject_failed',
 77      detail: injectResult || injectOut,
 78      serverPort: serverInfo.port,
 79    }));
 80    process.exit(1);
 81  }
 82
 83  // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
 84  const ctx = loadContext(process.cwd());
 85
 86  // 5. Compute drift-heal: compare resolved inject targets against the
 87  //    project's HTML files. Orphans are HTML files not covered by config.
 88  //    Warning only — the agent decides whether to act.
 89  const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
 90  const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
 91
 92  // 6. Emit everything the agent needs
 93  console.log(JSON.stringify({
 94    ok: true,
 95    serverPort: serverInfo.port,
 96    serverToken: serverInfo.token,
 97    pageFiles: resolvedFiles,
 98    configDrift: drift,
 99    hasProduct: ctx.hasProduct,
100    product: ctx.product,
101    productPath: ctx.productPath,
102    hasDesign: ctx.hasDesign,
103    design: ctx.design,
104    designPath: ctx.designPath,
105    migrated: ctx.migrated,
106  }, null, 2));
107}
108
109/**
110 * Drift-heal scan. Walks the project for HTML files under common
111 * page-source directories (public/, src/, app/, pages/) and reports any
112 * that aren't covered by the resolved inject targets. This is purely
113 * advisory — the agent can ignore it, or suggest the user add the
114 * orphans to config.files.
115 *
116 * Skipped if config.files already contains at least one glob pattern
117 * covering everything in practice (signaled by the orphan count being 0).
118 */
119function scanForDrift(rootDir, resolvedFiles, config) {
120  const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
121  const IGNORE_DIRS = new Set([
122    'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
123    '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
124  ]);
125
126  const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
127
128  // Files matching the user's `exclude` globs are intentional omissions,
129  // not drift. Compile them to regexes so the orphan list stays signal.
130  const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
131    .map((p) => globToRegex(p));
132  const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
133
134  const orphans = [];
135
136  const walk = (dir, relBase) => {
137    let entries;
138    try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
139    catch { return; }
140    for (const e of entries) {
141      const rel = relBase ? `${relBase}/${e.name}` : e.name;
142      if (e.isDirectory()) {
143        if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
144        walk(path.join(dir, e.name), rel);
145      } else if (e.isFile() && e.name.endsWith('.html')) {
146        if (resolvedSet.has(rel)) continue;
147        if (isUserExcluded(rel)) continue;
148        orphans.push(rel);
149      }
150    }
151  };
152
153  for (const root of SCAN_ROOTS) {
154    const abs = path.join(rootDir, root);
155    if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
156      walk(abs, root);
157    }
158  }
159
160  if (orphans.length === 0) return null;
161  const capped = orphans.slice(0, 20);
162  return {
163    orphans: capped,
164    orphanCount: orphans.length,
165    hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
166  };
167}
168
169/**
170 * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
171 * to avoid a circular import (live-inject.mjs already imports nothing
172 * from live.mjs). The two must stay in sync.
173 */
174function globToRegex(pattern) {
175  let re = '';
176  let i = 0;
177  while (i < pattern.length) {
178    const c = pattern[i];
179    if (c === '*') {
180      if (pattern[i + 1] === '*') {
181        if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
182        else { re += '.*'; i += 2; }
183      } else {
184        re += '[^/]*';
185        i += 1;
186      }
187    } else if (c === '?') {
188      re += '[^/]';
189      i += 1;
190    } else if (/[.+^${}()|[\]\\]/.test(c)) {
191      re += '\\' + c;
192      i += 1;
193    } else {
194      re += c;
195      i += 1;
196    }
197  }
198  return new RegExp('^' + re + '$');
199}
200
201// ---------------------------------------------------------------------------
202// Helpers
203// ---------------------------------------------------------------------------
204
205function runScript(name, args) {
206  const scriptPath = path.join(__dirname, name);
207  const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
208  try {
209    return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
210  } catch (err) {
211    // execSync throws on non-zero exit; return stdout if any
212    return err.stdout || err.message || '';
213  }
214}
215
216function safeParse(out) {
217  try { return JSON.parse(String(out).trim()); } catch { return null; }
218}
219
220/**
221 * Return { pid, port, token } for the running live server, starting one if needed.
222 */
223function ensureServerRunning() {
224  // Try to reuse an existing server
225  try {
226    const existing = readLiveServerInfo(process.cwd())?.info;
227    if (existing && existing.pid) {
228      try {
229        process.kill(existing.pid, 0); // throws if dead
230        return existing;
231      } catch { /* stale PID file — the server script will clean it up */ }
232    }
233  } catch { /* no PID file */ }
234
235  // Start a new server
236  const out = runScript('live-server.mjs', ['--background']);
237  return safeParse(out);
238}
239
240// ---------------------------------------------------------------------------
241// Auto-execute
242// ---------------------------------------------------------------------------
243
244const _running = process.argv[1];
245if (_running?.endsWith('live.mjs') || _running?.endsWith('live.mjs/')) {
246  liveCli();
247}