/** * Resolve bun `workspace:` protocol build deps in an isolated staging build * (ISS-0147 / celilo#216). * * `celilo package ` stages the module into an isolated temp dir and * runs the manifest `build:` there. A module whose build depends on a sibling * monorepo package via `workspace:` (e.g. celilo-website's site/package.json * `@celilo/visualizer: workspace:^`) then fails: the staging dir has no * workspace root, so `bun install` errors with "Workspace dependency not * found". * * Fix: before the staged build runs, rewrite every `workspace:` spec in the * staged package.json files to a `file:` pointing at the live * monorepo member. bun installs local path deps from the current source — so * the bundle reflects the CURRENT workspace version, never a stale published * one (unlike an npm pin). No hand-rolled CELILO_MODULE_SOURCE_DIR dance. */ import { type Dirent, existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; const DEP_FIELDS = [ 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', ] as const; interface WorkspaceInfo { root: string; /** package name -> absolute member directory */ members: Map; } /** * Walk up from `startDir` to find the monorepo root — the nearest ancestor * package.json declaring a `workspaces` field — and map every member package * name to its absolute directory. Returns null if no workspace root exists. */ function findWorkspaceRoot(startDir: string): WorkspaceInfo | null { let dir = resolve(startDir); for (;;) { const pkgPath = join(dir, 'package.json'); if (existsSync(pkgPath)) { const patterns = readWorkspacePatterns(pkgPath); if (patterns) { return { root: dir, members: collectMembers(dir, patterns) }; } } const parent = dirname(dir); if (parent === dir) return null; dir = parent; } } function readWorkspacePatterns(pkgPath: string): string[] | null { try { const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); // npm/bun accept both `workspaces: [...]` and `workspaces: { packages: [...] }`. if (Array.isArray(pkg.workspaces)) return pkg.workspaces; if (Array.isArray(pkg.workspaces?.packages)) return pkg.workspaces.packages; return null; } catch { return null; } } function collectMembers(root: string, patterns: string[]): Map { const members = new Map(); for (const pattern of patterns) { for (const memberDir of expandGlob(root, pattern)) { const pkgPath = join(memberDir, 'package.json'); if (!existsSync(pkgPath)) continue; try { const name = JSON.parse(readFileSync(pkgPath, 'utf-8')).name; if (typeof name === 'string' && name.length > 0) members.set(name, memberDir); } catch { /* member without a valid package.json — skip */ } } } return members; } /** * Expand a workspace glob against `root`. Supports only the single `*` * per-segment wildcard bun/npm workspace patterns actually use (e.g. * "packages/[star]" or "modules/[star]/site"). ponytail: no globstar/brace * support — no workspace config uses them; add if one ever does. */ function expandGlob(root: string, pattern: string): string[] { let dirs = [root]; for (const segment of pattern.split('/')) { const next: string[] = []; for (const dir of dirs) { if (segment === '*') { let entries: Dirent[]; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (entry.isDirectory()) next.push(join(dir, entry.name)); } } else { const candidate = join(dir, segment); if (existsSync(candidate)) next.push(candidate); } } dirs = next; } return dirs; } function findPackageJsonFiles(dir: string): string[] { const found: string[] = []; let entries: Dirent[]; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return found; } for (const entry of entries) { if (entry.name === 'node_modules' || entry.name === '.git') continue; const full = join(dir, entry.name); if (entry.isDirectory()) { found.push(...findPackageJsonFiles(full)); } else if (entry.name === 'package.json') { found.push(full); } } return found; } /** * Rewrite every `workspace:` dep in the staged build dir's package.json files * to a `file:` path pointing at the live monorepo member. * * @param buildDir - isolated staging dir being built * @param sourceDir - original (unstaged) module dir, used to locate the monorepo root * @returns human-readable list of rewrites performed (for logging) * @throws if a `workspace:` dep can't be resolved to a monorepo member — * loud failure naming the offending dep, never a cryptic downstream error. */ export function rewriteWorkspaceDeps(buildDir: string, sourceDir: string): string[] { const rewrites: string[] = []; let workspace: WorkspaceInfo | null | undefined; for (const pkgPath of findPackageJsonFiles(buildDir)) { let pkg: Record; try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { continue; } let changed = false; for (const field of DEP_FIELDS) { const deps = pkg[field]; if (!deps || typeof deps !== 'object') continue; for (const [name, spec] of Object.entries(deps as Record)) { if (typeof spec !== 'string' || !spec.startsWith('workspace:')) continue; // Lazily locate the workspace root only once a workspace dep exists. if (workspace === undefined) workspace = findWorkspaceRoot(sourceDir); const memberDir = workspace?.members.get(name); if (!memberDir) { throw new Error( `Module build depends on workspace package "${name}" (${field}: "${spec}"), ` + `but it could not be resolved to a monorepo member from ${sourceDir}.\n` + `Ensure "${name}" is a workspace member, or replace the "workspace:" spec with a published version range.`, ); } (deps as Record)[name] = `file:${memberDir}`; rewrites.push(`${name}: ${spec} -> file:${memberDir}`); changed = true; } } if (changed) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2)); } return rewrites; }