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)
615 lines
16 KiB
TypeScript
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);
|
|
}
|
|
}
|