Files
ycode/scripts/ops/dev.mjs
T

205 lines
16 KiB
JavaScript

import { spawn, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { createHash, randomUUID } from 'node:crypto';
import { readFile, writeFile, mkdir, access, open, rename } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const exec = promisify(execFile);
const here = path.dirname(fileURLToPath(import.meta.url));
import { redact } from './core.mjs';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const result = (status, code, summary, evidence = {}) => ({ status, code, summary, evidence });
const normalize = value => value.replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase();
export function sameIdentity(a, b) {
return !!a && !!b && a.pid === b.pid && !!a.created && a.created === b.created && !!a.commandLine && a.commandLine === b.commandLine;
}
export function ownedProcesses(snapshot, owner) {
const root = snapshot.processes.find(p => sameIdentity(p, owner));
if (!root) return [];
const owned = [root];
for (let changed = true; changed;) {
changed = false;
for (const p of snapshot.processes) {
if (!owned.some(x => x.pid === p.pid) && owned.some(x => x.pid === p.parentPid && Date.parse(p.created) >= Date.parse(x.created))) {
owned.push(p); changed = true;
}
}
}
return owned;
}
export function rendererMatches(commandLine, repoRoot) {
const match = commandLine.match(/(?:^|\s)--app-path=(?:"([^"]+)"|([^\s]+))/i);
return /(?:^|\s)--type=renderer(?:\s|$)/i.test(commandLine) && !!match && normalize(match[1] || match[2]) === normalize(repoRoot);
}
export function localUrls(log) {
return [...new Set([...log.replace(/\x1b\[[0-9;]*m/g, '').matchAll(/Local:\s+(http:\/\/(?:localhost|127\.0\.0\.1):\d+\/)/g)].map(m => m[1]))].reverse();
}
export async function moduleMatches(url, source, fetchImpl = fetch) {
const response = await fetchImpl(new URL('/src/components/ChatPane.tsx?raw', url), { signal: AbortSignal.timeout(3500), redirect: 'error' });
if (!response.ok) return false;
const text = await response.text();
const match = text.match(/^\s*export default\s+("(?:[^"\\]|\\.)*")\s*;?/s);
if (!match || JSON.parse(match[1]) !== source) return false;
const module = await fetchImpl(new URL('/src/components/ChatPane.tsx', url), { signal: AbortSignal.timeout(3500), redirect: 'error' });
const body = await module.text();
return module.ok && /(?:javascript|ecmascript)/i.test(module.headers.get('content-type') || '') && !/^\s*<!doctype html/i.test(body);
}
export function verifyAttestation(attestation, state, health, repoRoot, now = Date.now()) {
if (!attestation || !state.runId || !health.healthy) return false;
const checkedAt = attestation.checkedAt;
return attestation.ready === true && attestation.runId === state.runId &&
attestation.pid === health.windowPid && attestation.rendererPid === health.rendererPid &&
Number.isFinite(checkedAt) && checkedAt >= Date.parse(state.startedAt) && checkedAt <= now + 2000 && now - checkedAt <= 5000;
}
async function verifiedHealth(snapshot, state, repoRoot, source, log) {
const health = await inspectHealth(snapshot, state, repoRoot, source, log);
const attestation = state.readyPath ? await readJson(state.readyPath) : null;
const rendererReady = verifyAttestation(attestation, state, health, repoRoot);
return { ...health, processWindowHttpHealthy: health.healthy, healthy: health.healthy && rendererReady,
rendererReady, rendererUI: rendererReady ? 'live-visible-app-shell' : 'unverified',
verificationScope: rendererReady ? 'process-window-http-source-and-visible-renderer' : health.verificationScope };
}
async function powershell(request) {
return new Promise((resolve, reject) => {
const child = spawn('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-File', path.join(here, 'dev-processes.ps1')], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
const timer = setTimeout(() => { child.kill(); reject(new Error('PROCESS_QUERY_TIMEOUT')); }, 15000);
child.stdout.on('data', chunk => { stdout += chunk; if (stdout.length > 16_000_000) { child.kill(); reject(new Error('PROCESS_QUERY_TOO_LARGE')); } });
child.stderr.on('data', chunk => { stderr += chunk; });
child.on('error', error => { clearTimeout(timer); reject(error); });
child.on('close', code => { clearTimeout(timer); if (code !== 0) reject(new Error(`PROCESS_QUERY_FAILED: ${stderr.slice(0, 1000)}`)); else { try { resolve(JSON.parse(stdout.replace(/^\uFEFF/, ''))); } catch { reject(new Error('INVALID_PROCESS_SNAPSHOT')); } } });
child.stdin.end(JSON.stringify(request));
});
}
async function gitIdentity(repoRoot) {
const git = async args => (await exec('git', ['-C', repoRoot, ...args], { timeout: 15000, maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' })).stdout;
const sha = (await git(['rev-parse', 'HEAD'])).trim();
const status = await git(['status', '--porcelain=v1', '-z', '--untracked-files=all']);
const hash = createHash('sha256').update(sha).update(status).update(await git(['diff', 'HEAD', '--binary']));
const untracked = (await git(['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean);
for (const file of untracked) hash.update(file).update(await readFile(path.join(repoRoot, file)));
return { sha, dirty: !!status, fingerprint: hash.digest('hex') };
}
async function readJson(file) {
try { return JSON.parse(await readFile(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
}
async function save(file, value) {
const tmp = `${file}.${process.pid}.tmp`;
await writeFile(tmp, JSON.stringify(value, null, 2)); await rename(tmp, file);
}
function targetCandidates(snapshot, repoRoot) {
const root = normalize(repoRoot);
return snapshot.processes.filter(p => {
const cmd = normalize(p.commandLine || '');
return (cmd.includes(root + '\\') || rendererMatches(p.commandLine || '', repoRoot)) && /electron-vite|dev-host\.mjs|--type=renderer/i.test(cmd);
});
}
export async function inspectHealth(snapshot, state, repoRoot, source, log, fetchImpl = fetch) {
const owned = ownedProcesses(snapshot, state.owner);
const renderer = owned.find(p => rendererMatches(p.commandLine, repoRoot));
const window = snapshot.windows.find(w => w.responding && /ycode/i.test(w.title) && owned.some(p => p.pid === w.pid && /^electron\.exe$/i.test(p.name) && !/--type=/.test(p.commandLine)));
const ports = snapshot.listeners.filter(l => owned.some(p => p.pid === l.pid && /^node\.exe$/i.test(p.name) && /electron-vite/i.test(p.commandLine))).map(l => l.port);
const urls = [...new Set([...localUrls(log), ...(state.url && /^http:\/\/(?:localhost|127\.0\.0\.1):\d+\/$/.test(state.url) ? [state.url] : [])])];
for (const url of urls) {
if (!ports.includes(Number(new URL(url).port))) continue;
if (renderer && window && await moduleMatches(url, source, fetchImpl).catch(() => false)) return { healthy: true, ownerAlive: true, url, port: Number(new URL(url).port), windowPid: window.pid, rendererPid: renderer.pid, ownerPid: state.owner.pid, verificationScope: 'process-window-and-http-source', rendererUI: 'unverified' };
}
return { healthy: false, ownerAlive: owned.length > 0, renderer: !!renderer, responsiveWindow: !!window, listeningPorts: ports, discoveredUrls: urls };
}
export async function runDev(action, options = {}) {
const repoRoot = path.resolve(options.repoRoot || options.root || process.cwd());
if (process.platform !== 'win32') return result('UNSUPPORTED', 'WINDOWS_REQUIRED', 'Native dev requires Windows; no GUI readiness is claimed.', { repoRoot, platform: process.platform });
if (!['dev', 'status', 'restart', 'stop'].includes(action)) return result('BLOCKED', 'INVALID_ACTION', 'Use dev, status, restart or stop.');
const stateDir = path.join(repoRoot, '.tmp', 'ops');
const stateFile = path.join(stateDir, 'dev-state.json');
const timeoutMs = Math.min(180000, Math.max(5000, options.timeoutMs || 90000));
let state;
let spawnedPid;
let launchPersisted = false;
try {
const unsafe = [...new Set(['YCODE_USER_DATA_DIR', 'ELECTRON_RUN_AS_NODE', 'ELECTRON_NO_ATTACH_CONSOLE', 'NODE_OPTIONS', ...Object.keys(process.env).filter(key => key.startsWith('YCODE_QA_'))])].filter(key => process.env[key]);
if (unsafe.length && action !== 'stop') return result('BLOCKED', 'UNSAFE_PREVIEW_ENV', 'Inherited test/profile environment must be reviewed before normal user preview.', { variables: unsafe });
const identity = await gitIdentity(repoRoot);
state = await readJson(stateFile);
if (state && (state.schema !== 1 || normalize(state.repoRoot) !== normalize(repoRoot))) return result('BLOCKED', 'INVALID_STATE', 'Dev state belongs to a different repository or schema; inspect it before continuing.', { stateFile });
if (state && (!state.owner || !state.identity || !state.logPath ||
path.dirname(path.resolve(state.logPath)) !== stateDir ||
(state.readyPath && path.resolve(state.readyPath) !== path.join(stateDir, 'renderer-health.json')))) {
return result('BLOCKED', 'INVALID_STATE', 'Dev state has invalid identity or log/health paths; inspect it before continuing.', { stateFile });
}
let snapshot = await powershell({ action: 'snapshot' });
const owned = state ? ownedProcesses(snapshot, state.owner) : [];
const candidates = targetCandidates(snapshot, repoRoot);
const unknown = candidates.filter(p => !owned.some(o => o.pid === p.pid));
if (unknown.length) return result('BLOCKED', 'UNTRACKED_DEV', 'Untracked target dev detected. Its version/ownership cannot be safely adopted; inspect it manually. No process was stopped.', { repoRoot, pids: unknown.map(p => p.pid), identity });
if (action === 'stop') {
if (!owned.length) return result('PASS', 'DEV_STOPPED', 'No tracked target dev is running; no process was stopped.', { identity, stateFile });
await powershell({ action: 'stop', processes: [...owned].reverse() });
await sleep(500);
snapshot = await powershell({ action: 'snapshot' });
const survivors = snapshot.processes.filter(p => owned.some(o => sameIdentity(p, o)));
if (survivors.length || targetCandidates(snapshot, repoRoot).length) return result('BLOCKED', 'STOP_INCOMPLETE', 'Target processes remain after identity-checked stop. Inspect before another mutation.', { pids: survivors.map(p => p.pid), stateFile });
return result('PASS', 'DEV_STOPPED', 'Only the verified target dev process tree was stopped; user data was preserved.', { identity, stoppedPids: owned.map(p => p.pid), stateFile });
}
const source = await readFile(path.join(repoRoot, 'src/renderer/src/components/ChatPane.tsx'), 'utf8');
if (owned.length) {
const log = await readFile(state.logPath, 'utf8').catch(() => '');
const health = await verifiedHealth(snapshot, state, repoRoot, source, log);
const matching = identity.fingerprint === state.identity.fingerprint;
if (action !== 'restart') {
if (!matching) return result('BLOCKED', 'VERSION_CHANGED', 'Running dev was started from different source contents. Use restart-dev to restart only its verified process tree.', { identity, startedIdentity: state.identity, health, stateFile });
if (health.healthy) {
const current = await gitIdentity(repoRoot);
if (current.fingerprint !== identity.fingerprint) return result('BLOCKED', 'VERSION_CHANGED_DURING_CHECK', 'Source changed while checking the existing dev; retry status or restart.', { identity, current, stateFile });
return result('PASS', 'DEV_READY_REUSED', 'Existing target dev is healthy and reused.', { ...health, identity, logPath: state.logPath, stateFile });
}
return result('BLOCKED', 'DEV_NOT_READY', 'Target dev exists but is not fully healthy. Inspect its log or explicitly restart-dev; no duplicate was launched.', { identity, health, logPath: state.logPath, stateFile });
}
// Stop only the captured tree; PowerShell rechecks creation time and full command line per handle.
await powershell({ action: 'stop', processes: [...owned].reverse() });
await sleep(500);
snapshot = await powershell({ action: 'snapshot' });
if (targetCandidates(snapshot, repoRoot).length || ownedProcesses(snapshot, state.owner).length) return result('BLOCKED', 'STOP_INCOMPLETE', 'Target processes remain after identity-checked stop; no new dev was launched.', { stateFile });
} else if (action === 'status') return result('BLOCKED', 'DEV_NOT_RUNNING', 'No tracked target dev is running.', { identity, stateFile });
try { await access(path.join(repoRoot, 'node_modules/.bin/electron-vite.cmd')); await access(path.join(repoRoot, 'node_modules/electron/dist/electron.exe')); }
catch { return result('BLOCKED', 'DEPENDENCIES_MISSING', 'Missing Windows dev dependencies. Run explicit prepare, then retry dev; dependencies are never reinstalled on each launch.', { nextCommand: 'npm ci --legacy-peer-deps --no-audit --no-fund', electronRepair: 'If Electron alone is absent after install: node node_modules/electron/install.js' }); }
const pkg = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8'));
if (pkg.scripts?.dev !== 'electron-vite dev' || pkg.scripts?.predev || pkg.scripts?.postdev) return result('BLOCKED', 'DEV_SCRIPT_CHANGED', 'Expected npm run dev to be exactly electron-vite dev with no predev/postdev hooks; review changed scripts.');
await mkdir(stateDir, { recursive: true });
const stamp = `${Date.now()}-${process.pid}`;
const logPath = path.join(stateDir, `dev-${stamp}.log`);
const exitPath = path.join(stateDir, `dev-${stamp}-exit.json`);
const runId = randomUUID();
const readyPath = path.join(stateDir, 'renderer-health.json');
const log = await open(logPath, 'a');
const host = spawn(process.execPath, [path.join(here, 'dev-host.mjs'), repoRoot, exitPath], { cwd: repoRoot, detached: true, windowsHide: true, stdio: ['ignore', log.fd, log.fd], env: { ...process.env, YCODE_SKIP_KERNEL_DATA_RESET: '1', YCODE_OPS_RUN_ID: runId } });
await new Promise((resolve, reject) => { host.once('spawn', resolve); host.once('error', reject); });
spawnedPid = host.pid;
host.unref(); await log.close();
snapshot = await powershell({ action: 'snapshot' });
const owner = snapshot.processes.find(p => p.pid === host.pid && p.commandLine.includes(path.join(here, 'dev-host.mjs')) && p.commandLine.includes(exitPath));
if (!owner) return result('BLOCKED', 'START_IDENTITY_UNCONFIRMED', 'Launch owner could not be captured. Inspect logs/processes before retry; do not start another instance.', { logPath, exitPath, pid: host.pid, possibleOrphans: true });
state = { schema: 1, repoRoot, identity, owner, logPath, exitPath, runId, readyPath, startedAt: new Date().toISOString() };
await save(stateFile, state);
launchPersisted = true;
const deadline = Date.now() + timeoutMs;
let health;
do {
snapshot = await powershell({ action: 'snapshot' });
health = await verifiedHealth(snapshot, state, repoRoot, source, await readFile(logPath, 'utf8'));
if (health.healthy) {
const current = await gitIdentity(repoRoot);
if (current.fingerprint !== identity.fingerprint) return result('BLOCKED', 'VERSION_CHANGED_DURING_START', 'Source changed during launch; restart to verify the exact version.', { identity, current, logPath, stateFile });
state.url = health.url; state.verifiedAt = new Date().toISOString(); await save(stateFile, state);
return result('PASS', 'DEV_READY', 'Target dev started; owned window responds, renderer shell is visible, and served module matches this repository.', { ...health, identity, logPath, stateFile });
}
const exit = await readJson(exitPath);
if (exit || !health.ownerAlive) return result('FAIL', 'DEV_EXITED', 'Dev exited before full readiness. Inspect the startup log; no automatic reinstall or relaunch was attempted.', { exit, health, identity, logPath, stateFile });
if (Date.now() < deadline) await sleep(1000);
} while (Date.now() < deadline);
return result('BLOCKED', 'DEV_READY_TIMEOUT', 'Readiness deadline reached. The single tracked dev is retained for diagnosis; no GUI success is claimed.', { health, identity, logPath, stateFile });
} catch (error) {
return result('FAIL', 'DEV_CHECK_FAILED', redact(error.message), { repoRoot, stateFile, ...(spawnedPid && !launchPersisted ? { possibleOrphans: true, spawnedPid } : {}), ...(state?.logPath ? { logPath: state.logPath } : {}) });
}
}