clawdie-ai/setup/preflight.ts
Operator & Codex f1dc7ea6df Drop stale jail and agent migration paths (Codex)
Remove completed controlplane agent-id migration, simplify jail-name resolution to current canonical names, and drop SUDO_UID ownership fallback from service setup.

---
Build: pass | Tests: pass — 2370 passed (704 files)
2026-05-10 21:30:17 +02:00

615 lines
16 KiB
TypeScript

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<string, string>;
}
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<string, string> {
const matches = output.match(/=== CLAWDIE SETUP: [\s\S]*?=== END ===/gu) || [];
const block = matches.at(-1);
if (!block) {
return {};
}
const fields: Record<string, string> = {};
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<void> {
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);
}
}