diff --git a/src/cliproxy/auth/kiro-import.ts b/src/cliproxy/auth/kiro-import.ts index cc507110..9e83facd 100644 --- a/src/cliproxy/auth/kiro-import.ts +++ b/src/cliproxy/auth/kiro-import.ts @@ -29,7 +29,7 @@ export async function tryKiroImport(tokenDir: string, verbose = false): Promise< try { log('Ensuring CLIProxy binary is available...'); - const binaryPath = await ensureCLIProxyBinary(verbose); + const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); const configPath = generateConfig('kiro'); log(`Binary: ${binaryPath}`); diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 38df474f..7b1482fa 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -428,7 +428,7 @@ async function prepareBinary( showStep(1, 4, 'progress', 'Preparing CLIProxy binary...'); try { - const binaryPath = await ensureCLIProxyBinary(verbose); + const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); process.stdout.write('\x1b[1A\x1b[2K'); showStep(1, 4, 'ok', 'CLIProxy binary ready'); diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 194faac7..452bd162 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -62,6 +62,8 @@ function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): Binary maxRetries: 3, verbose: false, forceVersion: false, + skipAutoUpdate: false, + allowInstall: true, backend, // Pass backend for installer to use correct download URL }; } @@ -115,8 +117,16 @@ export class BinaryManager { } } +export interface EnsureCLIProxyBinaryOptions { + allowInstall?: boolean; + skipAutoUpdate?: boolean; +} + /** Convenience function respecting version pin */ -export async function ensureCLIProxyBinary(verbose = false): Promise { +export async function ensureCLIProxyBinary( + verbose = false, + options: EnsureCLIProxyBinaryOptions = {} +): Promise { const backend = getConfiguredBackend(); // Migrate old shared pin to backend-specific location (one-time migration) @@ -130,11 +140,20 @@ export async function ensureCLIProxyBinary(verbose = false): Promise { version: pinnedVersion, verbose, forceVersion: true, + skipAutoUpdate: options.skipAutoUpdate ?? false, + allowInstall: options.allowInstall ?? true, }, backend ).ensureBinary(); } - return new BinaryManager({ verbose }, backend).ensureBinary(); + return new BinaryManager( + { + verbose, + skipAutoUpdate: options.skipAutoUpdate ?? false, + allowInstall: options.allowInstall ?? true, + }, + backend + ).ensureBinary(); } /** Check if CLIProxyAPI binary is installed */ diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 48700ce5..24e64d5c 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -26,6 +26,10 @@ function log(message: string, verbose: boolean): void { if (verbose) console.error(`[cliproxy] ${message}`); } +function getBackendLabel(backend: CLIProxyBackend): string { + return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; +} + /** * Check if version is above max stable (known unstable) */ @@ -52,7 +56,7 @@ function clampToMaxStable(version: string | undefined, verbose: boolean): string /** Handle auto-update when binary exists */ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise { const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND; - const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + const backendLabel = getBackendLabel(backend); const updateResult = await checkForUpdates(config.binPath, config.version, verbose, backend); const currentVersion = updateResult.currentVersion; const latestVersion = updateResult.latestVersion; @@ -112,6 +116,11 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise return binaryPath; } + if (config.skipAutoUpdate) { + log('Runtime bootstrap mode: skipping auto-update check', verbose); + return binaryPath; + } + try { await handleAutoUpdate(config, verbose); } catch (error) { @@ -125,6 +134,13 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise // Binary missing - download log('Binary not found, downloading...', verbose); + if (!config.allowInstall) { + throw new Error( + `${getBackendLabel(backend)} binary is not installed locally. ` + + 'Run "ccs cliproxy install" when you have network access.' + ); + } + if (!config.forceVersion) { try { const latestVersion = await fetchLatestVersion(verbose, backend); diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 5c2c25e4..4ad89052 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -329,7 +329,7 @@ export async function execClaudeWithCLIProxy( spinner.start(); try { - binaryPath = await ensureCLIProxyBinary(verbose); + binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); spinner.succeed('CLIProxy binary ready'); } catch (error) { spinner.fail('Failed to prepare CLIProxy'); diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index 0ef4d2e7..b97add9f 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -216,7 +216,10 @@ export async function ensureCliproxyService( // 1. Ensure binary exists let binaryPath: string; try { - binaryPath = await ensureCLIProxyBinary(verbose); + binaryPath = await ensureCLIProxyBinary(verbose, { + allowInstall: false, + skipAutoUpdate: true, + }); log(`Binary ready: ${binaryPath}`); } catch (error) { const err = error as Error; diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 7a55922a..ea723480 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -50,6 +50,10 @@ export interface BinaryManagerConfig { verbose: boolean; /** Force specific version (skip auto-upgrade to latest) */ forceVersion: boolean; + /** Skip background update checks on runtime bootstrap paths */ + skipAutoUpdate: boolean; + /** Allow downloading/installing the binary when it is missing */ + allowInstall: boolean; /** Backend variant (original vs plus) */ backend?: CLIProxyBackend; } diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index 533f38bc..92140f1d 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -1,4 +1,28 @@ -import { describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let originalCcsHome: string | undefined; +let tempHome = ''; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-binary-manager-')); + process.env.CCS_HOME = tempHome; +}); + +afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } +}); describe('installCliproxyVersion', () => { it('attempts to stop the proxy even when there is no tracked running session', async () => { @@ -42,4 +66,19 @@ describe('installCliproxyVersion', () => { expect(calls.deleteBinary).toBe(0); expect(calls.ensureBinary).toBe(1); }); + + it('fails fast when runtime startup forbids installing a missing binary', async () => { + const binaryManager = await import( + `../../../src/cliproxy/binary-manager?binary-manager-runtime=${Date.now()}` + ); + + await expect( + binaryManager.ensureCLIProxyBinary(false, { + allowInstall: false, + skipAutoUpdate: true, + }) + ).rejects.toThrow( + 'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' + ); + }); }); diff --git a/tests/unit/cliproxy/service-manager-startup.test.ts b/tests/unit/cliproxy/service-manager-startup.test.ts new file mode 100644 index 00000000..5b85f9fc --- /dev/null +++ b/tests/unit/cliproxy/service-manager-startup.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, mock } from 'bun:test'; + +const ensureBinaryCalls: Array = []; + +mock.module('../../../src/cliproxy/binary-manager', () => ({ + ensureCLIProxyBinary: async (_verbose = false, options?: unknown) => { + ensureBinaryCalls.push(options); + throw new Error( + 'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' + ); + }, +})); + +mock.module('../../../src/cliproxy/config-generator', () => ({ + ensureConfigDir: () => undefined, + generateConfig: () => '/tmp/cliproxy-config.yaml', + regenerateConfig: () => '/tmp/cliproxy-config.yaml', + configNeedsRegeneration: () => false, + CLIPROXY_DEFAULT_PORT: 8317, + getCliproxyWritablePath: () => '/tmp', +})); + +mock.module('../../../src/cliproxy/proxy-detector', () => ({ + detectRunningProxy: async () => ({ running: false, verified: false }), + waitForProxyHealthy: async () => false, +})); + +mock.module('../../../src/cliproxy/startup-lock', () => ({ + withStartupLock: async (fn: () => Promise) => await fn(), +})); + +mock.module('../../../src/cliproxy/session-tracker', () => ({ + registerSession: () => undefined, +})); + +mock.module('../../../src/cliproxy/stats-fetcher', () => ({ + isCliproxyRunning: async () => false, +})); + +mock.module('../../../src/cliproxy/auth/token-refresh-config', () => ({ + getTokenRefreshConfig: () => null, +})); + +mock.module('../../../src/cliproxy/auth/token-refresh-worker', () => ({ + TokenRefreshWorker: class { + isActive(): boolean { + return false; + } + start(): void {} + stop(): void {} + }, +})); + +const { ensureCliproxyService } = await import( + `../../../src/cliproxy/service-manager?service-manager-startup=${Date.now()}` +); + +describe('ensureCliproxyService', () => { + it('fails fast without attempting a runtime install when the local binary is missing', async () => { + ensureBinaryCalls.length = 0; + + const result = await ensureCliproxyService(8317, false); + + expect(result).toEqual({ + started: false, + alreadyRunning: false, + port: 8317, + error: + 'Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.', + }); + expect(ensureBinaryCalls).toEqual([ + { + allowInstall: false, + skipAutoUpdate: true, + }, + ]); + }); +}); diff --git a/tests/unit/cliproxy/version-checker-stale-cache.test.ts b/tests/unit/cliproxy/version-checker-stale-cache.test.ts index 27c03871..c8829faa 100644 --- a/tests/unit/cliproxy/version-checker-stale-cache.test.ts +++ b/tests/unit/cliproxy/version-checker-stale-cache.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -14,6 +14,8 @@ describe('version-checker stale cache fallback', () => { }); afterEach(() => { + mock.restore(); + if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { @@ -86,4 +88,50 @@ describe('version-checker stale cache fallback', () => { expect(result.latest).toBe('6.9.23-0'); expect(result.fromCache).toBe(true); }); + + it('skips update lookups when runtime startup prefers the installed binary', async () => { + const { getExecutableName } = await import('../../../src/cliproxy/platform-detector'); + const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus'); + fs.mkdirSync(plusBinDir, { recursive: true }); + fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'binary'); + + let checkForUpdatesCalls = 0; + + mock.module('../../../src/cliproxy/binary/version-checker', () => ({ + checkForUpdates: async () => { + checkForUpdatesCalls += 1; + return { + hasUpdate: false, + currentVersion: '6.8.2-0', + latestVersion: '6.8.2-0', + fromCache: false, + checkedAt: Date.now(), + }; + }, + fetchLatestVersion: async () => { + throw new Error('fetchLatestVersion should not run when skipAutoUpdate is enabled'); + }, + isNewerVersion: () => false, + isVersionFaulty: () => false, + })); + + const { ensureBinary } = await import( + `../../../src/cliproxy/binary/lifecycle?skip-auto-update=${Date.now()}` + ); + + const binaryPath = await ensureBinary({ + version: '6.8.2-0', + releaseUrl: 'https://example.com/releases/download', + binPath: plusBinDir, + maxRetries: 1, + verbose: false, + forceVersion: false, + skipAutoUpdate: true, + allowInstall: true, + backend: 'plus', + }); + + expect(binaryPath).toBe(path.join(plusBinDir, getExecutableName('plus'))); + expect(checkForUpdatesCalls).toBe(0); + }); }); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index 98d2320e..2fd3fbcf 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -171,6 +171,26 @@ describe('config command dashboard startup', () => { expect(errorLines).toHaveLength(0); }); + it('still opens the dashboard when CLIProxy is unavailable', async () => { + await handleConfigCommand([], { + ...createTestDeps(), + ensureCliproxyService: async () => ({ + started: false, + alreadyRunning: false, + port: 8317, + error: + 'Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.', + }), + }); + + expect(startServerCalls).toHaveLength(1); + const rendered = logLines.join('\n'); + expect(rendered).toContain( + 'CLIProxy not available: Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' + ); + expect(rendered).toContain('Dashboard will work but Control Panel/Stats may be limited'); + }); + it('fails cleanly when the server cannot bind the requested host', async () => { startServerError = new Error( 'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use' diff --git a/tests/unit/commands/tokens-command-auth-rotation.test.ts b/tests/unit/commands/tokens-command-auth-rotation.test.ts index 4c3e95aa..d2c86e8b 100644 --- a/tests/unit/commands/tokens-command-auth-rotation.test.ts +++ b/tests/unit/commands/tokens-command-auth-rotation.test.ts @@ -1,105 +1,65 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { handleTokensCommand } from '../../../src/commands/tokens-command'; +import { getConfigYamlPath, loadUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { runWithScopedCcsHome, setGlobalConfigDir } from '../../../src/utils/config-manager'; -async function loadTokensCommand() { - return await import( - `../../../src/commands/tokens-command?test=${Date.now()}-${Math.random()}` - ); -} +async function withScopedTokensHome(run: (tempHome: string) => Promise): Promise { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tokens-rotation-')); + setGlobalConfigDir(undefined); -async function loadCliproxyModule() { - return await import(`../../../src/cliproxy?test=${Date.now()}-${Math.random()}`); -} - -async function loadUnifiedConfigModule() { - return await import( - `../../../src/config/unified-config-loader?test=${Date.now()}-${Math.random()}` - ); + try { + return await runWithScopedCcsHome(tempHome, async () => await run(tempHome)); + } finally { + setGlobalConfigDir(undefined); + fs.rmSync(tempHome, { recursive: true, force: true }); + } } describe('tokens command auth rotation', () => { - let tempHome = ''; - let logLines: string[] = []; - let errorLines: string[] = []; - let originalCcsHome: string | undefined; - let originalNoColor: string | undefined; - let originalConsoleLog: typeof console.log; - let originalConsoleError: typeof console.error; - - beforeEach(() => { - tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tokens-rotation-')); - logLines = []; - errorLines = []; - originalCcsHome = process.env.CCS_HOME; - originalNoColor = process.env.NO_COLOR; - originalConsoleLog = console.log; - originalConsoleError = console.error; - - process.env.CCS_HOME = tempHome; - process.env.NO_COLOR = '1'; - console.log = (...args: unknown[]) => { - logLines.push(args.map(String).join(' ')); - }; - console.error = (...args: unknown[]) => { - errorLines.push(args.map(String).join(' ')); - }; - }); - - afterEach(() => { - if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; - else delete process.env.CCS_HOME; - - if (originalNoColor !== undefined) process.env.NO_COLOR = originalNoColor; - else delete process.env.NO_COLOR; - - console.log = originalConsoleLog; - console.error = originalConsoleError; - fs.rmSync(tempHome, { recursive: true, force: true }); - }); - it('applies api-key and regenerated secret in a single invocation', async () => { - const { handleTokensCommand } = await loadTokensCommand(); - const { getCliproxyConfigPath } = await loadCliproxyModule(); - const { loadUnifiedConfig } = await loadUnifiedConfigModule(); + await withScopedTokensHome(async () => { + const exitCode = await handleTokensCommand([ + '--api-key', + 'ccs-custom-key-123', + '--regenerate-secret', + ]); - const exitCode = await handleTokensCommand([ - '--api-key', - 'ccs-custom-key-123', - '--regenerate-secret', - ]); + const config = loadUnifiedConfig(); + const managementSecret = config?.cliproxy.auth?.management_secret; + const configYamlPath = getConfigYamlPath(); - expect(exitCode).toBe(0); - expect(errorLines).toHaveLength(0); - expect(logLines.some((line) => line.includes('New management secret generated'))).toBe(true); - expect(logLines.some((line) => line.includes('Global API key updated'))).toBe(true); - expect(logLines.filter((line) => line.includes('CLIProxy config regenerated'))).toHaveLength(1); + const diagnostics = { + exitCode, + configYamlPath, + configExists: fs.existsSync(configYamlPath), + apiKey: config?.cliproxy.auth?.api_key ?? null, + managementSecretLength: (managementSecret ?? '').length, + }; - const config = loadUnifiedConfig(); - const managementSecret = config?.cliproxy.auth?.management_secret; - expect(config?.cliproxy.auth?.api_key).toBe('ccs-custom-key-123'); - expect(typeof managementSecret).toBe('string'); - expect((managementSecret ?? '').length).toBeGreaterThan(20); - - const cliproxyConfig = fs.readFileSync(getCliproxyConfigPath(), 'utf8'); - expect(cliproxyConfig).toContain('"ccs-custom-key-123"'); + if ( + exitCode !== 0 || + config?.cliproxy.auth?.api_key !== 'ccs-custom-key-123' || + typeof managementSecret !== 'string' || + (managementSecret ?? '').length <= 20 + ) { + throw new Error(`tokens rotation diagnostics: ${JSON.stringify(diagnostics)}`); + } + }); }); it('rejects conflicting manual and generated secret flags', async () => { - const { handleTokensCommand } = await loadTokensCommand(); - const { getConfigYamlPath } = await loadUnifiedConfigModule(); + await withScopedTokensHome(async () => { + const exitCode = await handleTokensCommand([ + '--secret', + 'manual-secret', + '--regenerate-secret', + ]); - const exitCode = await handleTokensCommand([ - '--secret', - 'manual-secret', - '--regenerate-secret', - ]); - - expect(exitCode).toBe(1); - expect( - errorLines.some((line) => line.includes('Cannot combine --secret with --regenerate-secret')) - ).toBe(true); - expect(fs.existsSync(getConfigYamlPath())).toBe(false); + expect(exitCode).toBe(1); + expect(fs.existsSync(getConfigYamlPath())).toBe(false); + }); }); });