360 lines
13 KiB
JavaScript
360 lines
13 KiB
JavaScript
/**
|
|
* CLI entry point: prepare everything needed to enter the live variant poll loop.
|
|
*
|
|
* Does (all in one command):
|
|
* 1. Check .impeccable/live/config.json (returns config_missing if first-ever run)
|
|
* 2. Start the live server in the background (or reuse a running one)
|
|
* 3. Inject the browser script tag into the project's entry file
|
|
* 4. Read PRODUCT.md / DESIGN.md for project context
|
|
* 5. Print a single JSON blob with everything the agent needs
|
|
*
|
|
* After this, the agent's only remaining steps are:
|
|
* - 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
|
|
* - Enter the harness-native poll loop: `node live-poll.mjs`
|
|
*
|
|
* Usage:
|
|
* node live.mjs # Prepare everything, print JSON, exit
|
|
* node live.mjs --help
|
|
*/
|
|
|
|
import { execSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { resolveTargetSelection } from './context.mjs';
|
|
import { resolveFiles } from './live-inject.mjs';
|
|
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
|
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
|
|
import { resolveLiveTarget } from './live-target.mjs';
|
|
import { bootInstructions } from './live/instructions.mjs';
|
|
import { resolveRoots, writeRootsManifest } from './live/roots.mjs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
async function liveCli() {
|
|
const args = process.argv.slice(2);
|
|
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
|
|
|
if (args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage: node live.mjs
|
|
|
|
Prepare everything for live variant mode in a single command:
|
|
- Checks .impeccable/live/config.json (required, created once per project)
|
|
- Starts (or reuses) the live server in the background
|
|
- Injects the browser script tag
|
|
- Reads PRODUCT.md / DESIGN.md for project context
|
|
- Prepares the harness-native foreground/background poll loop
|
|
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
|
|
|
On success, prints a JSON blob with:
|
|
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
|
|
|
On target_selection_required, prints:
|
|
{ ok: false, error: "target_selection_required", targetCandidates }
|
|
|
|
On config_missing, prints:
|
|
{ ok: false, error: "config_missing", configPath, hint }
|
|
|
|
The agent should then:
|
|
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
|
2. If config_missing, create the config and re-run this script
|
|
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
|
4. Enter the poll loop: node live-poll.mjs`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// Legacy workspace-monorepo selection first: it carries richer candidate
|
|
// metadata (context inheritance status) than the roots scan.
|
|
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
|
if (targetSelection) {
|
|
console.log(JSON.stringify({
|
|
ok: false,
|
|
error: 'target_selection_required',
|
|
...targetSelection,
|
|
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
|
}, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
const rootsResult = resolveRoots({
|
|
cwd: liveTarget.originalCwd,
|
|
targetPath: liveTarget.absoluteTargetPath,
|
|
});
|
|
if (rootsResult.selection) {
|
|
console.log(JSON.stringify({
|
|
ok: false,
|
|
error: 'target_selection_required',
|
|
targetCandidates: rootsResult.selection.candidates,
|
|
hint: 'Several apps with a dev-server config exist. Ask the user which one to use, then rerun with --target <path into that app>.',
|
|
}, null, 2));
|
|
process.exit(0);
|
|
}
|
|
const roots = rootsResult.manifest;
|
|
const activeCwd = roots.appRoot;
|
|
const outputTargetPath = liveTarget.targetPath || null;
|
|
|
|
// Gate on readable CONTENT, not path existence, so an empty or unreadable
|
|
// PRODUCT.md routes to init instead of passing the gate and then reporting
|
|
// hasProduct: false in the same payload.
|
|
const product = safeRead(roots.productPath);
|
|
const design = safeRead(roots.designPath);
|
|
const missingContext = [];
|
|
if (!product) missingContext.push('PRODUCT.md');
|
|
if (!design) missingContext.push('DESIGN.md');
|
|
if (missingContext.length > 0) {
|
|
console.log(JSON.stringify({
|
|
ok: false,
|
|
error: 'context_missing',
|
|
missing: missingContext,
|
|
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
|
targetPath: outputTargetPath,
|
|
projectRoot: roots.appRoot,
|
|
repoRoot: roots.repoRoot,
|
|
productPath: relOrNull(liveTarget.originalCwd, roots.productPath),
|
|
designPath: relOrNull(liveTarget.originalCwd, roots.designPath),
|
|
}, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
// Persist the decision before anything else spawns, so every helper the
|
|
// agent runs later (from any cwd inside the repo) lands on the same roots.
|
|
writeRootsManifest(roots);
|
|
|
|
// 1. Check config (fail fast if missing — no point starting anything else)
|
|
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
|
const checkResult = safeParse(checkOut);
|
|
if (!checkResult || !checkResult.ok) {
|
|
console.log(JSON.stringify({
|
|
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
|
targetPath: outputTargetPath,
|
|
projectRoot: roots.appRoot,
|
|
repoRoot: roots.repoRoot,
|
|
}));
|
|
process.exit(0);
|
|
}
|
|
|
|
// 2. Start server (or reuse existing)
|
|
const serverInfo = ensureServerRunning(activeCwd);
|
|
if (!serverInfo) {
|
|
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
|
process.exit(1);
|
|
}
|
|
|
|
// 3. Inject the script tag at the current port
|
|
const injectOut = runScript(
|
|
'live-inject.mjs',
|
|
['--port', String(serverInfo.port), '--token', String(serverInfo.token)],
|
|
{ cwd: activeCwd },
|
|
);
|
|
const injectResult = safeParse(injectOut);
|
|
if (!injectResult || !injectResult.ok) {
|
|
console.log(JSON.stringify({
|
|
ok: false,
|
|
error: 'inject_failed',
|
|
detail: injectResult || injectOut,
|
|
serverPort: serverInfo.port,
|
|
}));
|
|
process.exit(1);
|
|
}
|
|
|
|
// 4. Compute drift-heal: compare resolved inject targets against the
|
|
// project's HTML files. Orphans are HTML files not covered by config.
|
|
// Warning only — the agent decides whether to act.
|
|
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
|
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
|
|
|
// 5. Emit everything the agent needs. The surface brief rides along so the
|
|
// agent does not spend three more tool calls (and a --help miss) on
|
|
// surface-brief.mjs before the first poll.
|
|
let surfaceBrief = null;
|
|
let surfaceBriefPath = null;
|
|
try {
|
|
// Briefs live under .impeccable/surfaces, which in a nested-app repo sits
|
|
// at the CONTEXT or repo root, not the app root; context.mjs already finds
|
|
// them there, and live must not report "no brief" for the same project.
|
|
const briefRoots = [roots.appRoot, roots.contextRoot, roots.repoRoot]
|
|
.filter(Boolean)
|
|
.filter((dir, i, arr) => arr.findIndex((other) => path.resolve(other) === path.resolve(dir)) === i);
|
|
for (const briefRoot of briefRoots) {
|
|
const resolvedBrief = resolveSurfaceBrief(briefRoot, liveTarget.absoluteTargetPath || null);
|
|
if (!resolvedBrief?.brief) continue;
|
|
surfaceBrief = resolvedBrief.brief.text ?? safeRead(resolvedBrief.brief.path);
|
|
surfaceBriefPath = resolvedBrief.brief.path
|
|
? path.relative(liveTarget.originalCwd, resolvedBrief.brief.path)
|
|
: null;
|
|
break;
|
|
}
|
|
} catch { /* briefs are optional context */ }
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
serverPort: serverInfo.port,
|
|
serverToken: serverInfo.token,
|
|
pageFiles: resolvedFiles,
|
|
liveConfigPath: checkResult.path,
|
|
configDrift: drift,
|
|
targetPath: outputTargetPath,
|
|
projectRoot: roots.appRoot,
|
|
repoRoot: roots.repoRoot,
|
|
roots,
|
|
hasProduct: !!product,
|
|
product,
|
|
productPath: relOrNull(liveTarget.originalCwd, roots.productPath),
|
|
hasDesign: !!design,
|
|
design,
|
|
designPath: relOrNull(liveTarget.originalCwd, roots.designPath),
|
|
hasSurfaceBrief: !!surfaceBrief,
|
|
surfaceBrief,
|
|
surfaceBriefPath,
|
|
_instructions: bootInstructions({ scriptsPath: __dirname }),
|
|
}, null, 2));
|
|
}
|
|
|
|
function safeRead(p) {
|
|
if (!p) return null;
|
|
try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
|
|
}
|
|
|
|
function relOrNull(base, p) {
|
|
return p ? path.relative(base, p) : null;
|
|
}
|
|
|
|
/**
|
|
* Drift-heal scan. Walks the project for HTML files under common
|
|
* page-source directories (public/, src/, app/, pages/) and reports any
|
|
* that aren't covered by the resolved inject targets. This is purely
|
|
* advisory — the agent can ignore it, or suggest the user add the
|
|
* orphans to config.files.
|
|
*
|
|
* Skipped if config.files already contains at least one glob pattern
|
|
* covering everything in practice (signaled by the orphan count being 0).
|
|
*/
|
|
function scanForDrift(rootDir, resolvedFiles, config) {
|
|
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
|
const IGNORE_DIRS = new Set([
|
|
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
|
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
|
]);
|
|
|
|
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
|
|
|
// Files matching the user's `exclude` globs are intentional omissions,
|
|
// not drift. Compile them to regexes so the orphan list stays signal.
|
|
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
|
.map((p) => globToRegex(p));
|
|
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
|
|
|
const orphans = [];
|
|
|
|
const walk = (dir, relBase) => {
|
|
let entries;
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
catch { return; }
|
|
for (const e of entries) {
|
|
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
|
if (e.isDirectory()) {
|
|
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
|
walk(path.join(dir, e.name), rel);
|
|
} else if (e.isFile() && e.name.endsWith('.html')) {
|
|
if (resolvedSet.has(rel)) continue;
|
|
if (isUserExcluded(rel)) continue;
|
|
orphans.push(rel);
|
|
}
|
|
}
|
|
};
|
|
|
|
for (const root of SCAN_ROOTS) {
|
|
const abs = path.join(rootDir, root);
|
|
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
walk(abs, root);
|
|
}
|
|
}
|
|
|
|
if (orphans.length === 0) return null;
|
|
const capped = orphans.slice(0, 20);
|
|
return {
|
|
orphans: capped,
|
|
orphanCount: orphans.length,
|
|
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
|
* to avoid a circular import (live-inject.mjs already imports nothing
|
|
* from live.mjs). The two must stay in sync.
|
|
*/
|
|
function globToRegex(pattern) {
|
|
let re = '';
|
|
let i = 0;
|
|
while (i < pattern.length) {
|
|
const c = pattern[i];
|
|
if (c === '*') {
|
|
if (pattern[i + 1] === '*') {
|
|
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 + '$');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function runScript(name, args, options = {}) {
|
|
const scriptPath = path.join(__dirname, name);
|
|
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
|
try {
|
|
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
|
} catch (err) {
|
|
// execSync throws on non-zero exit; return stdout if any
|
|
return err.stdout || err.message || '';
|
|
}
|
|
}
|
|
|
|
function safeParse(out) {
|
|
try { return JSON.parse(String(out).trim()); } catch { return null; }
|
|
}
|
|
|
|
/**
|
|
* Return { pid, port, token } for the running live server, starting one if needed.
|
|
*/
|
|
function ensureServerRunning(cwd = process.cwd()) {
|
|
// Try to reuse an existing server
|
|
try {
|
|
const existing = readLiveServerInfo(cwd)?.info;
|
|
if (existing && existing.pid) {
|
|
try {
|
|
process.kill(existing.pid, 0); // throws if dead
|
|
return existing;
|
|
} catch { /* stale PID file — the server script will clean it up */ }
|
|
}
|
|
} catch { /* no PID file */ }
|
|
|
|
// Start a new server
|
|
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
|
return safeParse(out);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto-execute
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const _running = process.argv[1];
|
|
if (_running?.endsWith('live.mjs') || _running?.endsWith('live.mjs/')) {
|
|
liveCli();
|
|
}
|