clawdie-ai/setup/install.ts
Sam & Pi e1d4fd4441
Some checks failed
CI / ci (pull_request) Has been cancelled
chore(freebsd): align host baseline with Python 3.12 (Sam & Pi)
---
Build: FAIL | Tests: FAIL
2026-06-17 14:57:19 +02:00

842 lines
24 KiB
TypeScript

/**
* setup/install.ts — Full install orchestrator.
*
* Runs all setup steps in order, takes ZFS checkpoints at key milestones,
* and handles optional/absent features gracefully. Never fails on a missing
* LLM key — reports provider status at the end and continues.
*
* Usage:
* just install
* just install -- --from pf # resume from a step
* just install -- --no-snapshots # skip ZFS checkpoints
* just install -- --dry-run # print plan, run nothing
*/
import { execFileSync, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { logger } from '../src/logger.js';
import { extractEnvValue } from './profile.js';
import {
commandExists,
getFreeBSDVersionSupport,
getPlatform,
isRoot,
} from './platform.js';
import { auditEnvFile } from './env-audit.js';
import { SERVICE_NAME } from '../src/platform-identity.js';
import { SOCKET_PATH as HOSTD_SOCKET_PATH } from '../src/hostd/types.js';
import type { FirstBootInstallMode } from './first-boot.js';
import { ensurePlatformRootDatasetIdentity } from './install-identity.js';
import { detectExistingInstall, resolveInstallMode } from './install-mode.js';
// ── Types ─────────────────────────────────────────────────────────────────────
interface StepDef {
name: string;
label: string;
args?: string[];
/** Take a ZFS snapshot after this step succeeds. */
snapshot?: string;
/** If false, failure is a warning — install continues. */
required?: boolean;
/** Run this step via sudo when install is non-root. */
requiresRoot?: boolean;
/** Skip with a note when this .env key is NOT set to YES. */
skipUnlessEnv?: string;
/** Skip silently when this file path does not exist. */
skipUnlessFile?: string;
/** Skip with a note when this .env key IS missing/empty. */
skipUnlessEnvKey?: string;
}
interface StepResult {
name: string;
status: 'ok' | 'warn' | 'skipped' | 'failed';
note?: string;
snapshotTaken?: string;
}
// ── LLM provider map ──────────────────────────────────────────────────────────
const LLM_PROVIDERS: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
zai: 'ZAI_API_KEY',
groq: 'GROQ_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
azure: 'AZURE_OPENAI_API_KEY',
};
// ── Step definitions ──────────────────────────────────────────────────────────
const STEPS: StepDef[] = [
{
name: 'onboarding',
label: 'Onboarding wizard',
// Install should not block on interactive questions. Operators can run
// `npm run wizard` when they want to override defaults.
args: ['--accept-detected'],
required: true,
},
{
name: 'environment',
label: 'Environment check',
required: true,
},
{
name: 'pi-config',
label: 'LLM provider config',
required: false, // warn only — missing key is not a blocker
},
{
name: 'pf',
label: 'PF firewall',
required: true,
snapshot: 'post-pf',
requiresRoot: true,
},
{
name: 'jails',
label: 'Worker',
args: ['--create'],
required: true,
snapshot: 'post-jails',
requiresRoot: true,
},
{
name: 'browser-jail',
label: 'Browser template (.6)',
required: false,
requiresRoot: true,
snapshot: 'post-browser-jail',
},
{
name: 'git',
label: 'Code Service (git jail)',
required: true,
snapshot: 'post-git',
requiresRoot: true,
},
{
name: 'forgejo',
label: 'Forgejo Web UI (git jail)',
required: false,
skipUnlessEnv: 'FEATURE_GITEA',
requiresRoot: true,
},
{
name: 'db',
label: 'Data Service (PostgreSQL)',
required: true,
snapshot: 'post-db',
requiresRoot: true,
},
{
name: 'controlplane',
label: 'Control plane (schema + agents)',
required: true,
},
{
name: 'agent-jails',
label: 'Agent worker jails (db-worker, git-worker, ctrl-worker)',
required: false,
requiresRoot: true,
snapshot: 'post-agent-jails',
},
{
name: 'skills-memory',
label: 'Built-in knowledge import',
args: ['--ensure'],
required: false,
skipUnlessFile: 'bootstrap/skills-memory/artifact.sql',
},
{
name: 'skills-init',
label: 'Skills engine init (.nanoclaw)',
required: false,
},
{
name: 'cms',
label: 'Web Service (cms jail)',
required: true,
snapshot: 'post-cms',
requiresRoot: true,
},
{
name: 'ollama',
label: 'Local Inference (Ollama jail .5)',
required: false,
skipUnlessEnv: 'FEATURE_OLLAMA',
requiresRoot: true,
},
{
name: 'llama-cpp',
label: 'Local Inference (llama-cpp jail .5)',
required: false,
skipUnlessEnv: 'FEATURE_LLAMA_CPP',
requiresRoot: true,
},
{
name: 'hosts',
label: 'Internal DNS (/etc/hosts)',
required: true,
requiresRoot: true,
},
{
name: 'mounts',
label: 'Mounts validation',
required: true,
},
{
name: 'telegram-auth',
label: 'Telegram auth',
required: false,
skipUnlessEnvKey: 'TELEGRAM_BOT_TOKEN',
},
{
name: 'service',
label: 'rc.d service install',
args: ['--boot-mode', 'AUTO'],
required: true,
requiresRoot: true,
},
{
name: 'hostd',
label: 'Host daemon (hostd)',
required: true,
requiresRoot: true,
},
{
name: 'identity-restore',
label: 'Identity restore (Supabase)',
required: false,
skipUnlessEnvKey: 'SUPABASE_URL',
},
{
name: 'verify',
label: 'Verification',
required: false, // warn, not fatal — partial setups are expected
snapshot: 'install-complete',
requiresRoot: true,
},
];
// ── Args ──────────────────────────────────────────────────────────────────────
interface InstallArgs {
from: string | null;
step: string | null;
installMode: FirstBootInstallMode | null;
noSnapshots: boolean;
dryRun: boolean;
}
function parseArgs(argv: string[]): InstallArgs {
const result: InstallArgs = {
from: null,
step: null,
installMode: null,
noSnapshots: false,
dryRun: false,
};
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--from' && argv[i + 1]) result.from = argv[++i];
if (argv[i] === '--step' && argv[i + 1]) result.step = argv[++i];
if (argv[i] === '--install-mode' && argv[i + 1]) {
const raw = (argv[++i] || '').trim().toLowerCase();
if (
raw === 'auto' ||
raw === 'fresh' ||
raw === 'upgrade' ||
raw === 'rescue'
) {
result.installMode = raw;
} else {
console.error(
`Invalid --install-mode=${raw}. Expected auto, fresh, upgrade, or rescue.`,
);
process.exit(1);
}
}
if (argv[i] === '--no-snapshots') result.noSnapshots = true;
if (argv[i] === '--dry-run') result.dryRun = true;
}
return result;
}
// ── ZFS snapshots ─────────────────────────────────────────────────────────────
/**
* Best-effort attempt to detect the ZFS dataset backing Bastille jails.
* Returns null if ZFS is not in use or can't be detected.
*/
function detectBastilleDataset(): string | null {
try {
const output = execFileSync(
'zfs',
['list', '-H', '-o', 'name', '-t', 'filesystem'],
{
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
},
);
const lines = output
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
// Prefer explicit bastille dataset, fall back to zroot/bastille
const bastille = lines.find(
(l) => l.endsWith('/bastille') || l === 'zroot/bastille',
);
return bastille ?? null;
} catch {
return null;
}
}
function takeSnapshot(
tag: string,
dataset: string,
noSnapshots: boolean,
): string | null {
if (noSnapshots) return null;
const snapshotName = `${dataset}@${tag}`;
try {
if (isRoot()) {
execFileSync('zfs', ['snapshot', snapshotName], {
stdio: ['ignore', 'ignore', 'ignore'],
});
} else {
// Prefer sudo for fresh installs (hostd is installed later in the plan).
if (commandExists('sudo')) {
execFileSync('sudo', ['zfs', 'snapshot', snapshotName], {
stdio: ['ignore', 'ignore', 'ignore'],
});
} else {
// Try hostd if the socket exists
const socketPath = HOSTD_SOCKET_PATH;
if (!fs.existsSync(socketPath)) return null;
// Inline sync call via nc — avoids importing async client in sync context
const result = spawnSync(
'sh',
[
'-c',
`echo '{"id":"snap","op":"zfs-snapshot","params":{"dataset":"${dataset}","name":"${tag}"}}' | nc -U ${socketPath}`,
],
{ encoding: 'utf-8' },
);
if (result.status !== 0) return null;
}
}
return snapshotName;
} catch {
return null;
}
}
// ── Step runner ───────────────────────────────────────────────────────────────
function runStep(
name: string,
extraArgs: string[],
projectRoot: string,
opts: { requiresRoot: boolean },
): { exitCode: number; stdout: string; stderr: string } {
const tsx = path.join(projectRoot, 'node_modules/.bin/tsx');
const bin = fs.existsSync(tsx) ? tsx : 'tsx';
const needsSudo = opts.requiresRoot && !isRoot();
const hasSudo = commandExists('sudo');
const useSudo = needsSudo && hasSudo;
if (needsSudo && !hasSudo) {
return {
exitCode: 1,
stdout: '',
stderr: 'sudo not available (requiresRoot step)',
};
}
const cmd = useSudo ? 'sudo' : bin;
const argv = useSudo
? [bin, 'setup/index.ts', '--step', name, '--', ...extraArgs]
: ['setup/index.ts', '--step', name, '--', ...extraArgs];
const result = spawnSync(cmd, argv, {
cwd: projectRoot,
encoding: 'utf-8',
env: { ...process.env },
stdio: ['inherit', 'pipe', 'pipe'],
});
return {
exitCode: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
}
// ── Display ───────────────────────────────────────────────────────────────────
const ICONS = { ok: '✓', warn: '△', skipped: '·', failed: '✗', running: '▶' };
const COLS = {
ok: '\x1b[32m',
warn: '\x1b[33m',
skipped: '\x1b[2m',
failed: '\x1b[31m',
reset: '\x1b[0m',
};
function printStep(
index: number,
total: number,
label: string,
status: 'running' | 'ok' | 'warn' | 'skipped' | 'failed',
note?: string,
): void {
const num = `[${String(index).padStart(2)}/${total}]`;
const icon = ICONS[status];
const col = status === 'running' ? '' : COLS[status];
const rst = COLS.reset;
const noteStr = note ? ` ${COLS.skipped}${note}${rst}` : '';
process.stdout.write(`${col}${icon}${rst} ${num} ${label}${noteStr}\n`);
}
function hasPiAuthProvider(provider: string): boolean {
const authFile = path.join(
process.env.HOME || '',
'.pi',
'agent',
'auth.json',
);
try {
const parsed = JSON.parse(fs.readFileSync(authFile, 'utf-8')) as Record<
string,
unknown
>;
const entry = parsed?.[provider];
if (typeof entry === 'string') return entry.trim().length > 0;
if (entry && typeof entry === 'object')
return Object.keys(entry).length > 0;
return false;
} catch {
return false;
}
}
function printLlmStatus(envFile: string): void {
const content = fs.existsSync(envFile)
? fs.readFileSync(envFile, 'utf-8')
: '';
const ollamaHost = extractEnvValue(content, 'OLLAMA_HOST');
console.log('\n△ LLM providers');
let found = 0;
const codexAuth = hasPiAuthProvider('openai-codex');
console.log(
` ${'openai-codex'.padEnd(12)} ${'Pi auth.json'.padEnd(24)} ${
codexAuth
? `${COLS.ok}✓ configured${COLS.reset}`
: `${COLS.skipped}✗ not set${COLS.reset}`
} ${COLS.ok}(recommended)${COLS.reset}`,
);
if (codexAuth) found++;
for (const [name, envKey] of Object.entries(LLM_PROVIDERS)) {
const val = extractEnvValue(content, envKey) || process.env[envKey];
const status = val
? `${COLS.ok}✓ configured${COLS.reset}`
: `${COLS.skipped}✗ not set${COLS.reset}`;
console.log(` ${name.padEnd(12)} ${envKey.padEnd(24)} ${status}`);
if (val) found++;
}
const ollamaStatus = ollamaHost
? `${COLS.ok}${ollamaHost}${COLS.reset}`
: `${COLS.skipped}✗ not set${COLS.reset}`;
console.log(
` ${'ollama'.padEnd(12)} ${'OLLAMA_HOST'.padEnd(24)} ${ollamaStatus}`,
);
if (ollamaHost) found++;
if (found === 0) {
console.log(
`\n ${COLS.warn}No LLM provider auth found. Configure one after install and restart:${COLS.reset}`,
);
console.log(
` ${COLS.skipped} Recommended: run pi, then /login and select ChatGPT Plus/Pro (Codex).${COLS.reset}`,
);
console.log(
` ${COLS.skipped} Or add an API key such as ANTHROPIC_API_KEY=sk-ant-...${COLS.reset}`,
);
console.log(
` ${COLS.skipped} sudo service ${SERVICE_NAME} restart${COLS.reset}`,
);
}
console.log();
}
function printEnvStatus(envFile: string): void {
const audit = auditEnvFile(envFile);
const missing = audit.missing.length ? audit.missing.join(', ') : 'none';
const notes = audit.warnings.length ? audit.warnings.join(' | ') : 'none';
console.log('△ .env audit');
console.log(` missing: ${missing}`);
console.log(` notes : ${notes}`);
console.log();
}
function printCodexTip(): void {
const hasCodex = commandExists('codex');
console.log('△ Codex CLI');
console.log(
` status: ${hasCodex ? `${COLS.ok}✓ installed${COLS.reset}` : `${COLS.warn}△ missing${COLS.reset}`}`,
);
if (!hasCodex) {
console.log(` install: ${COLS.skipped}pkg install -y codex${COLS.reset}`);
}
console.log(
` login : ${COLS.skipped}codex login --device-auth${COLS.reset}`,
);
console.log(
` key : ${COLS.skipped}printenv OPENAI_API_KEY | codex login --with-api-key${COLS.reset}`,
);
console.log(` status: ${COLS.skipped}codex login status${COLS.reset}`);
console.log();
}
function printAiderTip(): void {
const hasAider = commandExists('aider');
console.log('◇ Aider (controlplane harness)');
console.log(
` status: ${hasAider ? `${COLS.ok}✓ installed${COLS.reset}` : `${COLS.warn}△ missing${COLS.reset}`}`,
);
if (!hasAider) {
console.log(
` install: ${COLS.skipped}python3.12 -m venv /opt/clawdie/venv/aider && /opt/clawdie/venv/aider/bin/pip install aider-chat${COLS.reset}`,
);
}
console.log(` docs : ${COLS.skipped}https://aider.chat/docs/${COLS.reset}`);
console.log();
}
function printSummary(results: StepResult[]): void {
const failed = results.filter((r) => r.status === 'failed');
const warned = results.filter((r) => r.status === 'warn');
const skipped = results.filter((r) => r.status === 'skipped');
const ok = results.filter((r) => r.status === 'ok');
const snaps = results
.filter((r) => r.snapshotTaken)
.map((r) => r.snapshotTaken!);
console.log('─'.repeat(52));
console.log(
` ${COLS.ok}${ok.length} ok${COLS.reset} ` +
`${COLS.warn}${warned.length} warnings${COLS.reset} ` +
`${COLS.skipped}${skipped.length} skipped${COLS.reset} ` +
(failed.length
? `${COLS.failed}${failed.length} failed${COLS.reset}`
: `0 failed`),
);
if (snaps.length) {
console.log(`\n Snapshots taken:`);
snaps.forEach((s) => console.log(` ${COLS.skipped}${s}${COLS.reset}`));
}
if (warned.length) {
console.log(`\n Warnings:`);
warned.forEach((r) =>
console.log(
` ${COLS.warn}${COLS.reset} ${r.name}${r.note ? `${r.note}` : ''}`,
),
);
}
if (failed.length) {
console.log(`\n ${COLS.failed}Failed steps:${COLS.reset}`);
failed.forEach((r) =>
console.log(
` ${COLS.failed}${COLS.reset} ${r.name}${r.note ? `${r.note}` : ''}`,
),
);
console.log(`\n Resume from last failed step:`);
console.log(
` ${COLS.skipped}just install -- --from ${failed[0].name}${COLS.reset}`,
);
}
console.log('─'.repeat(52));
}
// ── Main ──────────────────────────────────────────────────────────────────────
export async function run(argv: string[]): Promise<void> {
const opts = parseArgs(argv);
const projectRoot = process.cwd();
const envFile = path.join(projectRoot, '.env');
const envContent = fs.existsSync(envFile)
? fs.readFileSync(envFile, 'utf-8')
: '';
if (getPlatform() !== 'freebsd') {
console.error('install orchestrator is FreeBSD only.');
process.exit(1);
}
const freebsdSupport = getFreeBSDVersionSupport();
if (!freebsdSupport.ok) {
console.error(
`Unsupported FreeBSD version: ${freebsdSupport.detected}. Required: ${freebsdSupport.required}.`,
);
process.exit(1);
}
// Detect ZFS dataset once up front
const bastilleDataset = opts.noSnapshots ? null : detectBastilleDataset();
if (bastilleDataset) {
logger.info({ bastilleDataset }, 'ZFS snapshots enabled');
} else if (!opts.noSnapshots) {
logger.info('ZFS dataset not detected — snapshots will be skipped');
}
if (opts.from && opts.step) {
console.error('Use only one of --from or --step.');
process.exit(1);
}
const envInstallMode = extractEnvValue(envContent, 'INSTALL_MODE');
const requestedInstallMode =
opts.installMode ||
(envInstallMode === 'auto' ||
envInstallMode === 'fresh' ||
envInstallMode === 'upgrade' ||
envInstallMode === 'rescue'
? envInstallMode
: 'auto');
const installDetection = detectExistingInstall(projectRoot);
const resolvedInstallMode = resolveInstallMode(
requestedInstallMode,
installDetection,
);
console.log(
`Install mode: ${resolvedInstallMode.effective} (requested ${resolvedInstallMode.requested})`,
);
if (installDetection.existing) {
const found = [
...installDetection.strongSignals,
...installDetection.softSignals,
].join(', ');
console.log(`Existing install signals: ${found}\n`);
} else {
console.log('Existing install signals: none\n');
}
// Determine which steps to run (--from skips earlier steps)
let steps = STEPS;
if (opts.step) {
const step = steps.find((s) => s.name === opts.step);
if (!step) {
console.error(`Unknown step for --step: ${opts.step}`);
console.error(`Valid steps: ${steps.map((s) => s.name).join(', ')}`);
process.exit(1);
}
steps = [step];
}
if (opts.from) {
const idx = steps.findIndex((s) => s.name === opts.from);
if (idx === -1) {
console.error(`Unknown step for --from: ${opts.from}`);
console.error(`Valid steps: ${steps.map((s) => s.name).join(', ')}`);
process.exit(1);
}
steps = steps.slice(idx);
console.log(`Resuming from step: ${opts.from}\n`);
}
if (opts.dryRun) {
console.log('Dry run — steps that would execute:\n');
steps.forEach((s, i) =>
console.log(
` [${String(i + 1).padStart(2)}/${steps.length}] ${s.label} (${s.name})`,
),
);
return;
}
const total = steps.length;
const results: StepResult[] = [];
console.log(`\n△ Clawdie install — ${total} steps\n`);
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const envContent = fs.existsSync(envFile)
? fs.readFileSync(envFile, 'utf-8')
: '';
const index = i + 1;
// ── Skip checks ───────────────────────────────────────────────────────────
if (step.skipUnlessFile) {
const target = path.join(projectRoot, step.skipUnlessFile);
if (!fs.existsSync(target)) {
printStep(
index,
total,
step.label,
'skipped',
'artifact not yet generated',
);
results.push({
name: step.name,
status: 'skipped',
note: 'artifact absent',
});
continue;
}
}
if (step.skipUnlessEnv) {
const val = extractEnvValue(envContent, step.skipUnlessEnv);
if (val !== 'YES') {
printStep(
index,
total,
step.label,
'skipped',
`${step.skipUnlessEnv} not YES`,
);
results.push({
name: step.name,
status: 'skipped',
note: `${step.skipUnlessEnv}=${val ?? 'unset'}`,
});
continue;
}
}
if (step.skipUnlessEnvKey) {
const val = extractEnvValue(envContent, step.skipUnlessEnvKey);
if (!val) {
printStep(
index,
total,
step.label,
'skipped',
`${step.skipUnlessEnvKey} not set`,
);
results.push({
name: step.name,
status: 'skipped',
note: `${step.skipUnlessEnvKey} missing`,
});
continue;
}
}
// ── Run ───────────────────────────────────────────────────────────────────
printStep(index, total, step.label, 'running');
const { exitCode, stderr } = runStep(
step.name,
step.args ?? [],
projectRoot,
{
requiresRoot: step.requiresRoot === true,
},
);
if (exitCode === 0) {
let snapshotTaken: string | undefined;
if (step.name === 'onboarding') {
try {
const identity = ensurePlatformRootDatasetIdentity(
projectRoot,
resolvedInstallMode.effective,
);
if (identity) {
logger.info(identity, 'Ensured platform root dataset identity');
}
} catch (error) {
logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
'Platform root dataset identity initialization failed',
);
}
}
// Snapshot after successful step if configured and dataset known
if (step.snapshot && bastilleDataset) {
const tag = `${step.snapshot}-${Date.now()}`;
const snap = takeSnapshot(tag, bastilleDataset, opts.noSnapshots);
if (snap) snapshotTaken = snap;
}
printStep(
index,
total,
step.label,
'ok',
snapshotTaken ? `snapshot: ${snapshotTaken}` : undefined,
);
results.push({ name: step.name, status: 'ok', snapshotTaken });
} else if (!step.required) {
// Non-required step failed — warn and continue
const note =
step.name === 'pi-config'
? 'no LLM key configured — add to .env later'
: (stderr.split('\n').find((l) => l.trim()) ?? `exit ${exitCode}`);
printStep(index, total, step.label, 'warn', note);
results.push({ name: step.name, status: 'warn', note });
} else {
// Required step failed — stop
const note =
stderr.split('\n').find((l) => l.trim()) ?? `exit ${exitCode}`;
printStep(index, total, step.label, 'failed', note);
results.push({ name: step.name, status: 'failed', note });
printSummary(results);
printLlmStatus(envFile);
logger.error(
{ step: step.name, exitCode },
'Required install step failed',
);
process.exit(1);
}
}
printSummary(results);
printLlmStatus(envFile);
printEnvStatus(envFile);
printCodexTip();
printAiderTip();
}
function isDirectInvocation(): boolean {
try {
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : '';
const here = fileURLToPath(import.meta.url);
return invoked === here;
} catch {
return false;
}
}
if (isDirectInvocation()) {
run(process.argv.slice(2)).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(message);
process.exit(1);
});
}