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;