import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { Check, NpmMetadata } from './types'; interface PackageJson { dependencies?: Record; devDependencies?: Record; } /** * Real-npm fetcher used by default. Returns null when the package * doesn't exist or the registry is unreachable; the orchestrator turns * that into a synthetic offline-skip warning rather than failing. */ export async function defaultFetchNpmMetadata(packageName: string): Promise { try { const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`); if (!res.ok) return null; const body = (await res.json()) as { 'dist-tags'?: { latest?: string } }; const latest = body['dist-tags']?.latest; if (!latest) return null; return { name: packageName, latestVersion: latest }; } catch { return null; } } /** * Strips a leading semver range operator (`^`, `~`, `>=`, etc.) from a * version string. We only inspect the first major/minor/patch trio for * drift comparisons; range semantics don't matter here. */ function stripRange(version: string): string { return version.replace(/^[\^~>=<\s]+/, '').trim(); } /** * Splits a semver-like version into [major, minor, patch] integers. * Returns null if the string doesn't have at least a major component. */ function parseVersion(version: string): [number, number, number] | null { const stripped = stripRange(version); const parts = stripped.split(/[+\-.]/, 3); if (parts.length === 0) return null; const [maj, min = '0', pat = '0'] = parts; const M = Number.parseInt(maj, 10); if (Number.isNaN(M)) return null; return [M, Number.parseInt(min, 10) || 0, Number.parseInt(pat, 10) || 0]; } function compareSemver(a: [number, number, number], b: [number, number, number]): number { for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] - b[i]; } return 0; } /** * Per-`@celilo/*`-dep freshness check. * * - `ok` — installed version equals the npm latest. * - `warn` — installed within the same major as latest but behind. A * within-major bump is painless; the user can take it whenever. * - `fail` — latest is in a higher major than installed. Bumping is a * breaking change; the user needs to read the migration notes. * * Non-`@celilo/*` deps are not our problem — npm itself flags those. * * The npm fetch is injectable via the orchestrator; tests pass a stub * so the suite isn't network-dependent. */ export async function checkWorkspaceDeps( modulePath: string, fetchNpmMetadata: (name: string) => Promise = defaultFetchNpmMetadata, ): Promise { const pkgPath = join(modulePath, 'package.json'); let pkg: PackageJson; try { const raw = await readFile(pkgPath, 'utf-8'); pkg = JSON.parse(raw) as PackageJson; } catch { return [ { category: 'workspace_dep', name: 'package.json', status: 'ok', message: 'no package.json — skipping workspace-dep check', }, ]; } const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }; const celiloDeps = Object.entries(allDeps).filter(([name]) => name.startsWith('@celilo/')); if (celiloDeps.length === 0) { return [ { category: 'workspace_dep', name: '@celilo/*', status: 'ok', message: 'package.json declares no @celilo/* dependencies', }, ]; } const checks: Check[] = []; let networkFailures = 0; for (const [name, declared] of celiloDeps) { const meta = await fetchNpmMetadata(name); if (!meta) { networkFailures++; continue; } const installed = parseVersion(declared); const latest = parseVersion(meta.latestVersion); if (!installed || !latest) { checks.push({ category: 'workspace_dep', name, status: 'warn', message: `couldn't parse versions (${declared} vs ${meta.latestVersion}); skipping`, currentValue: declared, suggestedValue: meta.latestVersion, }); continue; } const cmp = compareSemver(installed, latest); if (cmp === 0) { checks.push({ category: 'workspace_dep', name, status: 'ok', message: `${name} ${declared} matches latest ${meta.latestVersion}`, currentValue: declared, }); } else if (installed[0] === latest[0]) { checks.push({ category: 'workspace_dep', name, status: 'warn', message: `${name} ${declared} is behind latest ${meta.latestVersion} (within-major bump available)`, currentValue: declared, suggestedValue: `^${meta.latestVersion}`, }); } else if (installed[0] < latest[0]) { const major = latest[0]; const shortName = name.replace(/^@celilo\//, ''); checks.push({ category: 'workspace_dep', name, status: 'fail', message: `${name} ${declared} is across a major bump from latest ${meta.latestVersion} (BREAKING)`, currentValue: declared, suggestedValue: `^${meta.latestVersion}`, migrationUrl: `https://celilo.computer/docs/migrations/${shortName}-${major}`, }); } else { checks.push({ category: 'workspace_dep', name, status: 'warn', message: `${name} ${declared} is ahead of npm latest ${meta.latestVersion} (publishing in progress?)`, currentValue: declared, }); } } if (networkFailures > 0 && checks.length === 0) { checks.push({ category: 'workspace_dep', name: '@celilo/*', status: 'warn', message: `workspace-dep check skipped: couldn't reach npm for ${networkFailures} package(s)`, }); } else if (networkFailures > 0) { checks.push({ category: 'workspace_dep', name: '@celilo/* (partial)', status: 'warn', message: `${networkFailures} package(s) skipped: couldn't reach npm`, }); } return checks; }