/** * Binary Lifecycle Manager * Handles ensuring binary availability and auto-updates. */ import * as fs from 'fs'; import { BinaryManagerConfig, CLIProxyBackend } from '../types'; import { checkForUpdates, fetchLatestVersion, isNewerVersion, isVersionFaulty, } from './version-checker'; import { downloadAndInstall, getBinaryPath } from './installer'; import { info, warn } from '../../utils/ui'; import { isCliproxyRunning } from '../services/stats-fetcher'; import { resolveLifecyclePort } from '../config/port-manager'; import { BinaryError } from '../../errors/error-types'; import { CLIPROXY_MAX_STABLE_VERSION, CLIPROXY_FAULTY_RANGE, DEFAULT_BACKEND, } from '../binary/platform-detector'; /** Log helper */ function log(message: string, verbose: boolean): void { if (verbose) console.error(`[cliproxy] ${message}`); } function getBackendLabel(backend: CLIProxyBackend): string { return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; } /** * Check if version is above max stable (known unstable) */ function isAboveMaxStable(version: string): boolean { return isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION); } /** * Clamp version to max stable if newer versions are unstable * Returns max stable version if input is empty/invalid */ function clampToMaxStable(version: string | undefined, verbose: boolean): string { if (!version) { log(`Empty version, using max stable ${CLIPROXY_MAX_STABLE_VERSION}`, verbose); return CLIPROXY_MAX_STABLE_VERSION; } if (isAboveMaxStable(version)) { log(`Clamping ${version} to max stable ${CLIPROXY_MAX_STABLE_VERSION}`, verbose); return CLIPROXY_MAX_STABLE_VERSION; } return version; } /** Handle auto-update when binary exists */ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise { const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND; const backendLabel = getBackendLabel(backend); const checkFn = config.checkForUpdatesFn ?? checkForUpdates; const updateResult = await checkFn(config.binPath, config.version, verbose, backend); const currentVersion = updateResult.currentVersion; const latestVersion = updateResult.latestVersion; // Check if user is on known faulty version - recommend upgrade if (isVersionFaulty(currentVersion)) { console.log( warn( `${backendLabel} v${currentVersion} has known bugs (v${CLIPROXY_FAULTY_RANGE.min.replace(/-\d+$/, '')}-${CLIPROXY_FAULTY_RANGE.max.replace(/-\d+$/, '')}). ` + `Upgrade to latest stable recommended.` ) ); console.log(info(`Run "ccs cliproxy install" to upgrade to latest stable`)); } if (!updateResult.hasUpdate) return; // Clamp to max stable version const targetVersion = clampToMaxStable(latestVersion, verbose); if (!isNewerVersion(targetVersion, currentVersion)) { log(`Already at max stable version ${currentVersion}`, verbose); return; } const proxyRunning = await isCliproxyRunning(resolveLifecyclePort()); const latestNote = isAboveMaxStable(latestVersion) ? ` (latest v${latestVersion} unstable)` : ''; const updateMsg = `${backendLabel} update: v${currentVersion} -> v${targetVersion}${latestNote}`; if (proxyRunning) { console.log(info(updateMsg)); console.log(info('Run "ccs cliproxy stop" then restart to apply update')); log(`Skipping update: ${backendLabel} is currently running`, verbose); } else { console.log(info(updateMsg)); console.log(info(`Updating ${backendLabel}...`)); // The installer stages and validates the replacement before atomically swapping it. config.version = targetVersion; await downloadAndInstall(config, verbose); } } /** * Ensure binary is available (download if missing, update if outdated) * @returns Path to executable binary */ interface EnsureBinaryDeps { downloadAndInstallFn?: typeof downloadAndInstall; } export async function ensureBinary( config: BinaryManagerConfig, deps: EnsureBinaryDeps = {} ): Promise { const verbose = config.verbose; const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND; const binaryPath = getBinaryPath(config.binPath, backend); const downloadAndInstallFn = deps.downloadAndInstallFn ?? downloadAndInstall; // Binary exists - check for updates unless forceVersion if (fs.existsSync(binaryPath)) { log(`Binary exists: ${binaryPath}`, verbose); if (config.replaceExisting) { log(`Force version mode: installing specified version ${config.version}`, verbose); await downloadAndInstallFn(config, verbose); return binaryPath; } if (config.forceVersion) { log(`Pinned version mode: using installed version ${config.version}`, verbose); return binaryPath; } if (config.skipAutoUpdate) { log('Runtime bootstrap mode: skipping auto-update check', verbose); return binaryPath; } try { await handleAutoUpdate(config, verbose); } catch (error) { const err = error as Error; log(`Update check failed (non-blocking): ${err.message}`, verbose); } return binaryPath; } // Binary missing if (!config.allowInstall) { throw new BinaryError( `${getBackendLabel(backend)} binary is not installed locally. ` + 'Run "ccs cliproxy install" when you have network access.', binaryPath ); } // Only log download intent when a download will actually happen log('Binary not found, downloading...', verbose); if (!config.forceVersion) { try { const latestVersion = await fetchLatestVersion(verbose, backend); const targetVersion = clampToMaxStable(latestVersion, verbose); if (targetVersion && isNewerVersion(targetVersion, config.version)) { log(`Using version: ${targetVersion} (instead of ${config.version})`, verbose); config.version = targetVersion; } } catch { // API failed - use fallback but still clamp to max stable const fallbackVersion = clampToMaxStable(config.version, verbose); config.version = fallbackVersion; log(`Using fallback version: ${fallbackVersion}`, verbose); } } else { log(`Force version mode: using specified version ${config.version}`, verbose); } await downloadAndInstallFn(config, verbose); return binaryPath; }