/** * Framework self-update on an upstream publish (module-orchestrator-primitives * slice 7, tasks.md 7.2 / design D6). * * This was `modules/celilo-mgmt/scripts/on_upstream_publish.sh` — a * module-declared bash hook spawned detached by the build-bus dispatcher with * the whole of celilo's `process.env` (design D6's second execution path). The * work is celilo replacing celilo's own binaries on the management host, which * is class H one more time: none of it wanted to be a module hook, and the * same publish event reaches this function in celilo's own process. * * The trigger survives; the execution path does not. The dispatcher calls * `planSelfUpdate` on every verified PublishEvent and runs the update only * where the control-plane module is installed — the same gate that decided * whether celilo-mgmt's hook existed at all, since the module is deployed * exactly once, on the management host. * * Pure planning (`planSelfUpdate`) is split from execution (`runSelfUpdate`) * so a test can assert the match rules without spawning a package manager, * and the package-manager calls are injectable (Rule 2.3) so no test touches * the operator's real global install. */ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { PublishEvent } from '@celilo/event-bus/build-bus'; /** * The binary packages celilo keeps globally installed on the management host. * Only these self-update: the other `@celilo/*` packages are transitive deps * of the CLI's bundled install, have no standalone global binary, and a * `bun add -g` of them would be a no-op. This was celilo-mgmt's manifest * comment ("two entries instead of one packagePattern='@celilo/*'"); it moves * here with the work. */ const BINARY_PACKAGES = { '@celilo/cli': 'celilo', '@celilo/e2e': 'cele2e', } as const satisfies Record; export type BinaryPackage = keyof typeof BINARY_PACKAGES; /** What the update will do. Carries everything `runSelfUpdate` needs. */ export interface SelfUpdatePlan { packageName: BinaryPackage; version: string; /** The binary whose `--version` proves the update took. */ binary: (typeof BINARY_PACKAGES)[BinaryPackage]; } /** * Pure: does this publish event self-update the management host? * The old manifest's match rules (registry npm, tag latest, package in the * binary pair) are unchanged — they moved from YAML into the framework with * the rest of the work. */ export function planSelfUpdate(event: PublishEvent): SelfUpdatePlan | null { if (event.registry !== 'npm' || event.tag !== 'latest') return null; const binary = BINARY_PACKAGES[event.package.name as BinaryPackage]; if (!binary) return null; return { packageName: event.package.name as BinaryPackage, version: event.package.version, binary, }; } export interface SelfUpdateResult { packageName: BinaryPackage; version: string; /** Globally installed version before the update; null when not installed. */ beforeVersion: string | null; /** False when the install step failed — the operator follows up. */ updated: boolean; /** True when the binary answered `--version` after the update. */ verified: boolean; error?: string; } /** * The seams a test injects. Defaults do the real work against * `$HOME/.bun/install/global/node_modules`. */ export interface SelfUpdateDeps { /** Root of the bun global install. Default: `$HOME/.bun/install/global/node_modules`. */ globalRoot?: string; install?: (packageName: BinaryPackage, version: string) => { ok: boolean; output: string }; verify?: (binary: string) => { ok: boolean; output: string }; } function defaultGlobalRoot(): string { return join(process.env.HOME ?? '', '.bun', 'install', 'global', 'node_modules'); } function defaultInstall(packageName: BinaryPackage, version: string) { const result = spawnSync('bun', ['add', '-g', `${packageName}@${version}`], { encoding: 'utf-8', }); return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}` }; } function defaultVerify(binary: string) { const result = spawnSync(binary, ['--version'], { encoding: 'utf-8' }); return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() }; } /** * Run one self-update plan. Never throws — the outcome is the operator's * signal, not a publish blocker (the old hook's exit-code semantics, kept). * * Records the installed version first so a failed update leaves the operator * a `bun add -g @` rollback target, exactly as the bash * hook printed it. */ export function runSelfUpdate(plan: SelfUpdatePlan, deps: SelfUpdateDeps = {}): SelfUpdateResult { const globalRoot = deps.globalRoot ?? defaultGlobalRoot(); const install = deps.install ?? defaultInstall; const verify = deps.verify ?? defaultVerify; const base: SelfUpdateResult = { packageName: plan.packageName, version: plan.version, beforeVersion: null, updated: false, verified: false, }; const pkgJsonPath = join(globalRoot, plan.packageName, 'package.json'); if (existsSync(pkgJsonPath)) { try { const parsed = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as { version?: string }; if (typeof parsed.version === 'string') base.beforeVersion = parsed.version; } catch (error) { // A global package.json that does not parse is unusual but must not // block the update — record it and carry on (Rule 6.2: the swallow is // surfaced in the result the dispatcher logs). base.error = `could not read current version: ${(error as Error).message}`; } } const installed = install(plan.packageName, plan.version); if (!installed.ok) { return { ...base, error: `bun add -g failed: ${installed.output.trim().slice(0, 500)}` }; } base.updated = true; const checked = verify(plan.binary); if (!checked.ok) { return { ...base, error: `${plan.binary} --version failed after update: ${checked.output.slice(0, 300)}`, }; } return { ...base, verified: true }; }