/** * CLI version drift check. * * Compares the running `@celilo/cli` version against the latest version * on npm. A newer published version produces a single `drift` finding; a * network failure (offline, npm down) produces an `unmeasured` one. It used to * produce nothing, which rendered as READY — "I could not ask npm" and "you are * on the latest" are different statements and the operator should be able to * tell them apart (D7). It still does not BLOCK: schema drift and capability * ABI drift are the blocking gates; CLI version is informational. * * `latestVersionFetcher` is injectable so tests don't hit npm. */ import type { DriftFinding } from './types'; export type LatestCliVersionFetcher = () => Promise; /** * Default fetcher — queries the npm registry's metadata endpoint. * * Returns null on any non-2xx or parse failure, so a network blip * doesn't surface as a confusing "drift" finding. */ export const fetchLatestCliVersion: LatestCliVersionFetcher = async () => { try { const resp = await fetch('https://registry.npmjs.org/@celilo/cli', { signal: AbortSignal.timeout(5_000), headers: { Accept: 'application/json' }, }); if (!resp.ok) return null; const data = (await resp.json()) as { 'dist-tags'?: { latest?: string } }; return data['dist-tags']?.latest ?? null; } catch { return null; } }; /** * Strict semver comparison. Returns: * - `-1` if `a < b` * - `0` if `a === b` * - `1` if `a > b` * * Doesn't support pre-release tags (`-beta.1` etc.) — we strip * everything after a `-` and compare the numeric portions only. */ export function compareSemver(a: string, b: string): number { const parse = (v: string): [number, number, number] => { const stripped = v.replace(/^v/, '').split('-')[0].split('+')[0]; const parts = stripped.split('.').map((n) => Number.parseInt(n, 10) || 0); return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; }; const [aa, ab, ac] = parse(a); const [ba, bb, bc] = parse(b); if (aa !== ba) return aa < ba ? -1 : 1; if (ab !== bb) return ab < bb ? -1 : 1; if (ac !== bc) return ac < bc ? -1 : 1; return 0; } export interface CliVersionAuditDeps { /** Version of the running CLI, typically read from `package.json`. */ installedVersion: string; fetcher?: LatestCliVersionFetcher; } export async function auditCliVersion(deps: CliVersionAuditDeps): Promise { const fetcher = deps.fetcher ?? fetchLatestCliVersion; const latest = await fetcher(); if (!latest) { return [ { category: 'cli_version', severity: 'unmeasured', code: 'cli_version_unmeasured', message: `@celilo/cli ${deps.installedVersion}: could not reach the npm registry, so version drift is unknown`, remediation: 'Check outbound network to registry.npmjs.org, then re-audit. This finding records that the comparison did not happen, not that the CLI is current.', actionable: false, subject: 'system', }, ]; } if (compareSemver(deps.installedVersion, latest) >= 0) { return []; // up to date or ahead (dev build) } return [ { category: 'cli_version', severity: 'drift', code: 'cli_version_drift', message: `@celilo/cli ${deps.installedVersion} → ${latest} available`, remediation: 'celilo system update', actionable: true, subject: 'system', }, ]; }