/** * Pure-read utilities shared across the publish planner + executor. * * Nothing here mutates state — these helpers query npm / git / fs to * inform planning decisions. The planner is "logically pure" (same * world state → same plan) even though it makes I/O calls, because * everything in this file is a read. * * Write-side helpers (rewriteWorkspaceDeps, restorePackageJson, etc.) * live alongside the executors in their respective phase files. */ import { execSync, spawnSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { publishableSet, readWorkspace } from '../../../../../../scripts/workspace-graph'; import type { PackageJson, WorkspaceVersionMap } from './types'; // REPO_ROOT discovery — this file lives at // apps/celilo/src/cli/commands/publish/helpers.ts, six dirs deep. // Kept as a relative path (matches existing convention in // services/module-types-drift.test.ts and friends) rather than // `git rev-parse` to avoid a subprocess on every publish startup. export const REPO_ROOT = resolve(import.meta.dir, '../../../../../..'); export const ENV_FILE = join(REPO_ROOT, '.env'); /** * The publishable workspace packages (repo-relative dirs), topologically * ordered leaves-first — the order the executor publishes in, so a dep is on * npm before its dependent references it. DERIVED from the live workspace * (every non-private @celilo/* package), never a hardcoded list: a hand-list is * exactly what left @celilo/core/mcp/visualizer invisible to the driver and let * npm-consumer-smoke rot (apps/celilo/designs/WORKSPACE_PACKAGE_GRAPH.md). * * A FUNCTION, not a module-load const: the publish command ships in the * consumer @celilo/cli, and readWorkspace() against a consumer's non-monorepo * install would throw at import — bricking the whole CLI. Deriving lazily means * the disk read happens only when a publish actually runs (in the monorepo). */ export function getPublishPackages(): string[] { return publishableSet(readWorkspace(REPO_ROOT)).map((p) => p.dir); } export function readPkg(dir: string): PackageJson { return JSON.parse(readFileSync(join(REPO_ROOT, dir, 'package.json'), 'utf-8')); } export function isPublished(name: string, version: string): boolean { const r = spawnSync('npm', ['view', `${name}@${version}`, 'version'], { stdio: ['ignore', 'pipe', 'pipe'], }); return r.status === 0; } export function currentGitHead(): string { return execSync('git rev-parse HEAD', { encoding: 'utf-8', cwd: REPO_ROOT }).trim(); } /** * Find the most recent commit touching any of the given pathspecs. Used * by the stale-version check to compare "last src change" vs "last * package.json change." */ export function lastCommitTouching(pathspec: string[]): string | null { const r = spawnSync('git', ['log', '-1', '--format=%H', '--', ...pathspec], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', }); if (r.status !== 0) return null; const sha = r.stdout.trim(); return sha || null; } export function isAncestor(maybeAncestor: string, descendant: string): boolean { const r = spawnSync('git', ['merge-base', '--is-ancestor', maybeAncestor, descendant], { cwd: REPO_ROOT, stdio: 'ignore', }); return r.status === 0; } export function buildWorkspaceVersionMap(): WorkspaceVersionMap { const m = new Map(); for (const pkg of getPublishPackages()) { const { name, version } = readPkg(pkg); if (name && version) m.set(name, version); } return m; } /** * List every publishable module under modules/. Excludes `__archive__/` * (operator-confirmed: not publishable, and renamed with the dunder prefix so * nothing mistakes it for a module) and any dir lacking a * manifest.yml. */ export function listModuleDirs(): string[] { const modulesRoot = join(REPO_ROOT, 'modules'); if (!existsSync(modulesRoot)) return []; const out: string[] = []; for (const name of readdirSync(modulesRoot)) { if (name === '__archive__') continue; const dir = join(modulesRoot, name); let st: ReturnType; try { st = statSync(dir); } catch { continue; } if (!st.isDirectory()) continue; if (!existsSync(join(dir, 'manifest.yml'))) continue; out.push(dir); } return out; } /** * Read `EXTERNAL_PROJECT_PATHS` from .env (space-separated absolute paths). * Returns [] if the file is absent or the var is unset — bumping just * the in-repo modules is still useful on its own. */ export function readExternalProjectPaths(): string[] { if (!existsSync(ENV_FILE)) return []; const content = readFileSync(ENV_FILE, 'utf-8'); for (const line of content.split('\n')) { const m = line.match(/^\s*EXTERNAL_PROJECT_PATHS\s*=\s*(.+?)\s*$/); if (!m) continue; const raw = m[1].replace(/^["']|["']$/g, ''); return raw.split(/\s+/).filter(Boolean); } return []; } /** * Read `NPM_PUBLISH_TARGET` from .env — the registry URL `celilo publish` * points `bun publish --registry` at for @celilo/* packages (a deployed * npm-cache-node). Unset → publish to npmjs (current behavior). See * openspec/changes/private-npm-registry/proposal.md Phase 3.1 / openspec/changes/publilo-cli/proposal.md decision 10. */ export function readNpmPublishTarget(): string | null { if (!existsSync(ENV_FILE)) return null; const content = readFileSync(ENV_FILE, 'utf-8'); for (const line of content.split('\n')) { const m = line.match(/^\s*NPM_PUBLISH_TARGET\s*=\s*(.+?)\s*$/); if (!m) continue; const raw = m[1].replace(/^["']|["']$/g, '').trim(); return raw || null; } return null; } /** * Recursively find every package.json under a root, skipping * node_modules and common build-output dirs (so we don't try to rewrite * lock-installed copies — those get refreshed by `bun install`). */ export function findPackageJsons(root: string): string[] { const out: string[] = []; const SKIP_DIRS = new Set(['node_modules', '.bun', 'dist', '.nx', '.git']); function walk(dir: string): void { let entries: string[]; try { entries = readdirSync(dir); } catch { return; } for (const name of entries) { if (SKIP_DIRS.has(name)) continue; const full = join(dir, name); let st: ReturnType; try { st = statSync(full); } catch { continue; } if (st.isDirectory()) { walk(full); } else if (name === 'package.json') { out.push(full); } } } walk(root); return out; } /** Strip semver operators (^, ~, =, >=, etc.) to get the bare version. */ export function bareVersion(spec: string): string { return spec.replace(/^[\s^~=><]+/, '').trim(); } /** * Re-apply the same operator that was on the old spec. An exact pin * (`"0.1.9"`) stays exact (`"0.1.10"`). A caret pin (`"^0.1.9"`) stays * caret (`"^0.1.10"`). Don't widen exact pins to caret silently — * that masks a deliberate operator choice. */ export function withOperator(oldSpec: string, newVersion: string): string { if (oldSpec.startsWith('workspace:')) return oldSpec; if (/^[a-z]+:/.test(oldSpec) && !oldSpec.startsWith('npm:')) return oldSpec; const m = oldSpec.match(/^(\^|~|>=|<=|=|>|<)?/); const op = m?.[1] ?? ''; return `${op}${newVersion}`; } /** * Read the installed version of `` from bun's global node_modules. * Returns null if the package isn't installed there (or its package.json * is unreadable/malformed — we'd rather warn loudly later than crash here). */ export function readGlobalInstalledVersion(name: string): string | null { const pkgJsonPath = join( process.env.HOME ?? '', '.bun', 'install', 'global', 'node_modules', ...name.split('/'), 'package.json', ); if (!existsSync(pkgJsonPath)) return null; try { const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as PackageJson; return pkg.version ?? null; } catch { return null; } } /** * Look up the npm-latest version of every package we manage. Runs * regardless of whether anything was published this session — the * goal is to drag consumers up to current head, not just to apply * the deltas from this run. */ export function fetchLatestVersions(): Map { const out = new Map(); for (const pkg of getPublishPackages()) { const { name } = readPkg(pkg); if (!name) continue; const r = spawnSync('npm', ['view', name, 'version'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', }); if (r.status !== 0) continue; const version = r.stdout.trim(); if (version) out.set(name, version); } return out; }