mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 02:14:32 +00:00
28 files changed
+1320
-248
No files matched your search
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user