singularity-forge/src/update-check.ts
Mikael Hugo b24f426f2b batch: snapshot of in-flight v2 work
This commit captures uncommitted modifications that accumulated in the
working tree across multiple in-progress workstreams. It is a snapshot
to clear the deck before sf v3 work begins; individual workstreams
should land separately on top of this.

Notable additions:
- trace-collector.ts, traces.ts, src/tests/trace-export.test.ts —
  trace export plumbing
- biome.json — Biome linter configuration
- .gitignore — exclude native/npm/**/*.node compiled binaries

The bulk of the diff is across src/resources/extensions/sf/ (301 files)
and src/resources/extensions/sf/tests/ (277 files), reflecting the
ongoing sf extension work. Specific feature commits should follow this
snapshot rather than being archaeology'd out of it.

The 76MB native/npm/linux-x64-gnu/forge_engine.node compiled binary
was left out of the commit — it's now gitignored and built locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 12:42:31 +02:00

261 lines
8 KiB
TypeScript

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");
const NPM_PACKAGE_NAME = "sf-run";
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<string | null> {
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("sf-run");
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<void> {
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<boolean> {
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<string>((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;
}