Merge pull request #1180 from kaitranntt/dev

fix: promote CLIProxy ARM asset patch
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-05-04 12:11:16 -04:00
commit c371ba1044
7 files changed
+115 -16

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.77.0",
"version": "7.77.0-dev.2",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
+21 -8
View File
@@ -64,9 +64,7 @@ export const CLIPROXY_FAULTY_RANGE = { min: '6.6.81-0', max: '6.6.88-0' };
/** @deprecated Use CLIPROXY_FALLBACK_VERSION instead */
export const CLIPROXY_VERSION = CLIPROXY_FALLBACK_VERSION;
/**
* Platform mapping from Node.js values to CLIProxyAPI naming
*/
/** Platform mapping from Node.js values to CCS public architecture labels. */
const OS_MAP: Record<string, SupportedOS | undefined> = {
darwin: 'darwin',
linux: 'linux',
@@ -78,6 +76,18 @@ const ARCH_MAP: Record<string, SupportedArch | undefined> = {
arm64: 'arm64',
};
/** CLIProxy release assets use aarch64 while Node.js reports arm64. */
const RELEASE_ARCH_MAP: Record<SupportedArch, SupportedArch> = {
amd64: 'amd64',
arm64: 'aarch64',
aarch64: 'aarch64',
};
export function mapNodeArchToReleaseArch(nodeArch: string): SupportedArch | undefined {
const arch = ARCH_MAP[nodeArch];
return arch ? RELEASE_ARCH_MAP[arch] : undefined;
}
/**
* Detect current platform and return binary info
* @param version Optional version for binaryName (defaults to backend fallback)
@@ -93,6 +103,7 @@ export function detectPlatform(
const os = OS_MAP[nodePlatform];
const arch = ARCH_MAP[nodeArch];
const releaseArch = mapNodeArchToReleaseArch(nodeArch);
if (!os) {
throw new Error(
@@ -101,16 +112,16 @@ export function detectPlatform(
);
}
if (!arch) {
if (!arch || !releaseArch) {
throw new Error(
`Unsupported CPU architecture: ${nodeArch}\n` + `Supported: x64 (amd64), arm64`
`Unsupported CPU architecture: ${nodeArch}\n` + `Supported: x64 (amd64), arm64 (aarch64)`
);
}
const config = BACKEND_CONFIG[backend];
const ver = version || config.fallbackVersion;
const extension: ArchiveExtension = os === 'windows' ? 'zip' : 'tar.gz';
const binaryName = `${config.binaryPrefix}_${ver}_${os}_${arch}.${extension}`;
const binaryName = `${config.binaryPrefix}_${ver}_${os}_${releaseArch}.${extension}`;
return {
os,
@@ -195,14 +206,16 @@ export function isPlatformSupported(): boolean {
/**
* Get human-readable platform description
* @returns Description string (e.g., "macOS arm64")
* @returns Description string (e.g., "macOS ARM64")
*/
export function getPlatformDescription(): string {
try {
const platform = detectPlatform();
const osName =
platform.os === 'darwin' ? 'macOS' : platform.os === 'linux' ? 'Linux' : 'Windows';
return `${osName} ${platform.arch}`;
const archName =
platform.arch === 'arm64' || platform.arch === 'aarch64' ? 'ARM64' : platform.arch;
return `${osName} ${archName}`;
} catch {
return `${process.platform} ${process.arch} (unsupported)`;
}
@@ -9,6 +9,21 @@ describe('Backend Selection', () => {
const platformDetector = require('../../../../dist/cliproxy/binary/platform-detector');
const types = require('../../../../dist/cliproxy/types');
function withMockedProcessPlatform(platform, arch, callback) {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
const originalArch = Object.getOwnPropertyDescriptor(process, 'arch');
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
Object.defineProperty(process, 'arch', { value: arch, configurable: true });
try {
callback();
} finally {
Object.defineProperty(process, 'platform', originalPlatform);
Object.defineProperty(process, 'arch', originalArch);
}
}
describe('BACKEND_CONFIG', () => {
it('has correct configuration for original backend', () => {
const config = platformDetector.BACKEND_CONFIG.original;
@@ -34,6 +49,24 @@ describe('Backend Selection', () => {
});
describe('detectPlatform', () => {
it('maps Node arm64 to CLIProxy aarch64 release assets', () => {
assert.strictEqual(platformDetector.mapNodeArchToReleaseArch('arm64'), 'aarch64');
assert.strictEqual(platformDetector.mapNodeArchToReleaseArch('x64'), 'amd64');
});
it('keeps public ARM64 arch compatibility while using aarch64 release assets', () => {
withMockedProcessPlatform('darwin', 'arm64', () => {
const info = platformDetector.detectPlatform('6.10.4', 'original');
assert.strictEqual(info.arch, 'arm64');
assert.strictEqual(info.binaryName, 'CLIProxyAPI_6.10.4_darwin_aarch64.tar.gz');
assert.strictEqual(
platformDetector.getDownloadUrl('6.10.4', 'original'),
'https://github.com/router-for-me/CLIProxyAPI/releases/download/v6.10.4/CLIProxyAPI_6.10.4_darwin_aarch64.tar.gz'
);
});
});
it('generates correct binary name for original backend', () => {
const info = platformDetector.detectPlatform('6.6.51', 'original');
assert(info.binaryName.startsWith('CLIProxyAPI_6.6.51_'));
@@ -35,6 +35,7 @@ describe('types.ts backward compatibility', () => {
it('exports all platform types', () => {
const os: SupportedOS = 'darwin';
const arch: SupportedArch = 'arm64';
const releaseArch: SupportedArch = 'aarch64';
const ext: ArchiveExtension = 'tar.gz';
const info: PlatformInfo = {
os,
@@ -44,6 +45,7 @@ describe('types.ts backward compatibility', () => {
};
expect(info.os).toBe('darwin');
expect(info.arch).toBe('arm64');
expect(releaseArch).toBe('aarch64');
});
it('exports all binary types', () => {
+2 -2
View File
@@ -5,8 +5,8 @@
/** Supported operating systems */
export type SupportedOS = 'darwin' | 'linux' | 'windows';
/** Supported CPU architectures */
export type SupportedArch = 'amd64' | 'arm64';
/** Supported CPU architecture labels from Node.js and CLIProxy release assets */
export type SupportedArch = 'amd64' | 'arm64' | 'aarch64';
/** Archive extension based on platform */
export type ArchiveExtension = 'tar.gz' | 'zip';
+29 -4
View File
@@ -23,6 +23,8 @@ export { DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODELS, detectProvider, formatMode
const CURSOR_MODELS_API_ENDPOINT = 'https://api2.cursor.sh/v1/models';
const CURSOR_MODELS_CACHE_TTL_MS = 5 * 60 * 1000;
const CURSOR_MODELS_MAX_BODY_SIZE = 1024 * 1024; // 1MB limit
const CURSOR_DAEMON_MODELS_TIMEOUT_MS = 5000;
let liveModelsCache: {
models: CursorModel[];
@@ -81,6 +83,14 @@ function parseApiModelsResponse(payload: unknown): CursorModel[] | null {
return models.length > 0 ? models : null;
}
function parseContentLength(value: string | string[] | undefined): number | null {
const rawValue = Array.isArray(value) ? value[0] : value;
if (!rawValue) return null;
const parsed = Number.parseInt(rawValue, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
function getCachedLiveModels(nowMs: number = Date.now()): CursorModel[] | null {
if (!liveModelsCache) return null;
if (liveModelsCache.expiresAtMs <= nowMs) {
@@ -195,24 +205,39 @@ export async function fetchModelsFromDaemon(port: number): Promise<CursorModel[]
port,
path: '/v1/models',
method: 'GET',
timeout: 5000,
timeout: CURSOR_DAEMON_MODELS_TIMEOUT_MS,
},
(res) => {
const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit
const contentLength = parseContentLength(res.headers['content-length']);
if (contentLength !== null && contentLength > CURSOR_MODELS_MAX_BODY_SIZE) {
debugLog(
'Cursor daemon /v1/models content-length exceeded 1MB; falling back to defaults'
);
res.destroy();
req.destroy();
safeResolve(DEFAULT_CURSOR_MODELS);
return;
}
let data = '';
let bodySize = 0;
res.on('data', (chunk) => {
data += chunk;
if (data.length > MAX_BODY_SIZE) {
if (resolved) return;
bodySize += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length;
if (bodySize > CURSOR_MODELS_MAX_BODY_SIZE) {
debugLog('Cursor daemon /v1/models body exceeded 1MB; falling back to defaults');
res.destroy();
req.destroy();
safeResolve(DEFAULT_CURSOR_MODELS);
return;
}
data += chunk;
});
res.on('end', () => {
if (resolved) return;
try {
const response = JSON.parse(data) as { data?: Array<{ id?: unknown }> };
if (Array.isArray(response.data)) {
+27 -1
View File
@@ -170,7 +170,7 @@ describe('fetchModelsFromDaemon', () => {
it(
'falls back to defaults when daemon response exceeds max body size',
async () => {
const oversizedPayload = 'x'.repeat(1024 * 1024 + 1024);
const oversizedPayload = Buffer.alloc(1024 * 1024 + 1024, 'x');
const server = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(oversizedPayload);
@@ -187,11 +187,37 @@ describe('fetchModelsFromDaemon', () => {
expect(models).toEqual(DEFAULT_CURSOR_MODELS);
} finally {
server.closeAllConnections?.();
server.closeIdleConnections?.();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
},
30000
);
it('falls back to defaults when daemon advertises an oversized response', async () => {
const server = http.createServer((_req, res) => {
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': String(1024 * 1024 + 1),
});
res.flushHeaders();
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
try {
const models = await fetchModelsFromDaemon(address.port);
expect(models).toEqual(DEFAULT_CURSOR_MODELS);
} finally {
server.closeAllConnections?.();
server.closeIdleConnections?.();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
describe('fetchModelsFromCursorApi', () => {