/** * Mosaic update checker — compares the installed @mosaicstack/mosaic package * 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; /** Currently installed package name */ currentPackage?: string; /** Latest published version (empty if check failed) */ latest: string; /** Package that should be installed for the latest version */ targetPackage?: 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/mosaicstack/npm/'; const PKG = '@mosaicstack/mosaic'; 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; } // ─── Known packages for checkForAllUpdates() ────────────────────────────── const KNOWN_PACKAGES = [ '@mosaicstack/agent', '@mosaicstack/auth', '@mosaicstack/brain', '@mosaicstack/config', '@mosaicstack/coord', '@mosaicstack/db', '@mosaicstack/design-tokens', '@mosaicstack/discord-plugin', '@mosaicstack/forge', '@mosaicstack/gateway', '@mosaicstack/log', '@mosaicstack/macp', '@mosaicstack/memory', '@mosaicstack/mosaic', '@mosaicstack/oc-framework-plugin', '@mosaicstack/oc-macp-plugin', '@mosaicstack/prdy', '@mosaicstack/quality-rails', '@mosaicstack/queue', '@mosaicstack/storage', '@mosaicstack/telegram-plugin', '@mosaicstack/types', ]; // ─── Multi-package types ────────────────────────────────────────────────── export interface PackageUpdateResult { /** Package name */ package: string; /** Currently installed version (empty if not installed) */ current: string; /** Latest published version (empty if check failed) */ latest: string; /** True when a newer version is available */ updateAvailable: boolean; } // ─── Cache ────────────────────────────────────────────────────────────────── /** Cache stores the latest registry versions for all checked packages. * The installed version is always checked fresh — it's a local `npm ls`. */ interface AllPackagesCache { packages: Record; checkedAt: string; registry: string; } function readAllCache(): AllPackagesCache | null { try { if (!existsSync(CACHE_FILE)) return null; const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as AllPackagesCache; const age = Date.now() - new Date(raw.checkedAt).getTime(); if (age > CACHE_TTL_MS) return null; return raw; } catch { return null; } } function writeAllCache(entry: AllPackagesCache): 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 } } // Legacy single-package cache (backward compat) type RegistryCache = { latest: string; checkedAt: string; registry: string }; function readCache(): RegistryCache | null { const c = readAllCache(); if (!c) return null; const mosaicLatest = c.packages[PKG]?.latest; if (!mosaicLatest) return null; return { latest: mosaicLatest, checkedAt: c.checkedAt, registry: c.registry }; } function writeCache(entry: RegistryCache): void { const existing = readAllCache(); const packages = { ...(existing?.packages ?? {}) }; packages[PKG] = { latest: entry.latest }; writeAllCache({ packages, checkedAt: entry.checkedAt, registry: entry.registry }); } // ─── Public API ───────────────────────────────────────────────────────────── /** * Get the currently installed @mosaicstack/mosaic version. */ export function getInstalledVersion(): { name: string; version: string } { try { const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000); if (raw) { const data = JSON.parse(raw) as { dependencies?: Record; }; const version = data?.dependencies?.[PKG]?.version; if (version) { return { name: PKG, version }; } } } catch { // fall through } return { name: '', version: '' }; } /** * Fetch the latest published @mosaicstack/mosaic version from the Gitea npm registry. * Returns empty string on failure. */ export function getLatestVersion(): { name: string; version: string } { const version = npmExec(`view ${PKG} version --registry=${REGISTRY}`); if (version) { return { name: PKG, version }; } return { name: '', version: '' }; } export function getInstallCommand(result: Pick): string { return `npm i -g ${result.targetPackage || PKG}@latest`; } /** * 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. * * @deprecated Use checkForAllUpdates() for multi-package checking. * This function is kept for backward compatibility. */ export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult { const currentInfo = getInstalledVersion(); const current = currentInfo.version; let latestInfo: { name: string; version: string }; let checkedAt: string; if (!options?.skipCache) { const cached = readCache(); if (cached) { latestInfo = { name: PKG, version: cached.latest, }; checkedAt = cached.checkedAt; } else { latestInfo = getLatestVersion(); checkedAt = new Date().toISOString(); writeCache({ latest: latestInfo.version, checkedAt, registry: REGISTRY, }); } } else { latestInfo = getLatestVersion(); checkedAt = new Date().toISOString(); writeCache({ latest: latestInfo.version, checkedAt, registry: REGISTRY, }); } return { current, currentPackage: currentInfo.name || PKG, latest: latestInfo.version, targetPackage: latestInfo.name || PKG, updateAvailable: !!(current && latestInfo.version && semverLt(current, latestInfo.version)), 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 installCommand = getInstallCommand(result); const lines = [ '', '╭─────────────────────────────────────────────────╮', '│ │', `│ Update available: ${result.current} → ${result.latest}`.padEnd(50) + '│', '│ │', '│ Run: bash tools/install.sh │', `│ Or: ${installCommand}`.padEnd(50) + '│', '│ │', '╰─────────────────────────────────────────────────╯', '', ]; 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 } } // ─── Multi-package update check ──────────────────────────────────────────── /** * Get the currently installed versions of all globally-installed @mosaicstack/* packages. */ function getInstalledVersions(): Record { const result: Record = {}; try { const raw = npmExec('ls -g --depth=0 --json 2>/dev/null', 3000); if (raw) { const data = JSON.parse(raw) as { dependencies?: Record; }; for (const [name, info] of Object.entries(data?.dependencies ?? {})) { if (name.startsWith('@mosaicstack/') && info.version) { result[name] = info.version; } } } } catch { // fall through } return result; } /** * Fetch the latest published version of a single package from the registry. */ function getLatestVersionFor(pkgName: string): string { return npmExec(`view ${pkgName} version --registry=${REGISTRY}`); } /** * Check all known @mosaicstack/* packages for updates. * Returns an array of per-package results, sorted by package name. * Never throws. */ export function checkForAllUpdates(options?: { skipCache?: boolean }): PackageUpdateResult[] { const installed = getInstalledVersions(); const checkedAt = new Date().toISOString(); // Resolve latest versions (from cache or network) let latestVersions: Record; if (!options?.skipCache) { const cached = readAllCache(); if (cached) { latestVersions = {}; for (const pkg of KNOWN_PACKAGES) { const cachedLatest = cached.packages[pkg]?.latest; if (cachedLatest) { latestVersions[pkg] = cachedLatest; } } } else { latestVersions = {}; for (const pkg of KNOWN_PACKAGES) { const v = getLatestVersionFor(pkg); if (v) latestVersions[pkg] = v; } writeAllCache({ packages: Object.fromEntries( Object.entries(latestVersions).map(([k, v]) => [k, { latest: v }]), ), checkedAt, registry: REGISTRY, }); } } else { latestVersions = {}; for (const pkg of KNOWN_PACKAGES) { const v = getLatestVersionFor(pkg); if (v) latestVersions[pkg] = v; } writeAllCache({ packages: Object.fromEntries( Object.entries(latestVersions).map(([k, v]) => [k, { latest: v }]), ), checkedAt, registry: REGISTRY, }); } const results: PackageUpdateResult[] = []; for (const pkg of KNOWN_PACKAGES) { const current = installed[pkg] ?? ''; const latest = latestVersions[pkg] ?? ''; results.push({ package: pkg, current, latest, updateAvailable: !!(current && latest && semverLt(current, latest)), }); } return results.sort((a, b) => a.package.localeCompare(b.package)); } /** * Get the install command for all outdated packages. */ export function getInstallAllCommand(outdated: PackageUpdateResult[]): string { const pkgs = outdated.filter((r) => r.updateAvailable).map((r) => `${r.package}@latest`); if (pkgs.length === 0) return ''; return `npm i -g ${pkgs.join(' ')}`; } /** * Format a table showing all packages with their current/latest versions. */ export function formatAllPackagesTable(results: PackageUpdateResult[]): string { if (results.length === 0) return 'No @mosaicstack/* packages found.'; const nameWidth = Math.max(...results.map((r) => r.package.length), 10); const verWidth = 10; const header = ' ' + 'Package'.padEnd(nameWidth + 2) + 'Current'.padEnd(verWidth + 2) + 'Latest'.padEnd(verWidth + 2) + 'Status'; const sep = ' ' + '-'.repeat(header.length - 2); const rows = results.map((r) => { const status = !r.current ? 'not installed' : r.updateAvailable ? '⬆ update available' : '✔ up to date'; return ( ' ' + r.package.padEnd(nameWidth + 2) + (r.current || '-').padEnd(verWidth + 2) + (r.latest || '-').padEnd(verWidth + 2) + status ); }); return [header, sep, ...rows].join('\n'); }