From e14df1fe05ce1fcbe515f6b1d969132ace91ed24 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 7 Mar 2026 10:45:29 +0700 Subject: [PATCH 1/4] fix: restart CLIProxy after dashboard version install --- .../routes/cliproxy-stats-routes.ts | 21 +--- .../cliproxy-dashboard-install-service.ts | 80 +++++++++++++ ...cliproxy-dashboard-install-service.test.ts | 105 ++++++++++++++++++ ui/src/lib/api-client.ts | 2 + 4 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 src/web-server/services/cliproxy-dashboard-install-service.ts create mode 100644 tests/unit/web-server/cliproxy-dashboard-install-service.test.ts diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 4b082730..4f92fc38 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -34,11 +34,7 @@ import { } from '../../cliproxy/config-generator'; import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker'; import { ensureCliproxyService } from '../../cliproxy/service-manager'; -import { - checkCliproxyUpdate, - getInstalledCliproxyVersion, - installCliproxyVersion, -} from '../../cliproxy/binary-manager'; +import { checkCliproxyUpdate, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager'; import { fetchAllVersions, isNewerVersion, @@ -56,6 +52,7 @@ import { canonicalizeModelIdForProvider, getDeniedModelIdReasonForProvider, } from '../../cliproxy/model-id-normalizer'; +import { installDashboardCliproxyVersion } from '../services/cliproxy-dashboard-install-service'; const router = Router(); @@ -928,7 +925,7 @@ router.get('/versions', async (_req: Request, res: Response): Promise => { /** * POST /api/cliproxy/install - Install specific CLIProxyAPI version * Body: { version: string, force?: boolean } - * Returns: { success, requiresConfirmation?, message? } + * Returns: { success, restarted?, port?, requiresConfirmation?, message? } */ router.post('/install', async (req: Request, res: Response): Promise => { try { @@ -967,22 +964,14 @@ router.post('/install', async (req: Request, res: Response): Promise => { return; } - // Stop proxy first if running - await stopProxy(); - - // Small delay to ensure port is released - await new Promise((r) => setTimeout(r, 500)); - - // Install the version const backend = getConfiguredBackend(); - await installCliproxyVersion(version, true, backend); + const installResult = await installDashboardCliproxyVersion(version, backend); res.json({ - success: true, version, isFaulty, isExperimental, - message: `Successfully installed CLIProxy Plus v${version}`, + ...installResult, }); } catch (error) { console.error(`[cliproxy-stats] ${(error as Error).message}`); diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts new file mode 100644 index 00000000..cb96487d --- /dev/null +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -0,0 +1,80 @@ +import { installCliproxyVersion } from '../../cliproxy/binary-manager'; +import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/service-manager'; +import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker'; +import { isCliproxyRunning } from '../../cliproxy/stats-fetcher'; +import type { CLIProxyBackend } from '../../cliproxy/types'; + +interface ProxyStatusLike { + running: boolean; +} + +interface InstallDashboardCliproxyVersionDeps { + getProxyStatus: () => ProxyStatusLike; + isCliproxyRunning: () => Promise; + installCliproxyVersion: ( + version: string, + verbose?: boolean, + backend?: CLIProxyBackend + ) => Promise; + ensureCliproxyService: () => Promise; +} + +const defaultDeps: InstallDashboardCliproxyVersionDeps = { + getProxyStatus: getProxyProcessStatus, + isCliproxyRunning, + installCliproxyVersion, + ensureCliproxyService: () => ensureCliproxyService(), +}; + +export interface DashboardCliproxyInstallResult { + success: boolean; + restarted: boolean; + port?: number; + message: string; + error?: string; +} + +async function wasProxyRunning(deps: InstallDashboardCliproxyVersionDeps): Promise { + const status = deps.getProxyStatus(); + if (status.running) { + return true; + } + + return deps.isCliproxyRunning(); +} + +export async function installDashboardCliproxyVersion( + version: string, + backend: CLIProxyBackend, + deps: InstallDashboardCliproxyVersionDeps = defaultDeps +): Promise { + const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + const shouldRestoreService = await wasProxyRunning(deps); + + await deps.installCliproxyVersion(version, true, backend); + + if (!shouldRestoreService) { + return { + success: true, + restarted: false, + message: `Successfully installed ${backendLabel} v${version}`, + }; + } + + const startResult = await deps.ensureCliproxyService(); + if (!startResult.started && !startResult.alreadyRunning) { + return { + success: false, + restarted: false, + error: startResult.error || `Installed ${backendLabel} v${version}, but restart failed`, + message: `Installed ${backendLabel} v${version}, but failed to restart it`, + }; + } + + return { + success: true, + restarted: true, + port: startResult.port, + message: `Successfully installed ${backendLabel} v${version} and restarted it on port ${startResult.port}`, + }; +} diff --git a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts new file mode 100644 index 00000000..9af88014 --- /dev/null +++ b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'bun:test'; +import type { CLIProxyBackend } from '../../../src/cliproxy/types'; +import { + installDashboardCliproxyVersion, + type DashboardCliproxyInstallResult, +} from '../../../src/web-server/services/cliproxy-dashboard-install-service'; + +function createDeps( + overrides: { + sessionRunning?: boolean; + remoteRunning?: boolean; + startResult?: { started: boolean; alreadyRunning: boolean; port: number; error?: string }; + } = {} +) { + const calls = { + isCliproxyRunning: 0, + installCliproxyVersion: 0, + ensureCliproxyService: 0, + }; + + const deps = { + getProxyStatus: () => ({ running: overrides.sessionRunning ?? false }), + isCliproxyRunning: async () => { + calls.isCliproxyRunning += 1; + return overrides.remoteRunning ?? false; + }, + installCliproxyVersion: async ( + _version: string, + _verbose?: boolean, + _backend?: CLIProxyBackend + ) => { + calls.installCliproxyVersion += 1; + }, + ensureCliproxyService: async () => { + calls.ensureCliproxyService += 1; + return ( + overrides.startResult ?? { + started: true, + alreadyRunning: false, + port: 8317, + } + ); + }, + }; + + return { deps, calls }; +} + +describe('installDashboardCliproxyVersion', () => { + it('restarts the proxy after install when it was already running', async () => { + const { deps, calls } = createDeps({ sessionRunning: true }); + + const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps); + + expect(result).toEqual({ + success: true, + restarted: true, + port: 8317, + message: 'Successfully installed CLIProxy Plus v6.7.1 and restarted it on port 8317', + }); + expect(calls.isCliproxyRunning).toBe(0); + expect(calls.installCliproxyVersion).toBe(1); + expect(calls.ensureCliproxyService).toBe(1); + }); + + it('keeps the proxy stopped after install when it was not running beforehand', async () => { + const { deps, calls } = createDeps({ sessionRunning: false, remoteRunning: false }); + + const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps); + + expect(result).toEqual({ + success: true, + restarted: false, + message: 'Successfully installed CLIProxy Plus v6.7.1', + }); + expect(calls.isCliproxyRunning).toBe(1); + expect(calls.installCliproxyVersion).toBe(1); + expect(calls.ensureCliproxyService).toBe(0); + }); + + it('reports a restart failure after a successful install when the proxy had been running', async () => { + const { deps, calls } = createDeps({ + sessionRunning: false, + remoteRunning: true, + startResult: { + started: false, + alreadyRunning: false, + port: 8317, + error: 'Port 8317 is blocked by another process', + }, + }); + + const result = await installDashboardCliproxyVersion('6.7.1', 'original', deps); + + expect(result).toEqual({ + success: false, + restarted: false, + error: 'Port 8317 is blocked by another process', + message: 'Installed CLIProxy v6.7.1, but failed to restart it', + }); + expect(calls.isCliproxyRunning).toBe(1); + expect(calls.installCliproxyVersion).toBe(1); + expect(calls.ensureCliproxyService).toBe(1); + }); +}); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 5f581f2f..607873fa 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -606,6 +606,8 @@ export interface CliproxyVersionsResponse { export interface CliproxyInstallResult { success: boolean; version?: string; + restarted?: boolean; + port?: number; isUnstable?: boolean; requiresConfirmation?: boolean; message?: string; From a4b626aedef2a0cf690a955bf4abd31c5284047a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 7 Mar 2026 10:52:46 +0700 Subject: [PATCH 2/4] fix: clarify CLIProxy dashboard install lifecycle --- .../cliproxy-dashboard-install-service.ts | 2 ++ ...cliproxy-dashboard-install-service.test.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index cb96487d..60f99a60 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -51,6 +51,8 @@ export async function installDashboardCliproxyVersion( const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; const shouldRestoreService = await wasProxyRunning(deps); + // The installer owns the stop-and-replace lifecycle: it stops a running proxy + // and waits for the port to free before swapping the binary. await deps.installCliproxyVersion(version, true, backend); if (!shouldRestoreService) { diff --git a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts index 9af88014..6522dd58 100644 --- a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts +++ b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts @@ -102,4 +102,24 @@ describe('installDashboardCliproxyVersion', () => { expect(calls.installCliproxyVersion).toBe(1); expect(calls.ensureCliproxyService).toBe(1); }); + + it('uses a fallback restart error when the start result omits one', async () => { + const { deps } = createDeps({ + remoteRunning: true, + startResult: { + started: false, + alreadyRunning: false, + port: 8317, + }, + }); + + const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps); + + expect(result).toEqual({ + success: false, + restarted: false, + error: 'Installed CLIProxy Plus v6.7.1, but restart failed', + message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it', + }); + }); }); From ad01196964bfdbdd0696cc845f3b3d3901b3be2f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 7 Mar 2026 11:01:35 +0700 Subject: [PATCH 3/4] fix: stop untracked CLIProxy installs safely --- src/cliproxy/binary-manager.ts | 25 +++-- .../cliproxy-dashboard-install-service.ts | 4 +- .../cliproxy/binary-manager-install.test.ts | 101 ++++++++++++++++++ 3 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 tests/unit/cliproxy/binary-manager-install.test.ts diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index bda4604a..f74d9257 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -9,7 +9,7 @@ import { info, warn } from '../utils/ui'; import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; import { BinaryInfo, BinaryManagerConfig } from './types'; import { BACKEND_CONFIG, DEFAULT_BACKEND, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector'; -import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service'; +import { stopProxy } from './services/proxy-lifecycle-service'; import { waitForPortFree } from '../utils/port-utils'; import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { @@ -167,19 +167,18 @@ export async function installCliproxyVersion( const effectiveBackend = backend ?? getConfiguredBackend(); const manager = new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); - // Check if proxy is running and stop it first - if (isProxyRunning()) { - if (verbose) console.log(info('Stopping running CLIProxy before update...')); - const result = await stopProxy(); - if (result.stopped) { - // Wait for port to be fully released - const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000); - if (!portFree && verbose) { - console.log(warn('Port did not free up in time, proceeding anyway...')); - } - } else if (verbose && result.error) { - console.log(warn(`Could not stop proxy: ${result.error}`)); + // Always attempt a best-effort stop first so we also catch untracked proxies + // that are running without a session lock. + if (verbose) console.log(info('Stopping running CLIProxy before update...')); + const result = await stopProxy(); + if (result.stopped) { + // Wait for port to be fully released + const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000); + if (!portFree && verbose) { + console.log(warn('Port did not free up in time, proceeding anyway...')); } + } else if (verbose && result.error && result.error !== 'No active CLIProxy session found') { + console.log(warn(`Could not stop proxy: ${result.error}`)); } if (manager.isBinaryInstalled()) { diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 60f99a60..7091965f 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -51,8 +51,8 @@ export async function installDashboardCliproxyVersion( const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; const shouldRestoreService = await wasProxyRunning(deps); - // The installer owns the stop-and-replace lifecycle: it stops a running proxy - // and waits for the port to free before swapping the binary. + // The installer owns the stop-and-replace lifecycle, including best-effort + // shutdown for tracked and untracked proxies before swapping the binary. await deps.installCliproxyVersion(version, true, backend); if (!shouldRestoreService) { diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts new file mode 100644 index 00000000..b30bf963 --- /dev/null +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -0,0 +1,101 @@ +import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'; + +const calls = { + stopProxy: 0, + waitForPortFree: 0, + deleteBinary: 0, + ensureBinary: 0, +}; + +mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, +})); + +mock.module('../../../src/cliproxy/config-generator', () => ({ + getBinDir: () => '/tmp/ccs-bin', + CLIPROXY_DEFAULT_PORT: 8317, +})); + +mock.module('../../../src/cliproxy/platform-detector', () => ({ + DEFAULT_BACKEND: 'plus', + CLIPROXY_MAX_STABLE_VERSION: '6.6.80', + BACKEND_CONFIG: { + plus: { + fallbackVersion: '6.6.80', + repo: 'router-for-me/CLIProxyAPIPlus', + }, + original: { + fallbackVersion: '0.0.0', + repo: 'router-for-me/CLIProxyAPI', + }, + }, +})); + +mock.module('../../../src/cliproxy/services/proxy-lifecycle-service', () => ({ + stopProxy: async () => { + calls.stopProxy += 1; + return { stopped: false, error: 'No active CLIProxy session found' }; + }, +})); + +mock.module('../../../src/utils/port-utils', () => ({ + waitForPortFree: async () => { + calls.waitForPortFree += 1; + return true; + }, +})); + +mock.module('../../../src/config/unified-config-loader', () => ({ + loadOrCreateUnifiedConfig: () => ({ + cliproxy: { backend: 'plus' }, + }), +})); + +mock.module('../../../src/cliproxy/binary', () => ({ + checkForUpdates: async () => ({ + hasUpdate: false, + currentVersion: '6.6.80', + latestVersion: '6.6.80', + fromCache: false, + checkedAt: Date.now(), + }), + deleteBinary: () => { + calls.deleteBinary += 1; + }, + getBinaryPath: () => '/tmp/ccs-bin/plus/cliproxy', + isBinaryInstalled: () => false, + getBinaryInfo: async () => null, + getPinnedVersion: () => null, + savePinnedVersion: () => {}, + clearPinnedVersion: () => {}, + isVersionPinned: () => false, + getVersionPinPath: () => '/tmp/ccs-bin/plus/.version-pin', + readInstalledVersion: () => '6.6.80', + ensureBinary: async () => { + calls.ensureBinary += 1; + return '/tmp/ccs-bin/plus/cliproxy'; + }, + migrateVersionPin: () => {}, +})); + +let binaryManager: typeof import('../../../src/cliproxy/binary-manager'); + +beforeAll(async () => { + binaryManager = await import('../../../src/cliproxy/binary-manager'); +}); + +afterAll(() => { + mock.restore(); +}); + +describe('installCliproxyVersion', () => { + it('attempts to stop the proxy even when there is no tracked running session', async () => { + await binaryManager.installCliproxyVersion('6.7.1', false, 'plus'); + + expect(calls.stopProxy).toBe(1); + expect(calls.waitForPortFree).toBe(0); + expect(calls.deleteBinary).toBe(0); + expect(calls.ensureBinary).toBe(1); + }); +}); From aed7beb075c2f31263423005c34e690af3463039 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 7 Mar 2026 11:13:49 +0700 Subject: [PATCH 4/4] fix: confirm risky cliproxy installs in dashboard --- .../routes/cliproxy-stats-routes.ts | 4 + .../cliproxy/binary-manager-install.test.ts | 178 +++++++++--------- tests/unit/ui/cliproxy-version-risk.test.ts | 25 +++ .../cliproxy-stats-routes-install.test.ts | 168 +++++++++++++++++ .../monitoring/proxy-status-widget.tsx | 143 ++++++++++---- ui/src/lib/api-client.ts | 7 +- ui/src/lib/cliproxy-version-risk.ts | 26 +++ ui/src/lib/i18n.ts | 17 ++ 8 files changed, 437 insertions(+), 131 deletions(-) create mode 100644 tests/unit/ui/cliproxy-version-risk.test.ts create mode 100644 tests/unit/web-server/cliproxy-stats-routes-install.test.ts create mode 100644 ui/src/lib/cliproxy-version-risk.ts diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 4f92fc38..87290273 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -949,6 +949,8 @@ router.post('/install', async (req: Request, res: Response): Promise => { if (isFaulty && !force) { res.json({ success: false, + isFaulty, + isExperimental, requiresConfirmation: true, message: `Version ${version} has known bugs (v${CLIPROXY_FAULTY_RANGE.min.replace(/-\d+$/, '')}-${CLIPROXY_FAULTY_RANGE.max.replace(/-\d+$/, '')}). Set force=true to proceed.`, }); @@ -958,6 +960,8 @@ router.post('/install', async (req: Request, res: Response): Promise => { if (isExperimental && !force) { res.json({ success: false, + isFaulty, + isExperimental, requiresConfirmation: true, message: `Version ${version} is experimental (above stable ${CLIPROXY_MAX_STABLE_VERSION.replace(/-\d+$/, '')}). Set force=true to proceed.`, }); diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index b30bf963..b2a9bddb 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -1,96 +1,94 @@ -import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'; - -const calls = { - stopProxy: 0, - waitForPortFree: 0, - deleteBinary: 0, - ensureBinary: 0, -}; - -mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, -})); - -mock.module('../../../src/cliproxy/config-generator', () => ({ - getBinDir: () => '/tmp/ccs-bin', - CLIPROXY_DEFAULT_PORT: 8317, -})); - -mock.module('../../../src/cliproxy/platform-detector', () => ({ - DEFAULT_BACKEND: 'plus', - CLIPROXY_MAX_STABLE_VERSION: '6.6.80', - BACKEND_CONFIG: { - plus: { - fallbackVersion: '6.6.80', - repo: 'router-for-me/CLIProxyAPIPlus', - }, - original: { - fallbackVersion: '0.0.0', - repo: 'router-for-me/CLIProxyAPI', - }, - }, -})); - -mock.module('../../../src/cliproxy/services/proxy-lifecycle-service', () => ({ - stopProxy: async () => { - calls.stopProxy += 1; - return { stopped: false, error: 'No active CLIProxy session found' }; - }, -})); - -mock.module('../../../src/utils/port-utils', () => ({ - waitForPortFree: async () => { - calls.waitForPortFree += 1; - return true; - }, -})); - -mock.module('../../../src/config/unified-config-loader', () => ({ - loadOrCreateUnifiedConfig: () => ({ - cliproxy: { backend: 'plus' }, - }), -})); - -mock.module('../../../src/cliproxy/binary', () => ({ - checkForUpdates: async () => ({ - hasUpdate: false, - currentVersion: '6.6.80', - latestVersion: '6.6.80', - fromCache: false, - checkedAt: Date.now(), - }), - deleteBinary: () => { - calls.deleteBinary += 1; - }, - getBinaryPath: () => '/tmp/ccs-bin/plus/cliproxy', - isBinaryInstalled: () => false, - getBinaryInfo: async () => null, - getPinnedVersion: () => null, - savePinnedVersion: () => {}, - clearPinnedVersion: () => {}, - isVersionPinned: () => false, - getVersionPinPath: () => '/tmp/ccs-bin/plus/.version-pin', - readInstalledVersion: () => '6.6.80', - ensureBinary: async () => { - calls.ensureBinary += 1; - return '/tmp/ccs-bin/plus/cliproxy'; - }, - migrateVersionPin: () => {}, -})); - -let binaryManager: typeof import('../../../src/cliproxy/binary-manager'); - -beforeAll(async () => { - binaryManager = await import('../../../src/cliproxy/binary-manager'); -}); - -afterAll(() => { - mock.restore(); -}); +import { afterEach, describe, expect, it, mock } from 'bun:test'; describe('installCliproxyVersion', () => { + afterEach(() => { + mock.restore(); + }); + it('attempts to stop the proxy even when there is no tracked running session', async () => { + const calls = { + stopProxy: 0, + waitForPortFree: 0, + deleteBinary: 0, + ensureBinary: 0, + }; + + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + mock.module('../../../src/cliproxy/config-generator', () => ({ + getBinDir: () => '/tmp/ccs-bin', + CLIPROXY_DEFAULT_PORT: 8317, + })); + + mock.module('../../../src/cliproxy/platform-detector', () => ({ + DEFAULT_BACKEND: 'plus', + CLIPROXY_MAX_STABLE_VERSION: '9.9.999-0', + BACKEND_CONFIG: { + plus: { + fallbackVersion: '6.6.80', + repo: 'router-for-me/CLIProxyAPIPlus', + }, + original: { + fallbackVersion: '0.0.0', + repo: 'router-for-me/CLIProxyAPI', + }, + }, + })); + + mock.module('../../../src/cliproxy/services/proxy-lifecycle-service', () => ({ + stopProxy: async () => { + calls.stopProxy += 1; + return { stopped: false, error: 'No active CLIProxy session found' }; + }, + })); + + mock.module('../../../src/utils/port-utils', () => ({ + waitForPortFree: async () => { + calls.waitForPortFree += 1; + return true; + }, + })); + + mock.module('../../../src/config/unified-config-loader', () => ({ + loadOrCreateUnifiedConfig: () => ({ + cliproxy: { backend: 'plus' }, + }), + })); + + mock.module('../../../src/cliproxy/binary', () => ({ + checkForUpdates: async () => ({ + hasUpdate: false, + currentVersion: '6.6.80', + latestVersion: '6.6.80', + fromCache: false, + checkedAt: Date.now(), + }), + deleteBinary: () => { + calls.deleteBinary += 1; + }, + getBinaryPath: () => '/tmp/ccs-bin/plus/cliproxy', + isBinaryInstalled: () => false, + getBinaryInfo: async () => null, + getPinnedVersion: () => null, + savePinnedVersion: () => {}, + clearPinnedVersion: () => {}, + isVersionPinned: () => false, + getVersionPinPath: () => '/tmp/ccs-bin/plus/.version-pin', + readInstalledVersion: () => '6.6.80', + ensureBinary: async () => { + calls.ensureBinary += 1; + return '/tmp/ccs-bin/plus/cliproxy'; + }, + migrateVersionPin: () => {}, + })); + + const binaryManager = await import( + `../../../src/cliproxy/binary-manager?binary-manager-install=${Date.now()}` + ); + await binaryManager.installCliproxyVersion('6.7.1', false, 'plus'); expect(calls.stopProxy).toBe(1); diff --git a/tests/unit/ui/cliproxy-version-risk.test.ts b/tests/unit/ui/cliproxy-version-risk.test.ts new file mode 100644 index 00000000..b44c398f --- /dev/null +++ b/tests/unit/ui/cliproxy-version-risk.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'bun:test'; +import { + compareCliproxyVersions, + isCliproxyVersionExperimental, + isCliproxyVersionInRange, +} from '../../../ui/src/lib/cliproxy-version-risk'; + +describe('cliproxy-version-risk helpers', () => { + it('compares versions while ignoring release suffixes', () => { + expect(compareCliproxyVersions('6.6.88', '6.6.81-0')).toBe(1); + expect(compareCliproxyVersions('6.6.81-0', '6.6.81')).toBe(0); + expect(compareCliproxyVersions('6.6.80', '6.6.81')).toBe(-1); + }); + + it('detects experimental versions against max stable', () => { + expect(isCliproxyVersionExperimental('10.0.0', '9.9.999-0')).toBe(true); + expect(isCliproxyVersionExperimental('6.6.88', '9.9.999-0')).toBe(false); + }); + + it('detects versions inside the faulty range', () => { + expect(isCliproxyVersionInRange('6.6.81', '6.6.81-0', '6.6.88-0')).toBe(true); + expect(isCliproxyVersionInRange('6.6.88', '6.6.81-0', '6.6.88-0')).toBe(true); + expect(isCliproxyVersionInRange('6.6.89', '6.6.81-0', '6.6.88-0')).toBe(false); + }); +}); diff --git a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts new file mode 100644 index 00000000..a64465e7 --- /dev/null +++ b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts @@ -0,0 +1,168 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; +import express from 'express'; +import type { Server } from 'http'; + +const installSpy = { + calls: 0, +}; + +mock.module('../../../src/config/unified-config-loader', () => ({ + loadOrCreateUnifiedConfig: () => ({ + cliproxy: { backend: 'plus' }, + }), +})); + +mock.module('../../../src/cliproxy/binary-manager', () => ({ + checkCliproxyUpdate: async () => ({ + hasUpdate: false, + currentVersion: '6.6.80', + latestVersion: '6.6.89', + fromCache: false, + checkedAt: Date.now(), + backend: 'plus', + backendLabel: 'CLIProxy Plus', + isStable: true, + maxStableVersion: '9.9.999-0', + }), + getInstalledCliproxyVersion: () => '6.6.80', + installCliproxyVersion: async () => {}, +})); + +mock.module('../../../src/cliproxy/binary/version-checker', () => ({ + fetchAllVersions: async () => ({ + versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'], + latestStable: '6.6.89', + latest: '6.6.89', + fromCache: false, + checkedAt: Date.now(), + }), + isNewerVersion: (version: string, maxStable: string) => { + const normalize = (value: string) => value.replace(/-\d+$/, '').split('.').map(Number); + const versionParts = normalize(version); + const maxStableParts = normalize(maxStable); + + for (let index = 0; index < 3; index += 1) { + const versionPart = versionParts[index] || 0; + const maxStablePart = maxStableParts[index] || 0; + + if (versionPart > maxStablePart) return true; + if (versionPart < maxStablePart) return false; + } + + return false; + }, + isVersionFaulty: (version: string) => + ['6.6.81', '6.6.82', '6.6.83', '6.6.84', '6.6.85', '6.6.86', '6.6.87', '6.6.88'].includes( + version + ), +})); + +mock.module('../../../src/web-server/services/cliproxy-dashboard-install-service', () => ({ + installDashboardCliproxyVersion: async () => { + installSpy.calls += 1; + return { + success: true, + restarted: true, + port: 8317, + message: 'installed', + }; + }, +})); + +let cliproxyStatsRoutes: typeof import('../../../src/web-server/routes/cliproxy-stats-routes').default; +let server: Server; +let baseUrl = ''; + +beforeAll(async () => { + cliproxyStatsRoutes = (await import('../../../src/web-server/routes/cliproxy-stats-routes')) + .default; + + const app = express(); + app.use(express.json()); + app.use('/api/cliproxy', cliproxyStatsRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +beforeEach(() => { + installSpy.calls = 0; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + mock.restore(); +}); + +describe('cliproxy-stats-routes install contract', () => { + it('returns faultyRange in the versions response', async () => { + const response = await fetch(`${baseUrl}/api/cliproxy/versions`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + faultyRange: { min: string; max: string }; + currentVersion: string; + }; + expect(body.currentVersion).toBe('6.6.80'); + expect(body.faultyRange).toEqual({ min: '6.6.81-0', max: '6.6.88-0' }); + }); + + it('returns faulty confirmation metadata without calling the installer', async () => { + const response = await fetch(`${baseUrl}/api/cliproxy/install`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: '6.6.81' }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + success: boolean; + requiresConfirmation: boolean; + isFaulty: boolean; + isExperimental: boolean; + message: string; + }; + expect(body.success).toBe(false); + expect(body.requiresConfirmation).toBe(true); + expect(body.isFaulty).toBe(true); + expect(body.isExperimental).toBe(false); + expect(body.message).toContain('known bugs'); + expect(installSpy.calls).toBe(0); + }); + + it('returns experimental confirmation metadata without calling the installer', async () => { + const response = await fetch(`${baseUrl}/api/cliproxy/install`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: '10.0.0' }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + success: boolean; + requiresConfirmation: boolean; + isFaulty: boolean; + isExperimental: boolean; + message: string; + }; + expect(body.success).toBe(false); + expect(body.requiresConfirmation).toBe(true); + expect(body.isFaulty).toBe(false); + expect(body.isExperimental).toBe(true); + expect(body.message).toContain('experimental'); + expect(installSpy.calls).toBe(0); + }); +}); diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index 6e3c2adc..4d69d54b 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -63,17 +63,12 @@ import { } from '@/hooks/use-cliproxy'; import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync'; import { cn } from '@/lib/utils'; +import { + isCliproxyVersionExperimental, + isCliproxyVersionInRange, +} from '@/lib/cliproxy-version-risk'; -/** Client-side semver comparison (true if a > b) */ -function isNewerVersionClient(a: string, b: string): boolean { - const aParts = a.replace(/-\d+$/, '').split('.').map(Number); - const bParts = b.replace(/-\d+$/, '').split('.').map(Number); - for (let i = 0; i < 3; i++) { - if ((aParts[i] || 0) > (bParts[i] || 0)) return true; - if ((aParts[i] || 0) < (bParts[i] || 0)) return false; - } - return false; -} +type PendingInstallRisk = 'faulty' | 'experimental'; function formatUptime(startedAt?: string): string { if (!startedAt) return ''; @@ -168,9 +163,10 @@ export function ProxyStatusWidget() { const [isExpanded, setIsExpanded] = useState(false); const [selectedVersion, setSelectedVersion] = useState(''); - // Confirmation dialog state for unstable versions + // Confirmation dialog state for risky versions const [showUnstableConfirm, setShowUnstableConfirm] = useState(false); const [pendingInstallVersion, setPendingInstallVersion] = useState(null); + const [pendingInstallRisk, setPendingInstallRisk] = useState(null); // Fetch cliproxy_server config for remote mode detection const { data: cliproxyConfig } = useQuery({ @@ -208,36 +204,62 @@ export function ProxyStatusWidget() { const targetVersion = isUnstable ? updateCheck?.maxStableVersion || versionsData?.latestStable : updateCheck?.latestVersion; + const maxStableVersion = + versionsData?.maxStableVersion || updateCheck?.maxStableVersion || '6.6.80'; - // Handle version install (shows confirmation for unstable) - const handleInstallVersion = (version: string) => { + const faultyRange = versionsData?.faultyRange; + const faultyRangeLabel = + faultyRange && + `${faultyRange.min.replace(/-\d+$/, '')}-${faultyRange.max.replace(/-\d+$/, '')}`; + + const queueInstallConfirmation = (version: string, risk: PendingInstallRisk) => { + setPendingInstallVersion(version); + setPendingInstallRisk(risk); + setShowUnstableConfirm(true); + }; + + // Handle version install (shows confirmation for risky versions) + const handleInstallVersion = async (version: string) => { if (!version) return; - const maxStable = versionsData?.maxStableVersion || '6.6.80'; - const isVersionUnstable = isNewerVersionClient(version, maxStable); + const isVersionExperimental = isCliproxyVersionExperimental(version, maxStableVersion); + const isVersionFaulty = + faultyRange !== undefined && + isCliproxyVersionInRange(version, faultyRange.min, faultyRange.max); - if (isVersionUnstable) { - // Show confirmation dialog for unstable versions - setPendingInstallVersion(version); - setShowUnstableConfirm(true); + if (isVersionFaulty) { + queueInstallConfirmation(version, 'faulty'); return; } - // Install directly if stable - installVersion.mutate({ version }); + if (isVersionExperimental) { + queueInstallConfirmation(version, 'experimental'); + return; + } + + try { + const result = await installVersion.mutateAsync({ version }); + if (result.requiresConfirmation) { + queueInstallConfirmation(version, result.isFaulty ? 'faulty' : 'experimental'); + } + } catch { + // Hook-level onError already reports install failures. + } }; - // Confirm unstable version install + // Confirm risky version install const handleConfirmUnstableInstall = () => { if (pendingInstallVersion) { installVersion.mutate({ version: pendingInstallVersion, force: true }); } setShowUnstableConfirm(false); setPendingInstallVersion(null); + setPendingInstallRisk(null); }; const handleCancelUnstableInstall = () => { setShowUnstableConfirm(false); setPendingInstallVersion(null); + setPendingInstallRisk(null); }; // Build remote display info @@ -372,7 +394,7 @@ export function ProxyStatusWidget() { ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50' : 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50' )} - onClick={() => handleInstallVersion(targetVersion)} + onClick={() => void handleInstallVersion(targetVersion)} title={ isUnstable ? t('proxyStatusWidget.clickToDowngrade') @@ -449,9 +471,16 @@ export function ProxyStatusWidget() { {versionsData?.versions.slice(0, 20).map((v) => { - const vIsUnstable = + const vIsExperimental = versionsData?.maxStableVersion && - isNewerVersionClient(v, versionsData.maxStableVersion); + isCliproxyVersionExperimental(v, versionsData.maxStableVersion); + const vIsFaulty = + versionsData?.faultyRange && + isCliproxyVersionInRange( + v, + versionsData.faultyRange.min, + versionsData.faultyRange.max + ); return ( @@ -461,7 +490,7 @@ export function ProxyStatusWidget() { {t('proxyStatusWidget.stable')} )} - {vIsUnstable && ( + {(vIsFaulty || vIsExperimental) && ( )} @@ -476,7 +505,7 @@ export function ProxyStatusWidget() { variant="outline" size="sm" className="h-8 text-xs gap-1.5 px-3" - onClick={() => handleInstallVersion(selectedVersion)} + onClick={() => void handleInstallVersion(selectedVersion)} disabled={installVersion.isPending || !selectedVersion} > {installVersion.isPending ? ( @@ -491,7 +520,7 @@ export function ProxyStatusWidget() { {/* Stability warning for selected version */} {selectedVersion && versionsData?.maxStableVersion && - isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && ( + isCliproxyVersionExperimental(selectedVersion, versionsData.maxStableVersion) && (
@@ -502,6 +531,23 @@ export function ProxyStatusWidget() {
)} + {selectedVersion && + versionsData?.faultyRange && + isCliproxyVersionInRange( + selectedVersion, + versionsData.faultyRange.min, + versionsData.faultyRange.max + ) && ( +
+ + + {t('proxyStatusWidget.versionsKnownIssues', { + version: selectedVersion, + })} + +
+ )} + {/* Sync time */} {updateCheck?.checkedAt && (
@@ -542,21 +588,38 @@ export function ProxyStatusWidget() { - {t('proxyStatusWidget.installUnstableTitle')} + {pendingInstallRisk === 'faulty' + ? t('proxyStatusWidget.installFaultyTitle') + : t('proxyStatusWidget.installUnstableTitle')} -

- }} - /> -

+ {pendingInstallRisk === 'faulty' ? ( +

+ }} + /> +

+ ) : ( +

+ }} + /> +

+ )}

- {t('proxyStatusWidget.installUnstableWarning')} + {pendingInstallRisk === 'faulty' + ? t('proxyStatusWidget.installFaultyWarning') + : t('proxyStatusWidget.installUnstableWarning')}

{t('proxyStatusWidget.installUnstableConfirm')}

diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 607873fa..111c895c 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -598,6 +598,10 @@ export interface CliproxyVersionsResponse { latest: string; currentVersion: string; maxStableVersion: string; + faultyRange?: { + min: string; + max: string; + }; fromCache: boolean; checkedAt: number; } @@ -608,7 +612,8 @@ export interface CliproxyInstallResult { version?: string; restarted?: boolean; port?: number; - isUnstable?: boolean; + isFaulty?: boolean; + isExperimental?: boolean; requiresConfirmation?: boolean; message?: string; error?: string; diff --git a/ui/src/lib/cliproxy-version-risk.ts b/ui/src/lib/cliproxy-version-risk.ts new file mode 100644 index 00000000..3e55ea2e --- /dev/null +++ b/ui/src/lib/cliproxy-version-risk.ts @@ -0,0 +1,26 @@ +function normalizeVersionParts(version: string): number[] { + return version.replace(/-\d+$/, '').split('.').map(Number); +} + +export function compareCliproxyVersions(a: string, b: string): number { + const aParts = normalizeVersionParts(a); + const bParts = normalizeVersionParts(b); + + for (let index = 0; index < 3; index += 1) { + const aPart = aParts[index] || 0; + const bPart = bParts[index] || 0; + + if (aPart > bPart) return 1; + if (aPart < bPart) return -1; + } + + return 0; +} + +export function isCliproxyVersionExperimental(version: string, maxStableVersion: string): boolean { + return compareCliproxyVersions(version, maxStableVersion) > 0; +} + +export function isCliproxyVersionInRange(version: string, min: string, max: string): boolean { + return compareCliproxyVersions(version, min) >= 0 && compareCliproxyVersions(version, max) <= 0; +} diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 3d72b140..c990d792 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -286,12 +286,18 @@ const resources = { stable: '(stable)', install: 'Install', versionsAboveUnstable: 'Versions above {{version}} have known issues', + versionsKnownIssues: 'Version {{version}} has known issues', lastChecked: 'Last checked {{time}}', notRunning: 'Not running', start: 'Start', port: 'Port {{port}}', sessionCount: '{{count}} session', sessionCount_other: '{{count}} sessions', + installFaultyTitle: 'Install Version With Known Issues?', + installFaultyDesc: + 'You are about to install v{{version}}, which falls inside the known faulty range {{range}}.', + installFaultyWarning: + 'This version has known bugs and may fail or leave the proxy in a bad state.', installUnstableTitle: 'Install Unstable Version?', installUnstableDesc: 'You are about to install v{{version}}, which is above the maximum stable version v{{maxStable}}.', @@ -1427,12 +1433,17 @@ const resources = { stable: '(稳定版)', install: '安装', versionsAboveUnstable: '高于 {{version}} 的版本存在已知问题', + versionsKnownIssues: '版本 {{version}} 存在已知问题', lastChecked: '上次检查 {{time}}', notRunning: '未运行', start: '启动', port: '端口 {{port}}', sessionCount: '{{count}} 个会话', sessionCount_other: '{{count}} 个会话', + installFaultyTitle: '安装存在已知问题的版本?', + installFaultyDesc: + '即将安装 v{{version}},该版本位于已知故障范围 {{range}} 内。', + installFaultyWarning: '该版本存在已知缺陷,可能安装失败或让代理处于异常状态。', installUnstableTitle: '安装非稳定版本?', installUnstableDesc: '即将安装 v{{version}},该版本高于当前最大稳定版 v{{maxStable}}。', @@ -2539,12 +2550,18 @@ const resources = { stable: '(ổn định)', install: 'Cài đặt', versionsAboveUnstable: 'Các phiên bản trên {{version}} có vấn đề đã biết', + versionsKnownIssues: 'Phiên bản {{version}} có vấn đề đã biết', lastChecked: 'Đã kiểm tra lần cuối {{time}}', notRunning: 'Không chạy', start: 'Bắt đầu', port: 'Cổng {{port}}', sessionCount: '{{count}} phiên', sessionCount_other: '{{count}} phiên', + installFaultyTitle: 'Cài đặt phiên bản có lỗi đã biết?', + installFaultyDesc: + 'Bạn sắp cài đặt v{{version}}, phiên bản này nằm trong dải lỗi đã biết {{range}}.', + installFaultyWarning: + 'Phiên bản này có lỗi đã biết và có thể cài đặt thất bại hoặc làm proxy ở trạng thái xấu.', installUnstableTitle: 'Cài đặt phiên bản không ổn định?', installUnstableDesc: 'Bạn sắp cài đặt v{{version}}, phiên bản này cao hơn phiên bản ổn định tối đa v{{maxStable}}.',