Some checks failed
CI / ci (pull_request) Has been cancelled
--- Build: FAIL | Tests: FAIL
842 lines
24 KiB
TypeScript
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);
|
|
});
|
|
}
|