/** * Update checker — polls the Codex Sparkle appcast for new versions. * Automatically applies version updates to config file and runtime. */ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { resolve } from "path"; import { fork } from "child_process"; import yaml from "js-yaml"; import { mutateClientConfig, reloadAllConfigs } from "./config.js"; import { jitterInt } from "./utils/jitter.js"; import { curlFetchGet } from "./tls/curl-fetch.js"; import { getConfigDir, getDataDir, isEmbedded } from "./paths.js"; function getConfigPath(): string { return resolve(getConfigDir(), "default.yaml"); } function getStatePath(): string { return resolve(getDataDir(), "update-state.json"); } const APPCAST_URL = "https://persistent.oaistatic.com/codex-app-prod/appcast.xml"; const POLL_INTERVAL_MS = 3 * 24 * 60 * 60 * 1000; // 3 days export interface UpdateState { last_check: string; latest_version: string | null; latest_build: string | null; download_url: string | null; update_available: boolean; current_version: string; current_build: string; } let _currentState: UpdateState | null = null; let _pollTimer: ReturnType | null = null; let _updateInProgress = false; function getVersionOverridePath(): string { return resolve(getDataDir(), "version-state.json"); } function loadCurrentConfig(): { app_version: string; build_number: string } { // Check runtime override first (written by applyVersionUpdate) try { const overridePath = getVersionOverridePath(); if (existsSync(overridePath)) { const override = JSON.parse(readFileSync(overridePath, "utf-8")) as { app_version: string; build_number: string; }; if (override.app_version && override.build_number) { return override; } } } catch { // Corrupt override — fall through to YAML baseline } const raw = yaml.load(readFileSync(getConfigPath(), "utf-8")) as Record; const client = raw.client as Record; return { app_version: client.app_version, build_number: client.build_number, }; } function parseAppcast(xml: string): { version: string | null; build: string | null; downloadUrl: string | null; } { const itemMatch = xml.match(/([\s\S]*?)<\/item>/i); if (!itemMatch) return { version: null, build: null, downloadUrl: null }; const item = itemMatch[1]; // Support both attribute syntax (sparkle:version="X") and element syntax (X) const versionMatch = item.match(/sparkle:shortVersionString="([^"]+)"/) ?? item.match(/([^<]+)<\/sparkle:shortVersionString>/); const buildMatch = item.match(/sparkle:version="([^"]+)"/) ?? item.match(/([^<]+)<\/sparkle:version>/); const urlMatch = item.match(/url="([^"]+)"/); return { version: versionMatch?.[1] ?? null, build: buildMatch?.[1] ?? null, downloadUrl: urlMatch?.[1] ?? null, }; } function applyVersionUpdate(version: string, build: string): void { // Persist to data/ (gitignored) — config/default.yaml stays read-only try { const dataDir = getDataDir(); mkdirSync(dataDir, { recursive: true }); writeFileSync( getVersionOverridePath(), JSON.stringify({ app_version: version, build_number: build }, null, 2), ); } catch { // best-effort persistence } // Update runtime config in memory mutateClientConfig({ app_version: version, build_number: build }); } /** * Trigger the full-update pipeline in a background child process. * Downloads new Codex.app, extracts fingerprint, and applies config updates. * Protected by a lock to prevent concurrent runs. */ const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes function triggerFullUpdate(): void { if (_updateInProgress) { console.log("[UpdateChecker] Full update already in progress, skipping"); return; } // Skip fork-based update in embedded (Electron) mode — no tsx/scripts available if (isEmbedded()) { console.log("[UpdateChecker] Embedded mode — skipping full-update pipeline"); return; } _updateInProgress = true; console.log("[UpdateChecker] Triggering full-update pipeline..."); const child = fork( resolve(process.cwd(), "scripts/full-update.ts"), ["--force"], { execArgv: ["--import", "tsx"], stdio: "pipe", cwd: process.cwd(), }, ); // Kill the child if it hangs beyond the timeout const killTimer = setTimeout(() => { console.warn("[UpdateChecker] Full update timed out, killing child"); child.kill("SIGTERM"); }, UPDATE_TIMEOUT_MS); let output = ""; child.stdout?.on("data", (chunk: Buffer) => { output += chunk.toString(); }); child.stderr?.on("data", (chunk: Buffer) => { output += chunk.toString(); }); child.on("exit", (code) => { clearTimeout(killTimer); _updateInProgress = false; if (code === 0) { console.log("[UpdateChecker] Full update completed. Reloading config..."); try { reloadAllConfigs(); } catch (err) { console.error("[UpdateChecker] Failed to reload config after update:", err instanceof Error ? err.message : err); } } else { console.warn(`[UpdateChecker] Full update exited with code ${code}`); if (output) { // Log last few lines for debugging const lines = output.trim().split("\n").slice(-5); for (const line of lines) { console.warn(`[UpdateChecker] ${line}`); } } } }); child.on("error", (err) => { clearTimeout(killTimer); _updateInProgress = false; console.error("[UpdateChecker] Failed to spawn full-update:", err.message); }); } export async function checkForUpdate(): Promise { const current = loadCurrentConfig(); const res = await curlFetchGet(APPCAST_URL); if (!res.ok) { throw new Error(`Failed to fetch appcast: ${res.status}`); } const xml = res.body; const { version, build, downloadUrl } = parseAppcast(xml); const updateAvailable = !!(version && build && (version !== current.app_version || build !== current.build_number)); const state: UpdateState = { last_check: new Date().toISOString(), latest_version: version, latest_build: build, download_url: downloadUrl, update_available: updateAvailable, current_version: current.app_version, current_build: current.build_number, }; _currentState = state; // Persist state try { mkdirSync(getDataDir(), { recursive: true }); writeFileSync(getStatePath(), JSON.stringify(state, null, 2)); } catch { // best-effort persistence } if (updateAvailable) { console.log( `[UpdateChecker] *** UPDATE AVAILABLE: v${version} (build ${build}) — current: v${current.app_version} (build ${current.build_number})`, ); applyVersionUpdate(version!, build!); state.current_version = version!; state.current_build = build!; state.update_available = false; console.log(`[UpdateChecker] Auto-applied: v${version} (build ${build})`); // Trigger full-update pipeline in background (download + fingerprint extraction) triggerFullUpdate(); } return state; } /** Get the most recent update check state. */ export function getUpdateState(): UpdateState | null { return _currentState; } /** Whether a full-update pipeline is currently running. */ export function isUpdateInProgress(): boolean { return _updateInProgress; } function scheduleNextPoll(): void { _pollTimer = setTimeout(() => { checkForUpdate().catch((err) => { console.warn(`[UpdateChecker] Poll failed: ${err instanceof Error ? err.message : err}`); }); scheduleNextPoll(); }, jitterInt(POLL_INTERVAL_MS, 0.1)); if (_pollTimer.unref) _pollTimer.unref(); } /** * Start periodic update checking. * Runs an initial check immediately (non-blocking), then polls with jittered intervals. */ export function startUpdateChecker(): void { // Initial check (non-blocking) checkForUpdate().catch((err) => { console.warn(`[UpdateChecker] Initial check failed: ${err instanceof Error ? err.message : err}`); }); // Periodic polling with jitter scheduleNextPoll(); } /** Stop the periodic update checker. */ export function stopUpdateChecker(): void { if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; } }