import { chmodSync, copyFileSync, existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; // The iris sidecar: a builder wearing a supervisor's clothes. // // The install pipeline installs packages and fetches release binaries, but deliberately runs no build // step — so compiling a Rust crate is this plugin's own job, and the sidecar is the only process the // platform starts for a plugin. First start pays for the build (minutes — watch `pm2 logs officer-iris`); // every later start finds the binary and settles into a heartbeat. // // Everything lands where the platform already looks: `~/.local/bin` is first on the probe path in // os-deps.ts, so `iris` and `iris-browser` are found by whoever asks, without anyone's PATH changing. const name = 'officer-iris'; const CRATE = 'iris-screenshot'; // Upstream's declared rust-version. Distro cargo lags real Rust by a year or more, so this is checked // BEFORE building — a clear refusal with instructions beats cargo's own MSRV error buried in build spam. const MSRV: readonly [number, number] = [1, 88]; const home = homedir(); const localBin = join(home, '.local', 'bin'); // `Bun.which` alone reads the PATH this process started with, which under PM2 is minimal. Look where // things actually are: our install target first, rustup's home second, then whatever PATH says. const searchPath = (): string => `${localBin}:${join(home, '.cargo', 'bin')}:${process.env.PATH ?? ''}`; const which = (binary: string): string | null => Bun.which(binary, { PATH: searchPath() }); const log = (message: string): void => console.log(`[${name}] ${message}`); /** The wrapper ships in this repository; agents need it on a path they can name. Overwrite = upgrade. */ function installWrapper(): void { const source = join(import.meta.dir, '..', 'bin', 'iris-browser'); const target = join(localBin, 'iris-browser'); mkdirSync(localBin, { recursive: true }); copyFileSync(source, target); chmodSync(target, 0o755); log(`iris-browser installed to ${target}`); } /** `cargo 1.75.0 (…)` → [1, 75], or null when it will not even say. */ function cargoVersion(cargo: string): [number, number] | null { try { const out = Bun.spawnSync([cargo, '--version'], { stdout: 'pipe', stderr: 'pipe' }); const match = new TextDecoder().decode(out.stdout).match(/cargo (\d+)\.(\d+)/); return match ? [Number(match[1]), Number(match[2])] : null; } catch { return null; } } const meetsMsrv = ([major, minor]: [number, number]): boolean => major > MSRV[0] || (major === MSRV[0] && minor >= MSRV[1]); /** * Build iris into ~/.local/bin. Streams cargo's output straight to PM2's log, because a silent * multi-minute compile is indistinguishable from a hang. * * `--locked` builds against upstream's committed lockfile rather than re-resolving dependencies at * install time — the platform's frozen-lockfile rule, honoured one language over. */ async function build(cargo: string): Promise { log(`building ${CRATE} — first start only, takes a few minutes`); const proc = Bun.spawn([cargo, 'install', CRATE, '--locked', '--root', join(home, '.local')], { stdout: 'inherit', stderr: 'inherit', }); const code = await proc.exited; if (code !== 0) { log(`build failed (cargo exited ${code}) — read the output above; restart this process to retry`); return false; } return true; } /** True when the binary is usable. Says why not, with the fix, when it is not. */ async function ensureIris(): Promise { const present = which('iris'); if (present) { log(`iris ready at ${present}`); return true; } const cargo = which('cargo'); if (!cargo) { log('cargo is not on PATH — the install should have provided it; install rust and restart this process'); return false; } const version = cargoVersion(cargo); if (!version || !meetsMsrv(version)) { log(`${cargo} is ${version ? `cargo ${version.join('.')}` : 'unidentifiable'}, and ${CRATE} needs ${MSRV.join('.')}+`); log('your distribution\'s rust is too old. Install rustup as the user officer runs as:'); log(" curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal"); log('then restart this process: pm2 restart officer-iris'); return false; } if (!(await build(cargo))) return false; const built = which('iris'); if (!built) { log('cargo reported success but iris is not in ~/.local/bin — read the build output above'); return false; } log(`iris built and ready at ${built}`); return true; } log(`started (pid ${process.pid})`); installWrapper(); let ready = await ensureIris(); // The heartbeat re-probes rather than repeating a remembered answer: a binary deleted out from under us // shows up here, and a rustup installed after a refusal turns the next restart into a build — which the // log has already told the owner. Building inside the interval is deliberately avoided: a periodic // compile that nobody asked for is not a heartbeat. const beat = setInterval(() => { const present = which('iris'); if (present && !ready) { ready = true; log(`iris appeared at ${present}`); } else if (!present && ready) { ready = false; log('iris is GONE from PATH — restart this process to rebuild'); } else { log(`alive — iris ${present ? 'ready' : 'missing'}`); } }, 300_000); const shutdown = (signal: string): void => { log(`${signal} — exiting`); clearInterval(beat); process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));