/** * ISS-0104: a module's hooks resolve `@celilo/*` from the module's OWN * `scripts/node_modules` (nearest-wins), and those are gitignored local * installs that go stale. The publish bundles them as-is, so a deployed module * can silently run capability code months older than what was just published * (e.g. caddy shipped capabilities@0.1.8 while the workspace was at 0.4.1, so * ISS-0102 never reached the rendered Caddyfile). * * Before packing, the publish refreshes a module's `scripts/` deps and verifies * the bundled `@celilo/*` versions match the workspace — the versions being * co-published. A mismatch fails the publish (unless --allow-stale). */ import { execSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; /** The @celilo workspace packages a module may bundle (by published name). */ const CELILO_WORKSPACE_PACKAGES = ['capabilities', 'cli-display', 'event-bus'] as const; export interface DepMismatch { pkg: string; bundled: string; workspace: string; } /** * Pure: which bundled `@celilo/*` versions differ from the workspace. Only deps * the module actually bundles AND the workspace owns are compared — a module * that doesn't bundle a given package, or a package the workspace doesn't own, * is ignored. */ export function findStaleBundledDeps( bundled: Record, workspace: Record, ): DepMismatch[] { const mismatches: DepMismatch[] = []; for (const [pkg, wsVersion] of Object.entries(workspace)) { const bundledVersion = bundled[pkg]; if (bundledVersion && bundledVersion !== wsVersion) { mismatches.push({ pkg, bundled: bundledVersion, workspace: wsVersion }); } } return mismatches; } function readVersion(packageJsonPath: string): string | undefined { try { const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { version?: string }; return parsed.version; } catch { return undefined; } } /** Find the monorepo root (the dir with `packages/capabilities`) by walking up. */ function findWorkspaceRoot(start: string): string | undefined { let dir = start; for (let depth = 0; depth < 8; depth++) { if (existsSync(join(dir, 'packages', 'capabilities', 'package.json'))) return dir; const parent = dirname(dir); if (parent === dir) break; dir = parent; } return undefined; } export interface RefreshResult { /** Whether the module had a scripts/ package to refresh. */ refreshed: boolean; /** Bundled @celilo/* that don't match the workspace (empty = clean). */ mismatches: DepMismatch[]; } /** * Refresh a module's `scripts/` deps (so the bundled hook runtime is current) * and verify the bundled `@celilo/*` versions match the workspace. No-op for a * module without `scripts/package.json`, or when run outside the monorepo (a * standalone module has no workspace to compare against — refresh only). * * `run` is injected so tests can stub the `bun install` side effect. */ export function refreshAndVerifyBundledDeps( moduleDir: string, run: (cmd: string, cwd: string) => void = (cmd, cwd) => void execSync(cmd, { cwd, stdio: 'pipe' }), ): RefreshResult { const scriptsDir = join(moduleDir, 'scripts'); if (!existsSync(join(scriptsDir, 'package.json'))) { return { refreshed: false, mismatches: [] }; } // Reconcile node_modules with the lockfile + package.json pins so the pack // bundles a CURRENT closure, not whatever the operator last installed. run('bun install', scriptsDir); const workspaceRoot = findWorkspaceRoot(moduleDir); if (!workspaceRoot) { return { refreshed: true, mismatches: [] }; } const workspace: Record = {}; for (const name of CELILO_WORKSPACE_PACKAGES) { const version = readVersion(join(workspaceRoot, 'packages', name, 'package.json')); if (version) workspace[`@celilo/${name}`] = version; } const bundled: Record = {}; for (const pkg of Object.keys(workspace)) { const version = readVersion(join(scriptsDir, 'node_modules', pkg, 'package.json')); if (version) bundled[pkg] = version; } return { refreshed: true, mismatches: findStaleBundledDeps(bundled, workspace) }; }