Files
stack/packages/mosaic/src/runtime/update-checker.ts
jason.woltje 0cf80dab8c
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
fix: stale update banner + skill sync dirty worktree crash (#358)
2026-04-03 02:04:05 +00:00

258 lines
8.7 KiB
TypeScript

/**
* Mosaic update checker — compares installed @mosaic/cli version against the
* Gitea npm registry and reports when an upgrade is available.
*
* Used by:
* - CLI startup (non-blocking background check)
* - session-start.sh (via `mosaic update --check`)
* - install.sh (direct invocation)
*
* Design:
* - Result is cached to ~/.cache/mosaic/update-check.json for 1 hour
* - Network call is fire-and-forget with a 5 s timeout
* - Never throws on failure — returns stale/unknown result
*/
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface UpdateCheckResult {
/** Currently installed version (empty if not found) */
current: string;
/** Latest published version (empty if check failed) */
latest: 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/mosaic/npm/';
const CLI_PKG = '@mosaic/cli';
const CACHE_DIR = join(homedir(), '.cache', 'mosaic');
const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const NETWORK_TIMEOUT_MS = 5_000;
// ─── Helpers ────────────────────────────────────────────────────────────────
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
try {
return execSync(`npm ${args}`, {
encoding: 'utf-8',
timeout: timeoutMs,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return '';
}
}
/**
* Rudimentary semver "a < b" — handles pre-release tags.
* Per semver spec: 1.0.0-alpha.1 < 1.0.0 (pre-release has lower precedence).
*/
export function semverLt(a: string, b: string): boolean {
if (!a || !b) return false;
const stripped = (v: string) => v.replace(/^v/, '');
const splitPre = (v: string): [string, string | null] => {
const idx = v.indexOf('-');
return idx === -1 ? [v, null] : [v.slice(0, idx), v.slice(idx + 1)];
};
const sa = stripped(a);
const sb = stripped(b);
const [coreA, preA] = splitPre(sa);
const [coreB, preB] = splitPre(sb);
// Compare core version (major.minor.patch)
const partsA = coreA.split('.').map(Number);
const partsB = coreB.split('.').map(Number);
const len = Math.max(partsA.length, partsB.length);
for (let i = 0; i < len; i++) {
const va = partsA[i] ?? 0;
const vb = partsB[i] ?? 0;
if (va < vb) return true;
if (va > vb) return false;
}
// Core versions are equal — compare pre-release
// No pre-release > any pre-release (1.0.0 > 1.0.0-alpha)
if (preA !== null && preB === null) return true;
if (preA === null && preB !== null) return false;
if (preA === null && preB === null) return false;
// Both have pre-release — compare dot-separated identifiers
const idsA = preA!.split('.');
const idsB = preB!.split('.');
const preLen = Math.max(idsA.length, idsB.length);
for (let i = 0; i < preLen; i++) {
const ia = idsA[i];
const ib = idsB[i];
// Fewer fields = lower precedence
if (ia === undefined && ib !== undefined) return true;
if (ia !== undefined && ib === undefined) return false;
const na = /^\d+$/.test(ia!) ? parseInt(ia!, 10) : null;
const nb = /^\d+$/.test(ib!) ? parseInt(ib!, 10) : null;
// Numeric vs string: numeric < string
if (na !== null && nb !== null) {
if (na < nb) return true;
if (na > nb) return false;
continue;
}
if (na !== null && nb === null) return true;
if (na === null && nb !== null) return false;
// Both strings
if (ia! < ib!) return true;
if (ia! > ib!) return false;
}
return false;
}
// ─── Cache ──────────────────────────────────────────────────────────────────
/** Cache stores only the latest registry version (the expensive network call).
* The installed version is always checked fresh — it's a local `npm ls`. */
interface RegistryCache {
latest: string;
checkedAt: string;
registry: string;
}
function readCache(): RegistryCache | null {
try {
if (!existsSync(CACHE_FILE)) return null;
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as RegistryCache;
const age = Date.now() - new Date(raw.checkedAt).getTime();
if (age > CACHE_TTL_MS) return null;
return raw;
} catch {
return null;
}
}
function writeCache(entry: RegistryCache): 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
}
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Get the currently installed version of @mosaic/cli.
* Returns empty string if not installed.
*/
export function getInstalledVersion(): string {
// Fast path: check via package.json require chain
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 }>;
};
return data?.dependencies?.[CLI_PKG]?.version ?? '';
}
} catch {
// fall through
}
return '';
}
/**
* Fetch the latest published version from the Gitea npm registry.
* Returns empty string on failure.
*/
export function getLatestVersion(): string {
return npmExec(`view ${CLI_PKG} version --registry=${REGISTRY}`);
}
/**
* 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.
*/
export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult {
const current = getInstalledVersion();
let latest: string;
let checkedAt: string;
if (!options?.skipCache) {
const cached = readCache();
if (cached) {
latest = cached.latest;
checkedAt = cached.checkedAt;
} else {
latest = getLatestVersion();
checkedAt = new Date().toISOString();
writeCache({ latest, checkedAt, registry: REGISTRY });
}
} else {
latest = getLatestVersion();
checkedAt = new Date().toISOString();
writeCache({ latest, checkedAt, registry: REGISTRY });
}
return {
current,
latest,
updateAvailable: !!(current && latest && semverLt(current, latest)),
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 lines = [
'',
'╭─────────────────────────────────────────────────╮',
'│ │',
`│ Update available: ${result.current}${result.latest}`.padEnd(50) + '│',
'│ │',
'│ Run: bash tools/install.sh │',
'│ Or: npm i -g @mosaic/cli@latest │',
'│ │',
'╰─────────────────────────────────────────────────╯',
'',
];
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
}
}