mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* Fix: use argv exec and single-quote escaping for the four #476 shell-injection sites JSON.stringify and raw double-quote interpolation were used as shell quoting, but /bin/sh still expands $(...), backticks, and ${} inside double quotes. - is-generated.mjs / live.mjs runScript: switch execSync string commands to execFileSync argv form, which never invokes a shell. Closes the remote path where a source file named `$(...)` executes during the live-mode walk. - skills.mjs hook command + hook-lib.mjs ignore-value suggestion: values that must stay shell strings now use POSIX single-quote escaping instead of JSON/double quotes. The doctor's hook-token parser learns the single-quoted absolute form so it keeps verifying user-level installs. Adds regression tests for the single-quoted absolute hook form and the single-quoted ignore-value suggestion. Verified end to end in a browser through a real live-mode wrap walk against a hostile-named source file. Prepared with AI assistance (Cursor) under maintainer instruction. Co-authored-by: Cursor <cursoragent@cursor.com> * Test: lock in POSIX single-quoting for a $(...) absolute install path (#476) Follow-up from security review: prove an install path embedding $(...) is single-quoted in the written hook manifest, not double-quoted. Prepared with AI assistance (Cursor) under maintainer instruction. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix: quote ignore-command args per platform so Windows cmd.exe keeps spaces (#533) Greptile flagged that switching quoteCommandArg to POSIX single quotes fixed $(...) injection on /bin/sh but regressed Windows cmd.exe, where single quotes are literal, so a --file path containing spaces was split and the ignore scope was stored malformed. The suggested command runs on the same machine the hook fired on, so branch on process.platform (the pattern skills.mjs already uses): single-quote on POSIX for the #476 fix, and keep the original double-quote escaping on Windows so that path's behavior is unchanged. Adds a regression test asserting both forms. Prepared with AI assistance (Cursor) under maintainer instruction. Co-authored-by: Cursor <cursoragent@cursor.com> * Test: prove the POSIX hook guard is inert under /bin/sh and Windows keeps double quotes (#533) Greptile's probe could not reach the generated manifest, leaving the hook command contract unverified. Convert that into committed proof: - POSIX: install with a $(touch pwned) absolute path, then actually execute the generated guard under /bin/sh from a clean cwd and assert no marker file appears and the guard exits 0 (single-quoted substitution stays inert). - Windows: drive copyProviderHooks as win32 in-process and assert the command keeps the double-quoted absolute path (usable when the install path has spaces; $(...) is inert on cmd.exe anyway). Test-only; source quoting is unchanged. Prepared with AI assistance (Cursor) under maintainer instruction. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
366 lines
13 KiB
JavaScript
366 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 { execFileSync } 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);
|
|
try {
|
|
// argv form, never a shell: string interpolation into double quotes would
|
|
// let a `"` or `$(...)` in any future caller's arg escape into the shell
|
|
// (issue #476).
|
|
return execFileSync(process.execPath, [scriptPath, ...args], {
|
|
encoding: 'utf-8',
|
|
cwd: options.cwd || process.cwd(),
|
|
timeout: 15_000,
|
|
});
|
|
} catch (err) {
|
|
// execFileSync 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();
|
|
}
|