diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 43906eb..4381b84 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -30,6 +30,7 @@ import { refreshActiveFleetUnits, readRosterAgentNames, buildRelaunchCommands, + checkFrameworkDrift, FRAMEWORK_RESEED_PACKAGE, } from './runtime/update-checker.js'; import { runWizard } from './wizard.js'; @@ -418,6 +419,48 @@ program // checkForAllUpdates imported statically above const { execSync } = await import('node:child_process'); + // Re-seed the framework from the freshly-installed package, propagate shipped + // systemd unit fixes to the active units, and (opt-in) relaunch durable + // agents. Shared by the "packages updated" and the "framework drift" paths. + const reseedFramework = (reason: string): void => { + console.log(reason); + const reseed = runFrameworkReseed(); + if (!reseed.ok) { + console.error( + `\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` + + ' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' + + '(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)', + ); + return; + } + console.log('✔ Framework re-seeded.'); + // Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only + // touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user). + const units = refreshActiveFleetUnits(); + if (units.refreshed.length > 0) { + console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`); + } + const agents = readRosterAgentNames(); + if (agents.length === 0) return; + if (opts.relaunch) { + console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`); + for (const restart of buildRelaunchCommands(agents)) { + try { + execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 }); + } catch { + console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`); + } + } + console.log('✔ Agents relaunched.'); + } else { + console.log( + `\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` + + 'Restart them to activate the update:\n mosaic update --relaunch ' + + '(or: mosaic fleet restart )', + ); + } + }; + console.log('Checking for updates…'); const results = checkForAllUpdates({ skipCache: true }); @@ -432,6 +475,18 @@ program process.exit(1); } console.log('\n✔ All packages up to date.'); + // #642: the CLI may have been upgraded outside `mosaic update` (e.g. a + // direct `npm i -g`), leaving the framework files stale even though no + // package is reported outdated. Detect that via the framework version and + // re-seed so shipped launcher/runtime fixes still activate. + const drift = checkFrameworkDrift(); + if (drift.drifted && opts.reseed !== false) { + reseedFramework( + `\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` + + 'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' + + '~/.config/mosaic (data-safe; keeps your edits)…', + ); + } return; } @@ -456,52 +511,17 @@ program // F3-m3 / R13: the CLI is updated, but the framework files in // ~/.config/mosaic/ are still the previous version. Re-seed them from the // freshly-installed package so shipped launcher/runtime changes ACTIVATE. - // Only when the framework-bearing package itself updated. + // Re-seed when the framework-bearing package itself updated OR the on-disk + // framework is older than the freshly-installed one (#642 — e.g. only + // sibling packages were outdated but the CLI was already ahead). const mosaicUpdated = outdated.some( (r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE, ); - if (mosaicUpdated && opts.reseed !== false) { - console.log( + const drift = checkFrameworkDrift(); + if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) { + reseedFramework( '\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…', ); - const reseed = runFrameworkReseed(); - if (reseed.ok) { - console.log('✔ Framework re-seeded.'); - // Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only - // touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user). - const units = refreshActiveFleetUnits(); - if (units.refreshed.length > 0) { - console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`); - } - const agents = readRosterAgentNames(); - if (agents.length > 0) { - if (opts.relaunch) { - console.log( - `\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`, - ); - for (const restart of buildRelaunchCommands(agents)) { - try { - execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 }); - } catch { - console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`); - } - } - console.log('✔ Agents relaunched.'); - } else { - console.log( - `\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` + - 'Restart them to activate the update:\n mosaic update --relaunch ' + - '(or: mosaic fleet restart )', - ); - } - } - } else { - console.error( - `\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` + - ' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' + - '(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)', - ); - } } }); diff --git a/packages/mosaic/src/runtime/update-checker.reseed.spec.ts b/packages/mosaic/src/runtime/update-checker.reseed.spec.ts index c51b74b..b0a8d9c 100644 --- a/packages/mosaic/src/runtime/update-checker.reseed.spec.ts +++ b/packages/mosaic/src/runtime/update-checker.reseed.spec.ts @@ -8,6 +8,9 @@ import { readRosterAgentNames, runFrameworkReseed, refreshActiveFleetUnits, + readInstalledFrameworkVersion, + readBundledFrameworkVersion, + checkFrameworkDrift, } from './update-checker.js'; import { existsSync, readFileSync } from 'node:fs'; @@ -123,3 +126,73 @@ describe('refreshActiveFleetUnits', () => { expect(existsSync(join(configHome, 'systemd', 'user', 'mosaic-agent@.service'))).toBe(false); }); }); + +/** + * #642: re-seed when the on-disk framework is older than the bundled one even + * if no package is reported outdated (CLI upgraded outside `mosaic update`). + */ +describe('framework drift detection', () => { + let home: string; // stand-in for ~/.config/mosaic + let fw: string; // stand-in for the bundled framework root + + beforeEach(() => { + const root = mkdtempSync(join(tmpdir(), 'mosaic-drift-')); + home = join(root, 'mosaic'); + fw = join(root, 'framework'); + mkdirSync(home, { recursive: true }); + mkdirSync(fw, { recursive: true }); + }); + afterEach(() => { + rmSync(join(home, '..'), { recursive: true, force: true }); + }); + + const writeInstalled = (v: string) => writeFileSync(join(home, '.framework-version'), v); + const writeBundled = (v: string) => + writeFileSync(join(fw, 'install.sh'), `#!/usr/bin/env bash\nFRAMEWORK_VERSION=${v}\n`); + + describe('readInstalledFrameworkVersion', () => { + it('returns undefined when the version file is absent', () => { + expect(readInstalledFrameworkVersion(home)).toBeUndefined(); + }); + it('parses the integer (tolerating surrounding whitespace)', () => { + writeInstalled(' 3\n'); + expect(readInstalledFrameworkVersion(home)).toBe(3); + }); + it('returns undefined for non-numeric content', () => { + writeInstalled('not-a-number\n'); + expect(readInstalledFrameworkVersion(home)).toBeUndefined(); + }); + }); + + describe('readBundledFrameworkVersion', () => { + it('returns undefined when install.sh is absent', () => { + expect(readBundledFrameworkVersion(fw)).toBeUndefined(); + }); + it('parses FRAMEWORK_VERSION= from install.sh', () => { + writeBundled('4'); + expect(readBundledFrameworkVersion(fw)).toBe(4); + }); + }); + + describe('checkFrameworkDrift', () => { + it('reports drift when on-disk is older than bundled', () => { + writeInstalled('3'); + writeBundled('4'); + expect(checkFrameworkDrift(home, fw)).toEqual({ drifted: true, installed: 3, bundled: 4 }); + }); + it('no drift when versions match', () => { + writeInstalled('4'); + writeBundled('4'); + expect(checkFrameworkDrift(home, fw)).toMatchObject({ drifted: false }); + }); + it('no drift when on-disk is newer than bundled', () => { + writeInstalled('5'); + writeBundled('4'); + expect(checkFrameworkDrift(home, fw)).toMatchObject({ drifted: false }); + }); + it('no drift (conservative) when a version cannot be read', () => { + writeBundled('4'); // installed version file missing + expect(checkFrameworkDrift(home, fw)).toMatchObject({ drifted: false, bundled: 4 }); + }); + }); +}); diff --git a/packages/mosaic/src/runtime/update-checker.ts b/packages/mosaic/src/runtime/update-checker.ts index 243cf90..4145582 100644 --- a/packages/mosaic/src/runtime/update-checker.ts +++ b/packages/mosaic/src/runtime/update-checker.ts @@ -521,6 +521,75 @@ export function runFrameworkReseed( } } +// ─── Framework drift detection (#642) ──────────────────────────────────────── +// +// `mosaic update` only re-seeds the framework when the @mosaicstack/mosaic +// package itself is upgraded *within that command*. When the CLI is upgraded +// some OTHER way — a direct `npm i -g @mosaicstack/mosaic`, or an upgrade run +// where only sibling packages were outdated — the framework files in +// ~/.config/mosaic stay stale and shipped launcher/runtime fixes never +// activate. Comparing the on-disk framework schema version against the version +// bundled in the installed package detects exactly that situation. + +/** Read the framework schema version recorded on disk (~/.config/mosaic/.framework-version). */ +export function readInstalledFrameworkVersion( + mosaicHome = join(homedir(), '.config', 'mosaic'), +): number | undefined { + const vf = join(mosaicHome, '.framework-version'); + if (!existsSync(vf)) return undefined; + try { + const n = parseInt(readFileSync(vf, 'utf-8').trim(), 10); + return Number.isFinite(n) ? n : undefined; + } catch { + return undefined; + } +} + +/** + * Read the framework schema version shipped in the installed package by parsing + * `FRAMEWORK_VERSION=` out of the bundled install.sh (the authoritative + * source the installer writes to .framework-version). + */ +export function readBundledFrameworkVersion( + frameworkRoot = resolveBundledFrameworkRoot(), +): number | undefined { + const installer = join(frameworkRoot, 'install.sh'); + if (!existsSync(installer)) return undefined; + try { + const m = readFileSync(installer, 'utf-8').match(/^\s*FRAMEWORK_VERSION=(\d+)/m); + const raw = m?.[1]; + if (!raw) return undefined; + const n = parseInt(raw, 10); + return Number.isFinite(n) ? n : undefined; + } catch { + return undefined; + } +} + +export interface FrameworkDrift { + /** True only when both versions are known AND the on-disk one is older. */ + drifted: boolean; + installed?: number; + bundled?: number; +} + +/** + * Detect whether the on-disk framework is older than the framework bundled in + * the installed CLI (#642). Conservative: if either version can't be read the + * result is no-drift, so a missing/unreadable version file never triggers an + * unexpected re-seed. + */ +export function checkFrameworkDrift( + mosaicHome = join(homedir(), '.config', 'mosaic'), + frameworkRoot = resolveBundledFrameworkRoot(), +): FrameworkDrift { + const installed = readInstalledFrameworkVersion(mosaicHome); + const bundled = readBundledFrameworkVersion(frameworkRoot); + const drifted = + typeof installed === 'number' && typeof bundled === 'number' && installed < bundled; + return { drifted, installed, bundled }; +} + /** * Best-effort parse of the fleet roster for agent names (used to relaunch * durable agents after a re-seed). Returns [] when no roster exists.