import { execSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import chalk from "chalk"; import { appRoot } from "./app-paths.js"; const CACHE_FILE = join(appRoot, ".update-check"); export const NPM_PACKAGE_NAME = "singularity-forge"; const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const FETCH_TIMEOUT_MS = 5000; const DEFAULT_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`; interface UpdateCheckCache { lastCheck: number; latestVersion: string; } /** * Compares two semver strings. Returns 1 if a > b, -1 if a < b, 0 if equal. */ export function compareSemver(a: string, b: string): number { const pa = a.split(".").map(Number); const pb = b.split(".").map(Number); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const va = pa[i] || 0; const vb = pb[i] || 0; if (va > vb) return 1; if (va < vb) return -1; } return 0; } export function readUpdateCache( cachePath: string = CACHE_FILE, ): UpdateCheckCache | null { try { if (!existsSync(cachePath)) return null; return JSON.parse(readFileSync(cachePath, "utf-8")); } catch { return null; } } export function writeUpdateCache( cache: UpdateCheckCache, cachePath: string = CACHE_FILE, ): void { try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify(cache)); } catch { // Non-fatal — don't block startup if cache write fails } } function normalizeLatestVersion(version: unknown): string | null { if (typeof version !== "string") return null; const trimmed = version.trim().replace(/^v/, ""); return trimmed.length > 0 ? trimmed : null; } export async function fetchLatestVersionFromRegistry( registryUrl: string = DEFAULT_REGISTRY_URL, fetchTimeoutMs: number = FETCH_TIMEOUT_MS, ): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); try { const res = await fetch(registryUrl, { signal: controller.signal }); if (!res.ok) return null; const data = (await res.json()) as { version?: string }; return normalizeLatestVersion(data.version); } catch { return null; } finally { clearTimeout(timeout); } } export function resolveInstallCommand(pkg: string): string { if ("bun" in process.versions) { return `bun add -g ${pkg}`; } return `npm install -g ${pkg}`; } function printUpdateBanner(current: string, latest: string): void { const installCmd = resolveInstallCommand(`${NPM_PACKAGE_NAME}@latest`); process.stderr.write( ` ${chalk.yellow("Update available:")} ${chalk.dim(`v${current}`)} → ${chalk.bold(`v${latest}`)}\n` + ` ${chalk.dim("Run")} ${installCmd} ${chalk.dim("or")} /sf update ${chalk.dim("to upgrade")}\n\n`, ); } export interface UpdateCheckOptions { currentVersion?: string; cachePath?: string; registryUrl?: string; checkIntervalMs?: number; fetchTimeoutMs?: number; onUpdate?: (current: string, latest: string) => void; } /** * Non-blocking update check. Queries npm registry at most once per 24h, * caches the result, and prints a banner if a newer version is available. */ export async function checkForUpdates( options: UpdateCheckOptions = {}, ): Promise { const currentVersion = options.currentVersion || process.env.SF_VERSION || "0.0.0"; const cachePath = options.cachePath || CACHE_FILE; const registryUrl = options.registryUrl || DEFAULT_REGISTRY_URL; const checkIntervalMs = options.checkIntervalMs ?? CHECK_INTERVAL_MS; const fetchTimeoutMs = options.fetchTimeoutMs ?? FETCH_TIMEOUT_MS; const onUpdate = options.onUpdate || printUpdateBanner; // Check cache — skip network if checked recently const cache = readUpdateCache(cachePath); if (cache && Date.now() - cache.lastCheck < checkIntervalMs) { if (compareSemver(cache.latestVersion, currentVersion) > 0) { onUpdate(currentVersion, cache.latestVersion); } return; } try { const latestVersion = await fetchLatestVersionFromRegistry( registryUrl, fetchTimeoutMs, ); if (!latestVersion) return; writeUpdateCache({ lastCheck: Date.now(), latestVersion }, cachePath); if (compareSemver(latestVersion, currentVersion) > 0) { onUpdate(currentVersion, latestVersion); } } catch { // Network error or timeout — silently ignore, don't block startup } } const PROMPT_TIMEOUT_MS = 30_000; /** * Interactive update prompt shown at startup when a newer version is available. * Fetches the latest version (with cache), then asks the user whether to * update now or skip. Runs at most once per 24 hours (same cache as checkForUpdates). * Defaults to skip after 30 seconds of inactivity. * * Returns true if an update was performed, false otherwise. */ export async function checkAndPromptForUpdates( options: UpdateCheckOptions = {}, ): Promise { const currentVersion = options.currentVersion || process.env.SF_VERSION || "0.0.0"; const cachePath = options.cachePath || CACHE_FILE; const registryUrl = options.registryUrl || DEFAULT_REGISTRY_URL; const checkIntervalMs = options.checkIntervalMs ?? CHECK_INTERVAL_MS; const fetchTimeoutMs = options.fetchTimeoutMs ?? FETCH_TIMEOUT_MS; // Determine latest version (from cache or network) let latestVersion: string | null = null; const cache = readUpdateCache(cachePath); if (cache && Date.now() - cache.lastCheck < checkIntervalMs) { latestVersion = cache.latestVersion; } else { try { latestVersion = await fetchLatestVersionFromRegistry( registryUrl, fetchTimeoutMs, ); if (latestVersion) { writeUpdateCache({ lastCheck: Date.now(), latestVersion }, cachePath); } } catch { // Network unavailable — silently skip } } if (!latestVersion || compareSemver(latestVersion, currentVersion) <= 0) { return false; } // Update available — show interactive prompt // Measure visible (ANSI-free) width to size the box, then render with chalk. const midContent = ` ${chalk.bold("Update available!")} ${chalk.dim(`v${currentVersion}`)} → ${chalk.bold.green(`v${latestVersion}`)} `; const midVisible = ` Update available! v${currentVersion} → v${latestVersion} `; const innerWidth = midVisible.length; const top = "╔" + "═".repeat(innerWidth) + "╗"; const bot = "╚" + "═".repeat(innerWidth) + "╝"; process.stderr.write("\n"); process.stderr.write( ` ${chalk.yellow(top)}\n` + ` ${chalk.yellow("║")}${midContent}${chalk.yellow("║")}\n` + ` ${chalk.yellow(bot)}\n\n`, ); // Use readline for a simple two-option prompt that works without @clack/prompts const readline = await import("node:readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stderr, }); const choice = await new Promise((resolve) => { process.stderr.write( ` ${chalk.bold("[1]")} Update now ${chalk.dim(resolveInstallCommand(`${NPM_PACKAGE_NAME}@latest`))}\n` + ` ${chalk.bold("[2]")} Skip\n\n`, ); // Default to skip if the user doesn't respond within PROMPT_TIMEOUT_MS const timer = setTimeout(() => { process.stderr.write("\n"); rl.close(); resolve("2"); }, PROMPT_TIMEOUT_MS); rl.question(` ${chalk.bold("Choose [1/2]:")} `, (answer) => { clearTimeout(timer); resolve(answer.trim()); }); }); rl.close(); // Clean up stdin state so the TUI can start with a clean slate process.stdin.removeAllListeners("data"); process.stdin.removeAllListeners("keypress"); if (process.stdin.setRawMode) process.stdin.setRawMode(false); process.stdin.pause(); if (choice === "1") { const installCmd = resolveInstallCommand(`${NPM_PACKAGE_NAME}@latest`); process.stderr.write(`\n ${chalk.dim("Running:")} ${installCmd}\n\n`); try { execSync(installCmd, { stdio: "inherit" }); process.stderr.write( `\n ${chalk.green.bold(`✓ Updated to v${latestVersion}`)}\n\n`, ); return true; } catch { process.stderr.write( `\n ${chalk.yellow(`Update failed. You can run: ${installCmd}`)}\n\n`, ); } } else { process.stderr.write( ` ${chalk.dim("Skipped. Run")} sf update ${chalk.dim("anytime to upgrade.")}\n\n`, ); } return false; }