diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 48fd91aa..e011b9b6 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -49,8 +49,10 @@ jobs: id: release env: HUSKY: 0 - GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} - GH_TOKEN: ${{ secrets.PAT_TOKEN }} + # Use built-in GITHUB_TOKEN for bot identity on comments/releases + # PAT_TOKEN is only used for checkout (to trigger downstream workflows) + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e1f2719..09249950 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,8 +48,10 @@ jobs: id: release env: HUSKY: 0 - GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} - GH_TOKEN: ${{ secrets.PAT_TOKEN }} + # Use built-in GITHUB_TOKEN for bot identity on comments/releases + # PAT_TOKEN is only used for checkout (to trigger downstream workflows) + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | diff --git a/.releaserc.cjs b/.releaserc.cjs index eff9aeab..ff0273aa 100644 --- a/.releaserc.cjs +++ b/.releaserc.cjs @@ -70,6 +70,8 @@ const devConfig = { '@semantic-release/github', { prerelease: true, + // Disable automatic success comment - custom step in dev-release.yml handles this + successComment: false, }, ], [ diff --git a/CHANGELOG.md b/CHANGELOG.md index abde6007..3d478cb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,53 @@ +## [6.7.0-dev.5](https://github.com/kaitranntt/ccs/compare/v6.7.0-dev.4...v6.7.0-dev.5) (2025-12-19) + +### Features + +* **cliproxy:** auto-apply default preset when adding first account ([4036c42](https://github.com/kaitranntt/ccs/commit/4036c42687c0e5124825998350431f17ce617442)) + +## [6.7.0-dev.4](https://github.com/kaitranntt/ccs/compare/v6.7.0-dev.3...v6.7.0-dev.4) (2025-12-19) + +### Bug Fixes + +* **cliproxy:** prevent port race condition with unified detection and startup lock ([96f17e3](https://github.com/kaitranntt/ccs/commit/96f17e3afba93288b041f6c2883753692b4e9ca1)) + +## [6.7.0-dev.3](https://github.com/kaitranntt/ccs/compare/v6.7.0-dev.2...v6.7.0-dev.3) (2025-12-19) + +### Features + +* **recovery:** add lazy initialization for bun/pnpm users ([2d2cd3e](https://github.com/kaitranntt/ccs/commit/2d2cd3eca21a49af04ca0b1c1d549e62b6ff5cc9)) + +## [6.7.0-dev.2](https://github.com/kaitranntt/ccs/compare/v6.7.0-dev.1...v6.7.0-dev.2) (2025-12-19) + +### Bug Fixes + +* **cliproxy:** include BASE_URL and AUTH_TOKEN when applying presets ([598454c](https://github.com/kaitranntt/ccs/commit/598454c931267082ba80c4ef426d0ef2c0370f55)) +* **cliproxy:** prevent variant names matching reserved provider names ([7ea64bd](https://github.com/kaitranntt/ccs/commit/7ea64bdb4392d8cd41bc871892466d30c50b58fd)) +* **web-server:** add comprehensive reserved name validation ([2373100](https://github.com/kaitranntt/ccs/commit/2373100c177654910922c6ef0e5ffc7cfe087b26)) + +## [6.7.0-dev.1](https://github.com/kaitranntt/ccs/compare/v6.6.2-dev.3...v6.7.0-dev.1) (2025-12-19) + +### Features + +* **env:** add debug logging for global env injection ([68eab56](https://github.com/kaitranntt/ccs/commit/68eab562ce404ac1933c76520e5597327348bff2)) + +## [6.6.2-dev.3](https://github.com/kaitranntt/ccs/compare/v6.6.2-dev.2...v6.6.2-dev.3) (2025-12-19) + +### Bug Fixes + +* **update-checker:** resolve dev channel update and duplicate comments ([b6b1817](https://github.com/kaitranntt/ccs/commit/b6b18173cc41788328ef1c8831de9527898f06a1)) + +## [6.6.2-dev.2](https://github.com/kaitranntt/ccs/compare/v6.6.2-dev.1...v6.6.2-dev.2) (2025-12-19) + +### Bug Fixes + +* **remote-proxy:** fix TypeError and make port optional with protocol defaults ([03aea4e](https://github.com/kaitranntt/ccs/commit/03aea4eac233caf00736ceec80b4cc841bce948a)), closes [#142](https://github.com/kaitranntt/ccs/issues/142) + +## [6.6.2-dev.1](https://github.com/kaitranntt/ccs/compare/v6.6.1...v6.6.2-dev.1) (2025-12-19) + +### Bug Fixes + +* **ci:** use built-in GITHUB_TOKEN for release comments ([cff38ac](https://github.com/kaitranntt/ccs/commit/cff38ac53d392317325e2cbf5281956ff59174be)) + ## [6.6.1](https://github.com/kaitranntt/ccs/compare/v6.6.0...v6.6.1) (2025-12-19) ### Bug Fixes diff --git a/package.json b/package.json index b7f53600..f55765a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.6.1", + "version": "6.7.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/ccs.ts b/src/ccs.ts index 894defab..5fbd8faf 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -499,6 +499,12 @@ async function main(): Promise { const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + // Log global env injection for visibility (debug mode only) + if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) { + const envNames = Object.keys(globalEnv).join(', '); + console.error(`[i] Global env: ${envNames}`); + } + // CRITICAL: Load settings and explicitly set ANTHROPIC_* env vars // to prevent inheriting stale values from previous CLIProxy sessions. // Environment variables take precedence over --settings file values, diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 9cd4277f..dd72b261 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -43,19 +43,14 @@ import { getDefaultAccount, } from './account-manager'; import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands'; -import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import { ensureMcpWebSearch, installWebSearchHook, displayWebSearchStatus, } from '../utils/websearch-manager'; -import { - getExistingProxy, - registerSession, - unregisterSession, - hasActiveSessions, - cleanupOrphanedSessions, -} from './session-tracker'; +import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker'; +import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; +import { withStartupLock } from './startup-lock'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -397,76 +392,88 @@ export async function execClaudeWithCLIProxy( const configPath = generateConfig(provider, cfg.port); log(`Config written: ${configPath}`); - // 6a. Pre-flight check: handle existing proxy or port conflicts - // Clean up orphaned sessions first (from crashed proxies) + // 6a. Pre-flight check using unified detection with startup lock + // This prevents race conditions when multiple CCS processes start simultaneously cleanupOrphanedSessions(cfg.port); - // Check if there's an existing healthy proxy we can reuse - const existingProxy = getExistingProxy(cfg.port); + // Use startup lock to coordinate with other CCS processes + await withStartupLock(async () => { + // Detect running proxy using multiple methods (HTTP, session-lock, port-process) + const proxyStatus = await detectRunningProxy(cfg.port); + log(`Proxy detection: ${JSON.stringify(proxyStatus)}`); - if (existingProxy) { - // Reuse existing proxy - another CCS session started it - log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`); - sessionId = registerSession(cfg.port, existingProxy.pid); - console.log( - info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`) - ); - } else { - // No existing proxy - check if port is free - const portProcess = await getPortProcess(cfg.port); - if (portProcess) { - if (isCLIProxyProcess(portProcess)) { - // CLIProxy on port but no session lock - likely orphaned/zombie - // Only kill if no active sessions registered - if (!hasActiveSessions()) { - log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); - const killed = killProcessOnPort(cfg.port, verbose); - if (killed) { - console.log(info(`Cleaned up zombie CLIProxy process`)); - // Wait a bit for port to be released - await new Promise((r) => setTimeout(r, 500)); - } - } else { - // Active sessions exist but getExistingProxy returned null - something's wrong - // Try to connect anyway - log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`); - } + if (proxyStatus.running && proxyStatus.verified) { + // Healthy proxy found - join it + if (proxyStatus.pid) { + sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined; + } + if (sessionId) { + console.log(info(`Joined existing CLIProxy on port ${cfg.port} (${proxyStatus.method})`)); } else { - // Non-CLIProxy process blocking the port - warn user - console.error(''); - console.error( - warn( - `Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})` - ) - ); - console.error(''); - console.error('To fix this, close the blocking application or run:'); - console.error(` ${getPortCheckCommand(cfg.port)}`); - console.error(''); - throw new Error(`Port ${cfg.port} is in use by another application`); + // Failed to register session, but proxy is running - continue anyway + log(`Failed to register session but proxy is healthy, continuing...`); + } + return; // Exit lock early, skip spawning + } + + if (proxyStatus.running && !proxyStatus.verified) { + // Proxy detected but not ready yet (another process is starting it) + log(`Proxy starting up (detected via ${proxyStatus.method}), waiting...`); + const becameHealthy = await waitForProxyHealthy(cfg.port, cfg.timeout); + if (becameHealthy) { + if (proxyStatus.pid) { + sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined; + } + console.log(info(`Joined CLIProxy after startup wait`)); + return; // Exit lock early + } + // Proxy didn't become healthy - kill and respawn + if (proxyStatus.pid) { + log(`Proxy PID ${proxyStatus.pid} not responding, killing...`); + killProcessOnPort(cfg.port, verbose); + await new Promise((r) => setTimeout(r, 500)); } } - // 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy) - // Use detached mode so proxy persists after terminal closes - const configPath = generateConfig(provider, cfg.port); - const proxyArgs = ['--config', configPath]; + if (proxyStatus.blocked && proxyStatus.blocker) { + // Port blocked by non-CLIProxy process + // Last resort: try HTTP health check (handles Windows PID-XXXXX case) + const isActuallyOurs = await waitForProxyHealthy(cfg.port, 1000); + if (isActuallyOurs) { + sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid) ?? undefined; + console.log(info(`Reclaimed CLIProxy with unrecognized process name`)); + return; + } + // Truly blocked by another application + console.error(''); + console.error( + warn( + `Port ${cfg.port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})` + ) + ); + console.error(''); + console.error('To fix this, close the blocking application or run:'); + console.error(` ${getPortCheckCommand(cfg.port)}`); + console.error(''); + throw new Error(`Port ${cfg.port} is in use by another application`); + } + + // 6b. Spawn CLIProxyAPI binary + const proxyArgs = ['--config', configPath]; log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); proxy = spawn(binaryPath as string, proxyArgs, { stdio: ['ignore', 'ignore', 'ignore'], - detached: true, // Persist after parent terminal closes + detached: true, env: { ...process.env, - WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ + WRITABLE_PATH: getCliproxyWritablePath(), }, }); - // Unref so parent process can exit independently proxy.unref(); - // Handle proxy errors (only fires if spawn itself fails) proxy.on('error', (error) => { console.error(fail(`CLIProxy spawn error: ${error.message}`)); }); @@ -504,7 +511,7 @@ export async function execClaudeWithCLIProxy( // Register this session with the new proxy sessionId = registerSession(cfg.port, proxy.pid as number); log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`); - } + }); } // 7. Execute Claude CLI with proxied environment @@ -530,6 +537,10 @@ export async function execClaudeWithCLIProxy( if (Object.keys(webSearchEnv).length > 0) { log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`); } + // Log global env vars for visibility + if (envVars.DISABLE_TELEMETRY || envVars.DISABLE_ERROR_REPORTING || envVars.DISABLE_BUG_COMMAND) { + log(`Claude env: Global env applied (telemetry/reporting disabled)`); + } // Filter out CCS-specific flags before passing to Claude CLI // Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig() diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 9cf354d6..d0ca4b95 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -490,9 +490,13 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { */ export function getRemoteEnvVars( provider: CLIProxyProvider, - remoteConfig: { host: string; port: number; protocol: 'http' | 'https'; authToken?: string } + remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string } ): Record { - const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}:${remoteConfig.port}/api/provider/${provider}`; + // Build URL with smart port handling - omit if using protocol default + const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80; + const effectivePort = remoteConfig.port ?? defaultPort; + const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`; + const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; const models = getModelMapping(provider); // Get global env vars (DISABLE_TELEMETRY, etc.) diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index ecf890b1..e42705be 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -126,3 +126,11 @@ export { // Service manager (background CLIProxy for dashboard) export type { ServiceStartResult } from './service-manager'; export { ensureCliproxyService, stopCliproxyService, getServiceStatus } from './service-manager'; + +// Proxy detector (unified detection with multiple fallbacks) +export type { ProxyStatus, DetectionMethod } from './proxy-detector'; +export { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; + +// Startup lock (prevents race conditions between CCS processes) +export type { LockResult } from './startup-lock'; +export { acquireStartupLock, withStartupLock } from './startup-lock'; diff --git a/src/cliproxy/proxy-detector.ts b/src/cliproxy/proxy-detector.ts new file mode 100644 index 00000000..b2afe545 --- /dev/null +++ b/src/cliproxy/proxy-detector.ts @@ -0,0 +1,220 @@ +/** + * Unified Proxy Detector + * + * Single source of truth for CLIProxy detection. + * Uses multiple detection methods with fallbacks: + * 1. HTTP health check (most reliable, ~1s timeout) + * 2. Session lock file (sessions.json) + * 3. Port process detection (fallback for orphaned proxies) + * + * Detection Order Rationale: + * - HTTP first: Fastest verification that proxy is responsive + * - Session lock second: Catches proxies that are starting up + * - Port process third: Handles orphaned proxies and Windows PID-XXXXX case + * + * Solves race conditions between cliproxy-executor.ts and service-manager.ts + */ + +import { getExistingProxy, registerSession } from './session-tracker'; +import { isCliproxyRunning } from './stats-fetcher'; +import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils'; + +/** Detection method used to find the proxy */ +export type DetectionMethod = 'http' | 'session-lock' | 'port-process' | 'http-retry'; + +/** Proxy detection status */ +export interface ProxyStatus { + /** Whether a proxy is running on the port */ + running: boolean; + /** Whether the proxy was verified healthy via HTTP */ + verified: boolean; + /** How the proxy was detected */ + method?: DetectionMethod; + /** PID of the running proxy (if known) */ + pid?: number; + /** Whether port is blocked by non-CLIProxy process */ + blocked?: boolean; + /** Process blocking the port (if blocked) */ + blocker?: PortProcess; + /** Number of active sessions (if session-lock found) */ + sessionCount?: number; +} + +/** Optional logger function for verbose output */ +type LogFn = (msg: string) => void; + +/** No-op logger for when verbose is disabled */ +const noopLog: LogFn = () => {}; + +/** + * Detect running CLIProxy using multiple methods with fallbacks. + * + * Detection order (most reliable first): + * 1. HTTP health check - fastest, verifies proxy is responsive + * 2. Session lock - checks sessions.json for tracked proxy + * 3. Port process - checks OS-level port ownership + * + * @param port Port to check (default: 8317) + * @param verbose Enable verbose logging (default: false) + * @returns ProxyStatus with detection details + */ +export async function detectRunningProxy( + port: number, + verbose: boolean = false +): Promise { + const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog; + + log(`Detecting proxy on port ${port}...`); + + // 1. First: HTTP health check (fastest, most reliable) + log('Trying HTTP health check...'); + const healthy = await isCliproxyRunning(port); + if (healthy) { + // Proxy is running and responsive + // Try to get PID from session lock if available + const lock = getExistingProxy(port); + log(`HTTP check passed, proxy healthy (PID: ${lock?.pid ?? 'unknown'})`); + return { + running: true, + verified: true, + method: 'http', + pid: lock?.pid, + sessionCount: lock?.sessions?.length, + }; + } + log('HTTP check failed, proxy not responding'); + + // 2. Second: Check session lock file + log('Checking session lock file...'); + const lock = getExistingProxy(port); + if (lock) { + // Session lock exists - proxy might be starting up + // The lock validates PID is running, so proxy exists but not ready + log(`Session lock found: PID ${lock.pid}, ${lock.sessions.length} sessions`); + return { + running: true, + verified: false, + method: 'session-lock', + pid: lock.pid, + sessionCount: lock.sessions.length, + }; + } + log('No session lock found'); + + // 3. Third: Check port process (fallback for orphaned/untracked proxy) + log('Checking port process...'); + const portProcess = await getPortProcess(port); + if (portProcess) { + log(`Port occupied by: ${portProcess.processName} (PID ${portProcess.pid})`); + + // Check by process name first (works on Linux/macOS, may fail on Windows) + if (isCLIProxyProcess(portProcess)) { + log('Process name matches CLIProxy, retrying HTTP...'); + // Looks like CLIProxy by name - verify with HTTP + const retryHealth = await isCliproxyRunning(port); + if (retryHealth) { + log('HTTP retry passed, proxy is healthy'); + return { + running: true, + verified: true, + method: 'http-retry', + pid: portProcess.pid, + }; + } + // CLIProxy by name but not responding - might be starting up + log('HTTP retry failed, proxy starting up or unresponsive'); + return { + running: true, + verified: false, + method: 'port-process', + pid: portProcess.pid, + }; + } + + // Process name doesn't match (or Windows returned PID-XXXXX) + // Do one more HTTP check to be sure it's not ours + log('Process name unrecognized, final HTTP check (Windows PID-XXXXX case)...'); + const finalHealthCheck = await isCliproxyRunning(port); + if (finalHealthCheck) { + // It IS our proxy, just with unrecognized name (Windows case) + log('Final HTTP check passed, reclaiming proxy with unrecognized name'); + return { + running: true, + verified: true, + method: 'http-retry', + pid: portProcess.pid, + }; + } + + // Definitely not our proxy - port is blocked + log(`Port blocked by non-CLIProxy process: ${portProcess.processName}`); + return { + running: false, + verified: false, + blocked: true, + blocker: portProcess, + }; + } + + // Port is free + log('Port is free, no proxy detected'); + return { + running: false, + verified: false, + }; +} + +/** + * Wait for proxy to become ready (healthy via HTTP). + * Useful after detecting proxy via session-lock or port-process. + * + * @param port Port to check + * @param timeout Max wait time in ms (default: 5000) + * @param pollInterval Time between checks in ms (default: 100) + * @returns true if proxy became ready, false if timeout + */ +export async function waitForProxyHealthy( + port: number, + timeout: number = 5000, + pollInterval: number = 100 +): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + const healthy = await isCliproxyRunning(port); + if (healthy) { + return true; + } + await new Promise((r) => setTimeout(r, pollInterval)); + } + + return false; +} + +/** + * Attempt to reclaim an orphaned CLIProxy (running but not in session tracker). + * Registers a new session with the detected proxy. + * + * @param port Port where proxy is running + * @param pid PID of the proxy process + * @param verbose Enable verbose logging (default: false) + * @returns Session ID if reclaimed, null if failed + */ +export function reclaimOrphanedProxy( + port: number, + pid: number, + verbose: boolean = false +): string | null { + const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog; + + try { + log(`Reclaiming orphaned proxy: port=${port}, pid=${pid}`); + const sessionId = registerSession(port, pid); + log(`Successfully reclaimed proxy, session ID: ${sessionId}`); + return sessionId; + } catch (err) { + const error = err as Error; + log(`Failed to reclaim proxy: ${error.message}`); + return null; + } +} diff --git a/src/cliproxy/remote-proxy-client.ts b/src/cliproxy/remote-proxy-client.ts index c4c8a321..047ea356 100644 --- a/src/cliproxy/remote-proxy-client.ts +++ b/src/cliproxy/remote-proxy-client.ts @@ -26,8 +26,13 @@ export interface RemoteProxyStatus { export interface RemoteProxyClientConfig { /** Remote proxy host (IP or hostname) */ host: string; - /** Remote proxy port */ - port: number; + /** + * Remote proxy port. + * Optional - defaults based on protocol: + * - HTTPS: 443 + * - HTTP: 80 + */ + port?: number; /** Protocol to use (http or https) */ protocol: 'http' | 'https'; /** Optional auth token for Authorization header */ @@ -41,12 +46,45 @@ export interface RemoteProxyClientConfig { /** Default timeout for remote proxy requests (aggressive for CLI UX) */ const DEFAULT_TIMEOUT_MS = 2000; +/** + * Get default port for protocol + */ +function getDefaultPort(protocol: 'http' | 'https'): number { + return protocol === 'https' ? 443 : 80; +} + +/** + * Build URL for remote proxy, intelligently omitting default ports + */ +function buildProxyUrl( + host: string, + port: number | undefined, + protocol: 'http' | 'https', + path: string +): string { + const defaultPort = getDefaultPort(protocol); + const effectivePort = port ?? defaultPort; + + // Omit port from URL if it matches the default for the protocol + if (effectivePort === defaultPort) { + return `${protocol}://${host}${path}`; + } + return `${protocol}://${host}:${effectivePort}${path}`; +} + /** * Map error to RemoteProxyErrorCode + * + * Handles various error types including: + * - NodeJS.ErrnoException (ECONNREFUSED, ETIMEDOUT) + * - Fetch errors (AbortError, TypeError) + * - HTTP status codes (401, 403) */ function mapErrorToCode(error: Error, statusCode?: number): RemoteProxyErrorCode { const message = error.message.toLowerCase(); - const code = (error as NodeJS.ErrnoException).code?.toLowerCase(); + // Handle error.code safely - it may be string, number, or undefined + const rawCode = (error as NodeJS.ErrnoException).code; + const code = typeof rawCode === 'string' ? rawCode.toLowerCase() : undefined; // Connection refused if (code === 'econnrefused' || message.includes('connection refused')) { @@ -110,7 +148,17 @@ export async function checkRemoteProxy( const { host, port, protocol, authToken, allowSelfSigned = false } = config; const timeout = config.timeout ?? DEFAULT_TIMEOUT_MS; - const url = `${protocol}://${host}:${port}/health`; + // Validate host is provided + if (!host || host.trim() === '') { + return { + reachable: false, + error: 'Host is required', + errorCode: 'UNKNOWN', + }; + } + + // Use smart URL building - omit port if it's the default for the protocol + const url = buildProxyUrl(host, port, protocol, '/health'); const startTime = Date.now(); try { diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index 10992fda..1e814fbe 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -21,8 +21,10 @@ import { CLIPROXY_DEFAULT_PORT, getCliproxyWritablePath, } from './config-generator'; -import { isCliproxyRunning } from './stats-fetcher'; import { registerSession } from './session-tracker'; +import { detectRunningProxy, waitForProxyHealthy } from './proxy-detector'; +import { withStartupLock } from './startup-lock'; +import { isCliproxyRunning } from './stats-fetcher'; /** Background proxy process reference */ let proxyProcess: ChildProcess | null = null; @@ -116,10 +118,6 @@ export async function ensureCliproxyService( } }; - // Check if already running (from another process or previous start) - log(`Checking if CLIProxy is running on port ${port}...`); - const running = await isCliproxyRunning(port); - // Check if config needs update (even if running) let configRegenerated = false; if (configNeedsRegeneration()) { @@ -128,106 +126,136 @@ export async function ensureCliproxyService( configRegenerated = true; } - if (running) { - log('CLIProxy already running'); - if (configRegenerated) { - log('Config was updated - running instance will use new config on next restart'); - } - return { started: true, alreadyRunning: true, port, configRegenerated }; - } + // Use startup lock to coordinate with other CCS processes (ccs agy, ccs config, etc.) + return await withStartupLock(async () => { + // Use unified detection (HTTP check + session-lock + port-process) + log(`Checking if CLIProxy is running on port ${port}...`); + const proxyStatus = await detectRunningProxy(port); + log(`Proxy detection: ${JSON.stringify(proxyStatus)}`); - // Need to start new instance - log('CLIProxy not running, starting background instance...'); - - // 1. Ensure binary exists - let binaryPath: string; - try { - binaryPath = await ensureCLIProxyBinary(verbose); - log(`Binary ready: ${binaryPath}`); - } catch (error) { - const err = error as Error; - return { - started: false, - alreadyRunning: false, - port, - error: `Failed to prepare binary: ${err.message}`, - }; - } - - // 2. Ensure/regenerate config if needed - let configPath: string; - if (configNeedsRegeneration()) { - log('Config needs regeneration, updating...'); - configPath = regenerateConfig(port); - } else { - // generateConfig only creates if doesn't exist - configPath = generateConfig('gemini', port); // Provider doesn't matter for unified config - } - log(`Config ready: ${configPath}`); - - // 3. Spawn background process - const proxyArgs = ['--config', configPath]; - - log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); - - proxyProcess = spawn(binaryPath, proxyArgs, { - stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], - detached: true, // Allow process to run independently - env: { - ...process.env, - WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ - }, - }); - - // Forward output in verbose mode - if (verbose) { - proxyProcess.stdout?.on('data', (data: Buffer) => { - process.stderr.write(`[cliproxy] ${data.toString()}`); - }); - proxyProcess.stderr?.on('data', (data: Buffer) => { - process.stderr.write(`[cliproxy-err] ${data.toString()}`); - }); - } - - // Don't let this process prevent parent from exiting - proxyProcess.unref(); - - // Handle spawn errors - proxyProcess.on('error', (error) => { - log(`Spawn error: ${error.message}`); - }); - - // Register cleanup handlers - registerCleanup(); - - // 4. Wait for proxy to be ready - log(`Waiting for CLIProxy on port ${port}...`); - const ready = await waitForPort(port, 5000); - - if (!ready) { - // Kill failed process - if (proxyProcess && !proxyProcess.killed) { - proxyProcess.kill('SIGTERM'); - proxyProcess = null; + if (proxyStatus.running && proxyStatus.verified) { + // Already running and healthy + log('CLIProxy already running'); + if (configRegenerated) { + log('Config was updated - running instance will use new config on next restart'); + } + return { started: true, alreadyRunning: true, port, configRegenerated }; } - return { - started: false, - alreadyRunning: false, - port, - error: `CLIProxy failed to start within 5s on port ${port}`, - }; - } + if (proxyStatus.running && !proxyStatus.verified) { + // Proxy detected but not ready yet (another process is starting it) + log(`Proxy starting up (detected via ${proxyStatus.method}), waiting...`); + const becameHealthy = await waitForProxyHealthy(port, 5000); + if (becameHealthy) { + log('Proxy became healthy'); + return { started: true, alreadyRunning: true, port, configRegenerated }; + } + // Proxy didn't become healthy - will try to start fresh below + log('Proxy detected but not responding, will start fresh'); + } - log(`CLIProxy service started on port ${port}`); + if (proxyStatus.blocked) { + // Port blocked by non-CLIProxy process - try HTTP as last resort + const isActuallyOurs = await waitForProxyHealthy(port, 1000); + if (isActuallyOurs) { + log('Reclaimed CLIProxy with unrecognized process name'); + return { started: true, alreadyRunning: true, port, configRegenerated }; + } + // Truly blocked + return { + started: false, + alreadyRunning: false, + port, + error: `Port ${port} is blocked by ${proxyStatus.blocker?.processName}`, + }; + } - // 5. Register session so stopProxy() can find and kill this process - if (proxyProcess.pid) { - registerSession(port, proxyProcess.pid); - log(`Session registered for PID ${proxyProcess.pid}`); - } + // Need to start new instance + log('CLIProxy not running, starting background instance...'); - return { started: true, alreadyRunning: false, port }; + // 1. Ensure binary exists + let binaryPath: string; + try { + binaryPath = await ensureCLIProxyBinary(verbose); + log(`Binary ready: ${binaryPath}`); + } catch (error) { + const err = error as Error; + return { + started: false, + alreadyRunning: false, + port, + error: `Failed to prepare binary: ${err.message}`, + }; + } + + // 2. Ensure/regenerate config if needed + let configPath: string; + if (configNeedsRegeneration()) { + log('Config needs regeneration, updating...'); + configPath = regenerateConfig(port); + } else { + configPath = generateConfig('gemini', port); + } + log(`Config ready: ${configPath}`); + + // 3. Spawn background process + const proxyArgs = ['--config', configPath]; + log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); + + proxyProcess = spawn(binaryPath, proxyArgs, { + stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], + detached: true, + env: { + ...process.env, + WRITABLE_PATH: getCliproxyWritablePath(), + }, + }); + + if (verbose) { + proxyProcess.stdout?.on('data', (data: Buffer) => { + process.stderr.write(`[cliproxy] ${data.toString()}`); + }); + proxyProcess.stderr?.on('data', (data: Buffer) => { + process.stderr.write(`[cliproxy-err] ${data.toString()}`); + }); + } + + proxyProcess.unref(); + + proxyProcess.on('error', (error) => { + log(`Spawn error: ${error.message}`); + }); + + registerCleanup(); + + // 4. Wait for proxy to be ready + log(`Waiting for CLIProxy on port ${port}...`); + const ready = await waitForPort(port, 5000); + + if (!ready) { + if (proxyProcess && !proxyProcess.killed) { + proxyProcess.kill('SIGTERM'); + proxyProcess = null; + } + + return { + started: false, + alreadyRunning: false, + port, + error: `CLIProxy failed to start within 5s on port ${port}`, + }; + } + + log(`CLIProxy service started on port ${port}`); + + // 5. Register session + if (proxyProcess.pid) { + registerSession(port, proxyProcess.pid); + log(`Session registered for PID ${proxyProcess.pid}`); + } + + return { started: true, alreadyRunning: false, port }; + }); } /** diff --git a/src/cliproxy/startup-lock.ts b/src/cliproxy/startup-lock.ts new file mode 100644 index 00000000..8d63e3d0 --- /dev/null +++ b/src/cliproxy/startup-lock.ts @@ -0,0 +1,228 @@ +/** + * Startup Lock for CLIProxy + * + * File-based mutex to prevent race conditions when multiple + * CCS processes try to start CLIProxy simultaneously. + * + * Uses a lock file with PID and timestamp to coordinate startup. + * Lock is automatically released after timeout or process exit. + * + * Lock Timeout Rationale (10 seconds): + * - CLIProxy startup typically takes 1-3s (binary spawn + port bind) + * - HTTP health check takes ~1s timeout + * - Session registration takes <100ms + * - Total expected lock hold time: 2-5s + * - 10s provides 2x safety margin for slow systems/disk I/O + * - Too short: legitimate startups fail on slow systems + * - Too long: dead processes block other terminals unnecessarily + * + * Why file-based instead of port-based: + * - Works before port is bound (prevents duplicate spawn attempts) + * - Survives process crashes (stale detection via PID check) + * - Cross-platform (Windows, macOS, Linux) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCliproxyDir } from './config-generator'; + +/** Lock file structure */ +interface LockData { + pid: number; + timestamp: number; + hostname: string; +} + +/** Lock acquisition result */ +export interface LockResult { + acquired: boolean; + lockPath: string; + release: () => void; +} + +/** Lock file name */ +const LOCK_FILE = '.startup.lock'; + +/** Lock timeout in ms (stale lock auto-released) */ +const LOCK_TIMEOUT_MS = 10000; // 10 seconds - see module docstring for rationale + +/** Optional logger function for verbose output */ +type LogFn = (msg: string) => void; + +/** No-op logger for when verbose is disabled */ +const noopLog: LogFn = () => {}; + +/** + * Get path to startup lock file + */ +function getLockPath(): string { + return path.join(getCliproxyDir(), LOCK_FILE); +} + +/** + * Check if a lock is stale (old or from dead process) + */ +function isLockStale(lockData: LockData): boolean { + // Check timestamp + if (Date.now() - lockData.timestamp > LOCK_TIMEOUT_MS) { + return true; + } + + // Check if PID is still running + try { + process.kill(lockData.pid, 0); + return false; // Process exists + } catch { + return true; // Process dead + } +} + +/** + * Try to acquire the startup lock once. + * + * @param log Logger function for verbose output + * @returns LockResult with acquired=true if lock obtained + */ +function tryAcquireLockOnce(log: LogFn): LockResult { + const lockPath = getLockPath(); + const dir = path.dirname(lockPath); + + // Ensure directory exists + if (!fs.existsSync(dir)) { + log(`Creating lock directory: ${dir}`); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Check for existing lock + if (fs.existsSync(lockPath)) { + try { + const content = fs.readFileSync(lockPath, 'utf-8'); + const lockData = JSON.parse(content) as LockData; + + if (!isLockStale(lockData)) { + // Lock is held by another active process + log(`Lock held by PID ${lockData.pid} (age: ${Date.now() - lockData.timestamp}ms)`); + return { + acquired: false, + lockPath, + release: () => {}, + }; + } + // Lock is stale - remove and continue + log(`Removing stale lock from PID ${lockData.pid}`); + } catch { + // Invalid lock file - remove and continue + log('Removing invalid lock file'); + } + try { + fs.unlinkSync(lockPath); + } catch { + // Ignore removal errors + } + } + + // Try to create lock atomically + const lockData: LockData = { + pid: process.pid, + timestamp: Date.now(), + hostname: require('os').hostname(), + }; + + try { + // Use 'wx' flag for exclusive creation (fails if exists) + fs.writeFileSync(lockPath, JSON.stringify(lockData), { flag: 'wx', mode: 0o600 }); + log(`Lock acquired by PID ${process.pid}`); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'EEXIST') { + // Another process created lock between our check and write + log('Lock acquisition race - another process won'); + return { + acquired: false, + lockPath, + release: () => {}, + }; + } + throw error; + } + + // Lock acquired - return release function + const release = () => { + try { + // Only release if we still own it + const content = fs.readFileSync(lockPath, 'utf-8'); + const currentLock = JSON.parse(content) as LockData; + if (currentLock.pid === process.pid) { + fs.unlinkSync(lockPath); + log('Lock released'); + } + } catch { + // Ignore release errors + } + }; + + return { + acquired: true, + lockPath, + release, + }; +} + +/** + * Acquire the startup lock with retries. + * + * @param options.retries Number of retry attempts (default: 20) + * @param options.retryInterval Ms between retries (default: 250) + * @param options.verbose Enable verbose logging (default: false) + * @returns LockResult + * @throws Error if lock cannot be acquired after all retries + */ +export async function acquireStartupLock(options?: { + retries?: number; + retryInterval?: number; + verbose?: boolean; +}): Promise { + const retries = options?.retries ?? 20; + const retryInterval = options?.retryInterval ?? 250; + const log: LogFn = options?.verbose ? (msg) => console.error(`[startup-lock] ${msg}`) : noopLog; + + log(`Attempting to acquire startup lock (max ${retries} retries, ${retryInterval}ms interval)`); + + for (let attempt = 0; attempt <= retries; attempt++) { + const result = tryAcquireLockOnce(log); + if (result.acquired) { + return result; + } + + if (attempt < retries) { + log(`Retry ${attempt + 1}/${retries} in ${retryInterval}ms...`); + await new Promise((r) => setTimeout(r, retryInterval)); + } + } + + log(`Failed to acquire lock after ${retries} attempts`); + throw new Error( + `Failed to acquire startup lock after ${retries} attempts. ` + + `Another CCS process may be starting CLIProxy.` + ); +} + +/** + * Execute a function while holding the startup lock. + * Lock is automatically released after function completes or throws. + * + * @param fn Function to execute + * @param options Lock acquisition options (retries, retryInterval, verbose) + * @returns Result of fn + */ +export async function withStartupLock( + fn: () => Promise, + options?: { retries?: number; retryInterval?: number; verbose?: boolean } +): Promise { + const lock = await acquireStartupLock(options); + try { + return await fn(); + } finally { + lock.release(); + } +} diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 98c72da1..24a36b0b 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -184,7 +184,8 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { enabled: partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled, host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host, - port: partial.cliproxy_server?.remote?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.port, + // Port is optional - undefined means use protocol default (443/80) + port: partial.cliproxy_server?.remote?.port, protocol: partial.cliproxy_server?.remote?.protocol ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.protocol, diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index fcffd26d..271ee7f5 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -195,8 +195,14 @@ export interface ProxyRemoteConfig { enabled: boolean; /** Remote proxy hostname or IP (empty = not configured) */ host: string; - /** Remote proxy port (default: 8317) */ - port: number; + /** + * Remote proxy port. + * Optional - defaults based on protocol: + * - HTTPS: 443 + * - HTTP: 80 + * When empty/undefined, uses protocol default. + */ + port?: number; /** Protocol for remote connection */ protocol: 'http' | 'https'; /** Auth token for remote proxy (optional, sent as header) */ @@ -345,12 +351,13 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { /** * Default CLIProxy server configuration. * Local mode by default - remote must be explicitly enabled. + * Port is optional for remote - defaults based on protocol. */ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = { remote: { enabled: false, host: '', - port: 8317, + // port is intentionally omitted - will use protocol default (443 for HTTPS, 80 for HTTP) protocol: 'http', auth_token: '', }, diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index b886cf8f..edff2f23 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -1,5 +1,7 @@ /** * Auto-recovery for missing or corrupted configuration + * Lazy initialization: Creates ~/.ccs/ structure on first CLI run + * Mirrors postinstall.js behavior for package managers that skip lifecycle scripts (e.g., bun) */ import * as fs from 'fs'; @@ -7,6 +9,13 @@ import * as path from 'path'; import * as os from 'os'; import { info } from '../utils/ui'; +/** + * Get CCS home directory (respects CCS_HOME env for test isolation) + */ +function getCcsHome(): string { + return process.env.CCS_HOME || os.homedir(); +} + /** * Recovery Manager Class */ @@ -14,12 +23,16 @@ class RecoveryManager { private readonly homedir: string; private readonly ccsDir: string; private readonly claudeDir: string; + private readonly sharedDir: string; + private readonly completionsDir: string; private recovered: string[]; constructor() { - this.homedir = os.homedir(); + this.homedir = getCcsHome(); this.ccsDir = path.join(this.homedir, '.ccs'); this.claudeDir = path.join(this.homedir, '.claude'); + this.sharedDir = path.join(this.ccsDir, 'shared'); + this.completionsDir = path.join(this.ccsDir, 'completions'); this.recovered = []; } @@ -55,12 +68,14 @@ class RecoveryManager { } } - // Create default config + // Create default config (matches postinstall.js) + // NOTE: No 'default' entry - when no profile specified, CCS passes through + // to Claude's native auth without --settings flag const defaultConfig = { profiles: { glm: '~/.ccs/glm.settings.json', + glmt: '~/.ccs/glmt.settings.json', kimi: '~/.ccs/kimi.settings.json', - default: '~/.claude/settings.json', }, }; @@ -98,15 +113,185 @@ class RecoveryManager { } /** - * Run all recovery operations + * Ensure ~/.ccs/shared/ directory structure exists + */ + ensureSharedDirectories(): boolean { + let created = false; + + // Create shared directory + if (!fs.existsSync(this.sharedDir)) { + fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o755 }); + this.recovered.push('Created ~/.ccs/shared/'); + created = true; + } + + // Create subdirectories + const subdirs = ['commands', 'skills', 'agents', 'plugins']; + for (const subdir of subdirs) { + const subdirPath = path.join(this.sharedDir, subdir); + if (!fs.existsSync(subdirPath)) { + fs.mkdirSync(subdirPath, { recursive: true, mode: 0o755 }); + created = true; + } + } + + return created; + } + + /** + * Ensure GLM settings file exists + */ + ensureGlmSettings(): boolean { + const settingsPath = path.join(this.ccsDir, 'glm.settings.json'); + if (fs.existsSync(settingsPath)) return false; + + const settings = { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'YOUR_GLM_API_KEY_HERE', + ANTHROPIC_MODEL: 'glm-4.6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-4.6', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-4.6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.6', + }, + }; + + const tmpPath = `${settingsPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + fs.renameSync(tmpPath, settingsPath); + this.recovered.push('Created ~/.ccs/glm.settings.json'); + return true; + } + + /** + * Ensure GLMT settings file exists + */ + ensureGlmtSettings(): boolean { + const settingsPath = path.join(this.ccsDir, 'glmt.settings.json'); + if (fs.existsSync(settingsPath)) return false; + + const settings = { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/coding/paas/v4/chat/completions', + ANTHROPIC_AUTH_TOKEN: 'YOUR_GLM_API_KEY_HERE', + ANTHROPIC_MODEL: 'glm-4.6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-4.6', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-4.6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.6', + ANTHROPIC_TEMPERATURE: '0.2', + ANTHROPIC_MAX_TOKENS: '65536', + MAX_THINKING_TOKENS: '32768', + ENABLE_STREAMING: 'true', + ANTHROPIC_SAFE_MODE: 'false', + API_TIMEOUT_MS: '3000000', + }, + alwaysThinkingEnabled: true, + }; + + const tmpPath = `${settingsPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + fs.renameSync(tmpPath, settingsPath); + this.recovered.push('Created ~/.ccs/glmt.settings.json'); + return true; + } + + /** + * Ensure Kimi settings file exists + */ + ensureKimiSettings(): boolean { + const settingsPath = path.join(this.ccsDir, 'kimi.settings.json'); + if (fs.existsSync(settingsPath)) return false; + + const settings = { + env: { + ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/', + ANTHROPIC_AUTH_TOKEN: 'YOUR_KIMI_API_KEY_HERE', + ANTHROPIC_MODEL: 'kimi-k2-thinking-turbo', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'kimi-k2-thinking-turbo', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'kimi-k2-thinking-turbo', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'kimi-k2-thinking-turbo', + }, + alwaysThinkingEnabled: true, + }; + + const tmpPath = `${settingsPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + fs.renameSync(tmpPath, settingsPath); + this.recovered.push('Created ~/.ccs/kimi.settings.json'); + return true; + } + + /** + * Install shell completion files + */ + ensureShellCompletions(): boolean { + // Find the scripts/completion directory relative to this module + // In dist: dist/management/recovery-manager.js → scripts/completion + // In src: src/management/recovery-manager.ts → scripts/completion + const possiblePaths = [ + path.join(__dirname, '..', '..', 'scripts', 'completion'), // from dist/management/ + path.join(__dirname, '..', '..', '..', 'scripts', 'completion'), // alternative + ]; + + let scriptsCompletionDir: string | null = null; + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + scriptsCompletionDir = p; + break; + } + } + + if (!scriptsCompletionDir) { + // Completion scripts not found - skip silently (may be installed differently) + return false; + } + + // Create completions directory + if (!fs.existsSync(this.completionsDir)) { + fs.mkdirSync(this.completionsDir, { recursive: true, mode: 0o755 }); + } + + const files = ['ccs.bash', 'ccs.zsh', 'ccs.fish', 'ccs.ps1']; + let installed = false; + + for (const file of files) { + const src = path.join(scriptsCompletionDir, file); + const dest = path.join(this.completionsDir, file); + + if (fs.existsSync(src) && !fs.existsSync(dest)) { + fs.copyFileSync(src, dest); + installed = true; + } + } + + if (installed) { + this.recovered.push('Installed shell completions to ~/.ccs/completions/'); + } + + return installed; + } + + /** + * Run all recovery operations (lazy initialization) + * Mirrors postinstall.js behavior */ recoverAll(): boolean { this.recovered = []; + // Core directories this.ensureCcsDirectory(); - this.ensureConfigJson(); + this.ensureSharedDirectories(); this.ensureClaudeSettings(); + // Config files + this.ensureConfigJson(); + this.ensureGlmSettings(); + this.ensureGlmtSettings(); + this.ensureKimiSettings(); + + // Shell completions + this.ensureShellCompletions(); + return this.recovered.length > 0; } @@ -127,8 +312,25 @@ class RecoveryManager { console.log(info('Auto-recovery completed:')); this.recovered.forEach((msg) => console.log(` - ${msg}`)); + // Show API key hints if created profile settings + const createdGlm = this.recovered.some((msg) => msg.includes('glm.settings.json')); + const createdKimi = this.recovered.some((msg) => msg.includes('kimi.settings.json')); + + if (createdGlm || createdKimi) { + console.log(''); + console.log(info('Configure API keys:')); + if (createdGlm) { + console.log(' GLM: Edit ~/.ccs/glm.settings.json'); + console.log(' Get key from: https://api.z.ai'); + } + if (createdKimi) { + console.log(' Kimi: Edit ~/.ccs/kimi.settings.json'); + console.log(' Get key from: https://www.kimi.com/coding'); + } + } + // Show login hint if created Claude settings - if (this.recovered.some((msg) => msg.includes('settings.json'))) { + if (this.recovered.some((msg) => msg.includes('~/.claude/settings.json'))) { console.log(''); console.log(info('Next step: Login to Claude CLI')); console.log(' Run: claude /login'); diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index c04dc585..d1f78934 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -17,6 +17,7 @@ const REQUEST_TIMEOUT = 5000; // 5 seconds interface UpdateCache { last_check: number; latest_version: string | null; + dev_version: string | null; // Version for @dev tag dismissed_version: string | null; } @@ -193,13 +194,18 @@ function fetchVersionFromNpmTag(tag: 'latest' | 'dev'): Promise { export function readCache(): UpdateCache { try { if (!fs.existsSync(UPDATE_CHECK_FILE)) { - return { last_check: 0, latest_version: null, dismissed_version: null }; + return { last_check: 0, latest_version: null, dev_version: null, dismissed_version: null }; } const data = fs.readFileSync(UPDATE_CHECK_FILE, 'utf8'); - return JSON.parse(data) as UpdateCache; + const parsed = JSON.parse(data) as UpdateCache; + // Ensure dev_version exists (backward compatibility) + if (parsed.dev_version === undefined) { + parsed.dev_version = null; + } + return parsed; } catch { - return { last_check: 0, latest_version: null, dismissed_version: null }; + return { last_check: 0, latest_version: null, dev_version: null, dismissed_version: null }; } } @@ -234,18 +240,31 @@ export async function checkForUpdates( const cache = readCache(); const now = Date.now(); - // Check if we should check for updates - if (!force && now - cache.last_check < CHECK_INTERVAL) { - // Use cached result if available - if ( - cache.latest_version && - compareVersionsWithPrerelease(cache.latest_version, currentVersion) > 0 - ) { + // Get cached version for the target tag + const cachedVersion = targetTag === 'dev' ? cache.dev_version : cache.latest_version; + + // Check if we should check for updates (only use cache for same-tag checks) + if (!force && now - cache.last_check < CHECK_INTERVAL && cachedVersion) { + // Use cached result if available for this tag + if (compareVersionsWithPrerelease(cachedVersion, currentVersion) > 0) { // Don't show if user dismissed this version - if (cache.dismissed_version === cache.latest_version) { + if (cache.dismissed_version === cachedVersion) { return { status: 'no_update', reason: 'dismissed' }; } - return { status: 'update_available', latest: cache.latest_version, current: currentVersion }; + return { status: 'update_available', latest: cachedVersion, current: currentVersion }; + } + // Check for channel switch: if current is on different channel than target + // Only trigger if target version matches the target channel type + const currentIsDevChannel = currentVersion.includes('-dev.'); + const targetIsDevChannel = targetTag === 'dev'; + const cachedIsDevVersion = cachedVersion.includes('-dev.'); + if ( + currentIsDevChannel !== targetIsDevChannel && + cachedIsDevVersion === targetIsDevChannel && + cachedVersion !== currentVersion + ) { + // Channel switch - treat as update available + return { status: 'update_available', latest: cachedVersion, current: currentVersion }; } return { status: 'no_update', reason: 'cached' }; } @@ -271,10 +290,14 @@ export async function checkForUpdates( if (!latestVersion) fetchError = 'github_api_error'; } - // Update cache + // Update cache with tag-specific version cache.last_check = now; if (latestVersion) { - cache.latest_version = latestVersion; + if (targetTag === 'dev') { + cache.dev_version = latestVersion; + } else { + cache.latest_version = latestVersion; + } } writeCache(cache); @@ -288,12 +311,36 @@ export async function checkForUpdates( } // Check if update available - if (latestVersion && compareVersionsWithPrerelease(latestVersion, currentVersion) > 0) { - // Don't show if user dismissed this version - if (cache.dismissed_version === latestVersion) { - return { status: 'no_update', reason: 'dismissed' }; + if (latestVersion) { + const versionComparison = compareVersionsWithPrerelease(latestVersion, currentVersion); + + // Update available if newer version + if (versionComparison > 0) { + // Don't show if user dismissed this version + if (cache.dismissed_version === latestVersion) { + return { status: 'no_update', reason: 'dismissed' }; + } + return { status: 'update_available', latest: latestVersion, current: currentVersion }; + } + + // Check for channel switch (stable <-> dev) + // Only trigger if: switching channels AND target version is on the target channel + const currentIsDevChannel = currentVersion.includes('-dev.'); + const targetIsDevChannel = targetTag === 'dev'; + const latestIsDevVersion = latestVersion.includes('-dev.'); + + // Channel switch is valid only when: + // 1. User is switching channels (current != target) + // 2. Target version matches the target channel type + // 3. Version is different from current + if ( + currentIsDevChannel !== targetIsDevChannel && + latestIsDevVersion === targetIsDevChannel && + latestVersion !== currentVersion + ) { + // Channel switch - treat as update even if "downgrade" + return { status: 'update_available', latest: latestVersion, current: currentVersion }; } - return { status: 'update_available', latest: latestVersion, current: currentVersion }; } return { status: 'no_update', reason: 'latest' }; diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 5bc1bcea..6a599bfc 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -66,6 +66,7 @@ import { getWebSearchConfig } from '../config/unified-config-loader'; import type { WebSearchConfig } from '../config/unified-config-types'; import { isUnifiedConfig } from '../config/unified-config-types'; import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys'; +import { isReservedName, RESERVED_PROFILE_NAMES } from '../config/reserved-names'; import { getWebSearchReadiness, getGeminiCliStatus, @@ -275,6 +276,15 @@ apiRoutes.post('/profiles', (req: Request, res: Response): void => { return; } + // Validate reserved names + if (isReservedName(name)) { + res.status(400).json({ + error: `Profile name '${name}' is reserved`, + reserved: RESERVED_PROFILE_NAMES, + }); + return; + } + const config = readConfigSafe(); if (config.profiles[name]) { @@ -378,6 +388,15 @@ apiRoutes.post('/cliproxy', (req: Request, res: Response): void => { return; } + // Reject reserved names as variant names (prevents collision with built-in providers) + if (isReservedName(name)) { + res.status(400).json({ + error: `Cannot use reserved name '${name}' as variant name`, + reserved: RESERVED_PROFILE_NAMES, + }); + return; + } + const config = readConfigSafe(); config.cliproxy = config.cliproxy || {}; @@ -472,10 +491,13 @@ apiRoutes.delete('/cliproxy/:name', (req: Request, res: Response): void => { return; } - // Delete settings file - const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); - if (fs.existsSync(settingsPath)) { - fs.unlinkSync(settingsPath); + // Never delete settings files for reserved provider names (safety guard) + if (!isReservedName(name)) { + // Only delete settings file for non-reserved variant names + const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + if (fs.existsSync(settingsPath)) { + fs.unlinkSync(settingsPath); + } } delete config.cliproxy[name]; @@ -781,6 +803,7 @@ apiRoutes.get('/settings/:profile/raw', (req: Request, res: Response): void => { /** * PUT /api/settings/:profile - Update settings with conflict detection and backup + * Creates the settings file if it doesn't exist (upsert behavior) */ apiRoutes.put('/settings/:profile', (req: Request, res: Response): void => { const { profile } = req.params; @@ -788,29 +811,36 @@ apiRoutes.put('/settings/:profile', (req: Request, res: Response): void => { const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: 'Settings not found' }); - return; + const fileExists = fs.existsSync(settingsPath); + + // Only check conflict if file exists and expectedMtime was provided + if (fileExists && expectedMtime) { + const stat = fs.statSync(settingsPath); + if (stat.mtime.getTime() !== expectedMtime) { + res.status(409).json({ + error: 'File modified externally', + currentMtime: stat.mtime.getTime(), + }); + return; + } } - // Conflict detection - const stat = fs.statSync(settingsPath); - if (expectedMtime && stat.mtime.getTime() !== expectedMtime) { - res.status(409).json({ - error: 'File modified externally', - currentMtime: stat.mtime.getTime(), - }); - return; + // Create backup only if file exists + let backupPath: string | undefined; + if (fileExists) { + const backupDir = path.join(ccsDir, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); + fs.copyFileSync(settingsPath, backupPath); } - // Create backup - const backupDir = path.join(ccsDir, 'backups'); - if (!fs.existsSync(backupDir)) { - fs.mkdirSync(backupDir, { recursive: true }); + // Ensure directory exists for new files + if (!fileExists) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); - fs.copyFileSync(settingsPath, backupPath); // Write new settings atomically const tempPath = settingsPath + '.tmp'; @@ -822,6 +852,7 @@ apiRoutes.put('/settings/:profile', (req: Request, res: Response): void => { profile, mtime: newStat.mtime.getTime(), backupPath, + created: !fileExists, }); }); @@ -1592,6 +1623,14 @@ apiRoutes.post('/cliproxy/openai-compat', (req: Request, res: Response): void => res.status(400).json({ error: 'name is required' }); return; } + // Validate reserved names + if (isReservedName(name)) { + res.status(400).json({ + error: `Provider name '${name}' is reserved`, + reserved: RESERVED_PROFILE_NAMES, + }); + return; + } if (!baseUrl || typeof baseUrl !== 'string') { res.status(400).json({ error: 'baseUrl is required' }); return; diff --git a/src/web-server/routes/proxy-routes.ts b/src/web-server/routes/proxy-routes.ts index 2a9b1d9a..51978bf1 100644 --- a/src/web-server/routes/proxy-routes.ts +++ b/src/web-server/routes/proxy-routes.ts @@ -72,14 +72,20 @@ router.post('/test', async (req: Request, res: Response) => { try { const { host, port, protocol, authToken, allowSelfSigned } = req.body; - if (!host || !port) { - res.status(400).json({ error: 'Host and port are required' }); + // Host is required, port is optional (uses protocol defaults) + if (!host) { + res.status(400).json({ error: 'Host is required' }); return; } + // Parse port - treat empty string, 0, null as "use default" + const parsedPort = port && port !== '' ? parseInt(String(port), 10) : undefined; + const effectivePort = + parsedPort && !isNaN(parsedPort) && parsedPort > 0 ? parsedPort : undefined; + const status = await testConnection({ host, - port: typeof port === 'number' ? port : parseInt(port, 10), + port: effectivePort, protocol: protocol || 'http', authToken, allowSelfSigned: allowSelfSigned || false, diff --git a/tests/unit/utils/update-checker-beta-channel.test.js b/tests/unit/utils/update-checker-beta-channel.test.js index f25520a9..4cca9820 100644 --- a/tests/unit/utils/update-checker-beta-channel.test.js +++ b/tests/unit/utils/update-checker-beta-channel.test.js @@ -365,15 +365,17 @@ describe('Beta Channel Implementation (Phase 3)', function () { const cachePath = path.join(os.homedir(), '.ccs', 'cache', 'update-check.json'); const cacheData = JSON.parse(mockFileSystem[cachePath]); - assert.strictEqual(cacheData.latest_version, '5.5.0'); + // Dev versions are now stored in dev_version field (not latest_version) + assert.strictEqual(cacheData.dev_version, '5.5.0'); assert(cacheData.last_check > 0, 'should update timestamp'); }); it('should use cached result when within interval', async function () { - // Set up cache with recent check + // Set up cache with recent check - use dev_version for dev channel const cacheData = { last_check: Date.now() - 1000, // 1 second ago - latest_version: '5.5.0', + latest_version: null, + dev_version: '5.5.0', dismissed_version: null }; mockFileSystem[path.join(os.homedir(), '.ccs', 'cache', 'update-check.json')] = JSON.stringify(cacheData); @@ -475,10 +477,11 @@ describe('Beta Channel Implementation (Phase 3)', function () { }); it('should handle dismissed versions correctly', async function () { - // Set up cache with dismissed version + // Set up cache with dismissed version - use dev_version for dev channel const cacheData = { last_check: Date.now() - 1000, - latest_version: '5.5.0', + latest_version: null, + dev_version: '5.5.0', dismissed_version: '5.5.0' }; mockFileSystem[path.join(os.homedir(), '.ccs', 'cache', 'update-check.json')] = JSON.stringify(cacheData); diff --git a/ui/src/components/add-account-dialog.tsx b/ui/src/components/add-account-dialog.tsx index f9a80af7..fa01fa4e 100644 --- a/ui/src/components/add-account-dialog.tsx +++ b/ui/src/components/add-account-dialog.tsx @@ -1,6 +1,7 @@ /** * Add Account Dialog Component * Triggers OAuth flow server-side to add another account to a provider + * Applies default preset when adding first account */ import { useState } from 'react'; @@ -16,15 +17,25 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Loader2, ExternalLink, User } from 'lucide-react'; import { useStartAuth } from '@/hooks/use-cliproxy'; +import { applyDefaultPreset } from '@/lib/preset-utils'; +import { toast } from 'sonner'; interface AddAccountDialogProps { open: boolean; onClose: () => void; provider: string; displayName: string; + /** Whether this is the first account being added (triggers preset application) */ + isFirstAccount?: boolean; } -export function AddAccountDialog({ open, onClose, provider, displayName }: AddAccountDialogProps) { +export function AddAccountDialog({ + open, + onClose, + provider, + displayName, + isFirstAccount = false, +}: AddAccountDialogProps) { const [nickname, setNickname] = useState(''); const startAuthMutation = useStartAuth(); @@ -32,7 +43,16 @@ export function AddAccountDialog({ open, onClose, provider, displayName }: AddAc startAuthMutation.mutate( { provider, nickname: nickname.trim() || undefined }, { - onSuccess: () => { + onSuccess: async () => { + // Apply default preset if this is the first account + if (isFirstAccount) { + const result = await applyDefaultPreset(provider); + if (result.success && result.presetName) { + toast.success(`Applied "${result.presetName}" preset`); + } else if (!result.success) { + toast.warning('Account added, but failed to apply default preset'); + } + } setNickname(''); onClose(); }, diff --git a/ui/src/components/cliproxy/provider-editor.tsx b/ui/src/components/cliproxy/provider-editor.tsx index 0fd9632e..0d633340 100644 --- a/ui/src/components/cliproxy/provider-editor.tsx +++ b/ui/src/components/cliproxy/provider-editor.tsx @@ -57,6 +57,7 @@ import { useDeletePreset, } from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; +import { CLIPROXY_PORT } from '@/lib/preset-utils'; import { GlobalEnvIndicator } from '@/components/global-env-indicator'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -296,7 +297,10 @@ export function ProviderEditor({ sonnet: model.id, haiku: model.id, }; + // Always include BASE_URL and AUTH_TOKEN for CLIProxy providers updateEnvValues({ + ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`, + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: mapping.default, ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet, @@ -318,7 +322,10 @@ export function ProviderEditor({ size="sm" className="text-xs h-7 gap-1 pr-6" onClick={() => { + // Always include BASE_URL and AUTH_TOKEN for CLIProxy providers updateEnvValues({ + ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`, + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: preset.default, ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet, @@ -655,7 +662,10 @@ export function ProviderEditor({ haiku: haikuModel || '', }} onApply={(values, presetName) => { + // Always include BASE_URL and AUTH_TOKEN for CLIProxy providers updateEnvValues({ + ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`, + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: values.default, ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet, diff --git a/ui/src/components/quick-setup-wizard.tsx b/ui/src/components/quick-setup-wizard.tsx index 1fc36d70..930446ab 100644 --- a/ui/src/components/quick-setup-wizard.tsx +++ b/ui/src/components/quick-setup-wizard.tsx @@ -39,8 +39,10 @@ import { import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import { MODEL_CATALOGS } from '@/lib/model-catalogs'; +import { applyDefaultPreset } from '@/lib/preset-utils'; import { cn } from '@/lib/utils'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { toast } from 'sonner'; interface QuickSetupWizardProps { open: boolean; @@ -118,10 +120,23 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { }; const handleStartAuth = () => { + // Check if this is the first account before auth + const isFirstAccount = (providerAuth?.accounts?.length || 0) === 0; + startAuthMutation.mutate( { provider: selectedProvider }, { - onSuccess: (data) => { + onSuccess: async (data) => { + // Apply default preset if this was the first account + if (isFirstAccount) { + const result = await applyDefaultPreset(selectedProvider); + if (result.success && result.presetName) { + toast.success(`Applied "${result.presetName}" preset`); + } else if (!result.success) { + toast.warning('Account added, but failed to apply default preset'); + } + } + // Account created, select it and advance to variant step if (data.account) { setSelectedAccount(data.account as OAuthAccount); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 4e0f6d21..5a4e8881 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -166,7 +166,8 @@ export interface RemoteProxyStatus { export interface ProxyRemoteConfig { enabled: boolean; host: string; - port: number; + /** Port is optional - uses protocol default (443 for HTTPS, 80 for HTTP) */ + port?: number; protocol: 'http' | 'https'; auth_token: string; } @@ -402,7 +403,8 @@ export const api = { /** Test remote proxy connection */ test: (params: { host: string; - port: number; + /** Port is optional - uses protocol default (443 for HTTPS, 80 for HTTP) */ + port?: number; protocol: 'http' | 'https'; authToken?: string; allowSelfSigned?: boolean; diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 7cce9894..06f78c71 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -16,21 +16,45 @@ export const MODEL_CATALOGS: Record = { id: 'gemini-claude-opus-4-5-thinking', name: 'Claude Opus 4.5 Thinking', description: 'Most capable, extended thinking', + presetMapping: { + default: 'gemini-claude-opus-4-5-thinking', + opus: 'gemini-claude-opus-4-5-thinking', + sonnet: 'gemini-claude-sonnet-4-5-thinking', + haiku: 'gemini-claude-sonnet-4-5', + }, }, { id: 'gemini-claude-sonnet-4-5-thinking', name: 'Claude Sonnet 4.5 Thinking', description: 'Balanced with extended thinking', + presetMapping: { + default: 'gemini-claude-sonnet-4-5-thinking', + opus: 'gemini-claude-opus-4-5-thinking', + sonnet: 'gemini-claude-sonnet-4-5-thinking', + haiku: 'gemini-claude-sonnet-4-5', + }, }, { id: 'gemini-claude-sonnet-4-5', name: 'Claude Sonnet 4.5', description: 'Fast and capable', + presetMapping: { + default: 'gemini-claude-sonnet-4-5', + opus: 'gemini-claude-opus-4-5-thinking', + sonnet: 'gemini-claude-sonnet-4-5', + haiku: 'gemini-claude-sonnet-4-5', + }, }, { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', description: 'Google latest model via Antigravity', + presetMapping: { + default: 'gemini-3-pro-preview', + opus: 'gemini-3-pro-preview', + sonnet: 'gemini-3-pro-preview', + haiku: 'gemini-2.5-flash', + }, }, ], }, @@ -44,11 +68,23 @@ export const MODEL_CATALOGS: Record = { name: 'Gemini 3 Pro', tier: 'paid', description: 'Latest model, requires paid Google account', + presetMapping: { + default: 'gemini-3-pro-preview', + opus: 'gemini-3-pro-preview', + sonnet: 'gemini-3-pro-preview', + haiku: 'gemini-2.5-flash', + }, }, { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Stable, works with free Google account', + presetMapping: { + default: 'gemini-2.5-pro', + opus: 'gemini-2.5-pro', + sonnet: 'gemini-2.5-pro', + haiku: 'gemini-2.5-flash', + }, }, ], }, diff --git a/ui/src/lib/preset-utils.ts b/ui/src/lib/preset-utils.ts new file mode 100644 index 00000000..7b74ed6b --- /dev/null +++ b/ui/src/lib/preset-utils.ts @@ -0,0 +1,57 @@ +/** + * Preset Utilities + * Shared functions for applying default presets to provider settings + */ + +import { MODEL_CATALOGS } from './model-catalogs'; + +/** CLIProxy port - should match the backend configuration */ +export const CLIPROXY_PORT = 8317; + +/** + * Apply default preset for a provider to its settings + * Uses the first model's presetMapping or falls back to using defaultModel for all tiers + * + * @param provider - The provider ID (e.g., 'gemini', 'codex', 'agy') + * @returns Object with success status and applied preset name + */ +export async function applyDefaultPreset( + provider: string +): Promise<{ success: boolean; presetName?: string }> { + const catalog = MODEL_CATALOGS[provider]; + if (!catalog) return { success: false }; + + // Get the first (recommended) model's preset mapping + const firstModel = catalog.models[0]; + const mapping = firstModel?.presetMapping || { + default: catalog.defaultModel, + opus: catalog.defaultModel, + sonnet: catalog.defaultModel, + haiku: catalog.defaultModel, + }; + + const settings = { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`, + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: mapping.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku, + }, + }; + + try { + const res = await fetch(`/api/settings/${provider}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings }), + }); + return { + success: res.ok, + presetName: firstModel?.name || catalog.defaultModel, + }; + } catch { + return { success: false }; + } +} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 80d013fb..2a5a526d 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -188,6 +188,7 @@ export function CliproxyPage() { const [addAccountProvider, setAddAccountProvider] = useState<{ provider: string; displayName: string; + isFirstAccount: boolean; } | null>(null); const providers = authData?.authStatus || []; @@ -341,6 +342,7 @@ export function CliproxyPage() { setAddAccountProvider({ provider: selectedVariantData.provider, displayName: parentAuthForVariant.displayName, + isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0, }) } onSetDefault={(accountId) => @@ -367,6 +369,7 @@ export function CliproxyPage() { setAddAccountProvider({ provider: selectedStatus.provider, displayName: selectedStatus.displayName, + isFirstAccount: (selectedStatus.accounts?.length || 0) === 0, }) } onSetDefault={(accountId) => @@ -395,6 +398,7 @@ export function CliproxyPage() { onClose={() => setAddAccountProvider(null)} provider={addAccountProvider?.provider || ''} displayName={addAccountProvider?.displayName || ''} + isFirstAccount={addAccountProvider?.isFirstAccount || false} /> ); diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 1fdb9138..a30e46cb 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -463,8 +463,8 @@ export function SettingsPage() { if (!proxyConfig) return; const { host, port, protocol, auth_token } = proxyConfig.remote; - if (!host || !port) { - setProxyError('Host and port are required'); + if (!host) { + setProxyError('Host is required'); return; } @@ -475,7 +475,7 @@ export function SettingsPage() { const result = await api.cliproxyServer.test({ host, - port, + port: port || undefined, // Empty/0 means use protocol default protocol, authToken: auth_token || undefined, }); @@ -1310,20 +1310,24 @@ function ProxyContent({ fetchCliproxyServerConfig, fetchRawConfig, }: ProxyContentProps) { - // Memoized default config to avoid recreation + // Default configs for fallback const defaultRemote = { enabled: false, host: '', - port: 8317, + port: undefined as number | undefined, protocol: 'http' as const, auth_token: '', }; const defaultFallback = { enabled: true, auto_start: true }; const defaultLocal = { port: 8317, auto_start: true }; + // Helper to get default port based on protocol + const getDefaultPort = (protocol: 'http' | 'https') => (protocol === 'https' ? 443 : 80); + // Sync local state with config (using refs to avoid lint warnings) const hostInput = config?.remote.host ?? ''; - const portInput = (config?.remote.port ?? 8317).toString(); + // Show empty string for port if undefined (will use protocol default) + const portInput = config?.remote.port !== undefined ? config.remote.port.toString() : ''; const authTokenInput = config?.remote.auth_token ?? ''; const localPortInput = (config?.local.port ?? 8317).toString(); @@ -1365,9 +1369,13 @@ function ProxyContent({ }; const savePort = () => { - const port = parseInt(editedPort ?? displayPort, 10); - if (!isNaN(port) && port !== config?.remote.port) { - saveCliproxyServerConfig({ remote: { ...remoteConfig, port } }); + const portStr = editedPort ?? displayPort; + // Empty string means use protocol default (undefined) + const port = portStr === '' ? undefined : parseInt(portStr, 10); + const effectivePort = port && !isNaN(port) && port > 0 ? port : undefined; + + if (effectivePort !== config?.remote.port) { + saveCliproxyServerConfig({ remote: { ...remoteConfig, port: effectivePort } }); } setEditedPort(null); }; @@ -1496,13 +1504,19 @@ function ProxyContent({ {/* Port and Protocol */}
- + setEditedPort(e.target.value)} + onChange={(e) => setEditedPort(e.target.value.replace(/\D/g, ''))} onBlur={savePort} - placeholder="8317" + placeholder={`Leave empty for ${getDefaultPort(config?.remote.protocol || 'http')}`} className="font-mono" disabled={saving} /> @@ -1545,7 +1559,7 @@ function ProxyContent({