clawdie-ai/setup/environment.ts
Clawdie AI 8e661311b5 feat(db): migrate SQLite to Postgres OPS_DB (Sam & Claude)
Replace better-sqlite3 with pg Pool for all operational data (chats,
messages, tasks, sessions, router_state, registered_groups). New
OPS_DB_URL config drives a dedicated ops database alongside the
existing memory and skills databases.

All db.ts functions are now async. Callers in src/, setup/, and tests
updated accordingly. Tests use a mock pool (src/test-helpers.ts) so
they run without a live Postgres connection.

---
Build: pass | Tests: not run (Linux)
2026-04-11 12:21:27 +02:00

428 lines
13 KiB
TypeScript

/**
* Step: environment — Detect FreeBSD/jail prerequisites and existing config.
*/
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import pg from 'pg';
import {
AGENT_NAME,
OPS_DB_URL,
PI_TUI_BIN,
STORE_DIR,
} from '../src/config.js';
import { logger } from '../src/logger.js';
import { commandExists, getPlatform, isRoot } from './platform.js';
import { loadAllPackageLists, loadPackageList } from './packages.js';
import { emitStatus } from './status.js';
interface HostPrerequisite {
key: string;
pkg: string;
check: () => boolean;
}
function commandVersion(cmd: string): string {
try {
return execSync(`${cmd} -V`, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return '';
}
}
const HOST_PREREQUISITE_CHECKS: Record<
string,
{ key: string; check: () => boolean }
> = {
bash: { key: 'BASH', check: () => commandExists('bash') },
bsddialog: { key: 'BSDDIALOG', check: () => commandExists('bsddialog') },
bastille: { key: 'BASTILLE', check: () => commandExists('bastille') },
codex: { key: 'CODEX', check: () => commandExists('codex') },
claude: { key: 'CLAUDE', check: () => commandExists('claude') },
gemini: { key: 'GEMINI', check: () => commandExists('gemini') },
pi: { key: 'PI', check: () => commandExists('pi') },
git: { key: 'GIT', check: () => commandExists('git') },
tmux: { key: 'TMUX', check: () => commandExists('tmux') },
python311: { key: 'PYTHON3', check: () => commandExists('python3') },
uv: { key: 'UV', check: () => commandExists('uv') },
ripgrep: { key: 'RIPGREP', check: () => commandExists('rg') },
fd: { key: 'FD', check: () => commandExists('fd') },
rsync: { key: 'RSYNC', check: () => commandExists('rsync') },
'postgresql17-client': { key: 'PSQL', check: () => commandExists('psql') },
node24: { key: 'NODE', check: () => commandExists('node') },
npm: { key: 'NPM', check: () => commandExists('npm') },
'py311-pillow': {
key: 'PILLOW',
check: () => {
try {
execSync('python3 -c "import PIL"', { stdio: 'ignore' });
return true;
} catch {
return false;
}
},
},
dejavu: {
key: 'DEJAVU_FONT',
check: () =>
fs.existsSync('/usr/local/share/fonts/dejavu/DejaVuSansMono.ttf'),
},
// Wayland display stack
seatd: { key: 'SEATD', check: () => commandExists('seatd') },
weston: { key: 'WESTON', check: () => commandExists('weston') },
cage: { key: 'CAGE', check: () => commandExists('cage') },
wayvnc: { key: 'WAYVNC', check: () => commandExists('wayvnc') },
waypipe: { key: 'WAYPIPE', check: () => commandExists('waypipe') },
xwayland: { key: 'XWAYLAND', check: () => commandExists('Xwayland') },
// bhyve VM management
'vm-bhyve': { key: 'VM_BHYVE', check: () => commandExists('vm') },
'grub2-bhyve': {
key: 'GRUB_BHYVE',
check: () => commandExists('grub-bhyve'),
},
'uefi-edk2-bhyve': {
key: 'UEFI_BHYVE',
check: () =>
fs.existsSync('/usr/local/share/uefi-firmware/BHYVE_UEFI.fd') ||
fs.existsSync('/usr/local/share/bhyve/BHYVE_UEFI.fd'),
},
};
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
logger.info('Starting environment check');
const platform = getPlatform();
const jexec = commandExists('jexec');
const jls = commandExists('jls');
const service = commandExists('service');
const sudo = commandExists('sudo');
const jailConf = fs.existsSync('/etc/jail.conf');
const hasPython311 = commandExists('python3.11');
const hasPython312 = commandExists('python3.12');
const python3Version = commandVersion('python3');
const python3NeedsPinning =
hasPython311 && hasPython312 && python3Version.includes('3.12');
// Check for pi binary (path from env or default 'pi')
const piBin = PI_TUI_BIN;
const hasPi = commandExists(piBin);
let jailed = false;
try {
jailed =
execSync('sysctl -n security.jail.jailed', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim() === '1';
} catch {
jailed = false;
}
const hostPrerequisites = loadPackageList('host-baseline.txt').map((pkg) => {
const check = HOST_PREREQUISITE_CHECKS[pkg];
if (!check) {
throw new Error(`missing_host_pkg_check_${pkg}`);
}
return {
key: check.key,
pkg,
check: check.check,
present: check.check(),
status: check.check() ? 'available' : 'missing',
installCmd: 'none',
};
});
// ── Shared pkg cache ──────────────────────────────────────────────────────
// Create zroot/pkg-cache ZFS dataset and prefetch all packages (host + all
// jail roles) in one network round-trip before individual installs run.
// Jail steps mount this read-only so bastille pkg install never hits the
// network again.
if (platform === 'freebsd' && !jailed) {
try {
// Create the dataset if ZFS is available and it doesn't exist yet
execSync('zfs list zroot/pkg-cache', { stdio: 'ignore' });
} catch {
try {
execSync('zfs create -p zroot/pkg-cache', { stdio: 'ignore' });
execSync('zfs set mountpoint=/var/cache/pkg zroot/pkg-cache', {
stdio: 'ignore',
});
logger.info('created zroot/pkg-cache dataset');
} catch {
logger.info('ZFS not available or zroot/pkg-cache creation skipped');
}
}
const allPackages = loadAllPackageLists();
logger.info(
{ count: allPackages.length },
'prefetching all packages to shared cache',
);
try {
execSync(`pkg fetch -yd ${allPackages.join(' ')}`, { stdio: 'inherit' });
} catch {
logger.warn(
'pkg prefetch failed — individual installs will fall back to network',
);
}
}
// ── Host package installs ─────────────────────────────────────────────────
if (platform === 'freebsd' && !jailed) {
for (const item of hostPrerequisites) {
if (item.present) continue;
item.installCmd = isRoot()
? `pkg install -y ${item.pkg}`
: sudo
? `sudo pkg install -y ${item.pkg}`
: 'unavailable';
if (item.installCmd === 'unavailable') {
item.status = 'missing_no_sudo';
continue;
}
try {
execSync(item.installCmd, { stdio: 'inherit' });
item.present = item.check();
item.status = item.present ? 'installed' : 'install_failed';
} catch {
item.present = item.check();
item.status = item.present ? 'installed' : 'install_failed';
}
}
}
const getPrereq = (key: string) =>
hostPrerequisites.find((item) => item.key === key) || {
key,
present: false,
status: 'missing',
installCmd: 'none',
};
const bash = getPrereq('BASH');
const bsddialog = getPrereq('BSDDIALOG');
const bastille = getPrereq('BASTILLE');
const git = getPrereq('GIT');
const tmux = getPrereq('TMUX');
const python3 = getPrereq('PYTHON3');
const uv = getPrereq('UV');
const ripgrep = getPrereq('RIPGREP');
const fd = getPrereq('FD');
const rsync = getPrereq('RSYNC');
const psql = getPrereq('PSQL');
const node = getPrereq('NODE');
const npm = getPrereq('NPM');
const pillow = getPrereq('PILLOW');
const dejavuFont = getPrereq('DEJAVU_FONT');
const seatd = getPrereq('SEATD');
const weston = getPrereq('WESTON');
const cage = getPrereq('CAGE');
const wayvnc = getPrereq('WAYVNC');
const waypipe = getPrereq('WAYPIPE');
const xwayland = getPrereq('XWAYLAND');
const vmBhyve = getPrereq('VM_BHYVE');
const grubBhyve = getPrereq('GRUB_BHYVE');
const uefiBhyve = getPrereq('UEFI_BHYVE');
const hostPrerequisitesReady = hostPrerequisites.every(
(item) => item.present,
);
const piInstallCmd = hasPi
? 'none'
: isRoot()
? 'npm install -g @mariozechner/pi-coding-agent'
: sudo
? 'sudo npm install -g @mariozechner/pi-coding-agent'
: 'npm install -g @mariozechner/pi-coding-agent';
// Check existing config
const hasEnv = fs.existsSync(path.join(projectRoot, '.env'));
const authDir = path.join(projectRoot, 'store', 'auth');
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
let hasRegisteredGroups = false;
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
hasRegisteredGroups = true;
} else {
try {
const pool = new pg.Pool({ connectionString: OPS_DB_URL, max: 3 });
const { rows } = await pool.query(
'SELECT COUNT(*) as count FROM registered_groups',
);
if (parseInt((rows[0] as { count: string }).count, 10) > 0)
hasRegisteredGroups = true;
await pool.end();
} catch {
// Table might not exist yet
}
}
logger.info(
{
platform,
jailed,
jexec,
jls,
service,
bash: bash.present,
bsddialog: bsddialog.present,
bastille: bastille.present,
git: git.present,
tmux: tmux.present,
python3: python3.present,
uv: uv.present,
ripgrep: ripgrep.present,
fd: fd.present,
rsync: rsync.present,
psql: psql.present,
node: node.present,
npm: npm.present,
pillow: pillow.present,
dejavuFont: dejavuFont.present,
seatd: seatd.present,
cage: cage.present,
weston: weston.present,
vmBhyve: vmBhyve.present,
hasPython311,
hasPython312,
python3Version,
python3NeedsPinning,
jailConf,
agentName: AGENT_NAME,
hasPi,
hasEnv,
hasAuth,
hasRegisteredGroups,
},
'Environment check complete',
);
if (python3NeedsPinning) {
logger.warn(
{
python3Version,
},
'Multiple Python versions detected. Pin uv to Python 3.11 until python3 is repointed to 3.11.',
);
}
emitStatus('CHECK_ENVIRONMENT', {
PLATFORM: platform,
IS_JAILED: jailed,
AGENT_NAME: AGENT_NAME,
JEXEC: jexec,
JLS: jls,
SERVICE: service,
BASH: bash.present,
BASH_STATUS: bash.status,
BASH_INSTALL_CMD: bash.installCmd,
BSDDIALOG: bsddialog.present,
BSDDIALOG_STATUS: bsddialog.status,
BSDDIALOG_INSTALL_CMD: bsddialog.installCmd,
BASTILLE: bastille.present,
BASTILLE_STATUS: bastille.status,
BASTILLE_INSTALL_CMD: bastille.installCmd,
GIT: git.present,
GIT_STATUS: git.status,
GIT_INSTALL_CMD: git.installCmd,
TMUX: tmux.present,
TMUX_STATUS: tmux.status,
TMUX_INSTALL_CMD: tmux.installCmd,
PYTHON3: python3.present,
PYTHON3_STATUS: python3.status,
PYTHON3_INSTALL_CMD: python3.installCmd,
PYTHON311: hasPython311,
PYTHON312: hasPython312,
PYTHON3_VERSION: python3Version || 'unknown',
UV_PYTHON_PIN_REQUIRED: python3NeedsPinning,
UV_PYTHON_HINT: python3NeedsPinning
? 'uv venv --python 3.11 && uv run --python 3.11 <command>'
: 'not_required',
NODE: node.present,
NODE_STATUS: node.status,
NODE_INSTALL_CMD: node.installCmd,
NPM: npm.present,
NPM_STATUS: npm.status,
NPM_INSTALL_CMD: npm.installCmd,
UV: uv.present,
UV_STATUS: uv.status,
UV_INSTALL_CMD: uv.installCmd,
RIPGREP: ripgrep.present,
RIPGREP_STATUS: ripgrep.status,
RIPGREP_INSTALL_CMD: ripgrep.installCmd,
FD: fd.present,
FD_STATUS: fd.status,
FD_INSTALL_CMD: fd.installCmd,
RSYNC: rsync.present,
RSYNC_STATUS: rsync.status,
RSYNC_INSTALL_CMD: rsync.installCmd,
PSQL: psql.present,
PSQL_STATUS: psql.status,
PSQL_INSTALL_CMD: psql.installCmd,
PILLOW: pillow.present,
PILLOW_STATUS: pillow.status,
PILLOW_INSTALL_CMD: pillow.installCmd,
DEJAVU_FONT: dejavuFont.present,
DEJAVU_FONT_STATUS: dejavuFont.status,
DEJAVU_FONT_INSTALL_CMD: dejavuFont.installCmd,
SEATD: seatd.present,
SEATD_STATUS: seatd.status,
SEATD_INSTALL_CMD: seatd.installCmd,
WESTON: weston.present,
WESTON_STATUS: weston.status,
WESTON_INSTALL_CMD: weston.installCmd,
CAGE: cage.present,
CAGE_STATUS: cage.status,
CAGE_INSTALL_CMD: cage.installCmd,
WAYVNC: wayvnc.present,
WAYVNC_STATUS: wayvnc.status,
WAYVNC_INSTALL_CMD: wayvnc.installCmd,
WAYPIPE: waypipe.present,
WAYPIPE_STATUS: waypipe.status,
WAYPIPE_INSTALL_CMD: waypipe.installCmd,
XWAYLAND: xwayland.present,
XWAYLAND_STATUS: xwayland.status,
XWAYLAND_INSTALL_CMD: xwayland.installCmd,
VM_BHYVE: vmBhyve.present,
VM_BHYVE_STATUS: vmBhyve.status,
VM_BHYVE_INSTALL_CMD: vmBhyve.installCmd,
GRUB_BHYVE: grubBhyve.present,
GRUB_BHYVE_STATUS: grubBhyve.status,
GRUB_BHYVE_INSTALL_CMD: grubBhyve.installCmd,
UEFI_BHYVE: uefiBhyve.present,
UEFI_BHYVE_STATUS: uefiBhyve.status,
UEFI_BHYVE_INSTALL_CMD: uefiBhyve.installCmd,
SUDO: sudo,
JAIL_CONF: jailConf,
HAS_PI: hasPi,
PI_BIN: piBin,
PI_INSTALL_CMD: piInstallCmd,
HAS_ENV: hasEnv,
HAS_AUTH: hasAuth,
HAS_REGISTERED_GROUPS: hasRegisteredGroups,
STATUS:
platform === 'freebsd' && !jailed && (!hostPrerequisitesReady || !hasPi)
? 'failed'
: 'success',
LOG: 'logs/setup.log',
});
if (
platform === 'freebsd' &&
!jailed &&
(!hostPrerequisitesReady || !hasPi)
) {
process.exit(1);
}
}