Files
stack/packages/mosaic/src/runtime/update-checker.ts
jason.woltje 529c177830
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
fix(update): mosaic update runs the install-ordering guard post-reseed (#882 --sync-only bypass) (#883)
Co-authored-by: jason.woltje <jason@diversecanvas.com>
Co-committed-by: jason.woltje <jason@diversecanvas.com>
2026-07-23 22:18:34 +00:00

1257 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 {
closeSync,
constants,
copyFileSync,
existsSync,
fchmodSync,
fsyncSync,
linkSync,
lstatSync,
mkdirSync,
openSync,
readFileSync,
readdirSync,
renameSync,
rmdirSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { createHash, randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseFleetRosterV1, resolveInstalledFleetRosterPath } from '../fleet/fleet-roster-v1.js';
import {
assertCanonicalContainment,
assertNoSymlinkAncestors,
ensureManagedDirectory,
readRegularFileSecure,
} from '../fleet/secure-file.js';
import { getDefaultSkillPaths, syncClaudeSkills, type SkillSyncResult } from '../commands/skill.js';
import {
runInstallOrderingGuard,
type InstallOrderingGuardDeps,
type InstallOrderingGuardOptions,
type RunInstallOrderingGuardResult,
} from '../commands/install-ordering-guard.js';
// ─── 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;
function isNodeErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && 'code' in error && error.code === code;
}
// ─── 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<string, { latest: string }>;
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<string, { version?: string }>;
};
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<UpdateCheckResult, 'targetPackage'>): 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<string, string> {
const result: Record<string, string> = {};
try {
const raw = npmExec('ls -g --depth=0 --json 2>/dev/null', 3000);
if (raw) {
const data = JSON.parse(raw) as {
dependencies?: Record<string, { version?: string }>;
};
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<string, string>;
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 re-seed
// is manifest-driven (#791): keep mode writes ONLY framework-owned paths from the
// shared framework-manifest.txt and prunes only retired framework files inside
// shipped subtrees — every operator path (SOUL/USER/*.local/credentials, fleet
// roster + agents + backlog, and anything the manifest never anticipated) is
// left byte-identical. Contract files are still reconciled (overwrite +
// backup-once). Opt-in, this also relaunches 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<string, string> } {
const installer = join(frameworkRoot, 'install.sh');
return {
installer,
command: `bash ${installer}`,
env: {
MOSAIC_SYNC_ONLY: '1',
MOSAIC_INSTALL_MODE: 'keep',
MOSAIC_HOME: mosaicHome,
},
};
}
export interface ToolsRepairResult {
ok: boolean;
changed: boolean;
backupPath?: string;
reason?: string;
}
export interface ToolsRepairHooks {
beforeCommit?: (which: 'backup' | 'tools' | 'helper') => void;
}
function optionalSecureFile(
path: string,
root: string,
): ReturnType<typeof readRegularFileSecure> | undefined {
try {
return readRegularFileSecure(path, { root });
} catch (error) {
if (isNodeErrorCode(error, 'ENOENT')) return undefined;
throw error;
}
}
function stageManagedFile(
root: string,
directory: string,
target: string,
content: Buffer,
mode: number,
): string {
assertCanonicalContainment(root, target);
ensureManagedDirectory(root, directory);
assertNoSymlinkAncestors(target);
const staged = join(directory, `.${basename(target)}.repair-${process.pid}-${cryptoRandom()}`);
assertCanonicalContainment(root, staged);
const fd = openSync(
staged,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
try {
writeFileSync(fd, content);
fchmodSync(fd, mode);
fsyncSync(fd);
} finally {
closeSync(fd);
}
return staged;
}
function cryptoRandom(): string {
return randomBytes(8).toString('hex');
}
interface ManagedOriginal {
path: string;
snapshot?: ReturnType<typeof readRegularFileSecure>;
}
function assertManagedOriginalUnchanged(original: ManagedOriginal, root: string): void {
if (!original.snapshot) {
try {
lstatSync(original.path);
throw new Error(`repair destination appeared during staging: ${original.path}`);
} catch (error) {
if (isNodeErrorCode(error, 'ENOENT')) return;
throw error;
}
}
const current = readRegularFileSecure(original.path, { root });
if (
current.dev !== original.snapshot.dev ||
current.ino !== original.snapshot.ino ||
current.mode !== original.snapshot.mode ||
!current.content.equals(original.snapshot.content)
) {
throw new Error(`repair destination changed during staging: ${original.path}`);
}
}
function installBackupNoClobber(staged: string, target: string, root: string): void {
assertCanonicalContainment(root, target);
assertNoSymlinkAncestors(target);
try {
lstatSync(target);
throw new Error(`digest-qualified backup collision at ${target}`);
} catch (error) {
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
}
linkSync(staged, target);
unlinkSync(staged);
}
function atomicInstall(staged: string, target: string, root: string): void {
assertCanonicalContainment(root, target);
assertNoSymlinkAncestors(target);
try {
const current = lstatSync(target);
if (current.isSymbolicLink() || !current.isFile()) {
throw new Error(`repair destination is not a regular file: ${target}`);
}
} catch (error) {
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
}
renameSync(staged, target);
}
/**
* Explicitly repair the user-owned TOOLS contract and required helper from the
* bundled current framework. Existing divergent TOOLS content is preserved in
* a digest-qualified no-clobber backup; repeated repairs are idempotent.
*/
export function repairFleetCommsTools(
frameworkRoot = resolveBundledFrameworkRoot(),
mosaicHome = join(homedir(), '.config', 'mosaic'),
hooks: ToolsRepairHooks = {},
): ToolsRepairResult {
const sourceTools = join(frameworkRoot, 'defaults', 'TOOLS.md');
const sourceHelper = join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh');
const installedTools = join(mosaicHome, 'TOOLS.md');
const helperDirectory = join(mosaicHome, 'tools', 'tmux');
const installedHelper = join(helperDirectory, 'agent-send.sh');
let stagedBackup: string | undefined;
let stagedTools: string | undefined;
let stagedHelper: string | undefined;
let rollbackTools: string | undefined;
let rollbackHelper: string | undefined;
let committedBackup = false;
let committedTools = false;
let committedHelper = false;
let createdHome = false;
let createdToolsDirectory = false;
let createdHelperDirectory = false;
let backupPath: string | undefined;
let toolsOriginal: ManagedOriginal | undefined;
let helperOriginal: ManagedOriginal | undefined;
try {
const sourceToolsSnapshot = readRegularFileSecure(sourceTools, { root: frameworkRoot });
const sourceHelperSnapshot = readRegularFileSecure(sourceHelper, {
root: frameworkRoot,
executable: true,
});
if (!sourceToolsSnapshot.content.includes('<!-- fleet-comms-contract: 1 -->')) {
return { ok: false, changed: false, reason: 'bundled TOOLS contract has wrong version' };
}
assertCanonicalContainment(mosaicHome, installedTools);
assertCanonicalContainment(mosaicHome, installedHelper);
assertNoSymlinkAncestors(mosaicHome);
const homeExisted = existsSync(mosaicHome);
const toolsDirectory = dirname(helperDirectory);
const toolsDirectoryExisted = existsSync(toolsDirectory);
const helperDirectoryExisted = existsSync(helperDirectory);
if (homeExisted) {
const homeStat = lstatSync(mosaicHome);
if (homeStat.isSymbolicLink()) {
throw new Error(`managed root is a symbolic link: ${mosaicHome}`);
}
if (!homeStat.isDirectory()) {
throw new Error(`managed root is not a real directory: ${mosaicHome}`);
}
}
const installedToolsSnapshot = homeExisted
? optionalSecureFile(installedTools, mosaicHome)
: undefined;
let installedHelperSnapshot: ReturnType<typeof readRegularFileSecure> | undefined;
let installedHelperExecutable = false;
if (homeExisted) {
try {
installedHelperSnapshot = readRegularFileSecure(installedHelper, {
root: mosaicHome,
executable: true,
});
installedHelperExecutable = true;
} catch (error) {
if (!isNodeErrorCode(error, 'ENOENT') && !isNodeErrorCode(error, 'EACCES')) throw error;
if (isNodeErrorCode(error, 'EACCES')) {
installedHelperSnapshot = optionalSecureFile(installedHelper, mosaicHome);
}
}
}
const toolsChanged = !installedToolsSnapshot?.content.equals(sourceToolsSnapshot.content);
const helperChanged =
!installedHelperExecutable ||
!installedHelperSnapshot?.content.equals(sourceHelperSnapshot.content);
if (!toolsChanged && !helperChanged) return { ok: true, changed: false };
toolsOriginal = { path: installedTools, snapshot: installedToolsSnapshot };
helperOriginal = { path: installedHelper, snapshot: installedHelperSnapshot };
ensureManagedDirectory(dirname(mosaicHome), mosaicHome);
createdHome = !homeExisted;
ensureManagedDirectory(mosaicHome, helperDirectory);
createdToolsDirectory = !toolsDirectoryExisted;
createdHelperDirectory = !helperDirectoryExisted;
if (toolsChanged) {
stagedTools = stageManagedFile(
mosaicHome,
mosaicHome,
installedTools,
sourceToolsSnapshot.content,
sourceToolsSnapshot.mode & 0o777,
);
if (installedToolsSnapshot) {
const digest = createHash('sha256')
.update(installedToolsSnapshot.content)
.digest('hex')
.slice(0, 16);
backupPath = `${installedTools}.pre-fleet-comms-${digest}.bak`;
const existingBackup = optionalSecureFile(backupPath, mosaicHome);
if (existingBackup && !existingBackup.content.equals(installedToolsSnapshot.content)) {
throw new Error(`digest-qualified backup collision at ${backupPath}`);
}
if (!existingBackup) {
stagedBackup = stageManagedFile(
mosaicHome,
mosaicHome,
backupPath,
installedToolsSnapshot.content,
installedToolsSnapshot.mode & 0o777,
);
}
rollbackTools = stageManagedFile(
mosaicHome,
mosaicHome,
installedTools,
installedToolsSnapshot.content,
installedToolsSnapshot.mode & 0o777,
);
}
}
if (helperChanged) {
stagedHelper = stageManagedFile(
mosaicHome,
helperDirectory,
installedHelper,
sourceHelperSnapshot.content,
sourceHelperSnapshot.mode & 0o777,
);
if (installedHelperSnapshot) {
rollbackHelper = stageManagedFile(
mosaicHome,
helperDirectory,
installedHelper,
installedHelperSnapshot.content,
installedHelperSnapshot.mode & 0o777,
);
}
}
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
if (stagedBackup && backupPath) {
hooks.beforeCommit?.('backup');
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
installBackupNoClobber(stagedBackup, backupPath, mosaicHome);
stagedBackup = undefined;
committedBackup = true;
}
if (stagedTools) {
hooks.beforeCommit?.('tools');
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
atomicInstall(stagedTools, installedTools, mosaicHome);
stagedTools = undefined;
committedTools = true;
}
if (stagedHelper) {
hooks.beforeCommit?.('helper');
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
atomicInstall(stagedHelper, installedHelper, mosaicHome);
stagedHelper = undefined;
committedHelper = true;
}
if (rollbackTools) unlinkSync(rollbackTools);
if (rollbackHelper) unlinkSync(rollbackHelper);
return { ok: true, changed: true, backupPath };
} catch (error) {
const failures: string[] = [];
try {
if (committedHelper) {
if (rollbackHelper) atomicInstall(rollbackHelper, installedHelper, mosaicHome);
else unlinkSync(installedHelper);
rollbackHelper = undefined;
}
} catch (rollbackError) {
failures.push(`helper rollback failed: ${String(rollbackError)}`);
}
try {
if (committedTools) {
if (rollbackTools) atomicInstall(rollbackTools, installedTools, mosaicHome);
else unlinkSync(installedTools);
rollbackTools = undefined;
}
} catch (rollbackError) {
failures.push(`TOOLS rollback failed: ${String(rollbackError)}`);
}
try {
if (committedBackup && backupPath) {
unlinkSync(backupPath);
committedBackup = false;
}
} catch (rollbackError) {
failures.push(`backup rollback failed: ${String(rollbackError)}`);
}
for (const staged of [stagedBackup, stagedTools, stagedHelper, rollbackTools, rollbackHelper]) {
if (!staged) continue;
try {
unlinkSync(staged);
} catch {
failures.push(`staging cleanup failed: ${staged}`);
}
}
for (const [created, directory] of [
[createdHelperDirectory, helperDirectory],
[createdToolsDirectory, dirname(helperDirectory)],
[createdHome, mosaicHome],
] as const) {
if (!created) continue;
try {
rmdirSync(directory);
} catch (cleanupError) {
if (!isNodeErrorCode(cleanupError, 'ENOENT')) {
failures.push(`directory cleanup failed: ${directory}`);
}
}
}
const reason = error instanceof Error ? error.message : String(error);
return {
ok: false,
changed: failures.length > 0,
backupPath: committedBackup ? backupPath : undefined,
reason: failures.length > 0 ? `${reason}; ${failures.join('; ')}` : reason,
};
}
}
/**
* 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 interface FrameworkReseedResult {
ok: boolean;
reason?: string;
skillSync?: SkillSyncResult;
skillSyncError?: string;
}
export function runFrameworkReseed(
frameworkRoot = resolveBundledFrameworkRoot(),
mosaicHome = join(homedir(), '.config', 'mosaic'),
claudeSkillsDir = getDefaultSkillPaths().claudeSkillsDir,
): FrameworkReseedResult {
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 });
} catch (error: unknown) {
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
}
try {
const skillSync = syncClaudeSkills({
mosaicSkillsDir: join(mosaicHome, 'skills'),
claudeSkillsDir,
});
return { ok: true, skillSync };
} catch (error: unknown) {
return {
ok: true,
skillSyncError: error instanceof Error ? error.message : String(error),
};
}
}
// ─── Post-reseed install-ordering guard (#882, Point-2 precondition) ────────
//
// Root cause (restated): `runFrameworkReseed` above runs the package's
// install.sh with MOSAIC_SYNC_ONLY=1, which — by design (see install.sh) —
// exits after the file-system phase, BEFORE the "Post-install tasks" step
// that runs `mosaic-link-runtime-assets`. That script is where the #869
// Point-1 C2 install-ordering guard (`runInstallOrderingGuard`,
// `packages/mosaic/src/commands/install-ordering-guard.ts`) decides whether
// the lease-enforcement hooks (PreToolUse mutator-gate.py / Stop
// receipt-observer-client.py) get wired into `~/.claude/settings.json`. A
// plain `mosaic update` reseed therefore never re-evaluated that wiring
// decision against current activation state — the bypass this closes.
//
// `runUpdatePathSettingsGuard` re-applies the EXACT SAME guard (no forked
// logic) against the MANAGED settings.json template the reseed just
// refreshed (`<mosaicHome>/runtime/claude/settings.json`) and the live
// `<claudeHome>/settings.json` — mirroring `copy_claude_settings_guarded`'s
// src/dest pair in `mosaic-link-runtime-assets`.
export interface UpdatePathSettingsGuardResult {
/** False when there is no settings.json template on disk to re-link (e.g. a
* framework layout that predates runtime/claude/settings.json) — nothing to
* guard, so the guard did not run. */
ran: boolean;
result?: RunInstallOrderingGuardResult;
}
export function runUpdatePathSettingsGuard(
mosaicHome = join(homedir(), '.config', 'mosaic'),
claudeHome = process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude'),
options: InstallOrderingGuardOptions = {},
deps: InstallOrderingGuardDeps = {},
): UpdatePathSettingsGuardResult {
const src = join(mosaicHome, 'runtime', 'claude', 'settings.json');
if (!existsSync(src)) {
return { ran: false };
}
const dest = join(claudeHome, 'settings.json');
return { ran: true, result: runInstallOrderingGuard(src, dest, options, deps) };
}
// ─── update-reseed flow (extracted for testability; called from cli.ts) ────
//
// Everything `mosaic update`'s `.action()` does once it has decided a reseed
// should happen (both call sites already gate on `opts.reseed !== false`
// before invoking this). Extracted out of cli.ts so the post-reseed guard
// wiring (#882 (b)) — and the `--no-reseed` short-circuit — are directly unit
// testable with injected fakes, matching the existing update-checker
// conventions (see update-checker.reseed.spec.ts).
export interface UpdateReseedFlowOptions {
/** Mirrors the CLI's `--no-reseed` flag (commander sets `reseed: false`
* when passed). `false` is a pure no-op: nothing is reseeded and the
* post-reseed settings guard is not invoked either — there is nothing to
* re-link. */
reseed?: boolean;
relaunch?: boolean;
/** Threads `--allow-inactive-enforcement` to the post-reseed settings
* guard, identically to the install path (see install-ordering-guard.ts).
* Never sourced from an environment variable — explicit per-invocation
* opt-out only. */
allowInactiveEnforcement?: boolean;
}
export interface UpdateReseedFlowDeps {
runFrameworkReseed?: typeof runFrameworkReseed;
runUpdatePathSettingsGuard?: typeof runUpdatePathSettingsGuard;
refreshActiveFleetUnits?: typeof refreshActiveFleetUnits;
readRosterAgentNames?: typeof readRosterAgentNames;
execSync?: typeof execSync;
log?: (message: string) => void;
warnLog?: (message: string) => void;
errorLog?: (message: string) => void;
}
export interface UpdateReseedFlowResult {
/** Whether a reseed was actually attempted (false only for `--no-reseed`). */
attempted: boolean;
reseed?: FrameworkReseedResult;
settingsGuard?: UpdatePathSettingsGuardResult;
}
export function runUpdateReseedFlow(
reason: string,
options: UpdateReseedFlowOptions = {},
deps: UpdateReseedFlowDeps = {},
): UpdateReseedFlowResult {
if (options.reseed === false) {
// Nothing to re-seed, and therefore nothing to re-link/guard either.
return { attempted: false };
}
const log = deps.log ?? console.log;
const warnLog = deps.warnLog ?? console.warn;
const errorLog = deps.errorLog ?? console.error;
const doReseed = deps.runFrameworkReseed ?? runFrameworkReseed;
const doGuard = deps.runUpdatePathSettingsGuard ?? runUpdatePathSettingsGuard;
const doRefresh = deps.refreshActiveFleetUnits ?? refreshActiveFleetUnits;
const doReadRoster = deps.readRosterAgentNames ?? readRosterAgentNames;
const exec = deps.execSync ?? execSync;
log(reason);
const reseed = doReseed();
if (!reseed.ok) {
errorLog(
`\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 { attempted: true, reseed };
}
log('✔ Framework re-seeded.');
if (reseed.skillSyncError) {
errorLog(` ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`);
}
const skillConflicts = reseed.skillSync?.conflicts ?? [];
const skillChanges =
(reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0);
if (skillChanges > 0) {
log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`);
}
for (const conflict of skillConflicts) {
errorLog(` ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
}
// #882 (b): re-apply the install-ordering guard (C2) to the MANAGED
// settings.json the reseed just refreshed. install.sh's sync-only mode
// never reaches the post-install step that would otherwise do this, so
// `mosaic update` must do it itself — closing the bypass for every update
// path. Never swallow the guard's fail-loud/opt-out output on this path.
const settingsGuard = doGuard(undefined, undefined, {
allowInactiveEnforcement: options.allowInactiveEnforcement === true,
});
if (settingsGuard.ran && settingsGuard.result) {
for (const line of settingsGuard.result.logs) {
(line.level === 'error' ? errorLog : warnLog)(line.message);
}
}
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
const units = doRefresh();
if (units.refreshed.length > 0) {
log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
}
const agents = doReadRoster();
if (agents.length > 0) {
if (options.relaunch) {
log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
for (const restart of buildRelaunchCommands(agents)) {
try {
exec(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
} catch {
errorLog(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
}
}
log('✔ Agents relaunched.');
} else {
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 <agent>)',
);
}
}
return { attempted: true, reseed, settingsGuard };
}
// ─── 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=<n>` 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 };
}
/**
* Canonically parse the installed fleet roster for relaunch targets. JSON is
* considered only when roster.yaml is genuinely absent; all other failures
* return no targets rather than guessing.
*/
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
try {
const rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
const source = readFileSync(rosterPath, 'utf8');
return parseFleetRosterV1(source, rosterPath.endsWith('.json') ? 'json' : 'yaml').agents.map(
(agent) => agent.name,
);
} catch {
return [];
}
}
/**
* 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');
}