/** * Consumer pin bumps — Phase 2 of `celilo publish`. * * Walks the in-repo `modules/`, `apps/`, `packages/` trees and any * external project paths from `.env`'s `EXTERNAL_PROJECT_PATHS`, and * bumps every `@celilo/*` dependency pin to the current npm-latest * version. Preserves the caret/tilde/exact operator each entry already * uses. `workspace:*`/`workspace:^` references are left alone (those * are rewritten at publish time, not consumer-install time). * * Skipped in --alpha mode (consumers opt into @alpha manually). Runs * in --normal and --promote modes. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { REPO_ROOT, bareVersion, fetchLatestVersions, findPackageJsons, readExternalProjectPaths, withOperator, } from './helpers'; import type { ConsumerPinItem, PackageJson } from './types'; // ─── Planner ─────────────────────────────────────────────────────── /** * Build the consumer-pin plan. For each package.json under managed * roots, compute which `@celilo/*` deps would change if we bumped them * to current npm-latest. Returns the file paths + per-bucket update * lists; doesn't write anything. */ export function planConsumerPins(versions?: Map): ConsumerPinItem[] { const latestMap = versions ?? fetchLatestVersions(); if (latestMap.size === 0) return []; const externalPaths = readExternalProjectPaths(); const roots = [ join(REPO_ROOT, 'modules'), join(REPO_ROOT, 'apps'), join(REPO_ROOT, 'packages'), ...externalPaths, ].filter((p) => existsSync(p)); const items: ConsumerPinItem[] = []; for (const root of roots) { for (const pkgPath of findPackageJsons(root)) { const updates = computeBumpUpdates(pkgPath, latestMap); if (updates.length > 0) { items.push({ filePath: pkgPath, updates }); } } } return items; } /** * Pure dep-bump computation. Given a parsed package.json and a map of * `name → latest-version`, returns the list of pin changes that would * be applied. No I/O. Skips: * - deps not in the published map (we only manage our own). * - `workspace:*` / `workspace:^` (those are rewritten at publish * time, not at consumer-install time). * - non-npm protocol specs (`file:`, `git:`, etc. — deliberate * local pins). * - deps already at the target bare version (operator-class match, * `^1.0.0` and `1.0.0` both count as "at version 1.0.0"). */ /** * Compare `major.minor.patch`, ignoring any prerelease suffix. Returns * -1 / 0 / 1. Deliberately tiny: pins only ever move between released * `@celilo/*` versions, so full semver precedence is not needed. */ function compareVersions(a: string, b: string): number { const parse = (v: string) => (v.split('-')[0] ?? '').split('.').map(Number); const pa = parse(a); const pb = parse(b); for (let i = 0; i < 3; i++) { const x = pa[i] ?? 0; const y = pb[i] ?? 0; if (x !== y) return x < y ? -1 : 1; } return 0; } export function bumpUpdatesFor( pkg: PackageJson, published: Map, ): ConsumerPinItem['updates'] { const updates: ConsumerPinItem['updates'] = []; const buckets: Array = [ 'dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies', ]; for (const bucket of buckets) { const deps = pkg[bucket] as Record | undefined; if (!deps) continue; for (const [name, oldSpec] of Object.entries(deps)) { const newVersion = published.get(name); if (!newVersion) continue; if (oldSpec.startsWith('workspace:')) continue; if (/^[a-z]+:/.test(oldSpec) && !oldSpec.startsWith('npm:')) continue; if (bareVersion(oldSpec) === newVersion) continue; // Never walk a pin backwards. Pins are now written during the version // phase from WORKSPACE versions, which are ahead of npm until the publish // lands. Without this, the publish-phase pass — which still reads // npm-latest — would "helpfully" downgrade every pin the version PR just // set, and ship modules bundling the previous capability code. if (compareVersions(newVersion, bareVersion(oldSpec)) < 0) continue; const newSpec = withOperator(oldSpec, newVersion); if (newSpec === oldSpec) continue; updates.push({ bucket, depName: name, oldSpec, newSpec }); } } return updates; } /** * Disk wrapper around `bumpUpdatesFor`. Reads the file, delegates, * returns. Exists so the planner doesn't have to do the read itself. */ function computeBumpUpdates( path: string, published: Map, ): ConsumerPinItem['updates'] { const pkg = JSON.parse(readFileSync(path, 'utf-8')) as PackageJson; return bumpUpdatesFor(pkg, published); } // ─── Executor ────────────────────────────────────────────────────── /** * Apply a consumer-pin plan. For each item, read the file, splice in * the updates, write back. Preserves 2-space indent + trailing newline. */ export function executeConsumerPins(items: ConsumerPinItem[]): void { if (items.length === 0) { console.log('All consumer pins already at npm-latest. Nothing to do.'); return; } console.log('\n──────────────────────────────────────────────'); console.log(' Bumping consumer pins to current npm-latest'); console.log('──────────────────────────────────────────────'); for (const item of items) { const original = readFileSync(item.filePath, 'utf-8'); const pkg = JSON.parse(original) as PackageJson; for (const u of item.updates) { const deps = pkg[u.bucket] as Record | undefined; if (deps) deps[u.depName] = u.newSpec; } const trailingNl = original.endsWith('\n') ? '\n' : ''; writeFileSync(item.filePath, JSON.stringify(pkg, null, 2) + trailingNl); const display = relative(REPO_ROOT, item.filePath); const shown = display.startsWith('..') ? item.filePath : display; console.log(`✎ ${shown}`); for (const u of item.updates) { console.log(` ${u.bucket}.${u.depName}: ${u.oldSpec} → ${u.newSpec}`); } } console.log(`\nUpdated ${items.length} package.json file(s).`); console.log('Review with `git diff` (and in external project paths) before committing.'); console.log('A `bun install` may be needed in each touched project to refresh its lockfile.'); }