| 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 | |
| 20 | import { execSync } from 'node:child_process'; |
| 21 | import fs from 'node:fs'; |
| 22 | import path from 'node:path'; |
| 23 | import { fileURLToPath } from 'node:url'; |
| 24 | import { loadContext } from './context.mjs'; |
| 25 | import { resolveFiles } from './live-inject.mjs'; |
| 26 | import { readLiveServerInfo } from './impeccable-paths.mjs'; |
| 27 | |
| 28 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 29 | |
| 30 | async 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 | |
| 36 | Prepare 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 | |
| 42 | On success, prints a JSON blob with: |
| 43 | { ok, serverPort, serverToken, pageFile, hasContext, context } |
| 44 | |
| 45 | On config_missing, prints: |
| 46 | { ok: false, error: "config_missing", configPath, hint } |
| 47 | |
| 48 | The 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. |
| 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 | }, null, 2)); |
| 106 | } |
| 107 | |
| 108 | /** |
| 109 | * Drift-heal scan. Walks the project for HTML files under common |
| 110 | * page-source directories (public/, src/, app/, pages/) and reports any |
| 111 | * that aren't covered by the resolved inject targets. This is purely |
| 112 | * advisory — the agent can ignore it, or suggest the user add the |
| 113 | * orphans to config.files. |
| 114 | * |
| 115 | * Skipped if config.files already contains at least one glob pattern |
| 116 | * covering everything in practice (signaled by the orphan count being 0). |
| 117 | */ |
| 118 | function scanForDrift(rootDir, resolvedFiles, config) { |
| 119 | const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; |
| 120 | const IGNORE_DIRS = new Set([ |
| 121 | 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', |
| 122 | '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', |
| 123 | ]); |
| 124 | |
| 125 | const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); |
| 126 | |
| 127 | // Files matching the user's `exclude` globs are intentional omissions, |
| 128 | // not drift. Compile them to regexes so the orphan list stays signal. |
| 129 | const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) |
| 130 | .map((p) => globToRegex(p)); |
| 131 | const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); |
| 132 | |
| 133 | const orphans = []; |
| 134 | |
| 135 | const walk = (dir, relBase) => { |
| 136 | let entries; |
| 137 | try { entries = fs.readdirSync(dir, { withFileTypes: true }); } |
| 138 | catch { return; } |
| 139 | for (const e of entries) { |
| 140 | const rel = relBase ? `${relBase}/${e.name}` : e.name; |
| 141 | if (e.isDirectory()) { |
| 142 | if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; |
| 143 | walk(path.join(dir, e.name), rel); |
| 144 | } else if (e.isFile() && e.name.endsWith('.html')) { |
| 145 | if (resolvedSet.has(rel)) continue; |
| 146 | if (isUserExcluded(rel)) continue; |
| 147 | orphans.push(rel); |
| 148 | } |
| 149 | } |
| 150 | }; |
| 151 | |
| 152 | for (const root of SCAN_ROOTS) { |
| 153 | const abs = path.join(rootDir, root); |
| 154 | if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { |
| 155 | walk(abs, root); |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | if (orphans.length === 0) return null; |
| 160 | const capped = orphans.slice(0, 20); |
| 161 | return { |
| 162 | orphans: capped, |
| 163 | orphanCount: orphans.length, |
| 164 | hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, |
| 165 | }; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here |
| 170 | * to avoid a circular import (live-inject.mjs already imports nothing |
| 171 | * from live.mjs). The two must stay in sync. |
| 172 | */ |
| 173 | function globToRegex(pattern) { |
| 174 | let re = ''; |
| 175 | let i = 0; |
| 176 | while (i < pattern.length) { |
| 177 | const c = pattern[i]; |
| 178 | if (c === '*') { |
| 179 | if (pattern[i + 1] === '*') { |
| 180 | if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } |
| 181 | else { re += '.*'; i += 2; } |
| 182 | } else { |
| 183 | re += '[^/]*'; |
| 184 | i += 1; |
| 185 | } |
| 186 | } else if (c === '?') { |
| 187 | re += '[^/]'; |
| 188 | i += 1; |
| 189 | } else if (/[.+^${}()|[\]\\]/.test(c)) { |
| 190 | re += '\\' + c; |
| 191 | i += 1; |
| 192 | } else { |
| 193 | re += c; |
| 194 | i += 1; |
| 195 | } |
| 196 | } |
| 197 | return new RegExp('^' + re + '$'); |
| 198 | } |
| 199 | |
| 200 | // --------------------------------------------------------------------------- |
| 201 | // Helpers |
| 202 | // --------------------------------------------------------------------------- |
| 203 | |
| 204 | function runScript(name, args) { |
| 205 | const scriptPath = path.join(__dirname, name); |
| 206 | const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; |
| 207 | try { |
| 208 | return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); |
| 209 | } catch (err) { |
| 210 | // execSync throws on non-zero exit; return stdout if any |
| 211 | return err.stdout || err.message || ''; |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | function safeParse(out) { |
| 216 | try { return JSON.parse(String(out).trim()); } catch { return null; } |
| 217 | } |
| 218 | |
| 219 | /** |
| 220 | * Return { pid, port, token } for the running live server, starting one if needed. |
| 221 | */ |
| 222 | function ensureServerRunning() { |
| 223 | // Try to reuse an existing server |
| 224 | try { |
| 225 | const existing = readLiveServerInfo(process.cwd())?.info; |
| 226 | if (existing && existing.pid) { |
| 227 | try { |
| 228 | process.kill(existing.pid, 0); // throws if dead |
| 229 | return existing; |
| 230 | } catch { /* stale PID file — the server script will clean it up */ } |
| 231 | } |
| 232 | } catch { /* no PID file */ } |
| 233 | |
| 234 | // Start a new server |
| 235 | const out = runScript('live-server.mjs', ['--background']); |
| 236 | return safeParse(out); |
| 237 | } |
| 238 | |
| 239 | // --------------------------------------------------------------------------- |
| 240 | // Auto-execute |
| 241 | // --------------------------------------------------------------------------- |
| 242 | |
| 243 | const _running = process.argv[1]; |
| 244 | if (_running?.endsWith('live.mjs') || _running?.endsWith('live.mjs/')) { |
| 245 | liveCli(); |
| 246 | } |
| 247 |