Merge pull request #360 from kaitranntt/dev

fix(cliproxy): complete backend switching implementation
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-23 15:53:42 -05:00
committed by GitHub
30 changed files with 584 additions and 291 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.26.0",
"version": "7.26.0-dev.1",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+1 -1
View File
@@ -271,7 +271,7 @@ async function handleTokenNotFound(
/** Handle process exit with error */
function handleProcessError(code: number | null, state: ProcessState, headless: boolean): void {
console.log('');
console.log(fail(`CLIProxy Plus auth exited with code ${code}`));
console.log(fail(`CLIProxy auth exited with code ${code}`));
if (state.stderrData && !state.urlDisplayed) {
console.log(` ${state.stderrData.trim().split('\n')[0]}`);
}
+49 -20
View File
@@ -26,9 +26,10 @@ import {
getVersionPinPath,
readInstalledVersion,
ensureBinary,
migrateVersionPin,
} from './binary';
type CLIProxyBackend = 'original' | 'plus';
import type { CLIProxyBackend } from './types';
/**
* Get backend from config or default to 'plus'
@@ -61,6 +62,7 @@ function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): Binary
maxRetries: 3,
verbose: false,
forceVersion: false,
backend, // Pass backend for installer to use correct download URL
};
}
@@ -84,34 +86,43 @@ export class BinaryManager {
/** Check for updates by comparing installed version with latest release */
async checkForUpdates(): Promise<UpdateCheckResult> {
return checkForUpdates(this.config.binPath, this.config.version, this.config.verbose);
return checkForUpdates(
this.config.binPath,
this.config.version,
this.config.verbose,
this.backend
);
}
/** Get full path to binary executable */
getBinaryPath(): string {
return getBinaryPath(this.config.binPath);
return getBinaryPath(this.config.binPath, this.backend);
}
/** Check if binary exists */
isBinaryInstalled(): boolean {
return isBinaryInstalled(this.config.binPath);
return isBinaryInstalled(this.config.binPath, this.backend);
}
/** Get binary info if installed */
async getBinaryInfo(): Promise<BinaryInfo | null> {
return getBinaryInfo(this.config.binPath, this.config.version);
return getBinaryInfo(this.config.binPath, this.config.version, this.backend);
}
/** Delete binary (for cleanup or reinstall) */
deleteBinary(): void {
deleteBinary(this.config.binPath, this.config.verbose);
deleteBinary(this.config.binPath, this.config.verbose, this.backend);
}
}
/** Convenience function respecting version pin */
export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
const backend = getConfiguredBackend();
const pinnedVersion = getPinnedVersion();
// Migrate old shared pin to backend-specific location (one-time migration)
migrateVersionPin(backend);
const pinnedVersion = getPinnedVersion(backend);
if (pinnedVersion) {
if (verbose) console.error(`[cliproxy] Using pinned version: ${pinnedVersion}`);
return new BinaryManager(
@@ -127,27 +138,34 @@ export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
}
/** Check if CLIProxyAPI binary is installed */
export function isCLIProxyInstalled(): boolean {
const backend = getConfiguredBackend();
return new BinaryManager({}, backend).isBinaryInstalled();
export function isCLIProxyInstalled(backend?: CLIProxyBackend): boolean {
const effectiveBackend = backend ?? getConfiguredBackend();
return new BinaryManager({}, effectiveBackend).isBinaryInstalled();
}
/** Get CLIProxyAPI binary path (may not exist) */
export function getCLIProxyPath(): string {
const backend = getConfiguredBackend();
return new BinaryManager({}, backend).getBinaryPath();
export function getCLIProxyPath(backend?: CLIProxyBackend): string {
const effectiveBackend = backend ?? getConfiguredBackend();
return new BinaryManager({}, effectiveBackend).getBinaryPath();
}
/** Get installed CLIProxyAPI version from .version file */
export function getInstalledCliproxyVersion(): string {
const backend = getConfiguredBackend();
return readInstalledVersion(getBackendBinDir(backend), BACKEND_CONFIG[backend].fallbackVersion);
export function getInstalledCliproxyVersion(backend?: CLIProxyBackend): string {
const effectiveBackend = backend ?? getConfiguredBackend();
return readInstalledVersion(
getBackendBinDir(effectiveBackend),
BACKEND_CONFIG[effectiveBackend].fallbackVersion
);
}
/** Install a specific version of CLIProxyAPI */
export async function installCliproxyVersion(version: string, verbose = false): Promise<void> {
const backend = getConfiguredBackend();
const manager = new BinaryManager({ version, verbose, forceVersion: true }, backend);
export async function installCliproxyVersion(
version: string,
verbose = false,
backend?: CLIProxyBackend
): Promise<void> {
const effectiveBackend = backend ?? getConfiguredBackend();
const manager = new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend);
// Check if proxy is running and stop it first
if (isProxyRunning()) {
@@ -165,8 +183,11 @@ export async function installCliproxyVersion(version: string, verbose = false):
}
if (manager.isBinaryInstalled()) {
const label = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
if (verbose)
console.log(info(`Removing existing CLIProxy Plus v${getInstalledCliproxyVersion()}`));
console.log(
info(`Removing existing ${label} v${getInstalledCliproxyVersion(effectiveBackend)}`)
);
manager.deleteBinary();
}
await manager.ensureBinary();
@@ -190,6 +211,9 @@ export interface CliproxyUpdateCheckResult {
latestVersion: string;
fromCache: boolean;
checkedAt: number;
// Backend info
backend: CLIProxyBackend;
backendLabel: string;
// Stability fields
isStable: boolean;
maxStableVersion: string;
@@ -208,8 +232,12 @@ export async function checkCliproxyUpdate(): Promise<CliproxyUpdateCheckResult>
? undefined
: `v${result.currentVersion} has known stability issues. Max stable: v${CLIPROXY_MAX_STABLE_VERSION}`;
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
return {
...result,
backend,
backendLabel,
isStable,
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
stabilityMessage,
@@ -223,6 +251,7 @@ export {
savePinnedVersion,
clearPinnedVersion,
isVersionPinned,
migrateVersionPin,
};
export default BinaryManager;
+6 -4
View File
@@ -3,7 +3,8 @@
* Facade for tar.gz and zip archive extraction.
*/
import { ArchiveExtension } from '../types';
import { ArchiveExtension, CLIProxyBackend } from '../types';
import { DEFAULT_BACKEND } from '../platform-detector';
import { extractTarGz } from './tar-extractor';
import { extractZip } from './zip-extractor';
@@ -18,11 +19,12 @@ export async function extractArchive(
archivePath: string,
destDir: string,
extension: ArchiveExtension,
verbose = false
verbose = false,
backend: CLIProxyBackend = DEFAULT_BACKEND
): Promise<void> {
if (extension === 'tar.gz') {
await extractTarGz(archivePath, destDir, verbose);
await extractTarGz(archivePath, destDir, verbose, backend);
} else {
await extractZip(archivePath, destDir, verbose);
await extractZip(archivePath, destDir, verbose, backend);
}
}
+1
View File
@@ -25,6 +25,7 @@ export {
savePinnedVersion,
clearPinnedVersion,
isVersionPinned,
migrateVersionPin,
} from './version-cache';
// Version Checker
+34 -17
View File
@@ -11,6 +11,7 @@ import {
getDownloadUrl,
getChecksumsUrl,
getExecutableName,
DEFAULT_BACKEND,
} from '../platform-detector';
import { downloadWithRetry } from './downloader';
import { verifyChecksum, computeChecksum } from './verifier';
@@ -26,13 +27,23 @@ export async function downloadAndInstall(
config: BinaryManagerConfig,
verbose = false
): Promise<void> {
const platform = detectPlatform(config.version);
const downloadUrl = getDownloadUrl(config.version);
const checksumsUrl = getChecksumsUrl(config.version);
const backend = config.backend ?? DEFAULT_BACKEND;
const platform = detectPlatform(config.version, backend);
const downloadUrl = getDownloadUrl(config.version, backend);
const checksumsUrl = getChecksumsUrl(config.version, backend);
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
fs.mkdirSync(config.binPath, { recursive: true });
// Delete existing binary before install to prevent mismatched binaries
const existingBinary = path.join(config.binPath, getExecutableName(backend));
if (fs.existsSync(existingBinary)) {
fs.unlinkSync(existingBinary);
if (verbose) console.error(`[cliproxy] Removed existing binary: ${existingBinary}`);
}
const archivePath = path.join(config.binPath, `cliproxy-archive.${platform.extension}`);
const spinner = new ProgressIndicator(`Downloading CLIProxy Plus v${config.version}`);
const spinner = new ProgressIndicator(`Downloading ${backendLabel} v${config.version}`);
spinner.start();
try {
@@ -63,27 +74,30 @@ export async function downloadAndInstall(
}
spinner.update('Extracting binary');
await extractArchive(archivePath, config.binPath, platform.extension, verbose);
spinner.succeed('CLIProxy Plus ready');
await extractArchive(archivePath, config.binPath, platform.extension, verbose, backend);
spinner.succeed(`${backendLabel} ready`);
fs.unlinkSync(archivePath);
const binaryPath = path.join(config.binPath, getExecutableName());
const binaryPath = path.join(config.binPath, getExecutableName(backend));
if (platform.os !== 'windows' && fs.existsSync(binaryPath)) {
fs.chmodSync(binaryPath, 0o755);
if (verbose) console.error(`[cliproxy] Set executable permissions: ${binaryPath}`);
}
writeInstalledVersion(config.binPath, config.version);
console.log(ok(`CLIProxy Plus v${config.version} installed successfully`));
console.log(ok(`${backendLabel} v${config.version} installed successfully`));
} catch (error) {
spinner.fail('Installation failed');
throw error;
}
}
import type { CLIProxyBackend } from '../types';
/** Delete binary (for cleanup or reinstall) */
export function deleteBinary(binPath: string, verbose = false): void {
const binaryPath = path.join(binPath, getExecutableName());
export function deleteBinary(binPath: string, verbose = false, backend?: CLIProxyBackend): void {
const effectiveBackend = backend ?? DEFAULT_BACKEND;
const binaryPath = path.join(binPath, getExecutableName(effectiveBackend));
if (fs.existsSync(binaryPath)) {
fs.unlinkSync(binaryPath);
if (verbose) console.error(`[cliproxy] Deleted: ${binaryPath}`);
@@ -91,29 +105,32 @@ export function deleteBinary(binPath: string, verbose = false): void {
}
/** Get binary path */
export function getBinaryPath(binPath: string): string {
return path.join(binPath, getExecutableName());
export function getBinaryPath(binPath: string, backend?: CLIProxyBackend): string {
const effectiveBackend = backend ?? DEFAULT_BACKEND;
return path.join(binPath, getExecutableName(effectiveBackend));
}
/** Check if binary exists */
export function isBinaryInstalled(binPath: string): boolean {
return fs.existsSync(getBinaryPath(binPath));
export function isBinaryInstalled(binPath: string, backend?: CLIProxyBackend): boolean {
return fs.existsSync(getBinaryPath(binPath, backend));
}
/** Get binary info if installed */
export async function getBinaryInfo(
binPath: string,
version: string
version: string,
backend?: CLIProxyBackend
): Promise<{
path: string;
version: string;
platform: ReturnType<typeof detectPlatform>;
checksum: string;
} | null> {
const binaryPath = getBinaryPath(binPath);
const effectiveBackend = backend ?? DEFAULT_BACKEND;
const binaryPath = getBinaryPath(binPath, effectiveBackend);
if (!fs.existsSync(binaryPath)) return null;
const platform = detectPlatform();
const platform = detectPlatform(undefined, effectiveBackend);
const checksum = await computeChecksum(binaryPath);
return { path: binaryPath, version, platform, checksum };
}
+17 -10
View File
@@ -4,7 +4,7 @@
*/
import * as fs from 'fs';
import { BinaryManagerConfig } from '../types';
import { BinaryManagerConfig, CLIProxyBackend } from '../types';
import {
checkForUpdates,
fetchLatestVersion,
@@ -15,7 +15,11 @@ import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer';
import { info, warn } from '../../utils/ui';
import { isCliproxyRunning } from '../stats-fetcher';
import { CLIPROXY_DEFAULT_PORT } from '../config-generator';
import { CLIPROXY_MAX_STABLE_VERSION, CLIPROXY_FAULTY_RANGE } from '../platform-detector';
import {
CLIPROXY_MAX_STABLE_VERSION,
CLIPROXY_FAULTY_RANGE,
DEFAULT_BACKEND,
} from '../platform-detector';
/** Log helper */
function log(message: string, verbose: boolean): void {
@@ -47,7 +51,9 @@ function clampToMaxStable(version: string | undefined, verbose: boolean): string
/** Handle auto-update when binary exists */
async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise<void> {
const updateResult = await checkForUpdates(config.binPath, config.version, verbose);
const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND;
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
const updateResult = await checkForUpdates(config.binPath, config.version, verbose, backend);
const currentVersion = updateResult.currentVersion;
const latestVersion = updateResult.latestVersion;
@@ -55,7 +61,7 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean):
if (isVersionFaulty(currentVersion)) {
console.log(
warn(
`CLIProxy Plus v${currentVersion} has known bugs (v${CLIPROXY_FAULTY_RANGE.min.replace(/-\d+$/, '')}-${CLIPROXY_FAULTY_RANGE.max.replace(/-\d+$/, '')}). ` +
`${backendLabel} v${currentVersion} has known bugs (v${CLIPROXY_FAULTY_RANGE.min.replace(/-\d+$/, '')}-${CLIPROXY_FAULTY_RANGE.max.replace(/-\d+$/, '')}). ` +
`Upgrade to latest stable recommended.`
)
);
@@ -73,16 +79,16 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean):
const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT);
const latestNote = isAboveMaxStable(latestVersion) ? ` (latest v${latestVersion} unstable)` : '';
const updateMsg = `CLIProxy Plus update: v${currentVersion} -> v${targetVersion}${latestNote}`;
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: CLIProxy Plus is currently running', verbose);
log(`Skipping update: ${backendLabel} is currently running`, verbose);
} else {
console.log(info(updateMsg));
console.log(info('Updating CLIProxy Plus...'));
deleteBinary(config.binPath, verbose);
console.log(info(`Updating ${backendLabel}...`));
deleteBinary(config.binPath, verbose, backend);
config.version = targetVersion;
await downloadAndInstall(config, verbose);
}
@@ -94,7 +100,8 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean):
*/
export async function ensureBinary(config: BinaryManagerConfig): Promise<string> {
const verbose = config.verbose;
const binaryPath = getBinaryPath(config.binPath);
const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND;
const binaryPath = getBinaryPath(config.binPath, backend);
// Binary exists - check for updates unless forceVersion
if (fs.existsSync(binaryPath)) {
@@ -120,7 +127,7 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise<string>
if (!config.forceVersion) {
try {
const latestVersion = await fetchLatestVersion(verbose);
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);
+10 -4
View File
@@ -6,15 +6,21 @@
import * as fs from 'fs';
import * as path from 'path';
import * as zlib from 'zlib';
import { getExecutableName, getArchiveBinaryName } from '../platform-detector';
import { getExecutableName, getArchiveBinaryName, DEFAULT_BACKEND } from '../platform-detector';
import type { CLIProxyBackend } from '../types';
/**
* Extract tar.gz archive using Node.js built-in modules
*/
export function extractTarGz(archivePath: string, destDir: string, verbose = false): Promise<void> {
export function extractTarGz(
archivePath: string,
destDir: string,
verbose = false,
backend: CLIProxyBackend = DEFAULT_BACKEND
): Promise<void> {
return new Promise((resolve, reject) => {
const execName = getExecutableName();
const archiveBinaryName = getArchiveBinaryName();
const execName = getExecutableName(backend);
const archiveBinaryName = getArchiveBinaryName(backend);
const gunzip = zlib.createGunzip();
const input = fs.createReadStream(archivePath);
+19 -3
View File
@@ -24,14 +24,30 @@ export const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000;
/** Version pin file name - stores user's explicit version choice */
export const VERSION_PIN_FILE = '.version-pin';
/** GitHub API URL for latest release (CLIProxyAPIPlus fork with Kiro + Copilot support) */
/**
* GitHub API URLs - backend-specific
* @deprecated Use getGitHubApiUrls(backend) instead
*/
export const GITHUB_API_LATEST_RELEASE =
'https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases/latest';
/** GitHub API URL for all releases */
export const GITHUB_API_ALL_RELEASES =
'https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases';
/** GitHub repos per backend */
export const GITHUB_REPOS = {
original: 'router-for-me/CLIProxyAPI',
plus: 'router-for-me/CLIProxyAPIPlus',
} as const;
/** Get GitHub API URLs for specific backend */
export function getGitHubApiUrls(backend: 'original' | 'plus') {
const repo = GITHUB_REPOS[backend];
return {
latestRelease: `https://api.github.com/repos/${repo}/releases/latest`,
allReleases: `https://api.github.com/repos/${repo}/releases`,
};
}
/** Version list cache structure */
export interface VersionListCache {
versions: string[];
+69 -34
View File
@@ -5,33 +5,35 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCliproxyDir, getBinDir } from '../config-generator';
import { getBinDir } from '../config-generator';
import {
VersionCache,
VERSION_CACHE_DURATION_MS,
VERSION_PIN_FILE,
VersionListCache,
} from './types';
import { DEFAULT_BACKEND } from '../platform-detector';
import type { CLIProxyBackend } from '../types';
/**
* Get path to version cache file
* Get path to version cache file (backend-specific)
*/
export function getVersionCachePath(): string {
return path.join(getCliproxyDir(), '.version-cache.json');
export function getVersionCachePath(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
return path.join(getBinDir(), backend, '.version-cache.json');
}
/**
* Get path to version pin file
* Get path to version pin file (backend-specific)
*/
export function getVersionPinPath(): string {
return path.join(getBinDir(), VERSION_PIN_FILE);
export function getVersionPinPath(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
return path.join(getBinDir(), backend, VERSION_PIN_FILE);
}
/**
* Read version cache if still valid
* Read version cache if still valid (backend-specific)
*/
export function readVersionCache(): VersionCache | null {
const cachePath = getVersionCachePath();
export function readVersionCache(backend: CLIProxyBackend = DEFAULT_BACKEND): VersionCache | null {
const cachePath = getVersionCachePath(backend);
if (!fs.existsSync(cachePath)) {
return null;
}
@@ -53,10 +55,13 @@ export function readVersionCache(): VersionCache | null {
}
/**
* Write version to cache
* Write version to cache (backend-specific)
*/
export function writeVersionCache(version: string): void {
const cachePath = getVersionCachePath();
export function writeVersionCache(
version: string,
backend: CLIProxyBackend = DEFAULT_BACKEND
): void {
const cachePath = getVersionCachePath(backend);
const cache: VersionCache = {
latestVersion: version,
checkedAt: Date.now(),
@@ -98,10 +103,10 @@ export function writeInstalledVersion(binPath: string, version: string): void {
}
/**
* Get pinned version if one exists
* Get pinned version if one exists (backend-specific)
*/
export function getPinnedVersion(): string | null {
const pinPath = getVersionPinPath();
export function getPinnedVersion(backend: CLIProxyBackend = DEFAULT_BACKEND): string | null {
const pinPath = getVersionPinPath(backend);
if (!fs.existsSync(pinPath)) {
return null;
}
@@ -113,10 +118,13 @@ export function getPinnedVersion(): string | null {
}
/**
* Save pinned version to persist user's explicit choice
* Save pinned version to persist user's explicit choice (backend-specific)
*/
export function savePinnedVersion(version: string): void {
const pinPath = getVersionPinPath();
export function savePinnedVersion(
version: string,
backend: CLIProxyBackend = DEFAULT_BACKEND
): void {
const pinPath = getVersionPinPath(backend);
try {
fs.mkdirSync(path.dirname(pinPath), { recursive: true });
fs.writeFileSync(pinPath, version, 'utf8');
@@ -126,10 +134,10 @@ export function savePinnedVersion(version: string): void {
}
/**
* Clear pinned version (unpin)
* Clear pinned version (unpin) - backend-specific
*/
export function clearPinnedVersion(): void {
const pinPath = getVersionPinPath();
export function clearPinnedVersion(backend: CLIProxyBackend = DEFAULT_BACKEND): void {
const pinPath = getVersionPinPath(backend);
if (fs.existsSync(pinPath)) {
try {
fs.unlinkSync(pinPath);
@@ -140,10 +148,32 @@ export function clearPinnedVersion(): void {
}
/**
* Check if a version is currently pinned
* Check if a version is currently pinned (backend-specific)
*/
export function isVersionPinned(): boolean {
return getPinnedVersion() !== null;
export function isVersionPinned(backend: CLIProxyBackend = DEFAULT_BACKEND): boolean {
return getPinnedVersion(backend) !== null;
}
/**
* Migrate old shared version pin to backend-specific location.
* Called once on first run after update.
*/
export function migrateVersionPin(backend: CLIProxyBackend): void {
const oldPinPath = path.join(getBinDir(), VERSION_PIN_FILE);
if (!fs.existsSync(oldPinPath)) return;
try {
const oldVersion = fs.readFileSync(oldPinPath, 'utf8').trim();
if (!oldVersion) return;
// Save to new backend-specific location
savePinnedVersion(oldVersion, backend);
// Delete old shared file
fs.unlinkSync(oldPinPath);
} catch {
// Silent fail - not critical
}
}
// ==================== Version List Cache ====================
@@ -151,17 +181,19 @@ export function isVersionPinned(): boolean {
const VERSION_LIST_CACHE_FILE = '.version-list-cache.json';
/**
* Get path to version list cache file
* Get path to version list cache file (backend-specific)
*/
export function getVersionListCachePath(): string {
return path.join(getCliproxyDir(), VERSION_LIST_CACHE_FILE);
export function getVersionListCachePath(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
return path.join(getBinDir(), backend, VERSION_LIST_CACHE_FILE);
}
/**
* Read version list cache if still valid
* Read version list cache if still valid (backend-specific)
*/
export function readVersionListCache(): VersionListCache | null {
const cachePath = getVersionListCachePath();
export function readVersionListCache(
backend: CLIProxyBackend = DEFAULT_BACKEND
): VersionListCache | null {
const cachePath = getVersionListCachePath(backend);
if (!fs.existsSync(cachePath)) {
return null;
}
@@ -182,10 +214,13 @@ export function readVersionListCache(): VersionListCache | null {
}
/**
* Write version list to cache
* Write version list to cache (backend-specific)
*/
export function writeVersionListCache(cache: VersionListCache): void {
const cachePath = getVersionListCachePath();
export function writeVersionListCache(
cache: VersionListCache,
backend: CLIProxyBackend = DEFAULT_BACKEND
): void {
const cachePath = getVersionListCachePath(backend);
try {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
+34 -20
View File
@@ -11,13 +11,13 @@ import {
readVersionListCache,
writeVersionListCache,
} from './version-cache';
import { UpdateCheckResult, VersionListResult, getGitHubApiUrls } from './types';
import {
UpdateCheckResult,
GITHUB_API_LATEST_RELEASE,
GITHUB_API_ALL_RELEASES,
VersionListResult,
} from './types';
import { CLIPROXY_MAX_STABLE_VERSION, CLIPROXY_FAULTY_RANGE } from '../platform-detector';
CLIPROXY_MAX_STABLE_VERSION,
CLIPROXY_FAULTY_RANGE,
DEFAULT_BACKEND,
} from '../platform-detector';
import type { CLIProxyBackend } from '../types';
/**
* Compare semver versions (true if latest > current)
@@ -56,9 +56,15 @@ export function isVersionFaulty(version: string): boolean {
/**
* Fetch latest version from GitHub API
* @param verbose Enable verbose logging
* @param backend Backend to fetch version for (uses correct GitHub repo)
*/
export async function fetchLatestVersion(verbose = false): Promise<string> {
const response = await fetchJson(GITHUB_API_LATEST_RELEASE, verbose);
export async function fetchLatestVersion(
verbose = false,
backend: CLIProxyBackend = DEFAULT_BACKEND
): Promise<string> {
const urls = getGitHubApiUrls(backend);
const response = await fetchJson(urls.latestRelease, verbose);
// Extract version from tag_name (format: "v6.5.27" or "6.5.27")
const tagName = response.tag_name as string;
@@ -72,16 +78,18 @@ export async function fetchLatestVersion(verbose = false): Promise<string> {
/**
* Check for updates by comparing installed version with latest release
* Uses cache to avoid hitting GitHub API on every run
* Cache is backend-specific to handle different repos for original vs plus
*/
export async function checkForUpdates(
binPath: string,
configVersion: string,
verbose = false
verbose = false,
backend: CLIProxyBackend = DEFAULT_BACKEND
): Promise<UpdateCheckResult> {
const currentVersion = readInstalledVersion(binPath, configVersion);
// Try cache first
const cache = readVersionCache();
// Try cache first (backend-specific)
const cache = readVersionCache(backend);
if (cache) {
if (verbose) {
console.error(`[cliproxy] Using cached version: ${cache.latestVersion}`);
@@ -95,10 +103,10 @@ export async function checkForUpdates(
};
}
// Fetch from GitHub API
const latestVersion = await fetchLatestVersion(verbose);
// Fetch from GitHub API (backend-specific repo)
const latestVersion = await fetchLatestVersion(verbose, backend);
const now = Date.now();
writeVersionCache(latestVersion);
writeVersionCache(latestVersion, backend);
return {
hasUpdate: isNewerVersion(latestVersion, currentVersion),
@@ -112,10 +120,15 @@ export async function checkForUpdates(
/**
* Fetch all available versions from GitHub releases
* Caches result for 1 hour to avoid rate limiting
* @param verbose Enable verbose logging
* @param backend Backend to fetch versions for (uses correct GitHub repo)
*/
export async function fetchAllVersions(verbose = false): Promise<VersionListResult> {
// Try cache first
const cache = readVersionListCache();
export async function fetchAllVersions(
verbose = false,
backend: CLIProxyBackend = DEFAULT_BACKEND
): Promise<VersionListResult> {
// Try cache first (backend-specific)
const cache = readVersionListCache(backend);
if (cache) {
if (verbose) {
console.error(`[cliproxy] Using cached version list (${cache.versions.length} versions)`);
@@ -123,8 +136,9 @@ export async function fetchAllVersions(verbose = false): Promise<VersionListResu
return { ...cache, fromCache: true };
}
// Fetch from GitHub API
const response = await fetchJson(GITHUB_API_ALL_RELEASES, verbose);
// Fetch from GitHub API (backend-specific repo)
const urls = getGitHubApiUrls(backend);
const response = await fetchJson(urls.allReleases, verbose);
// Extract and normalize versions
const releases = response as unknown as Array<{ tag_name: string }>;
@@ -147,6 +161,6 @@ export async function fetchAllVersions(verbose = false): Promise<VersionListResu
checkedAt: Date.now(),
};
writeVersionListCache(result);
writeVersionListCache(result, backend);
return result;
}
+10 -4
View File
@@ -6,15 +6,21 @@
import * as fs from 'fs';
import * as path from 'path';
import * as zlib from 'zlib';
import { getExecutableName, getArchiveBinaryName } from '../platform-detector';
import { getExecutableName, getArchiveBinaryName, DEFAULT_BACKEND } from '../platform-detector';
import type { CLIProxyBackend } from '../types';
/**
* Extract zip archive using Node.js (simple implementation)
*/
export function extractZip(archivePath: string, destDir: string, verbose = false): Promise<void> {
export function extractZip(
archivePath: string,
destDir: string,
verbose = false,
backend: CLIProxyBackend = DEFAULT_BACKEND
): Promise<void> {
return new Promise((resolve, reject) => {
const execName = getExecutableName();
const archiveBinaryName = getArchiveBinaryName();
const execName = getExecutableName(backend);
const archiveBinaryName = getArchiveBinaryName(backend);
const buffer = fs.readFileSync(archivePath);
// Find End of Central Directory record (EOCD)
+5 -4
View File
@@ -724,12 +724,13 @@ export async function execClaudeWithCLIProxy(
await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval);
readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`);
} catch (error) {
readySpinner.fail('CLIProxy Plus startup failed');
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
readySpinner.fail(`${backendLabel} startup failed`);
proxy.kill('SIGTERM');
const err = error as Error;
console.error('');
console.error(fail('CLIProxy Plus failed to start'));
console.error(fail(`${backendLabel} failed to start`));
console.error('');
console.error('Possible causes:');
console.error(` 1. Port ${cfg.port} already in use`);
@@ -746,9 +747,9 @@ export async function execClaudeWithCLIProxy(
throw new Error(`CLIProxy startup failed: ${err.message}`);
}
// Register this session with the new proxy, including the installed version
// Register this session with the new proxy, including version and backend
const installedVersion = getInstalledCliproxyVersion();
sessionId = registerSession(cfg.port, proxy.pid as number, installedVersion);
sessionId = registerSession(cfg.port, proxy.pid as number, installedVersion, backend);
log(
`Registered session ${sessionId} with new proxy (PID ${proxy.pid}, version ${installedVersion})`
);
+8 -1
View File
@@ -281,11 +281,18 @@ export async function ensureCliproxyService(
proxyProcess = null;
}
// Get backend label for error message
const { loadOrCreateUnifiedConfig } = await import('../config/unified-config-loader');
const { DEFAULT_BACKEND } = await import('./platform-detector');
const config = loadOrCreateUnifiedConfig();
const backendLabel =
(config.cliproxy?.backend ?? DEFAULT_BACKEND) === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
return {
started: false,
alreadyRunning: false,
port,
error: `CLIProxy Plus failed to start within 5s on port ${port}`,
error: `${backendLabel} failed to start within 5s on port ${port}`,
};
}
+47 -22
View File
@@ -58,10 +58,10 @@ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult {
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
const backendConfig = BACKEND_CONFIG[effectiveBackend];
return {
installed: isCLIProxyInstalled(),
currentVersion: getInstalledCliproxyVersion(),
pinnedVersion: getPinnedVersion(),
binaryPath: getCLIProxyPath(),
installed: isCLIProxyInstalled(effectiveBackend),
currentVersion: getInstalledCliproxyVersion(effectiveBackend),
pinnedVersion: getPinnedVersion(effectiveBackend),
binaryPath: getCLIProxyPath(effectiveBackend),
fallbackVersion: backendConfig.fallbackVersion,
backend: effectiveBackend,
};
@@ -70,10 +70,16 @@ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult {
/**
* Check for latest version
*/
export async function checkLatestVersion(): Promise<LatestVersionResult> {
export async function checkLatestVersion(backend?: CLIProxyBackend): Promise<LatestVersionResult> {
const effectiveBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
try {
const latestVersion = await fetchLatestCliproxyVersion();
const currentVersion = getInstalledCliproxyVersion();
// Use checkCliproxyUpdate which is backend-aware (uses correct GitHub repo)
const { checkCliproxyUpdate } = await import('../binary-manager');
const updateResult = await checkCliproxyUpdate();
const latestVersion = updateResult.latestVersion;
const currentVersion = getInstalledCliproxyVersion(effectiveBackend);
const updateAvailable = latestVersion !== currentVersion;
return {
@@ -100,7 +106,11 @@ export function isValidVersionFormat(version: string): boolean {
/**
* Install a specific version and pin it
*/
export async function installVersion(version: string, verbose = false): Promise<InstallResult> {
export async function installVersion(
version: string,
verbose = false,
backend?: CLIProxyBackend
): Promise<InstallResult> {
if (!isValidVersionFormat(version)) {
return {
success: false,
@@ -109,9 +119,12 @@ export async function installVersion(version: string, verbose = false): Promise<
};
}
const effectiveBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
try {
await installCliproxyVersion(version, verbose);
savePinnedVersion(version);
await installCliproxyVersion(version, verbose, effectiveBackend);
savePinnedVersion(version, effectiveBackend);
return {
success: true,
@@ -130,13 +143,19 @@ export async function installVersion(version: string, verbose = false): Promise<
/**
* Install latest version and clear any pin
*/
export async function installLatest(verbose = false): Promise<InstallResult> {
export async function installLatest(
verbose = false,
backend?: CLIProxyBackend
): Promise<InstallResult> {
const effectiveBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
try {
const latestVersion = await fetchLatestCliproxyVersion();
const currentVersion = getInstalledCliproxyVersion();
const wasPinned = isVersionPinned();
const currentVersion = getInstalledCliproxyVersion(effectiveBackend);
const wasPinned = isVersionPinned(effectiveBackend);
if (isCLIProxyInstalled() && latestVersion === currentVersion && !wasPinned) {
if (isCLIProxyInstalled(effectiveBackend) && latestVersion === currentVersion && !wasPinned) {
return {
success: true,
version: latestVersion,
@@ -144,8 +163,8 @@ export async function installLatest(verbose = false): Promise<InstallResult> {
};
}
await installCliproxyVersion(latestVersion, verbose);
clearPinnedVersion();
await installCliproxyVersion(latestVersion, verbose, effectiveBackend);
clearPinnedVersion(effectiveBackend);
return {
success: true,
@@ -164,20 +183,26 @@ export async function installLatest(verbose = false): Promise<InstallResult> {
/**
* Check if a version is pinned
*/
export function isPinned(): boolean {
return isVersionPinned();
export function isPinned(backend?: CLIProxyBackend): boolean {
const effectiveBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
return isVersionPinned(effectiveBackend);
}
/**
* Get pinned version if any
*/
export function getPinned(): string | null {
return getPinnedVersion();
export function getPinned(backend?: CLIProxyBackend): string | null {
const effectiveBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
return getPinnedVersion(effectiveBackend);
}
/**
* Clear version pin
*/
export function clearPin(): void {
clearPinnedVersion();
export function clearPin(backend?: CLIProxyBackend): void {
const effectiveBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
clearPinnedVersion(effectiveBackend);
}
+10 -1
View File
@@ -29,6 +29,8 @@ interface SessionLock {
startedAt: string;
/** CLIProxy version running (added for version mismatch detection) */
version?: string;
/** Backend type running (original vs plus) */
backend?: 'original' | 'plus';
}
/** Generate unique session ID */
@@ -175,9 +177,15 @@ export function getExistingProxy(port: number): SessionLock | null {
* @param port Port the proxy is running on
* @param proxyPid PID of the proxy process
* @param version Optional CLIProxy version (stored when spawning new proxy)
* @param backend Optional backend type (original vs plus)
* @returns Session ID for this session
*/
export function registerSession(port: number, proxyPid: number, version?: string): string {
export function registerSession(
port: number,
proxyPid: number,
version?: string,
backend?: 'original' | 'plus'
): string {
const sessionId = generateSessionId();
const existingLock = readSessionLockForPort(port);
@@ -193,6 +201,7 @@ export function registerSession(port: number, proxyPid: number, version?: string
sessions: [sessionId],
startedAt: new Date().toISOString(),
version,
backend,
};
writeSessionLockForPort(newLock);
}
+2
View File
@@ -48,6 +48,8 @@ export interface BinaryManagerConfig {
verbose: boolean;
/** Force specific version (skip auto-upgrade to latest) */
forceVersion: boolean;
/** Backend variant (original vs plus) */
backend?: CLIProxyBackend;
}
/**
+30 -15
View File
@@ -111,6 +111,13 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend {
return config.cliproxy?.backend ?? DEFAULT_BACKEND;
}
/**
* Get display label for backend
*/
function getBackendLabel(backend: CLIProxyBackend): string {
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
}
interface CliproxyProfileArgs {
name?: string;
provider?: CLIProxyProfileName;
@@ -152,8 +159,10 @@ function formatModelOption(model: ModelEntry): string {
async function handleCreate(args: string[]): Promise<void> {
await initUI();
const { backend } = parseBackendArg(args);
const effectiveBackend = getEffectiveBackend(backend);
const parsedArgs = parseProfileArgs(args);
console.log(header('Create CLIProxy Plus Variant'));
console.log(header(`Create ${getBackendLabel(effectiveBackend)} Variant`));
console.log('');
// Step 1: Profile name
@@ -292,7 +301,7 @@ async function handleCreate(args: string[]): Promise<void> {
// Create variant
console.log('');
console.log(info('Creating CLIProxy Plus variant...'));
console.log(info(`Creating ${getBackendLabel(effectiveBackend)} variant...`));
const result = createVariant(name, provider, model, account);
if (!result.success) {
@@ -479,7 +488,7 @@ async function showStatus(verbose: boolean, backend: CLIProxyBackend): Promise<v
const status = getBinaryStatus(backend);
console.log('');
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy (Original)';
const backendLabel = getBackendLabel(backend);
console.log(color(`${backendLabel} Status`, 'primary'));
console.log('');
@@ -530,14 +539,19 @@ async function showStatus(verbose: boolean, backend: CLIProxyBackend): Promise<v
console.log('');
}
async function handleInstallVersion(version: string, verbose: boolean): Promise<void> {
console.log(info(`Installing CLIProxy Plus v${version}...`));
async function handleInstallVersion(
version: string,
verbose: boolean,
backend: CLIProxyBackend
): Promise<void> {
const label = getBackendLabel(backend);
console.log(info(`Installing ${label} v${version}...`));
console.log('');
const result = await installVersion(version, verbose);
const result = await installVersion(version, verbose, backend);
if (!result.success) {
console.error('');
console.error(fail(`Failed to install CLIProxy Plus v${version}`));
console.error(fail(`Failed to install ${label} v${version}`));
console.error(` ${result.error}`);
console.error('');
console.error('Possible causes:');
@@ -546,12 +560,12 @@ async function handleInstallVersion(version: string, verbose: boolean): Promise<
console.error(' 3. GitHub API rate limiting');
console.error('');
console.error('Check available versions at:');
console.error(' https://github.com/router-for-me/CLIProxyAPIPlus/releases');
console.error(` https://github.com/${BACKEND_CONFIG[backend].repo}/releases`);
process.exit(1);
}
console.log('');
console.log(ok(`CLIProxy Plus v${version} installed (pinned)`));
console.log(ok(`${label} v${version} installed (pinned)`));
console.log('');
console.log(dim('This version will be used until you run:'));
console.log(
@@ -560,10 +574,11 @@ async function handleInstallVersion(version: string, verbose: boolean): Promise<
console.log('');
}
async function handleInstallLatest(verbose: boolean): Promise<void> {
console.log(info('Fetching latest CLIProxy Plus version...'));
async function handleInstallLatest(verbose: boolean, backend: CLIProxyBackend): Promise<void> {
const label = getBackendLabel(backend);
console.log(info(`Fetching latest ${label} version...`));
const result = await installLatest(verbose);
const result = await installLatest(verbose, backend);
if (!result.success) {
console.error(fail(`Failed to install latest version: ${result.error}`));
process.exit(1);
@@ -575,7 +590,7 @@ async function handleInstallLatest(verbose: boolean): Promise<void> {
}
console.log('');
console.log(ok(`CLIProxy Plus updated to v${result.version}`));
console.log(ok(`${label} updated to v${result.version}`));
console.log(dim('Auto-update is now enabled.'));
console.log('');
}
@@ -1036,12 +1051,12 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
}
// Strip leading 'v' prefix and whitespace (user may type " v6.6.80-0 ")
version = version.trim().replace(/^v/, '');
await handleInstallVersion(version, verbose);
await handleInstallVersion(version, verbose, effectiveBackend);
return;
}
if (remainingArgs.includes('--latest') || remainingArgs.includes('--update')) {
await handleInstallLatest(verbose);
await handleInstallLatest(verbose, effectiveBackend);
return;
}
+5
View File
@@ -153,6 +153,11 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
},
// Auth config - preserve user values, no defaults (uses constants as fallback)
auth: partial.cliproxy?.auth,
// Backend selection - validate and preserve user choice (original vs plus)
backend:
partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus'
? partial.cliproxy.backend
: undefined, // Invalid values become undefined (defaults to 'plus' at runtime)
},
preferences: {
...defaults.preferences,
+15 -2
View File
@@ -34,10 +34,22 @@ import {
import {
CLIPROXY_MAX_STABLE_VERSION,
CLIPROXY_FAULTY_RANGE,
DEFAULT_BACKEND,
} from '../../cliproxy/platform-detector';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
const router = Router();
/** Get configured backend from config */
function getConfiguredBackend() {
try {
const config = loadOrCreateUnifiedConfig();
return config.cliproxy?.backend || DEFAULT_BACKEND;
} catch {
return DEFAULT_BACKEND;
}
}
/**
* Extract status code and model from error log file (lightweight parsing)
* Reads first 4KB for model, last 2KB for status code
@@ -549,8 +561,9 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P
*/
router.get('/versions', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await fetchAllVersions();
const currentVersion = getInstalledCliproxyVersion();
const backend = getConfiguredBackend();
const result = await fetchAllVersions(false, backend);
const currentVersion = getInstalledCliproxyVersion(backend);
res.json({
...result,
+3 -2
View File
@@ -11,6 +11,7 @@ import { Router, Request, Response } from 'express';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
import { testConnection } from '../../cliproxy/remote-proxy-client';
import { isProxyRunning } from '../../cliproxy/services/proxy-lifecycle-service';
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
import {
DEFAULT_CLIPROXY_SERVER_CONFIG,
CliproxyServerConfig,
@@ -73,7 +74,7 @@ router.put('/', async (req: Request, res: Response) => {
router.get('/backend', async (_req: Request, res: Response) => {
try {
const config = await loadOrCreateUnifiedConfig();
res.json({ backend: config.cliproxy?.backend ?? 'plus' });
res.json({ backend: config.cliproxy?.backend ?? DEFAULT_BACKEND });
} catch (error) {
console.error('[cliproxy-server-routes] Failed to load backend config:', error);
res.status(500).json({ error: 'Failed to load backend config' });
@@ -99,7 +100,7 @@ router.put('/backend', async (req: Request, res: Response) => {
// Check if proxy is running - warn about restart requirement
const config = await loadOrCreateUnifiedConfig();
const currentBackend = config.cliproxy?.backend ?? 'plus';
const currentBackend = config.cliproxy?.backend ?? DEFAULT_BACKEND;
if (currentBackend !== backend && isProxyRunning() && !force) {
res.status(409).json({
error: 'Proxy is running. Stop proxy first or use force=true to change backend.',
@@ -15,6 +15,7 @@ interface VersionInfo {
currentVersion: string;
isStable: boolean;
stabilityMessage?: string;
backendLabel?: string;
}
interface LoginButtonProps {
@@ -127,6 +128,7 @@ export function CliproxyHeader({
currentVersion: data.currentVersion,
isStable: data.isStable,
stabilityMessage: data.stabilityMessage,
backendLabel: data.backendLabel,
});
}
})
@@ -157,7 +159,9 @@ export function CliproxyHeader({
{/* Top row: Title and Login Buttons */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">CLIProxy Plus</h1>
<h1 className="text-2xl font-bold tracking-tight">
{versionInfo?.backendLabel ?? 'CLIProxy'}
</h1>
<p className="text-sm text-muted-foreground mt-1">CCS-level account management</p>
</div>
@@ -27,6 +27,7 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
interface CliproxyStatsOverviewProps {
@@ -37,6 +38,8 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
const { privacyMode } = usePrivacy();
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
const { data: updateCheck } = useCliproxyUpdateCheck();
const backendLabel = updateCheck?.backendLabel ?? 'CLIProxy';
const isLoading = statusLoading || (status?.running && statsLoading);
@@ -71,7 +74,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
Session Statistics
</h2>
<p className="text-sm text-muted-foreground">
Real-time usage metrics from CLIProxy Plus
Real-time usage metrics from {backendLabel}
</p>
</div>
<Badge variant="secondary" className="w-fit gap-1.5">
@@ -146,7 +149,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
<Activity className="h-5 w-5" />
Session Statistics
</h2>
<p className="text-sm text-muted-foreground">Real-time usage metrics from CLIProxyAPI</p>
<p className="text-sm text-muted-foreground">
Real-time usage metrics from {backendLabel}
</p>
</div>
<Badge
variant="outline"
@@ -8,7 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Cpu, AlertCircle } from 'lucide-react';
import { useCliproxyModels } from '@/hooks/use-cliproxy';
import { useCliproxyModels, useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
import { cn } from '@/lib/utils';
/** Category display configuration */
@@ -70,6 +70,8 @@ function EmptyModelsState() {
export function ModelPreferencesGrid() {
const { data: modelsData, isLoading, isError } = useCliproxyModels();
const { data: updateCheck } = useCliproxyUpdateCheck();
const backendLabel = updateCheck?.backendLabel ?? 'CLIProxy';
// Sort categories by model count
const sortedCategories = useMemo(() => {
@@ -102,7 +104,7 @@ export function ModelPreferencesGrid() {
</Badge>
</CardTitle>
<CardDescription>
Models available through CLIProxy Plus, grouped by provider
Models available through {backendLabel}, grouped by provider
</CardDescription>
</CardHeader>
<CardContent>
+17 -4
View File
@@ -30,6 +30,7 @@ import {
} from '@/components/ui/sidebar';
import { CcsLogo } from '@/components/shared/ccs-logo';
import { useSidebar } from '@/hooks/use-sidebar';
import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -87,6 +88,18 @@ export function AppSidebar() {
const location = useLocation();
const navigate = useNavigate();
const { state } = useSidebar();
const { data: updateCheck } = useCliproxyUpdateCheck();
// Dynamic label for CLIProxy based on backend
const cliproxyLabel = updateCheck?.backendLabel ?? 'CLIProxy';
// Helper to get dynamic label (for CLIProxy route)
const getItemLabel = (item: { path: string; label: string }) => {
if (item.path === '/cliproxy') {
return cliproxyLabel;
}
return item.label;
};
// Helper to check if a route is active (exact match)
const isRouteActive = (path: string) => location.pathname === path;
@@ -122,13 +135,13 @@ export function AppSidebar() {
{/* Click navigates to overview AND opens submenu */}
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.label}
tooltip={getItemLabel(item)}
isActive={isParentActive(item.children)}
onClick={() => navigate(item.path)}
>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="group-data-[collapsible=icon]:hidden">
{item.label}
{getItemLabel(item)}
</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
@@ -155,12 +168,12 @@ export function AppSidebar() {
<SidebarMenuButton
asChild
isActive={isRouteActive(item.path)}
tooltip={item.label}
tooltip={getItemLabel(item)}
>
<Link to={item.path}>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="group-data-[collapsible=icon]:hidden flex-1">
{item.label}
{getItemLabel(item)}
</span>
{item.badge && (
<Tooltip>
@@ -46,7 +46,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useIsMutating } from '@tanstack/react-query';
import { api, type CliproxyServerConfig } from '@/lib/api-client';
import {
useProxyStatus,
@@ -167,6 +167,9 @@ export function ProxyStatusWidget() {
staleTime: 30000, // 30 seconds
});
// Detect if backend switch is in progress (prevents race condition)
const isBackendSwitching = useIsMutating({ mutationKey: ['update-backend'] }) > 0;
// Determine if remote mode is enabled
const remoteConfig = cliproxyConfig?.remote;
const isRemoteMode = remoteConfig?.enabled && remoteConfig?.host;
@@ -176,7 +179,8 @@ export function ProxyStatusWidget() {
startProxy.isPending ||
stopProxy.isPending ||
restartProxy.isPending ||
installVersion.isPending;
installVersion.isPending ||
isBackendSwitching;
const hasUpdate = updateCheck?.hasUpdate ?? false;
const isUnstable = updateCheck?.isStable === false;
const currentVersion = updateCheck?.currentVersion;
@@ -282,10 +286,10 @@ export function ProxyStatusWidget() {
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground/30'
)}
/>
<span className="text-sm font-medium">CLIProxy Plus</span>
<span className="text-sm font-medium">{updateCheck?.backendLabel ?? 'CLIProxy'}</span>
</div>
{/* Right side: icon buttons when running */}
{/* Right side: icon buttons */}
<div className="flex items-center gap-1">
{isLoading ? (
<RefreshCw className="w-3 h-3 animate-spin text-muted-foreground" />
@@ -306,16 +310,15 @@ export function ProxyStatusWidget() {
isPending={stopProxy.isPending}
variant="destructive-ghost"
/>
<IconButton
icon={isExpanded ? X : Settings}
tooltip={isExpanded ? 'Close' : 'Version settings'}
onClick={() => setIsExpanded(!isExpanded)}
className={isExpanded ? 'bg-muted' : undefined}
/>
</>
) : (
<Power className="w-3 h-3 text-muted-foreground" />
)}
) : null}
{/* Settings button always visible */}
<IconButton
icon={isExpanded ? X : Settings}
tooltip={isExpanded ? 'Close' : 'Version settings'}
onClick={() => setIsExpanded(!isExpanded)}
className={isExpanded ? 'bg-muted' : undefined}
/>
</div>
</div>
@@ -372,82 +375,80 @@ export function ProxyStatusWidget() {
</div>
)}
{/* Expanded section: Version Management */}
{isRunning && (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleContent className="mt-3 pt-3 border-t border-muted">
{/* Section header */}
<h4 className="text-xs font-medium text-muted-foreground mb-3">Version Management</h4>
{/* Expanded section: Version Management (available even when not running) */}
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleContent className="mt-3 pt-3 border-t border-muted">
{/* Section header */}
<h4 className="text-xs font-medium text-muted-foreground mb-3">Version Management</h4>
{/* Version picker row */}
<div className="flex items-center gap-2">
{/* Dropdown - full width, no truncation */}
<Select
value={selectedVersion}
onValueChange={setSelectedVersion}
disabled={versionsLoading}
>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="Select version to install..." />
</SelectTrigger>
<SelectContent>
{versionsData?.versions.slice(0, 20).map((v) => {
const vIsUnstable =
versionsData?.maxStableVersion &&
isNewerVersionClient(v, versionsData.maxStableVersion);
return (
<SelectItem key={v} value={v} className="text-xs">
<span className="flex items-center gap-2">
v{v}
{v === versionsData.latestStable && (
<span className="text-green-600 dark:text-green-400">(stable)</span>
)}
{vIsUnstable && (
<span className="text-amber-600 dark:text-amber-400"></span>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{/* Version picker row */}
<div className="flex items-center gap-2">
{/* Dropdown - full width, no truncation */}
<Select
value={selectedVersion}
onValueChange={setSelectedVersion}
disabled={versionsLoading}
>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="Select version to install..." />
</SelectTrigger>
<SelectContent>
{versionsData?.versions.slice(0, 20).map((v) => {
const vIsUnstable =
versionsData?.maxStableVersion &&
isNewerVersionClient(v, versionsData.maxStableVersion);
return (
<SelectItem key={v} value={v} className="text-xs">
<span className="flex items-center gap-2">
v{v}
{v === versionsData.latestStable && (
<span className="text-green-600 dark:text-green-400">(stable)</span>
)}
{vIsUnstable && (
<span className="text-amber-600 dark:text-amber-400"></span>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{/* Install button */}
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-1.5 px-3"
onClick={() => handleInstallVersion(selectedVersion)}
disabled={installVersion.isPending || !selectedVersion}
>
{installVersion.isPending ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
Install
</Button>
</div>
{/* Stability warning for selected version */}
{selectedVersion &&
versionsData?.maxStableVersion &&
isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && (
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />
<span>Versions above {versionsData.maxStableVersion} have known issues</span>
</div>
{/* Install button */}
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-1.5 px-3"
onClick={() => handleInstallVersion(selectedVersion)}
disabled={installVersion.isPending || !selectedVersion}
>
{installVersion.isPending ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
Install
</Button>
</div>
{/* Sync time */}
{updateCheck?.checkedAt && (
<div className="mt-2 text-[10px] text-muted-foreground/60">
Last checked {formatTimeAgo(updateCheck.checkedAt)}
{/* Stability warning for selected version */}
{selectedVersion &&
versionsData?.maxStableVersion &&
isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && (
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />
<span>Versions above {versionsData.maxStableVersion} have known issues</span>
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
{/* Sync time */}
{updateCheck?.checkedAt && (
<div className="mt-2 text-[10px] text-muted-foreground/60">
Last checked {formatTimeAgo(updateCheck.checkedAt)}
</div>
)}
</CollapsibleContent>
</Collapsible>
{/* Not running state */}
{!isRunning && (
+39 -5
View File
@@ -333,9 +333,43 @@ export function useCliproxyUpdateCheck() {
return useQuery({
queryKey: ['cliproxy-update-check'],
queryFn: () => api.cliproxy.updateCheck(),
staleTime: 60 * 60 * 1000, // 1 hour (matches backend cache)
refetchInterval: 60 * 60 * 1000, // Refresh every hour
refetchOnWindowFocus: false, // Don't refresh on window focus (save API calls)
staleTime: 5 * 60 * 1000, // 5 minutes (reduced from 1 hour for faster backend switch response)
refetchInterval: 5 * 60 * 1000, // Refresh every 5 minutes
refetchOnWindowFocus: true, // Refetch on window focus to catch backend changes
});
}
// ==================== Backend Management ====================
/**
* Hook for switching CLIProxy backend (original vs plus)
* Invalidates all backend-dependent queries to ensure UI consistency
*/
export function useUpdateBackend() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['update-backend'], // Used by ProxyStatusWidget to detect backend switching
mutationFn: ({ backend, force = false }: { backend: 'original' | 'plus'; force?: boolean }) =>
api.cliproxyServer.updateBackend(backend, force),
onSuccess: () => {
// Invalidate all queries that depend on backend setting
// Use refetchType: 'all' to force immediate refetch even if query is stale
queryClient.invalidateQueries({ queryKey: ['cliproxy-update-check'], refetchType: 'all' });
queryClient.invalidateQueries({ queryKey: ['cliproxy-versions'], refetchType: 'all' });
queryClient.invalidateQueries({ queryKey: ['cliproxy-server-config'], refetchType: 'all' });
queryClient.invalidateQueries({ queryKey: ['proxy-status'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] });
toast.success('Backend updated');
},
onError: (error: Error) => {
// Handle 409 conflict (proxy running)
if (error.message.includes('Proxy is running')) {
toast.error('Stop the proxy first to change backend');
} else {
toast.error(error.message);
}
},
});
}
@@ -345,8 +379,8 @@ export function useCliproxyVersions() {
return useQuery({
queryKey: ['cliproxy-versions'],
queryFn: () => api.cliproxy.versions(),
staleTime: 60 * 60 * 1000, // 1 hour (matches backend cache)
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000, // 5 minutes (reduced for faster backend switch response)
refetchOnWindowFocus: true, // Refetch on focus to catch backend changes
});
}
+4
View File
@@ -268,6 +268,10 @@ export interface CliproxyUpdateCheckResult {
latestVersion: string;
fromCache: boolean;
checkedAt: number; // Unix timestamp of last check
// Backend info
backend: 'original' | 'plus';
backendLabel: string;
// Stability fields
isStable: boolean; // Whether current version is at or below max stable
maxStableVersion: string; // Maximum stable version (e.g., "6.6.80")
stabilityMessage?: string; // Warning message if running unstable version
+3 -1
View File
@@ -19,6 +19,7 @@ import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
import {
useCliproxy,
useCliproxyAuth,
useCliproxyUpdateCheck,
useSetDefaultAccount,
useRemoveAccount,
usePauseAccount,
@@ -179,6 +180,7 @@ export function CliproxyPage() {
const queryClient = useQueryClient();
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
const { data: variantsData, isFetching } = useCliproxy();
const { data: updateCheck } = useCliproxyUpdateCheck();
const setDefaultMutation = useSetDefaultAccount();
const removeMutation = useRemoveAccount();
const pauseMutation = usePauseAccount();
@@ -249,7 +251,7 @@ export function CliproxyPage() {
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-primary" />
<h1 className="font-semibold">CLIProxy Plus</h1>
<h1 className="font-semibold">{updateCheck?.backendLabel ?? 'CLIProxy'}</h1>
</div>
<Button
variant="ghost"
+45 -28
View File
@@ -18,10 +18,11 @@ import {
Box,
AlertTriangle,
} from 'lucide-react';
import { toast } from 'sonner';
import { useProxyConfig, useRawConfig } from '../../hooks';
import { useUpdateBackend, useProxyStatus } from '@/hooks/use-cliproxy';
import { LocalProxyCard } from './local-proxy-card';
import { RemoteProxyCard } from './remote-proxy-card';
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
import { api } from '@/lib/api-client';
/** LocalStorage key for debug mode preference */
@@ -74,10 +75,12 @@ export default function ProxySection() {
}
};
// Backend state (loaded from API)
// Backend state (loaded from API) + mutation hook for proper query invalidation
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
const [backendSaving, setBackendSaving] = useState(false);
const [hasKiroGhcpVariants, setHasKiroGhcpVariants] = useState(false);
const updateBackendMutation = useUpdateBackend();
const { data: proxyStatus } = useProxyStatus();
const isProxyRunning = proxyStatus?.running ?? false;
// Fetch backend setting
const fetchBackend = useCallback(async () => {
@@ -100,24 +103,18 @@ export default function ProxySection() {
}
}, []);
// Save backend setting
const handleBackendChange = async (value: 'original' | 'plus') => {
// Save backend setting using mutation hook (invalidates all related queries)
const handleBackendChange = (value: 'original' | 'plus') => {
const previousValue = backend;
setBackend(value);
setBackendSaving(true);
try {
await api.cliproxyServer.updateBackend(value);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to save backend';
// Check if error is due to proxy running (409 conflict)
if (errorMessage.includes('Proxy is running')) {
toast.error('Stop the proxy first to change backend');
setBackend(value); // Optimistic update
updateBackendMutation.mutate(
{ backend: value },
{
onError: () => {
setBackend(previousValue); // Rollback on error
},
}
console.error('[Proxy] Failed to save backend:', err);
setBackend(previousValue);
} finally {
setBackendSaving(false);
}
);
};
// Log when debug mode changes (sanitize sensitive fields)
@@ -140,8 +137,10 @@ export default function ProxySection() {
useEffect(() => {
fetchConfig();
fetchRawConfig();
fetchBackend();
checkPlusOnlyVariants();
// eslint-disable-next-line react-hooks/set-state-in-effect -- Async data fetching on mount is intended
void fetchBackend();
void checkPlusOnlyVariants();
}, [fetchConfig, fetchRawConfig, fetchBackend, checkPlusOnlyVariants]);
if (loading || !config) {
@@ -253,9 +252,18 @@ export default function ProxySection() {
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground">
Configure local or remote CLIProxy Plus connection for proxy-based profiles
Configure local or remote {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} connection
for proxy-based profiles
</p>
{/* Proxy Status Widget - Quick access to start/stop controls */}
{!isRemoteMode && (
<div className="space-y-3">
<h3 className="text-base font-medium">Instance Status</h3>
<ProxyStatusWidget />
</div>
)}
{/* Mode Toggle - Card based selection */}
<div className="space-y-3">
<h3 className="text-base font-medium">Connection Mode</h3>
@@ -277,7 +285,7 @@ export default function ProxySection() {
<span className="font-medium">Local</span>
</div>
<p className="text-xs text-muted-foreground">
Run CLIProxy Plus binary on this machine
Run {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} binary on this machine
</p>
</button>
@@ -298,7 +306,7 @@ export default function ProxySection() {
<span className="font-medium">Remote</span>
</div>
<p className="text-xs text-muted-foreground">
Connect to a remote CLIProxy Plus server
Connect to a remote {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} server
</p>
</button>
</div>
@@ -310,16 +318,25 @@ export default function ProxySection() {
<Box className="w-4 h-4" />
Backend Binary
</h3>
{/* Warning when proxy is running - must stop to change backend */}
{isProxyRunning && (
<Alert className="py-2 border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20 [&>svg]:top-2.5">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-amber-700 dark:text-amber-400">
Stop the running proxy in Instance Status to switch backend.
</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-2 gap-3">
{/* Plus Backend Card */}
<button
onClick={() => handleBackendChange('plus')}
disabled={backendSaving}
disabled={updateBackendMutation.isPending || isProxyRunning}
className={`p-4 rounded-lg border-2 text-left transition-all ${
backend === 'plus'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/50'
}`}
} ${isProxyRunning ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">CLIProxyAPIPlus</span>
@@ -335,12 +352,12 @@ export default function ProxySection() {
{/* Original Backend Card */}
<button
onClick={() => handleBackendChange('original')}
disabled={backendSaving}
disabled={updateBackendMutation.isPending || isProxyRunning}
className={`p-4 rounded-lg border-2 text-left transition-all ${
backend === 'original'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/50'
}`}
} ${isProxyRunning ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">CLIProxyAPI</span>