/** * bun global install refresh — Phase 3 of `celilo publish`. * * Force-pins just-published packages into `~/.bun/install/global` so * the operator's `cele2e` / `celilo` binaries end up on the version * this run just shipped (rather than whatever `@latest` resolves to a * moment later). For managed packages NOT published this run, runs * `bun update -g ` to chase npm-latest (catches drift between * publish events). * * Skipped entirely in --alpha mode unless --track-alpha is passed. * When tracking alpha, only force-pins packages we just published — * leaves other managed globals alone (they may already be on their * own alpha streams). */ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { getPublishPackages, readGlobalInstalledVersion, readPkg } from './helpers'; import type { GlobalUpdateItem, PackageJson } from './types'; // ─── Planner ─────────────────────────────────────────────────────── export interface PlanGlobalUpdateInput { /** Versions we just published this run (force-pin targets). */ justPublished: Array<{ name: string; version: string }>; /** True only when `--alpha --track-alpha` was passed. */ trackAlpha: boolean; } /** * Inputs the global-update decision actually depends on. Lifted out * as a type so `decideGlobalUpdates` is pure and unit-testable: the * disk wrapper (`planGlobalUpdate`) reads bun's global package.json * and ~/.bun/install/global/node_modules//package.json then * delegates here. */ export interface GlobalUpdateDecisionInputs { /** Names currently installed globally (intersected with `ourNames`). */ installed: string[]; /** Names this run will publish (already filtered for managed packages). */ justPublished: Array<{ name: string; version: string }>; /** True only when `--alpha --track-alpha` was passed. */ trackAlpha: boolean; /** * Resolver from name → currently-installed version. Pure: caller * fetches versions once and hands them in; we don't re-read disk * per call. `null` means "couldn't read" (treated as unknown). */ installedVersion: (name: string) => string | null; } /** * Pure decision function: produce the per-package `GlobalUpdateItem` * list from the gathered inputs. Encodes the gating semantics of * --track-alpha (filter to just-published only) and the * force-pin-vs-update-pull policy (force-pin when we know the target, * `bun update -g` otherwise). */ export function decideGlobalUpdates(inputs: GlobalUpdateDecisionInputs): GlobalUpdateItem[] { const publishedMap = new Map(inputs.justPublished.map((p) => [p.name, p.version] as const)); let toUpdate = inputs.installed; if (inputs.trackAlpha) { const justPublishedNames = new Set(inputs.justPublished.map((p) => p.name)); toUpdate = toUpdate.filter((n) => justPublishedNames.has(n)); } const items: GlobalUpdateItem[] = []; for (const name of toUpdate) { const before = inputs.installedVersion(name); const expected = publishedMap.get(name); // Force-pin to a known target when we just published it; otherwise // resolve to whatever the executor's `bun update -g` lands on. items.push({ name, installed: before, target: expected ?? before ?? '?', forcePin: !!expected, }); } return items; } export function planGlobalUpdate(input: PlanGlobalUpdateInput): GlobalUpdateItem[] { const globalPkgPath = join(process.env.HOME ?? '', '.bun', 'install', 'global', 'package.json'); if (!existsSync(globalPkgPath)) return []; let globalPkg: PackageJson; try { globalPkg = JSON.parse(readFileSync(globalPkgPath, 'utf-8')); } catch { // Malformed global pkg — caller will surface this differently // (or skip the phase entirely). Returning [] is consistent with // "nothing planned". return []; } const ourNames = new Set( getPublishPackages() .map((p) => readPkg(p).name) .filter((n): n is string => !!n), ); const installed = Object.keys(globalPkg.dependencies ?? {}).filter((n) => ourNames.has(n)); return decideGlobalUpdates({ installed, justPublished: input.justPublished, trackAlpha: input.trackAlpha, installedVersion: readGlobalInstalledVersion, }); } // ─── Executor ────────────────────────────────────────────────────── export function executeGlobalUpdate(items: GlobalUpdateItem[]): void { if (items.length === 0) { return; } const globalPkgPath = join(process.env.HOME ?? '', '.bun', 'install', 'global', 'package.json'); if (!existsSync(globalPkgPath)) { console.log('\nNo bun global install found — skipping global update pass.'); return; } console.log('\n──────────────────────────────────────────────'); console.log(' Updating bun global install'); console.log('──────────────────────────────────────────────'); const mismatches: Array<{ name: string; expected: string; actual: string | null }> = []; for (const item of items) { const before = readGlobalInstalledVersion(item.name); const cmd = item.forcePin ? ['add', '-g', `${item.name}@${item.target}`] : ['update', '-g', item.name]; const r = spawnSync('bun', cmd, { stdio: 'pipe', encoding: 'utf-8' }); const after = readGlobalInstalledVersion(item.name); const delta = before === after ? `unchanged at ${after ?? '?'}` : `${before ?? '?'} → ${after ?? '?'}`; if (r.status !== 0) { console.log(`✗ ${item.name}: ${delta}`); console.log(` bun ${cmd.join(' ')} failed (exit ${r.status})`); if (r.stderr) console.log(` ${r.stderr.trim().split('\n').join('\n ')}`); if (item.forcePin && after !== item.target) { mismatches.push({ name: item.name, expected: item.target, actual: after }); } continue; } if (item.forcePin && after !== item.target) { console.log(`⚠ ${item.name}: ${delta} (expected ${item.target})`); mismatches.push({ name: item.name, expected: item.target, actual: after }); } else { console.log(`✓ ${item.name}: ${delta}`); } } if (mismatches.length > 0) { console.log('\n✗ Global install ended up on the wrong version for some packages:'); for (const m of mismatches) { console.log(` ${m.name}: have ${m.actual ?? '(missing)'}, expected ${m.expected}`); console.log(` fix: bun add -g ${m.name}@${m.expected}`); } console.log( '\n Until this is resolved, your global cele2e/celilo binary is NOT the one this publish just shipped.', ); process.exit(1); } }