#!/usr/bin/env bun /** * Pack the @celilo/* workspace packages that install.sh resolves through * the e2e npm-registry simulator. * * install.sh runs: * bun add -g @celilo/cli @celilo/event-bus @celilo/e2e * * Which packages must land in the simulator is the transitive runtime closure * of those roots — DERIVED from the live workspace by `installClosure()`, never * hand-listed here. A hand-maintained list is exactly what turned this red for * days when `@celilo/core` was added as a cli dep and forgotten * (apps/celilo/designs/WORKSPACE_PACKAGE_GRAPH.md). * * We rewrite each packed package's `workspace:^` deps to concrete * `^` ranges OURSELVES before `bun pm pack` — we do NOT rely * on bun's lock-based expansion, which goes stale after a cross-caret version * bump and pins dependents to an unresolvable old range (ISS-0103: a `0.3→0.4` * capabilities bump left the lock at 0.3, so packed cli pinned * `^0.3.0-alpha.0`, excluding the 0.4.x the sim served). The rewrite map is the * FULL workspace version map (every @celilo/* package), so any `workspace:` dep * that can't be resolved is a genuine error and throws (see rewriteWorkspacePins). * package.json is restored after packing so `workspace:^` stays in source. * * Output directory is typically `packages/e2e/.npm-registry-cache/` — * staged into the npm-registry-sim Docker image at build time. * * Usage: * bun run packages/e2e/scripts/pack-celilo-packages.ts [--dest=] */ import { execSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { installClosure, readWorkspace, workspaceVersionMap, } from '../../../scripts/workspace-graph'; import { ensureRegistryServerBundle } from '../src/registry-bundle'; import { computeSourceFingerprint } from '../src/source-fingerprint'; /** * Resolve the celilo monorepo root by walking up from `startDir` (the caller's * cwd by default) looking for `apps/celilo/package.json`. * * This replaces a hardcoded `join(import.meta.dir, '..', '..', '..')`. That * derivation assumed the script always lives at `/packages/e2e/scripts/`, * which holds in a checkout and nowhere else: the script ships inside the * published `@celilo/e2e` tarball, where `../../..` resolves to the consumer's * `node_modules/` and every path built from it points at * `node_modules/apps/celilo/...` → ENOENT (ce-dae). `build.ts` already spawns * this script with `cwd: `, so the caller is the single source of * truth for where the workspace is — take it from there, don't re-derive it. * Walking up (rather than requiring cwd to BE the root exactly) also lets it run * from a subdir of the checkout, e.g. `bun test` from `packages/e2e`. */ export function resolveRepoRoot(startDir: string = process.cwd()): string { let dir = startDir; for (let i = 0; i < 10; i++) { if (existsSync(join(dir, 'apps', 'celilo', 'package.json'))) return dir; const parent = resolve(dir, '..'); if (parent === dir) break; dir = parent; } throw new Error( `pack-celilo-packages: no celilo checkout found from '${startDir}' (looked for apps/celilo/package.json walking up). This is a monorepo-internal dev script — run it from a checkout via 'cele2e build-infra'.`, ); } // Lazily memoized so importing this module (e.g. the rewriteWorkspacePins unit // test) never triggers resolution — only the packing path, which runs inside a // checkout, resolves the root. let _repoRoot: string | undefined; function repoRoot(): string { if (_repoRoot === undefined) { _repoRoot = resolveRepoRoot(); } return _repoRoot; } interface PackageJson { name?: string; version?: string; dependencies?: Record; devDependencies?: Record; peerDependencies?: Record; optionalDependencies?: Record; } export interface WorkspaceRewrite { depName: string; oldSpec: string; newSpec: string; } const DEP_BUCKETS = [ 'dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies', ] as const; /** * Rewrite `workspace:` deps in `pkg` to concrete ranges using the * name→version map, mutating `pkg`. Mirrors the publish flow's * `rewriteWorkspaceDeps`: * workspace:^ → ^ * workspace:~ → ~ * workspace:* / workspace: → * (any version) * workspace:X.Y.Z → X.Y.Z (explicit, passes through) * * We do this ourselves rather than trust `bun pm pack`'s lock-based * expansion, which goes stale after a cross-caret version bump (ISS-0103). * Only deps present in `versions` (our own @celilo/* set) are touched. * Returns the rewrites applied. */ export function rewriteWorkspacePins( pkg: PackageJson, versions: Map, ): WorkspaceRewrite[] { const rewrites: WorkspaceRewrite[] = []; for (const bucket of DEP_BUCKETS) { const deps = pkg[bucket]; if (!deps) continue; for (const [name, spec] of Object.entries(deps)) { if (!spec.startsWith('workspace:')) continue; const version = versions.get(name); if (!version) { throw new Error( `Cannot rewrite ${bucket} "${name}": "${spec}" — ${name} is not a known @celilo/* workspace package. Add it to the workspace, or fix the dep name. (This is the loud failure the old hardcoded pack list lacked — see apps/celilo/designs/WORKSPACE_PACKAGE_GRAPH.md.)`, ); } const range = spec.slice('workspace:'.length); let newSpec: string; if (range === '*' || range === '') newSpec = '*'; else if (range === '^') newSpec = `^${version}`; else if (range === '~') newSpec = `~${version}`; else newSpec = range; if (newSpec === spec) continue; deps[name] = newSpec; rewrites.push({ depName: name, oldSpec: spec, newSpec }); } } return rewrites; } interface CliArgs { destination: string; } function parseArgs(): CliArgs { const defaultDest = join(repoRoot(), 'packages', 'e2e', '.npm-registry-cache'); let destination = defaultDest; for (const arg of process.argv.slice(2)) { if (arg.startsWith('--dest=')) { destination = arg.slice('--dest='.length); } else if (arg === '-h' || arg === '--help') { console.log('Usage: bun run pack-celilo-packages.ts [--dest=]'); process.exit(0); } } return { destination }; } function pack(pkgPath: string, destination: string, versions: Map): string { const fullPath = join(repoRoot(), pkgPath); const pkgJsonPath = join(fullPath, 'package.json'); if (!existsSync(pkgJsonPath)) { throw new Error(`No package.json in ${fullPath}`); } // Rewrite workspace:^ deps to concrete ranges ourselves (see header / // ISS-0103), then restore the original so workspace:^ stays in source. const originalPkgJson = readFileSync(pkgJsonPath, 'utf-8'); const parsed = JSON.parse(originalPkgJson) as PackageJson; const rewrites = rewriteWorkspacePins(parsed, versions); if (rewrites.length > 0) { const trailingNl = originalPkgJson.endsWith('\n') ? '\n' : ''; writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + trailingNl); for (const r of rewrites) { console.log(` rewrote ${r.depName}: ${r.oldSpec} → ${r.newSpec}`); } } try { // Capture filenames before/after so we can identify the tarball this // pack call produced even when the destination already contains tarballs // from earlier packages in the loop. const before = new Set(readdirSync(destination).filter((f) => f.endsWith('.tgz'))); execSync(`bun pm pack --destination=${destination}`, { cwd: fullPath, stdio: 'inherit', }); const after = readdirSync(destination).filter((f) => f.endsWith('.tgz')); const produced = after.filter((f) => !before.has(f)); if (produced.length !== 1) { throw new Error( `Expected exactly one new tarball from packing ${pkgPath}, got ${produced.length}: ${produced.join(', ')}`, ); } return produced[0]; } finally { writeFileSync(pkgJsonPath, originalPkgJson); } } function main(): void { const { destination } = parseArgs(); // Always start from a clean directory so stale tarballs from a prior // run (different versions, removed packages) can't leak into the image. rmSync(destination, { recursive: true, force: true }); mkdirSync(destination, { recursive: true }); // @celilo/e2e's `files[]` ships `registry-server/`, but that directory is a // .gitignored build artifact — absent in a fresh checkout, so `bun pm pack` // would silently pack an e2e tarball WITHOUT it. A monorepo-free consumer's // `cele2e build-infra` then dies in buildDockerImages (ensureRegistryServerBundle: // "published without its registry-server bundle"). Regenerate it from the // sibling packages/registry-server before packing, exactly as buildDockerImages // does in-monorepo. (This is why npm-consumer-smoke was red — ce-3rs.) ensureRegistryServerBundle(join(repoRoot(), 'packages', 'e2e')); console.log(`Packing @celilo/* packages → ${destination}`); console.log(''); // Derive the package set + version map from the live workspace (never a // hardcoded list). The rewrite map is the FULL workspace (every @celilo/* // package) so any workspace: dep resolves or throws; the pack set is the // runtime install closure of install.sh's roots. const workspace = readWorkspace(repoRoot()); const versions = workspaceVersionMap(workspace); const toPack = installClosure(workspace); const results: Array<{ name: string; version: string; tarball: string }> = []; for (const { name, dir, version } of toPack) { console.log(`▸ ${name}@${version} (${dir})`); const tarball = pack(dir, destination, versions); results.push({ name, version, tarball }); console.log(` → ${tarball}`); console.log(''); } console.log(`✓ Packed ${results.length} package(s) to ${destination}`); // Record the tree fingerprint these tarballs were packed FROM, next to the // tarballs. The management-image bake stamps THIS value, not a fingerprint // recomputed from the tree at bake time: the tarballs are what install.sh // actually installs, so the stamp must describe them (celilo#1299 — a bake // that reinstalls a stale cache used to stamp a fingerprint of the newer // tree, claiming CLI content the image did not carry). The npm-registry // server only serves *.tgz, so a JSON file in the same directory is inert. const manifest = { packedAt: new Date().toISOString(), sourceFingerprint: computeSourceFingerprint(repoRoot()), packages: results, }; writeFileSync(join(destination, 'pack-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); } if (import.meta.main) { main(); }