Require the tracked FreeBSD 15.x line during install and environment checks, and align docs and skill compatibility metadata with 15.x only. --- Build: pass Tests: pass — 37 passed (2 files) --- Build: pass | Tests: pass — 2363 passed (701 files)
467 lines
15 KiB
TypeScript
467 lines
15 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 { OPS_DB_URL, PI_TUI_BIN, STORE_DIR, TENANT_ID } from '../src/config.js';
|
|
import { logger } from '../src/logger.js';
|
|
import {
|
|
commandExists,
|
|
getFreeBSDVersionSupport,
|
|
getPlatform,
|
|
isRoot,
|
|
SUPPORTED_FREEBSD_LINE,
|
|
} 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') },
|
|
dnsmasq: { key: 'DNSMASQ', check: () => commandExists('dnsmasq') },
|
|
tmux: { key: 'TMUX', check: () => commandExists('tmux') },
|
|
btop: { key: 'BTOP', check: () => commandExists('btop') },
|
|
python311: { key: 'PYTHON3', check: () => commandExists('python3') },
|
|
uv: { key: 'UV', check: () => commandExists('uv') },
|
|
ripgrep: { key: 'RIPGREP', check: () => commandExists('rg') },
|
|
'fd-find': { key: 'FD', check: () => commandExists('fd') },
|
|
rsync: { key: 'RSYNC', check: () => commandExists('rsync') },
|
|
'postgresql18-client': { key: 'PSQL', check: () => commandExists('psql') },
|
|
tailscale: { key: 'TAILSCALE', check: () => commandExists('tailscale') },
|
|
just: { key: 'JUST', check: () => commandExists('just') },
|
|
rust: { key: 'RUST', check: () => commandExists('rustc') },
|
|
'py311-aider_chat': { key: 'AIDER', check: () => commandExists('aider') },
|
|
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 freebsdSupport =
|
|
platform === 'freebsd'
|
|
? getFreeBSDVersionSupport()
|
|
: {
|
|
ok: false,
|
|
detected: 'not_freebsd',
|
|
major: null,
|
|
required: SUPPORTED_FREEBSD_LINE,
|
|
};
|
|
|
|
if (platform === 'freebsd' && !freebsdSupport.ok) {
|
|
emitStatus('CHECK_ENVIRONMENT', {
|
|
PLATFORM: platform,
|
|
IS_JAILED: jailed,
|
|
FREEBSD_VERSION: freebsdSupport.detected,
|
|
FREEBSD_MAJOR: freebsdSupport.major ?? 'unknown',
|
|
FREEBSD_REQUIRED: freebsdSupport.required,
|
|
STATUS: 'failed',
|
|
ERROR: 'unsupported_freebsd_version',
|
|
LOG: 'logs/setup.log',
|
|
});
|
|
console.error(
|
|
`Unsupported FreeBSD version: ${freebsdSupport.detected}. Required: ${freebsdSupport.required}.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
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 @earendil-works/pi-coding-agent'
|
|
: sudo
|
|
? 'sudo npm install -g @earendil-works/pi-coding-agent'
|
|
: 'npm install -g @earendil-works/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: TENANT_ID,
|
|
hasPi,
|
|
hasEnv,
|
|
hasAuth,
|
|
hasRegisteredGroups,
|
|
freebsdVersion: freebsdSupport.detected,
|
|
freebsdRequired: freebsdSupport.required,
|
|
},
|
|
'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,
|
|
TENANT_ID,
|
|
FREEBSD_VERSION: freebsdSupport.detected,
|
|
FREEBSD_MAJOR: freebsdSupport.major ?? 'unknown',
|
|
FREEBSD_REQUIRED: freebsdSupport.required,
|
|
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);
|
|
}
|
|
}
|