clawdie-ai/setup/preflight.ts

521 lines
13 KiB
TypeScript
Raw Normal View History

import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { AGENT_DOMAIN, AGENT_NAME } from '../src/config.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;
}
interface StepResult {
id: string;
label: string;
commandLine: string;
exitCode: number;
status: 'success' | 'failed' | 'skipped';
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;
}
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 date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
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: 'jails',
label: 'Jails',
command: 'npm',
args: ['run', 'setup', '--', '--step', 'jails', '--create'],
},
{
id: 'db',
label: 'DB',
command: 'npm',
args: ['run', 'setup', '--', '--step', 'db'],
},
{
id: 'git',
label: 'Git',
command: 'npm',
args: ['run', 'setup', '--', '--step', 'git'],
},
{
id: 'cms',
label: 'CMS',
command: 'npm',
args: ['run', 'setup', '--', '--step', 'cms'],
},
{
id: 'hosts',
label: 'Hosts',
command: 'npm',
args: ['run', 'setup', '--', '--step', 'hosts'],
},
{
id: 'mounts',
label: 'Mounts',
command: 'npm',
args: ['run', 'setup', '--', '--step', 'mounts'],
},
{
id: 'telegram-auth',
label: 'Telegram Auth',
command: 'npm',
args: ['run', 'setup', '--', '--step', 'telegram-auth'],
},
{
id: 'service',
label: 'Service',
command: 'npm',
args: ['run', 'setup', '--', '--step', 'service'],
},
{
id: 'verify',
label: 'Verify',
command: 'npm',
args: ['run', 'setup', '--', '--step', 'verify'],
},
{
id: 'doctor',
label: 'Doctor',
command: 'npm',
args: ['run', 'doctor'],
},
);
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;
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' : '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',
'clawdie',
'--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 publishDir = path.join(
'/usr/local/bastille/jails',
readEnvValue(projectRoot, 'CMS_JAIL_NAME') || `${AGENT_NAME}-cms`,
'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 writeSummaryFiles(
runDir: string,
args: PreflightArgs,
results: StepResult[],
passwordCapture: PasswordCaptureResult | null,
): void {
const overallStatus =
results.every((result) => result.status === 'success') &&
(!passwordCapture ||
(!passwordCapture.enabled ||
(passwordCapture.captured && passwordCapture.published)))
? 'success'
: 'failed';
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(`Public domain: ${AGENT_DOMAIN}`);
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 {
console.log(` fail ${step.label} (${path.relative(projectRoot, result.logFile)})`);
if (opts.failFast) {
break;
}
}
}
writeSummaryFiles(runDir, opts, results, passwordCapture);
const overallStatus =
results.every((result) => result.status === 'success') &&
(!passwordCapture ||
(!passwordCapture.enabled ||
(passwordCapture.captured && passwordCapture.published)))
? 'success'
: 'failed';
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 !== 'success') {
process.exit(1);
}
}