import { SERVICE_NAME } from '../src/platform-identity.js'; import { execSync, spawnSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { AGENT_INTERNAL_DOMAIN, PLATFORM_INTERNAL_BASE, PLATFORM_PUBLIC_BASE, } from '../src/config.js'; import { formatDisplayDate } from '../src/display-date.js'; import { logger } from '../src/logger.js'; import { emitStatus } from './status.js'; interface PreflightArgs { withOnboarding: boolean; capturePasswordStep: boolean; failFast: boolean; } interface StepDefinition { id: string; label: string; command: string; args: string[]; interactive?: boolean; requiresRoot?: boolean; /** If true, a non-zero exit is logged as 'warning' and does not fail the overall run. */ softFail?: boolean; } interface StepResult { id: string; label: string; commandLine: string; exitCode: number; status: 'success' | 'failed' | 'skipped' | 'warning'; startedAt: string; finishedAt: string; logFile: string; fields: Record; } interface PasswordCaptureResult { enabled: boolean; captured: boolean; published: boolean; captureDir: string; publishDir: string; logFile: string; error?: string; } export function getPreflightTmuxSessionName(): string { return SERVICE_NAME; } function parseArgs(args: string[]): PreflightArgs { const result: PreflightArgs = { withOnboarding: false, capturePasswordStep: false, failFast: false, }; for (const arg of args) { if (arg === '--with-onboarding') result.withOnboarding = true; if (arg === '--capture-password-step') result.capturePasswordStep = true; if (arg === '--fail-fast') result.failFast = true; } if (result.capturePasswordStep) { result.withOnboarding = true; } return result; } function formatDisplayTimestamp(date: Date): string { return formatDisplayDate(date, { includeTime: true, includeSeconds: true }); } function formatRunStamp(date: Date): string { const pad = (value: number) => String(value).padStart(2, '0'); return [ date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate()), '-', pad(date.getHours()), pad(date.getMinutes()), pad(date.getSeconds()), ].join(''); } function quoteShell(arg: string): string { if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(arg)) { return arg; } return JSON.stringify(arg); } function parseStatusFields(output: string): Record { const matches = output.match(/=== CLAWDIE SETUP: [\s\S]*?=== END ===/gu) || []; const block = matches.at(-1); if (!block) { return {}; } const fields: Record = {}; for (const line of block.split('\n')) { if (line.startsWith('=== ')) { continue; } const separator = line.indexOf(':'); if (separator === -1) { continue; } const key = line.slice(0, separator).trim(); const value = line.slice(separator + 1).trim(); if (key) { fields[key] = value; } } return fields; } function readEnvValue(projectRoot: string, key: string): string | null { const envFile = path.join(projectRoot, '.env'); if (!fs.existsSync(envFile)) { return null; } const content = fs.readFileSync(envFile, 'utf-8'); const match = content.match(new RegExp(`^${key}=(.+)$`, 'm')); return match ? match[1].trim().replace(/^['"]|['"]$/gu, '') : null; } function buildSteps(opts: PreflightArgs): StepDefinition[] { const steps: StepDefinition[] = []; if (opts.withOnboarding) { steps.push({ id: 'onboarding', label: 'Onboarding', command: 'npm', args: ['run', 'wizard'], interactive: true, }); } steps.push( { id: 'environment', label: 'Environment', command: 'npm', args: ['run', 'setup', '--', '--step', 'environment'], }, { id: 'pi-config', label: 'PI Config', command: 'npm', args: ['run', 'setup', '--', '--step', 'pi-config'], }, { id: 'pf', label: 'PF Firewall', command: 'npm', args: ['run', 'install', '--', '--step', 'pf'], requiresRoot: true, }, { id: 'jails', label: 'Jails', command: 'npm', args: ['run', 'install', '--', '--step', 'jails'], requiresRoot: true, }, { id: 'db', label: 'DB', command: 'npm', args: ['run', 'install', '--', '--step', 'db'], requiresRoot: true, }, { id: 'controlplane', label: 'Control Plane', command: 'npm', args: ['run', 'install', '--', '--step', 'controlplane'], }, { id: 'git', label: 'Git', command: 'npm', args: ['run', 'install', '--', '--step', 'git'], requiresRoot: true, }, { id: 'cms', label: 'CMS', command: 'npm', args: ['run', 'install', '--', '--step', 'cms'], requiresRoot: true, }, { id: 'hosts', label: 'Hosts', command: 'npm', args: ['run', 'install', '--', '--step', 'hosts'], requiresRoot: true, }, { id: 'mounts', label: 'Mounts', command: 'npm', args: ['run', 'install', '--', '--step', 'mounts'], }, { id: 'telegram-auth', label: 'Telegram Auth', command: 'npm', args: ['run', 'setup', '--', '--step', 'telegram-auth'], }, { id: 'service', label: 'Service', command: 'npm', args: ['run', 'install', '--', '--step', 'service'], }, { id: 'hostd', label: 'Host Daemon', command: 'npm', args: ['run', 'install', '--', '--step', 'hostd'], requiresRoot: true, }, { id: 'sanoid', label: 'Sanoid', command: 'npm', args: ['run', 'install', '--', '--step', 'sanoid'], requiresRoot: true, }, { id: 'verify', label: 'Verify', command: 'npm', args: ['run', 'install', '--', '--step', 'verify'], requiresRoot: true, }, { id: 'doctor', label: 'Doctor (runtime health)', command: 'npm', args: ['run', 'doctor'], softFail: true, }, ); return steps; } function runStep( projectRoot: string, runDir: string, step: StepDefinition, ): StepResult { const started = new Date(); const commandLine = [step.command, ...step.args].map(quoteShell).join(' '); const logFile = path.join(runDir, `${step.id}.log`); const interactive = step.interactive === true; const freebsdNeedsRoot = os.platform() === 'freebsd' && process.getuid?.() !== 0; if (step.requiresRoot && freebsdNeedsRoot) { const output = 'root_required\n'; fs.writeFileSync(logFile, output); const finished = new Date(); return { id: step.id, label: step.label, commandLine, exitCode: 1, status: 'failed', startedAt: started.toISOString(), finishedAt: finished.toISOString(), logFile, fields: { ERROR: 'root_required', HINT: 'rerun_root_required_steps_as_root', }, }; } if (interactive && (!process.stdin.isTTY || !process.stdout.isTTY)) { const output = 'interactive_tty_required\n'; fs.writeFileSync(logFile, output); const finished = new Date(); return { id: step.id, label: step.label, commandLine, exitCode: 1, status: 'failed', startedAt: started.toISOString(), finishedAt: finished.toISOString(), logFile, fields: { ERROR: 'interactive_tty_required' }, }; } if (interactive) { const child = spawnSync(step.command, step.args, { cwd: projectRoot, env: process.env, stdio: 'inherit', }); fs.writeFileSync(logFile, '[interactive output inherited to terminal]\n'); const finished = new Date(); return { id: step.id, label: step.label, commandLine, exitCode: child.status ?? 1, status: (child.status ?? 1) === 0 ? 'success' : 'failed', startedAt: started.toISOString(), finishedAt: finished.toISOString(), logFile, fields: {}, }; } const child = spawnSync(step.command, step.args, { cwd: projectRoot, encoding: 'utf-8', env: process.env, }); const output = [ child.stdout || '', child.stderr || '', child.error ? String(child.error) : '', ] .filter(Boolean) .join('\n'); fs.writeFileSync(logFile, output || '\n'); const finished = new Date(); return { id: step.id, label: step.label, commandLine, exitCode: child.status ?? 1, status: (child.status ?? 1) === 0 ? 'success' : step.softFail ? 'warning' : 'failed', startedAt: started.toISOString(), finishedAt: finished.toISOString(), logFile, fields: parseStatusFields(output), }; } function capturePasswordStep( projectRoot: string, runDir: string, ): PasswordCaptureResult { const captureDir = path.join(runDir, 'password-step-screenshot'); const logFile = path.join(runDir, 'password-step-screenshot.log'); const child = spawnSync( 'python3', [ path.join( projectRoot, '.agent', 'skills', 'tmux-screenshot', 'tmux-screenshot.py', ), '--session', getPreflightTmuxSessionName(), '--window', 'main', '--outdir', captureDir, ], { cwd: projectRoot, encoding: 'utf-8', env: process.env, }, ); const output = [child.stdout || '', child.stderr || ''].filter(Boolean).join('\n'); const fullOutput = [ output, child.error ? String(child.error) : '', ] .filter(Boolean) .join('\n'); fs.writeFileSync(logFile, fullOutput || '\n'); if ((child.status ?? 1) !== 0) { return { enabled: true, captured: false, published: false, captureDir, publishDir: '', logFile, error: 'capture_failed', }; } return { enabled: true, captured: true, published: false, captureDir, publishDir: '', logFile, }; } function publishPasswordCapture( projectRoot: string, passwordCapture: PasswordCaptureResult, ): PasswordCaptureResult { const resolvedCmsJail = resolveCmsJailName(projectRoot); const publishDir = path.join( '/usr/local/bastille/jails', resolvedCmsJail, 'root', 'srv', 'www', 'screenshots', ); try { fs.mkdirSync(publishDir, { recursive: true }); for (const entry of fs.readdirSync(passwordCapture.captureDir)) { fs.cpSync(path.join(passwordCapture.captureDir, entry), path.join(publishDir, entry), { recursive: true, force: true, }); } return { ...passwordCapture, published: true, publishDir, }; } catch (error) { return { ...passwordCapture, published: false, publishDir, error: error instanceof Error ? error.message : String(error), }; } } function resolveCmsJailName(projectRoot: string): string { const explicit = readEnvValue(projectRoot, 'CMS_JAIL_NAME')?.trim(); if (explicit) return explicit; try { const output = execSync('jls -N name', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], }); const names = output .split('\n') .map((line) => line.trim()) .filter(Boolean); if (names.includes('cms')) return 'cms'; } catch { // Default to cms when jail discovery is unavailable. } return 'cms'; } function writeSummaryFiles( runDir: string, args: PreflightArgs, results: StepResult[], passwordCapture: PasswordCaptureResult | null, ): void { const hasFailures = results.some((result) => result.status === 'failed'); const hasWarnings = results.some((result) => result.status === 'warning'); const passwordOk = !passwordCapture || !passwordCapture.enabled || (passwordCapture.captured && passwordCapture.published); const overallStatus = hasFailures || !passwordOk ? 'failed' : hasWarnings ? 'warning' : 'success'; const summaryJson = { generatedAt: new Date().toISOString(), generatedAtDisplay: formatDisplayTimestamp(new Date()), overallStatus, args, passwordCapture, results: results.map((result) => ({ ...result, logFile: path.relative(runDir, result.logFile), })), }; fs.writeFileSync( path.join(runDir, 'summary.json'), `${JSON.stringify(summaryJson, null, 2)}\n`, ); const envLines = [ `OVERALL_STATUS=${overallStatus}`, `WITH_ONBOARDING=${args.withOnboarding}`, `CAPTURE_PASSWORD_STEP=${args.capturePasswordStep}`, ]; if (passwordCapture) { envLines.push(`PASSWORD_STEP_CAPTURE_ENABLED=${passwordCapture.enabled}`); envLines.push(`PASSWORD_STEP_CAPTURED=${passwordCapture.captured}`); envLines.push(`PASSWORD_STEP_PUBLISHED=${passwordCapture.published}`); envLines.push(`PASSWORD_STEP_CAPTURE_DIR=${passwordCapture.captureDir}`); envLines.push(`PASSWORD_STEP_PUBLISH_DIR=${passwordCapture.publishDir}`); envLines.push(`PASSWORD_STEP_LOG=${passwordCapture.logFile}`); if (passwordCapture.error) { envLines.push(`PASSWORD_STEP_ERROR=${JSON.stringify(passwordCapture.error)}`); } } for (const result of results) { const prefix = result.id.toUpperCase().replace(/[^A-Z0-9]+/gu, '_'); envLines.push(`${prefix}_STATUS=${result.status}`); envLines.push(`${prefix}_EXIT_CODE=${result.exitCode}`); envLines.push(`${prefix}_LOG=${result.logFile}`); for (const [key, value] of Object.entries(result.fields)) { const envKey = `${prefix}_${key.replace(/[^A-Z0-9]+/giu, '_').toUpperCase()}`; envLines.push(`${envKey}=${JSON.stringify(value)}`); } } fs.writeFileSync(path.join(runDir, 'summary.env'), `${envLines.join('\n')}\n`); } export async function run(args: string[]): Promise { const projectRoot = process.cwd(); const opts = parseArgs(args); const started = new Date(); const runDir = path.join(projectRoot, 'tmp', 'preflight', formatRunStamp(started)); const steps = buildSteps(opts); const results: StepResult[] = []; let passwordCapture: PasswordCaptureResult | null = null; fs.mkdirSync(runDir, { recursive: true }); console.log(`Preflight started: ${formatDisplayTimestamp(started)}`); console.log(`Run directory: ${runDir}`); console.log(`Internal base: ${PLATFORM_INTERNAL_BASE}`); console.log(`Tenant home: ${AGENT_INTERNAL_DOMAIN}`); console.log(`Public base: ${PLATFORM_PUBLIC_BASE || '(disabled)'}`); console.log(''); for (const step of steps) { console.log(`[preflight] ${step.label} -> ${[step.command, ...step.args].join(' ')}`); const result = runStep(projectRoot, runDir, step); results.push(result); if (step.id === 'onboarding' && opts.capturePasswordStep && result.status === 'success') { passwordCapture = capturePasswordStep(projectRoot, runDir); } if (step.id === 'cms' && passwordCapture?.captured && !passwordCapture.published) { passwordCapture = publishPasswordCapture(projectRoot, passwordCapture); } if (result.status === 'success') { console.log(` ok ${step.label} (${path.relative(projectRoot, result.logFile)})`); } else if (result.status === 'warning') { console.log(` warn ${step.label} (${path.relative(projectRoot, result.logFile)})`); } else { console.log(` fail ${step.label} (${path.relative(projectRoot, result.logFile)})`); if (opts.failFast) { break; } } } writeSummaryFiles(runDir, opts, results, passwordCapture); const hasFailures = results.some((result) => result.status === 'failed'); const hasWarnings = results.some((result) => result.status === 'warning'); const passwordOk = !passwordCapture || !passwordCapture.enabled || (passwordCapture.captured && passwordCapture.published); const overallStatus = hasFailures || !passwordOk ? 'failed' : hasWarnings ? 'warning' : 'success'; logger.info({ runDir, overallStatus }, 'Preflight check complete'); emitStatus('PREFLIGHT', { RUN_DIR: path.relative(projectRoot, runDir), OVERALL_STATUS: overallStatus, WITH_ONBOARDING: opts.withOnboarding, CAPTURE_PASSWORD_STEP: opts.capturePasswordStep, SUMMARY_JSON: path.relative(projectRoot, path.join(runDir, 'summary.json')), SUMMARY_ENV: path.relative(projectRoot, path.join(runDir, 'summary.env')), STATUS: overallStatus, LOG: path.relative(projectRoot, runDir), }); if (overallStatus === 'failed') { process.exit(1); } }