Merge pull request #154 from kaitranntt/dev

feat: CCS v6.7.0 release
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2025-12-19 11:28:30 -05:00
commit e46c1d0c1e
28 files changed
+1320 -248

No files matched your search

+4 -2
View File
@@ -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: |
+4 -2
View File
@@ -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: |
+2
View File
@@ -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,
},
],
[
+50
View File
@@ -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
+1 -1
View File
@@ -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",
+6
View File
@@ -499,6 +499,12 @@ async function main(): Promise<void> {
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,
+71 -60
View File
@@ -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()
+6 -2
View File
@@ -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<string, string> {
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.)
+8
View File
@@ -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';
+220
View File
@@ -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<ProxyStatus> {
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<boolean> {
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;
}
}
+52 -4
View File
@@ -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 {
+127 -99
View File
@@ -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 };
});
}
/**
+228
View File
@@ -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<LockResult> {
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<T>(
fn: () => Promise<T>,
options?: { retries?: number; retryInterval?: number; verbose?: boolean }
): Promise<T> {
const lock = await acquireStartupLock(options);
try {
return await fn();
} finally {
lock.release();
}
}
+2 -1
View File
@@ -184,7 +184,8 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): 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,
+10 -3
View File
@@ -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: '',
},
+208 -6
View File
@@ -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');
+66 -19
View File
@@ -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<string | null> {
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' };
+61 -22
View File
@@ -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;
+9 -3
View File
@@ -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,
@@ -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);
+22 -2
View File
@@ -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();
},
@@ -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,
+16 -1
View File
@@ -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);
+4 -2
View File
@@ -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;
+36
View File
@@ -16,21 +16,45 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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',
},
},
],
},
+57
View File
@@ -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 };
}
}
+4
View File
@@ -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}
/>
</div>
);
+28 -14
View File
@@ -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 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-sm text-muted-foreground">Port</label>
<label className="text-sm text-muted-foreground">
Port{' '}
<span className="text-xs opacity-70">
(default: {getDefaultPort(config?.remote.protocol || 'http')})
</span>
</label>
<Input
type="number"
type="text"
inputMode="numeric"
value={displayPort}
onChange={(e) => 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({
<div className="space-y-3 pt-2">
<Button
onClick={handleTestConnection}
disabled={testing || !displayHost || !displayPort}
disabled={testing || !displayHost}
variant="outline"
className="w-full"
>