- Remove @mosaicstack/cli (absorbed into @mosaicstack/mosaic) - Add all 21 remaining workspace packages so the multi-package update checker actually covers every published package
490 lines
16 KiB
TypeScript
490 lines
16 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 { 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;
|
|
/** Currently installed package name */
|
|
currentPackage?: string;
|
|
/** Latest published version (empty if check failed) */
|
|
latest: string;
|
|
/** Package that should be installed for the latest version */
|
|
targetPackage?: string;
|
|
/** True when a newer version is available */
|
|
updateAvailable: boolean;
|
|
/** ISO timestamp of this check */
|
|
checkedAt: string;
|
|
/** Where the latest version was resolved from */
|
|
registry: string;
|
|
}
|
|
|
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
|
|
const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
|
|
const PKG = '@mosaicstack/mosaic';
|
|
const CACHE_DIR = join(homedir(), '.cache', 'mosaic');
|
|
const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
|
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
const NETWORK_TIMEOUT_MS = 5_000;
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
|
|
try {
|
|
return execSync(`npm ${args}`, {
|
|
encoding: 'utf-8',
|
|
timeout: timeoutMs,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
}).trim();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rudimentary semver "a < b" — handles pre-release tags.
|
|
* Per semver spec: 1.0.0-alpha.1 < 1.0.0 (pre-release has lower precedence).
|
|
*/
|
|
export function semverLt(a: string, b: string): boolean {
|
|
if (!a || !b) return false;
|
|
|
|
const stripped = (v: string) => v.replace(/^v/, '');
|
|
const splitPre = (v: string): [string, string | null] => {
|
|
const idx = v.indexOf('-');
|
|
return idx === -1 ? [v, null] : [v.slice(0, idx), v.slice(idx + 1)];
|
|
};
|
|
|
|
const sa = stripped(a);
|
|
const sb = stripped(b);
|
|
const [coreA, preA] = splitPre(sa);
|
|
const [coreB, preB] = splitPre(sb);
|
|
|
|
// Compare core version (major.minor.patch)
|
|
const partsA = coreA.split('.').map(Number);
|
|
const partsB = coreB.split('.').map(Number);
|
|
const len = Math.max(partsA.length, partsB.length);
|
|
|
|
for (let i = 0; i < len; i++) {
|
|
const va = partsA[i] ?? 0;
|
|
const vb = partsB[i] ?? 0;
|
|
if (va < vb) return true;
|
|
if (va > vb) return false;
|
|
}
|
|
|
|
// Core versions are equal — compare pre-release
|
|
// No pre-release > any pre-release (1.0.0 > 1.0.0-alpha)
|
|
if (preA !== null && preB === null) return true;
|
|
if (preA === null && preB !== null) return false;
|
|
if (preA === null && preB === null) return false;
|
|
|
|
// Both have pre-release — compare dot-separated identifiers
|
|
const idsA = preA!.split('.');
|
|
const idsB = preB!.split('.');
|
|
const preLen = Math.max(idsA.length, idsB.length);
|
|
|
|
for (let i = 0; i < preLen; i++) {
|
|
const ia = idsA[i];
|
|
const ib = idsB[i];
|
|
// Fewer fields = lower precedence
|
|
if (ia === undefined && ib !== undefined) return true;
|
|
if (ia !== undefined && ib === undefined) return false;
|
|
const na = /^\d+$/.test(ia!) ? parseInt(ia!, 10) : null;
|
|
const nb = /^\d+$/.test(ib!) ? parseInt(ib!, 10) : null;
|
|
// Numeric vs string: numeric < string
|
|
if (na !== null && nb !== null) {
|
|
if (na < nb) return true;
|
|
if (na > nb) return false;
|
|
continue;
|
|
}
|
|
if (na !== null && nb === null) return true;
|
|
if (na === null && nb !== null) return false;
|
|
// Both strings
|
|
if (ia! < ib!) return true;
|
|
if (ia! > ib!) return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ─── Known packages for checkForAllUpdates() ──────────────────────────────
|
|
|
|
const KNOWN_PACKAGES = [
|
|
'@mosaicstack/agent',
|
|
'@mosaicstack/auth',
|
|
'@mosaicstack/brain',
|
|
'@mosaicstack/config',
|
|
'@mosaicstack/coord',
|
|
'@mosaicstack/db',
|
|
'@mosaicstack/design-tokens',
|
|
'@mosaicstack/discord-plugin',
|
|
'@mosaicstack/forge',
|
|
'@mosaicstack/gateway',
|
|
'@mosaicstack/log',
|
|
'@mosaicstack/macp',
|
|
'@mosaicstack/memory',
|
|
'@mosaicstack/mosaic',
|
|
'@mosaicstack/oc-framework-plugin',
|
|
'@mosaicstack/oc-macp-plugin',
|
|
'@mosaicstack/prdy',
|
|
'@mosaicstack/quality-rails',
|
|
'@mosaicstack/queue',
|
|
'@mosaicstack/storage',
|
|
'@mosaicstack/telegram-plugin',
|
|
'@mosaicstack/types',
|
|
];
|
|
|
|
// ─── Multi-package types ──────────────────────────────────────────────────
|
|
|
|
export interface PackageUpdateResult {
|
|
/** Package name */
|
|
package: string;
|
|
/** Currently installed version (empty if not installed) */
|
|
current: string;
|
|
/** Latest published version (empty if check failed) */
|
|
latest: string;
|
|
/** True when a newer version is available */
|
|
updateAvailable: boolean;
|
|
}
|
|
|
|
// ─── Cache ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Cache stores the latest registry versions for all checked packages.
|
|
* The installed version is always checked fresh — it's a local `npm ls`. */
|
|
interface AllPackagesCache {
|
|
packages: Record<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(' ')}`;
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
}
|