Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
1257 lines
44 KiB
TypeScript
1257 lines
44 KiB
TypeScript
/**
|
||
* 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');
|
||
}
|