From a7ba1a198398c33af23f43fc07ff871ce068b4e7 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 05:36:49 -0500 Subject: [PATCH] fix(cliproxy): respect version pin when user installs specific version When a user explicitly installs a specific CLIProxy version via `ccs cliproxy --install `, this version should persist and not be auto-updated. Previously, subsequent commands would check for updates and overwrite the user's choice. Changes: - Add .version-pin file to persist user's explicit version choice - Update ensureCLIProxyBinary() to check for version pin and skip auto-update - Update --install command to save version pin with "(pinned)" confirmation - Update --latest command to clear version pin and enable auto-update - Add --update command as alias for --latest (for unpinning workflow) - Update status display to show "(pinned)" indicator when version is pinned - Update help text to document new pin/unpin behavior Closes #88 --- src/cliproxy/binary-manager.ts | 80 ++++++++++++++++++++++++++++++++ src/cliproxy/index.ts | 5 ++ src/commands/cliproxy-command.ts | 67 +++++++++++++++++++++----- 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 5ebc8948..8137c108 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -39,6 +39,9 @@ import { /** Cache duration for version check (1 hour in milliseconds) */ const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000; +/** Version pin file name - stores user's explicit version choice */ +const VERSION_PIN_FILE = '.version-pin'; + /** GitHub API URL for latest release */ const GITHUB_API_LATEST_RELEASE = 'https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest'; @@ -887,9 +890,26 @@ export class BinaryManager { /** * Convenience function to ensure binary is available + * Respects version pin if set by user via 'ccs cliproxy --install ' * @returns Path to CLIProxyAPI executable */ export async function ensureCLIProxyBinary(verbose = false): Promise { + const pinnedVersion = getPinnedVersion(); + + if (pinnedVersion) { + // Version is pinned - use forceVersion to prevent auto-update + if (verbose) { + console.error(`[cliproxy] Using pinned version: ${pinnedVersion}`); + } + const manager = new BinaryManager({ + version: pinnedVersion, + verbose, + forceVersion: true, + }); + return manager.ensureBinary(); + } + + // No pin - allow auto-update to latest const manager = new BinaryManager({ verbose }); return manager.ensureBinary(); } @@ -960,4 +980,64 @@ export async function fetchLatestCliproxyVersion(): Promise { return result.latestVersion; } +/** + * Get path to version pin file + * @returns Absolute path to .version-pin file + */ +export function getVersionPinPath(): string { + return path.join(getBinDir(), VERSION_PIN_FILE); +} + +/** + * Get pinned version if one exists + * @returns Pinned version string, or null if not pinned + */ +export function getPinnedVersion(): string | null { + const pinPath = getVersionPinPath(); + if (!fs.existsSync(pinPath)) { + return null; + } + try { + return fs.readFileSync(pinPath, 'utf8').trim(); + } catch { + return null; + } +} + +/** + * Save pinned version to persist user's explicit choice + * @param version Version to pin (e.g., "6.5.50") + */ +export function savePinnedVersion(version: string): void { + const pinPath = getVersionPinPath(); + try { + fs.mkdirSync(path.dirname(pinPath), { recursive: true }); + fs.writeFileSync(pinPath, version, 'utf8'); + } catch { + // Silent fail - not critical but log if verbose + } +} + +/** + * Clear pinned version (unpin) + */ +export function clearPinnedVersion(): void { + const pinPath = getVersionPinPath(); + if (fs.existsSync(pinPath)) { + try { + fs.unlinkSync(pinPath); + } catch { + // Silent fail + } + } +} + +/** + * Check if a version is currently pinned + * @returns true if a version is pinned + */ +export function isVersionPinned(): boolean { + return getPinnedVersion() !== null; +} + export default BinaryManager; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index b3863907..ecf890b1 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -43,6 +43,11 @@ export { getInstalledCliproxyVersion, installCliproxyVersion, fetchLatestCliproxyVersion, + getPinnedVersion, + savePinnedVersion, + clearPinnedVersion, + isVersionPinned, + getVersionPinPath, } from './binary-manager'; // Config generation diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index a6fd1715..59d9dcb9 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -26,6 +26,10 @@ import { fetchLatestCliproxyVersion, isCLIProxyInstalled, getCLIProxyPath, + getPinnedVersion, + savePinnedVersion, + clearPinnedVersion, + isVersionPinned, } from '../cliproxy'; import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler'; import { getProviderAccounts } from '../cliproxy/account-manager'; @@ -803,8 +807,9 @@ async function showHelp(): Promise { // Binary Commands console.log(subheader('Binary Commands:')); const binaryCmds: [string, string][] = [ - ['--install ', 'Install a specific binary version'], - ['--latest', 'Install the latest binary version'], + ['--install ', 'Install and pin a specific version'], + ['--latest', 'Install the latest version (no pin)'], + ['--update', 'Unpin and update to latest version'], ]; const maxBinaryLen = Math.max(...binaryCmds.map(([cmd]) => cmd.length)); for (const [cmd, desc] of binaryCmds) { @@ -897,6 +902,7 @@ async function showStatus(verbose: boolean): Promise { const installed = isCLIProxyInstalled(); const currentVersion = getInstalledCliproxyVersion(); const binaryPath = getCLIProxyPath(); + const pinnedVersion = getPinnedVersion(); console.log(''); console.log(color('CLIProxyAPI Status', 'primary')); @@ -904,7 +910,10 @@ async function showStatus(verbose: boolean): Promise { if (installed) { console.log(` Installed: ${color('Yes', 'success')}`); - console.log(` Version: ${color(`v${currentVersion}`, 'info')}`); + const versionLabel = pinnedVersion + ? `${color(`v${currentVersion}`, 'info')} ${color('(pinned)', 'warning')}` + : color(`v${currentVersion}`, 'info'); + console.log(` Version: ${versionLabel}`); console.log(` Binary: ${dim(binaryPath)}`); } else { console.log(` Installed: ${color('No', 'error')}`); @@ -919,11 +928,19 @@ async function showStatus(verbose: boolean): Promise { const latestVersion = await fetchLatestCliproxyVersion(); if (latestVersion !== currentVersion) { - console.log( - ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(update available)')}` - ); - console.log(''); - console.log(` ${dim(`Run "ccs cliproxy --latest" to update`)}`); + if (pinnedVersion) { + console.log( + ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(pinned to v' + pinnedVersion + ')')}` + ); + console.log(''); + console.log(` ${dim('Run "ccs cliproxy --update" to unpin and update')}`); + } else { + console.log( + ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(update available)')}` + ); + console.log(''); + console.log(` ${dim('Run "ccs cliproxy --latest" to update')}`); + } } else { console.log(` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(up to date)')}`); } @@ -940,7 +957,7 @@ async function showStatus(verbose: boolean): Promise { } /** - * Install a specific version + * Install a specific version (pins the version to prevent auto-update) */ async function installVersion(version: string, verbose: boolean): Promise { // Validate version format (basic semver check) @@ -954,8 +971,18 @@ async function installVersion(version: string, verbose: boolean): Promise try { await installCliproxyVersion(version, verbose); + + // Pin the version to prevent auto-update + savePinnedVersion(version); + + console.log(''); + console.log(ok(`CLIProxyAPI v${version} installed (pinned)`)); + console.log(''); + console.log(dim('This version will be used until you run:')); + console.log( + ` ${color('ccs cliproxy --update', 'command')} ${dim('# Update to latest and unpin')}` + ); console.log(''); - console.log(ok(`CLIProxyAPI v${version} installed successfully`)); } catch (error) { const err = error as Error; console.error(''); @@ -974,7 +1001,7 @@ async function installVersion(version: string, verbose: boolean): Promise } /** - * Install latest version + * Install latest version (clears any version pin) */ async function installLatest(verbose: boolean): Promise { console.log(info('Fetching latest CLIProxyAPI version...')); @@ -982,8 +1009,9 @@ async function installLatest(verbose: boolean): Promise { try { const latestVersion = await fetchLatestCliproxyVersion(); const currentVersion = getInstalledCliproxyVersion(); + const wasPinned = isVersionPinned(); - if (isCLIProxyInstalled() && latestVersion === currentVersion) { + if (isCLIProxyInstalled() && latestVersion === currentVersion && !wasPinned) { console.log(ok(`Already running latest version: v${latestVersion}`)); return; } @@ -992,11 +1020,20 @@ async function installLatest(verbose: boolean): Promise { if (isCLIProxyInstalled()) { console.log(info(`Current version: v${currentVersion}`)); } + if (wasPinned) { + console.log(info(`Removing version pin (was v${getPinnedVersion()})`)); + } console.log(''); await installCliproxyVersion(latestVersion, verbose); + + // Clear any version pin so auto-update works again + clearPinnedVersion(); + console.log(''); console.log(ok(`CLIProxyAPI updated to v${latestVersion}`)); + console.log(dim('Auto-update is now enabled.')); + console.log(''); } catch (error) { const err = error as Error; console.error(fail(`Failed to install latest version: ${err.message}`)); @@ -1057,6 +1094,12 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + // Handle --update (unpin and update to latest) + if (args.includes('--update')) { + await installLatest(verbose); + return; + } + // Default: show status await showStatus(verbose); }