import { randomUUID } from 'node:crypto'; import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs'; import { homedir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { readResponseTextWithinLimit } from './bounded-response'; const DEFAULT_STATE_PATH = join(homedir(), '.config', 'rss-ai', 'update-state.json'); const REGISTRY_URL = 'https://registry.npmjs.org'; const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const REMIND_LATER_MS = 24 * 60 * 60 * 1000; const FETCH_TIMEOUT_MS = 5_000; const MAX_UPDATE_RESPONSE_SIZE = 64 * 1024; function resolveStatePath(override?: string): string { return override ?? process.env.RSS_UPDATE_STATE_PATH ?? DEFAULT_STATE_PATH; } export interface UpdateState { latestVersion?: string; lastCheckedAt?: number; skippedVersions?: string[]; remindAfter?: number; } export interface CheckOptions { currentVersion: string; packageName: string; /** Force a network call even if the cache is fresh */ force?: boolean; /** Override the current time (for tests) */ now?: number; /** Override the state file location (for tests) */ statePath?: string; /** Override the network fetcher (for tests) */ fetcher?: (packageName: string, signal?: AbortSignal) => Promise; /** Abort the registry request during coordinated TUI shutdown. */ signal?: AbortSignal; } export interface CheckResult { /** True if the user should be prompted right now (new version, not skipped, past cooldown). */ shouldPrompt: boolean; currentVersion: string; latestVersion: string | null; } interface ParsedVersion { core: [string, string, string]; pre: string[]; } function parseVersion(value: string): ParsedVersion | null { const match = value .trim() .match( /^v?(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, ); if (!match) return null; const pre = match[4]?.split('.') ?? []; if (pre.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0'))) { return null; } return { core: [match[1] ?? '0', match[2] ?? '0', match[3] ?? '0'], pre, }; } function compareNumericIdentifiers(a: string, b: string): number { if (a.length !== b.length) return a.length < b.length ? -1 : 1; if (a === b) return 0; return a < b ? -1 : 1; } export function loadUpdateState(path?: string): UpdateState { const target = resolveStatePath(path); try { if (!existsSync(target)) return {}; const parsed: unknown = JSON.parse(readFileSync(target, 'utf-8')); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; const raw = parsed as Record; const state: UpdateState = {}; if (typeof raw.latestVersion === 'string' && parseVersion(raw.latestVersion)) { state.latestVersion = raw.latestVersion; } if (typeof raw.lastCheckedAt === 'number' && Number.isFinite(raw.lastCheckedAt) && raw.lastCheckedAt >= 0) { state.lastCheckedAt = raw.lastCheckedAt; } if (Array.isArray(raw.skippedVersions)) { state.skippedVersions = raw.skippedVersions.filter( (version): version is string => typeof version === 'string' && parseVersion(version) !== null, ); } if (typeof raw.remindAfter === 'number' && Number.isFinite(raw.remindAfter) && raw.remindAfter >= 0) { state.remindAfter = raw.remindAfter; } return state; } catch { return {}; } } export function saveUpdateState(state: UpdateState, path?: string): void { const target = resolveStatePath(path); let tempPath: string | undefined; let descriptor: number | undefined; try { const dir = dirname(target); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } tempPath = join(dir, `.${basename(target)}.${process.pid}.${randomUUID()}.tmp`); descriptor = openSync(tempPath, 'wx', 0o600); writeFileSync(descriptor, JSON.stringify(state, null, 2), 'utf8'); fsyncSync(descriptor); closeSync(descriptor); descriptor = undefined; renameSync(tempPath, target); tempPath = undefined; // Persist the directory entry when the platform supports syncing a directory. try { const directoryDescriptor = openSync(dir, 'r'); try { fsyncSync(directoryDescriptor); } finally { closeSync(directoryDescriptor); } } catch { // The atomic rename already succeeded; directory fsync is best-effort. } } catch { // Persistence is best-effort; do not crash the app over update-prefs IO. } finally { if (descriptor !== undefined) { try { closeSync(descriptor); } catch { // Best-effort cleanup after a failed write. } } if (tempPath) { try { rmSync(tempPath, { force: true }); } catch { // Best-effort cleanup after a failed write. } } } } /** * Compare two semver-ish versions following SemVer 2.0 precedence rules. * * Compares major.minor.patch numerically first. When the cores are equal, a * release (no pre-release tag) has higher precedence than a pre-release of the * same core (1.0.0 > 1.0.0-beta.1). Two pre-releases are compared identifier by * identifier: numeric identifiers compare numerically and rank below * alphanumeric ones, otherwise identifiers compare lexically; a shorter set of * identifiers (a proper prefix) ranks lower. Build metadata (+build) is ignored. * * Returns -1 if a < b, 0 if equal, 1 if a > b. */ export function compareVersions(a: string, b: string): number { const pa = parseVersion(a); const pb = parseVersion(b); if (!pa || !pb) { throw new TypeError(`Invalid version comparison: ${JSON.stringify(a)} and ${JSON.stringify(b)}`); } for (let i = 0; i < 3; i++) { const comparison = compareNumericIdentifiers(pa.core[i] ?? '0', pb.core[i] ?? '0'); if (comparison !== 0) return comparison; } // Equal cores: a version with no pre-release outranks one with a pre-release. if (pa.pre.length === 0 && pb.pre.length === 0) return 0; if (pa.pre.length === 0) return 1; if (pb.pre.length === 0) return -1; // Both pre-release: compare identifiers per SemVer precedence rules. const len = Math.min(pa.pre.length, pb.pre.length); for (let i = 0; i < len; i++) { const ai = pa.pre[i] ?? ''; const bi = pb.pre[i] ?? ''; if (ai === bi) continue; const aNum = /^\d+$/.test(ai); const bNum = /^\d+$/.test(bi); if (aNum && bNum) { const comparison = compareNumericIdentifiers(ai, bi); if (comparison !== 0) return comparison; } else if (aNum) { return -1; // numeric identifiers rank lower than alphanumeric } else if (bNum) { return 1; } else { return ai < bi ? -1 : 1; } } // All shared identifiers equal: more identifiers wins. if (pa.pre.length < pb.pre.length) return -1; if (pa.pre.length > pb.pre.length) return 1; return 0; } export function shouldPromptForVersion( state: UpdateState, currentVersion: string, latestVersion: string, now: number = Date.now(), ): boolean { try { if (compareVersions(latestVersion, currentVersion) <= 0) return false; } catch { return false; } if (state.skippedVersions?.includes(latestVersion)) return false; if (state.remindAfter && state.remindAfter > now) return false; return true; } async function defaultFetcher(packageName: string, signal?: AbortSignal): Promise { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS); const onAbort = () => ac.abort(signal?.reason); signal?.addEventListener('abort', onAbort, { once: true }); try { const res = await fetch(`${REGISTRY_URL}/${encodeURIComponent(packageName)}/latest`, { headers: { accept: 'application/json' }, signal: ac.signal, }); if (!res.ok) { try { await res.body?.cancel(); } catch { // Update checks are best-effort; teardown must not replace graceful failure. } return null; } const body = await readResponseTextWithinLimit(res, { maxBytes: MAX_UPDATE_RESPONSE_SIZE, label: 'Update response', }); const data = JSON.parse(body) as { version?: unknown }; return typeof data.version === 'string' ? data.version : null; } catch { return null; } finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); } } export async function checkForUpdate(options: CheckOptions): Promise { const now = options.now ?? Date.now(); const statePath = resolveStatePath(options.statePath); const fetcher = options.fetcher ?? defaultFetcher; const state = loadUpdateState(statePath); let latestVersion: string | null = state.latestVersion ?? null; const isCacheFresh = state.lastCheckedAt != null && now - state.lastCheckedAt < CHECK_INTERVAL_MS; if (options.force || !isCacheFresh) { options.signal?.throwIfAborted(); const fetchedRaw = await fetcher(options.packageName, options.signal); options.signal?.throwIfAborted(); const fetched = fetchedRaw?.trim() ?? null; if (fetched && parseVersion(fetched)) { latestVersion = fetched; const next: UpdateState = { ...state, latestVersion: fetched, lastCheckedAt: now }; // Drop skip entries for versions older than the new latest — they're no longer relevant. if (state.skippedVersions?.length) { next.skippedVersions = state.skippedVersions.filter((v) => compareVersions(v, fetched) >= 0); } saveUpdateState(next, statePath); return { shouldPrompt: shouldPromptForVersion(next, options.currentVersion, fetched, now), currentVersion: options.currentVersion, latestVersion: fetched, }; } } return { shouldPrompt: latestVersion != null && shouldPromptForVersion(state, options.currentVersion, latestVersion, now), currentVersion: options.currentVersion, latestVersion, }; } export function markVersionSkipped(version: string, statePath?: string): void { const target = resolveStatePath(statePath); const state = loadUpdateState(target); const skipped = new Set(state.skippedVersions ?? []); skipped.add(version); saveUpdateState({ ...state, skippedVersions: Array.from(skipped) }, target); } export function markRemindLater(now: number = Date.now(), statePath?: string): void { const target = resolveStatePath(statePath); const state = loadUpdateState(target); saveUpdateState({ ...state, remindAfter: now + REMIND_LATER_MS }, target); } export function getInstallCommand(packageName: string, version: string): string { return getInstallArguments(packageName, version).join(' '); } /** * Build the exact Bun command used for a self-update. Keeping this separate * from the display string ensures the command a user sees is the command we * execute, without sending package data through a shell. */ export function getInstallArguments(packageName: string, version: string): [string, string, string, string] { const validPackageName = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(packageName); if (!validPackageName || !parseVersion(version)) { throw new Error('Invalid package name or version for update command'); } return ['bun', 'install', '-g', `${packageName}@${version}`]; } export type UpdateInstallRunner = (argv: [string, string, string, string], signal?: AbortSignal) => Promise; async function defaultUpdateInstallRunner(argv: [string, string, string, string], signal?: AbortSignal): Promise { signal?.throwIfAborted(); const process = Bun.spawn(argv, { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore', signal }); const exitCode = await process.exited; signal?.throwIfAborted(); if (exitCode !== 0) { throw new Error(`bun install exited with status ${exitCode}`); } } /** Run the validated, shell-free Bun update command. */ export async function runUpdateInstall( packageName: string, version: string, runner: UpdateInstallRunner = defaultUpdateInstallRunner, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); await runner(getInstallArguments(packageName, version), signal); }