/** * Mosaic update checker — compares installed @mosaic/cli version against the * Gitea npm registry and reports when an upgrade is available. * * Used by: * - CLI startup (non-blocking background check) * - session-start.sh (via `mosaic update --check`) * - install.sh (direct invocation) * * Design: * - Result is cached to ~/.cache/mosaic/update-check.json for 1 hour * - Network call is fire-and-forget with a 5 s timeout * - Never throws on failure — returns stale/unknown result */ import { execSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; // ─── Types ────────────────────────────────────────────────────────────────── export interface UpdateCheckResult { /** Currently installed version (empty if not found) */ current: string; /** Latest published version (empty if check failed) */ latest: string; /** True when a newer version is available */ updateAvailable: boolean; /** ISO timestamp of this check */ checkedAt: string; /** Where the latest version was resolved from */ registry: string; } // ─── Constants ────────────────────────────────────────────────────────────── const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/'; const CLI_PKG = '@mosaic/cli'; const CACHE_DIR = join(homedir(), '.cache', 'mosaic'); const CACHE_FILE = join(CACHE_DIR, 'update-check.json'); const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour const NETWORK_TIMEOUT_MS = 5_000; // ─── Helpers ──────────────────────────────────────────────────────────────── function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string { try { return execSync(`npm ${args}`, { encoding: 'utf-8', timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], }).trim(); } catch { return ''; } } /** * Rudimentary semver "a < b" — handles pre-release tags. * Per semver spec: 1.0.0-alpha.1 < 1.0.0 (pre-release has lower precedence). */ export function semverLt(a: string, b: string): boolean { if (!a || !b) return false; const stripped = (v: string) => v.replace(/^v/, ''); const splitPre = (v: string): [string, string | null] => { const idx = v.indexOf('-'); return idx === -1 ? [v, null] : [v.slice(0, idx), v.slice(idx + 1)]; }; const sa = stripped(a); const sb = stripped(b); const [coreA, preA] = splitPre(sa); const [coreB, preB] = splitPre(sb); // Compare core version (major.minor.patch) const partsA = coreA.split('.').map(Number); const partsB = coreB.split('.').map(Number); const len = Math.max(partsA.length, partsB.length); for (let i = 0; i < len; i++) { const va = partsA[i] ?? 0; const vb = partsB[i] ?? 0; if (va < vb) return true; if (va > vb) return false; } // Core versions are equal — compare pre-release // No pre-release > any pre-release (1.0.0 > 1.0.0-alpha) if (preA !== null && preB === null) return true; if (preA === null && preB !== null) return false; if (preA === null && preB === null) return false; // Both have pre-release — compare dot-separated identifiers const idsA = preA!.split('.'); const idsB = preB!.split('.'); const preLen = Math.max(idsA.length, idsB.length); for (let i = 0; i < preLen; i++) { const ia = idsA[i]; const ib = idsB[i]; // Fewer fields = lower precedence if (ia === undefined && ib !== undefined) return true; if (ia !== undefined && ib === undefined) return false; const na = /^\d+$/.test(ia!) ? parseInt(ia!, 10) : null; const nb = /^\d+$/.test(ib!) ? parseInt(ib!, 10) : null; // Numeric vs string: numeric < string if (na !== null && nb !== null) { if (na < nb) return true; if (na > nb) return false; continue; } if (na !== null && nb === null) return true; if (na === null && nb !== null) return false; // Both strings if (ia! < ib!) return true; if (ia! > ib!) return false; } return false; } // ─── Cache ────────────────────────────────────────────────────────────────── /** Cache stores only the latest registry version (the expensive network call). * The installed version is always checked fresh — it's a local `npm ls`. */ interface RegistryCache { latest: string; checkedAt: string; registry: string; } function readCache(): RegistryCache | null { try { if (!existsSync(CACHE_FILE)) return null; const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as RegistryCache; const age = Date.now() - new Date(raw.checkedAt).getTime(); if (age > CACHE_TTL_MS) return null; return raw; } catch { return null; } } function writeCache(entry: RegistryCache): void { try { mkdirSync(CACHE_DIR, { recursive: true }); writeFileSync(CACHE_FILE, JSON.stringify(entry, null, 2) + '\n', 'utf-8'); } catch { // Best-effort — cache is not critical } } // ─── Public API ───────────────────────────────────────────────────────────── /** * Get the currently installed version of @mosaic/cli. * Returns empty string if not installed. */ export function getInstalledVersion(): string { // Fast path: check via package.json require chain try { const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000); if (raw) { const data = JSON.parse(raw) as { dependencies?: Record; }; return data?.dependencies?.[CLI_PKG]?.version ?? ''; } } catch { // fall through } return ''; } /** * Fetch the latest published version from the Gitea npm registry. * Returns empty string on failure. */ export function getLatestVersion(): string { return npmExec(`view ${CLI_PKG} version --registry=${REGISTRY}`); } /** * Perform an update check — uses registry cache when fresh, always checks * installed version fresh (local npm ls is cheap, caching it causes stale * "update available" banners after an upgrade). * Never throws. */ export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult { const current = getInstalledVersion(); let latest: string; let checkedAt: string; if (!options?.skipCache) { const cached = readCache(); if (cached) { latest = cached.latest; checkedAt = cached.checkedAt; } else { latest = getLatestVersion(); checkedAt = new Date().toISOString(); writeCache({ latest, checkedAt, registry: REGISTRY }); } } else { latest = getLatestVersion(); checkedAt = new Date().toISOString(); writeCache({ latest, checkedAt, registry: REGISTRY }); } return { current, latest, updateAvailable: !!(current && latest && semverLt(current, latest)), checkedAt, registry: REGISTRY, }; } /** * Format a human-readable update notice. Returns empty string if up-to-date. */ export function formatUpdateNotice(result: UpdateCheckResult): string { if (!result.updateAvailable) return ''; const lines = [ '', '╭─────────────────────────────────────────────────╮', '│ │', `│ Update available: ${result.current} → ${result.latest}`.padEnd(50) + '│', '│ │', '│ Run: bash tools/install.sh │', '│ Or: npm i -g @mosaic/cli@latest │', '│ │', '╰─────────────────────────────────────────────────╯', '', ]; return lines.join('\n'); } /** * Non-blocking update check that prints a notice to stderr if an update * is available. Designed to be called at CLI startup without delaying * the user. */ export function backgroundUpdateCheck(): void { try { const result = checkForUpdate(); const notice = formatUpdateNotice(result); if (notice) { process.stderr.write(notice); } } catch { // Silently ignore — never block the user } }