/** * 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, readdirSync, copyFileSync, } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; // ─── 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(' ')}`; } // ─── Post-update framework re-seed + agent relaunch (F3-m3 / R13) ───────────── // // `mosaic update` installs the new npm CLI but, on its own, leaves the framework // files in ~/.config/mosaic/ stale — so shipped launcher/runtime changes (e.g. // the agent-name export + native heartbeat) never ACTIVATE until a re-seed. // These helpers run the package's own install.sh in sync-only mode (the P4 // data-safe reconcile: framework-owned overwrite + backup-once; SOUL/USER/ // *.local/credentials preserved) and, opt-in, relaunch durable agents. /** Resolve the framework/ directory bundled in the installed package. */ export function resolveBundledFrameworkRoot(): string { // dist/runtime/update-checker.js → ../../framework (package files: dist + framework) return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'framework'); } export const FRAMEWORK_RESEED_PACKAGE = PKG; /** * Build the framework re-seed invocation: the package's install.sh in * sync-only mode (file phase only — no environment-touching post-install), * keep mode (never overwrite user files). Returned as data so it is unit * testable; `runFrameworkReseed` executes it. */ export function buildReseedCommand( frameworkRoot: string, mosaicHome: string, ): { installer: string; command: string; env: Record } { const installer = join(frameworkRoot, 'install.sh'); return { installer, command: `bash ${installer}`, env: { MOSAIC_SYNC_ONLY: '1', MOSAIC_INSTALL_MODE: 'keep', MOSAIC_HOME: mosaicHome, }, }; } /** * Re-seed the framework from the freshly-installed package. Returns a result * describing what happened (so callers can message + decide on relaunch). * Best-effort: a missing installer or a non-zero exit is reported, not thrown. */ export function runFrameworkReseed( frameworkRoot = resolveBundledFrameworkRoot(), mosaicHome = join(homedir(), '.config', 'mosaic'), ): { ok: boolean; reason?: string } { const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome); if (!existsSync(installer)) { return { ok: false, reason: `installer not found: ${installer}` }; } try { execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 }); return { ok: true }; } catch (err) { return { ok: false, reason: err instanceof Error ? err.message : String(err) }; } } /** * Best-effort parse of the fleet roster for agent names (used to relaunch * durable agents after a re-seed). Returns [] when no roster exists. */ export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] { const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml'); if (!existsSync(rosterPath)) return []; let text: string; try { text = readFileSync(rosterPath, 'utf-8'); } catch { return []; } // Roster agents are listed as `- name: ` entries under `agents:`. const names: string[] = []; for (const line of text.split('\n')) { const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/); if (m && m[1]) names.push(m[1]); } return names; } /** * Refresh the ACTIVE systemd user units from the freshly re-seeded copies. * * The re-seed updates `~/.config/mosaic/systemd/user/*.service`, but the units * systemd actually runs live at `~/.config/systemd/user/`. Without this copy, * shipped unit fixes (e.g. the socket-env change) never take effect after * `mosaic update` until `mosaic fleet install` is re-run. Best-effort + scoped: * only refreshes when a fleet is already installed (the active dir already * carries `mosaic-*` units), so non-fleet hosts are untouched. */ export function refreshActiveFleetUnits( mosaicHome = join(homedir(), '.config', 'mosaic'), env: NodeJS.ProcessEnv = process.env, ): { refreshed: string[]; ok: boolean; reason?: string } { const src = join(mosaicHome, 'systemd', 'user'); const configHome = env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config'); const dest = join(configHome, 'systemd', 'user'); if (!existsSync(src)) return { refreshed: [], ok: true }; // Only refresh when a fleet is already installed (active dir has mosaic units). const fleetInstalled = existsSync(dest) && readdirSync(dest).some((f) => f.startsWith('mosaic-') && f.endsWith('.service')); if (!fleetInstalled) return { refreshed: [], ok: true }; const units = readdirSync(src).filter((f) => f.startsWith('mosaic-') && f.endsWith('.service')); const refreshed: string[] = []; for (const unit of units) { try { copyFileSync(join(src, unit), join(dest, unit)); refreshed.push(unit); } catch { // best-effort per unit } } try { execSync('systemctl --user daemon-reload', { stdio: 'ignore', timeout: 15_000 }); } catch { // non-systemd host or no session bus — non-fatal } return { refreshed, ok: true }; } /** Build the per-agent systemd relaunch commands (drain+relaunch via restart). */ export function buildRelaunchCommands(agentNames: string[]): string[][] { return agentNames.map((name) => [ 'systemctl', '--user', 'restart', `mosaic-agent@${name}.service`, ]); } /** * 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'); }