返回 AiToEarn
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
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
247 lines Plain Text