From 9e9cddd101dc7ae91d9e8051e6f172747a0fc1b4 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:20:37 -0400 Subject: [PATCH] fix(cliproxy): resolve plus release asset variants (#1523) Use version-aware CLIProxy Plus asset names so current no-plugin releases install without breaking older fallback releases. Tag latest-version caches with their GitHub repo and ignore legacy or cross-repo cache entries. Closes #1522 --- .../version-checker-stale-cache.test.ts | 28 +++++++++++ src/cliproxy/binary/platform-detector.ts | 48 ++++++++++++++++++- src/cliproxy/binary/types.ts | 8 +++- src/cliproxy/binary/version-cache.ts | 6 +++ .../__tests__/backend-selection.test.js | 47 ++++++++++++++++-- 5 files changed, 132 insertions(+), 5 deletions(-) diff --git a/src/cliproxy/binary/__tests__/version-checker-stale-cache.test.ts b/src/cliproxy/binary/__tests__/version-checker-stale-cache.test.ts index 03f35b76..9ba4f0f4 100644 --- a/src/cliproxy/binary/__tests__/version-checker-stale-cache.test.ts +++ b/src/cliproxy/binary/__tests__/version-checker-stale-cache.test.ts @@ -39,6 +39,7 @@ describe('version-checker stale cache fallback', () => { getVersionCachePath('plus'), JSON.stringify({ latestVersion: '6.9.23-0', + repo: 'kaitranntt/CLIProxyAPIPlus', checkedAt: Date.now() - VERSION_CACHE_DURATION_MS - 1_000, }), 'utf8' @@ -56,6 +57,33 @@ describe('version-checker stale cache fallback', () => { expect(result.fromCache).toBe(true); }); + it('ignores legacy or cross-repo latest-version caches', async () => { + const { getVersionCachePath, writeInstalledVersion } = await import('../version-cache'); + const { checkForUpdates } = await import('../version-checker'); + const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus'); + + fs.mkdirSync(plusBinDir, { recursive: true }); + writeInstalledVersion(plusBinDir, '6.6.80'); + for (const repo of [undefined, 'router-for-me/CLIProxyAPI']) { + fs.writeFileSync( + getVersionCachePath('plus'), + JSON.stringify({ + latestVersion: '7.2.5', + checkedAt: Date.now(), + ...(repo ? { repo } : {}), + }), + 'utf8' + ); + + const result = await checkForUpdates(plusBinDir, '6.6.80', false, 'plus', { + fetchLatestVersionFn: async () => '7.1.68-2', + }); + + expect(result.latestVersion).toBe('7.1.68-2'); + expect(result.fromCache).toBe(false); + } + }); + it('uses a stale release-list cache when GitHub list lookup fails', async () => { const { getVersionListCachePath } = await import('../version-cache'); const { VERSION_CACHE_DURATION_MS } = await import('../types'); diff --git a/src/cliproxy/binary/platform-detector.ts b/src/cliproxy/binary/platform-detector.ts index eeb7c9f4..e5e9877c 100644 --- a/src/cliproxy/binary/platform-detector.ts +++ b/src/cliproxy/binary/platform-detector.ts @@ -83,11 +83,55 @@ const RELEASE_ARCH_MAP: Record = { aarch64: 'aarch64', }; +const PLUS_NO_PLUGIN_ASSET_MIN_VERSION = '7.1.68-0'; +const PLUS_AARCH64_ASSET_MIN_VERSION = '7.1.45-1'; + export function mapNodeArchToReleaseArch(nodeArch: string): SupportedArch | undefined { const arch = ARCH_MAP[nodeArch]; return arch ? RELEASE_ARCH_MAP[arch] : undefined; } +function parseVersionParts(version: string): [number, number, number, number] { + const [coreVersion, forkRelease = '0'] = version.replace(/^v/, '').split('-', 2); + const [major = 0, minor = 0, patch = 0] = coreVersion + .split('.') + .map((part) => parseInt(part, 10) || 0); + return [major, minor, patch, parseInt(forkRelease, 10) || 0]; +} + +function isAtLeastVersion(version: string, minimum: string): boolean { + const left = parseVersionParts(version); + const right = parseVersionParts(minimum); + + for (let index = 0; index < left.length; index += 1) { + if (left[index] > right[index]) return true; + if (left[index] < right[index]) return false; + } + + return true; +} + +function usesPlusNoPluginAsset(version: string, os: SupportedOS): boolean { + return os !== 'windows' && isAtLeastVersion(version, PLUS_NO_PLUGIN_ASSET_MIN_VERSION); +} + +function getReleaseArchForBackend( + backend: CLIProxyBackend, + version: string, + publicArch: SupportedArch, + releaseArch: SupportedArch +): SupportedArch { + if ( + backend === 'plus' && + publicArch === 'arm64' && + !isAtLeastVersion(version, PLUS_AARCH64_ASSET_MIN_VERSION) + ) { + return 'arm64'; + } + + return releaseArch; +} + /** * Detect current platform and return binary info * @param version Optional version for binaryName (defaults to backend fallback) @@ -121,7 +165,9 @@ export function detectPlatform( const config = BACKEND_CONFIG[backend]; const ver = version || config.fallbackVersion; const extension: ArchiveExtension = os === 'windows' ? 'zip' : 'tar.gz'; - const binaryName = `${config.binaryPrefix}_${ver}_${os}_${releaseArch}.${extension}`; + const assetArch = getReleaseArchForBackend(backend, ver, arch, releaseArch); + const assetVariant = backend === 'plus' && usesPlusNoPluginAsset(ver, os) ? '_no-plugin' : ''; + const binaryName = `${config.binaryPrefix}_${ver}_${os}_${assetArch}${assetVariant}.${extension}`; return { os, diff --git a/src/cliproxy/binary/types.ts b/src/cliproxy/binary/types.ts index 22e6d6fa..5398112f 100644 --- a/src/cliproxy/binary/types.ts +++ b/src/cliproxy/binary/types.ts @@ -7,6 +7,7 @@ export interface VersionCache { latestVersion: string; checkedAt: number; + repo: string; } /** Update check result */ @@ -39,9 +40,14 @@ export const GITHUB_REPOS = { plus: 'kaitranntt/CLIProxyAPIPlus', } as const; +/** Get GitHub repository for specific backend */ +export function getGitHubRepo(backend: 'original' | 'plus'): string { + return GITHUB_REPOS[backend]; +} + /** Get GitHub API URLs for specific backend */ export function getGitHubApiUrls(backend: 'original' | 'plus') { - const repo = GITHUB_REPOS[backend]; + const repo = getGitHubRepo(backend); return { latestRelease: `https://api.github.com/repos/${repo}/releases/latest`, allReleases: `https://api.github.com/repos/${repo}/releases`, diff --git a/src/cliproxy/binary/version-cache.ts b/src/cliproxy/binary/version-cache.ts index 3b009425..453b0e2c 100644 --- a/src/cliproxy/binary/version-cache.ts +++ b/src/cliproxy/binary/version-cache.ts @@ -11,6 +11,7 @@ import { VERSION_CACHE_DURATION_MS, VERSION_PIN_FILE, VersionListCache, + getGitHubRepo, } from './types'; import { DEFAULT_BACKEND } from '../binary/platform-detector'; import type { CLIProxyBackend } from '../types'; @@ -59,6 +60,10 @@ function readVersionCacheInternal( const content = fs.readFileSync(cachePath, 'utf8'); const cache: VersionCache = JSON.parse(content); + if (cache.repo !== getGitHubRepo(backend)) { + return null; + } + // Check if cache is still valid, unless caller explicitly allows stale fallback. if (allowExpired || Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { return cache; @@ -82,6 +87,7 @@ export function writeVersionCache( const cache: VersionCache = { latestVersion: version, checkedAt: Date.now(), + repo: getGitHubRepo(backend), }; try { diff --git a/src/cliproxy/config/__tests__/backend-selection.test.js b/src/cliproxy/config/__tests__/backend-selection.test.js index b4d1ca11..3de155cb 100644 --- a/src/cliproxy/config/__tests__/backend-selection.test.js +++ b/src/cliproxy/config/__tests__/backend-selection.test.js @@ -73,9 +73,50 @@ describe('Backend Selection', () => { assert(!info.binaryName.includes('CLIProxyAPIPlus')); }); - it('generates correct binary name for plus backend', () => { - const info = platformDetector.detectPlatform('6.6.51-0', 'plus'); - assert(info.binaryName.startsWith('CLIProxyAPIPlus_6.6.51-0_')); + it('keeps old plus non-Windows archive names unsuffixed', () => { + withMockedProcessPlatform('darwin', 'arm64', () => { + assert.strictEqual( + platformDetector.detectPlatform('6.9.45-0', 'plus').binaryName, + 'CLIProxyAPIPlus_6.9.45-0_darwin_arm64.tar.gz' + ); + assert.strictEqual( + platformDetector.detectPlatform('7.1.45-1', 'plus').binaryName, + 'CLIProxyAPIPlus_7.1.45-1_darwin_aarch64.tar.gz' + ); + }); + }); + + it('generates no-plugin archive names for current plus backend on macOS and Linux', () => { + withMockedProcessPlatform('darwin', 'arm64', () => { + assert.strictEqual( + platformDetector.detectPlatform('7.1.68-0', 'plus').binaryName, + 'CLIProxyAPIPlus_7.1.68-0_darwin_aarch64_no-plugin.tar.gz' + ); + const info = platformDetector.detectPlatform('7.1.68-2', 'plus'); + assert.strictEqual( + info.binaryName, + 'CLIProxyAPIPlus_7.1.68-2_darwin_aarch64_no-plugin.tar.gz' + ); + assert.strictEqual( + platformDetector.getDownloadUrl('7.1.68-2', 'plus'), + 'https://github.com/kaitranntt/CLIProxyAPIPlus/releases/download/v7.1.68-2/CLIProxyAPIPlus_7.1.68-2_darwin_aarch64_no-plugin.tar.gz' + ); + }); + + withMockedProcessPlatform('linux', 'x64', () => { + const info = platformDetector.detectPlatform('7.1.68-2', 'plus'); + assert.strictEqual( + info.binaryName, + 'CLIProxyAPIPlus_7.1.68-2_linux_amd64_no-plugin.tar.gz' + ); + }); + }); + + it('does not add no-plugin suffix to plus Windows archives', () => { + withMockedProcessPlatform('win32', 'x64', () => { + const info = platformDetector.detectPlatform('7.1.68-2', 'plus'); + assert.strictEqual(info.binaryName, 'CLIProxyAPIPlus_7.1.68-2_windows_amd64.zip'); + }); }); it('uses original backend by default', () => {