diff --git a/CHANGELOG.md b/CHANGELOG.md index 8095569a..0891b2f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,105 @@ +## [7.9.0](https://github.com/kaitranntt/ccs/compare/v7.8.0...v7.9.0) (2025-12-27) + +### Features + +* **dashboard:** add Import from Kiro IDE button ([5f59d71](https://github.com/kaitranntt/ccs/commit/5f59d710a687aa23b22f470114fc763bf1412fbd)) +* **ui:** add auth profile management to Dashboard ([fa8830e](https://github.com/kaitranntt/ccs/commit/fa8830e1ce97b6f0bb5f89c93325414a15412369)) + +### Bug Fixes + +* **cliproxy:** ensure version sync after binary update ([29f1930](https://github.com/kaitranntt/ccs/commit/29f19308e627f46a5144521f8f2c75e9ff746f6a)) +* **config:** use safe inline logic in getSettingsPath() legacy fallback ([a4a473a](https://github.com/kaitranntt/ccs/commit/a4a473ac93a3adf9b51a0e9371bbd48fa7363157)) +* **dashboard:** support unified config.yaml in web server routes ([0c69740](https://github.com/kaitranntt/ccs/commit/0c697406947ef37f194db26e31d5822cc7e12463)), closes [#206](https://github.com/kaitranntt/ccs/issues/206) +* improve type safety and error handling in config-manager ([8a3c5a4](https://github.com/kaitranntt/ccs/commit/8a3c5a446beb197148a132900a88f09043cbab55)), closes [#215](https://github.com/kaitranntt/ccs/issues/215) +* **kiro:** add fallback import from Kiro IDE when OAuth callback redirects ([add4aa5](https://github.com/kaitranntt/ccs/commit/add4aa55c752be54164b42b0c108f54c24944570)), closes [#212](https://github.com/kaitranntt/ccs/issues/212) +* run RecoveryManager before early-exit commands and improve config handling ([0be3977](https://github.com/kaitranntt/ccs/commit/0be397784525275d0bbcc94877942f8963ca3d33)), closes [#214](https://github.com/kaitranntt/ccs/issues/214) +* **test:** remove redundant build from beforeAll hook ([67a48a8](https://github.com/kaitranntt/ccs/commit/67a48a8305125959ecab468f117cc9de0badddd5)) +* **tests:** update test files for renamed getCliproxyConfigPath function ([ec2ee0a](https://github.com/kaitranntt/ccs/commit/ec2ee0a36d8498fb596d2e3ef793ce89a9f254f8)) +* wrap RecoveryManager in try-catch to prevent blocking CLI commands ([2fff770](https://github.com/kaitranntt/ccs/commit/2fff770b6bc67616e855cc8dc940751bd1267a67)), closes [#215](https://github.com/kaitranntt/ccs/issues/215) + +### Documentation + +* update design principles and add feature interface requirements ([c200334](https://github.com/kaitranntt/ccs/commit/c20033473b150689ff4c581d5b4b2a6e12adb758)) + +## [7.8.0](https://github.com/kaitranntt/ccs/compare/v7.7.1...v7.8.0) (2025-12-26) + +### Features + +* **api:** add auth tokens REST endpoints ([ffd4996](https://github.com/kaitranntt/ccs/commit/ffd499698e03f1849a0deef3d289c08079a0951e)) +* **cli:** add tokens command for auth token management ([0c6491c](https://github.com/kaitranntt/ccs/commit/0c6491c9d27a3bfb1ecc8c1627d1e1a70f59220a)) +* **cliproxy:** add customizable auth token manager ([c4f0916](https://github.com/kaitranntt/ccs/commit/c4f09168ff35e52c8613a3181a86e4e4e5392dfc)) +* **cliproxy:** add variant port isolation for concurrent proxy instances ([0bcaf4b](https://github.com/kaitranntt/ccs/commit/0bcaf4bc681e26bd13485678c88b55f4ac471eed)) +* **ui:** add auth tokens settings tab ([71335a6](https://github.com/kaitranntt/ccs/commit/71335a61935971c7621fefcef973cc2b42e313fd)) +* **ui:** add Settings link to control panel key hint ([7a6341f](https://github.com/kaitranntt/ccs/commit/7a6341f0d9a8dffdcbb318cf34f3dbbfbea70cb5)) + +### Bug Fixes + +* **cliproxy:** use auth inheritance in stats-fetcher and config-generator ([133aeba](https://github.com/kaitranntt/ccs/commit/133aebaabc2295b75c13a14a448c2cc60d471363)) +* **dashboard:** read accounts from unified config ([8d7845d](https://github.com/kaitranntt/ccs/commit/8d7845d67fb156671713888726d631415d0f4f9c)), closes [#203](https://github.com/kaitranntt/ccs/issues/203) +* **dashboard:** support unified config across health checks and settings ([9722e19](https://github.com/kaitranntt/ccs/commit/9722e1905dd25b9dd4d602e860ba36586db043b9)), closes [#203](https://github.com/kaitranntt/ccs/issues/203) +* **dashboard:** support unified config in overview and file watcher ([25f0ddb](https://github.com/kaitranntt/ccs/commit/25f0ddb9ddb19f9eb75c880b7c878d840cb2a494)), closes [#203](https://github.com/kaitranntt/ccs/issues/203) +* **doctor:** comprehensive health check fixes ([ac74550](https://github.com/kaitranntt/ccs/commit/ac745503e2a1644b2cb3542b917dbce5e6109200)) +* **doctor:** prefer config.yaml and make settings files optional ([4fca7d1](https://github.com/kaitranntt/ccs/commit/4fca7d16edc6985d14422a21d45dccd619ef9aba)) +* **ui:** initialize colors early for consistent status output ([e38af6a](https://github.com/kaitranntt/ccs/commit/e38af6ad6e2f65a73b4d16f6b4f0cead2eb7374d)), closes [#201](https://github.com/kaitranntt/ccs/issues/201) +* **ui:** simplify config header and add explicit save button ([7e031b5](https://github.com/kaitranntt/ccs/commit/7e031b5097b49f0cfc07334d31b83af41fac9669)) +* **ui:** use effective management secret in control panel embed ([a762563](https://github.com/kaitranntt/ccs/commit/a762563f1b1fa8d984b7c9abf6b3b3c7f8ab6f97)) + +### Tests + +* **cliproxy:** add comprehensive auth token test suite ([ed6776a](https://github.com/kaitranntt/ccs/commit/ed6776aadcf06c0c8572babe1ddc1de4e0902a17)) +* **cliproxy:** add integration tests for variant port isolation ([8f120b5](https://github.com/kaitranntt/ccs/commit/8f120b515f0b71a2730c7affb50c9b148c00e502)), closes [#184](https://github.com/kaitranntt/ccs/issues/184) + +## [7.7.1](https://github.com/kaitranntt/ccs/compare/v7.7.0...v7.7.1) (2025-12-26) + +### Bug Fixes + +* **health:** correct CLIProxy port detection on macOS/Linux ([d1a0ebe](https://github.com/kaitranntt/ccs/commit/d1a0ebee61b8987df85c328d359967e46d1e5226)) +* **health:** use prefix matching for Linux process name truncation ([91e7b9f](https://github.com/kaitranntt/ccs/commit/91e7b9f93787e5b2d45bffdaed75e75c151281e4)) + +## [7.7.0](https://github.com/kaitranntt/ccs/compare/v7.6.0...v7.7.0) (2025-12-25) + +### Features + +* **api:** add Minimax, DeepSeek, Qwen provider presets ([e7066b9](https://github.com/kaitranntt/ccs/commit/e7066b99972129114fb223c6cde40f3127599ae6)), closes [#123](https://github.com/kaitranntt/ccs/issues/123) +* **kiro:** add UI toggle and auth hint for --no-incognito option ([083e674](https://github.com/kaitranntt/ccs/commit/083e67426c382ce534bed4830bedbede94cfdca7)) +* **kiro:** improve auth UX with normal browser default and URL display ([df0c947](https://github.com/kaitranntt/ccs/commit/df0c94781e5f198f867723e1b5bccf17d6c4b250)) + +### Bug Fixes + +* **cliproxy:** preserve user API keys during config regeneration ([2b4d21e](https://github.com/kaitranntt/ccs/commit/2b4d21e8ae615c840d76007d733017d375e6036f)), closes [#200](https://github.com/kaitranntt/ccs/issues/200) +* **core:** address all code review issues from PR [#199](https://github.com/kaitranntt/ccs/issues/199) ([f2a4200](https://github.com/kaitranntt/ccs/commit/f2a4200625e13754c7f79738dba0562e8ff27895)) +* **kiro:** add --no-incognito option for normal browser auth ([13e4bac](https://github.com/kaitranntt/ccs/commit/13e4baca228313462b3e0e83d0b97594654a989b)) +* **profiles:** prevent GLM auth regression from first-time install detection ([cc2d62d](https://github.com/kaitranntt/ccs/commit/cc2d62db38977fd5a0597388c2882e3600e5e179)), closes [#195](https://github.com/kaitranntt/ccs/issues/195) +* **qwen:** inherit stdin for Device Code flows to enable interactive prompts ([c811fdf](https://github.com/kaitranntt/ccs/commit/c811fdfc7914cc3bde3811ea04281055ebb3e273)), closes [#188](https://github.com/kaitranntt/ccs/issues/188) +* **ui:** add gemini-3-flash-preview to model dropdowns ([50653d1](https://github.com/kaitranntt/ccs/commit/50653d1054f89f0eaff24a6d8f471266269383b6)), closes [#194](https://github.com/kaitranntt/ccs/issues/194) +* **ui:** respect initialMode in profile create dialog ([db3662b](https://github.com/kaitranntt/ccs/commit/db3662b47986269ba9c12385021f4aa4bd1633f6)) + +### Code Refactoring + +* **paths:** use expandPath() consistently for cross-platform path handling ([adb6222](https://github.com/kaitranntt/ccs/commit/adb6222bc671c3c4ade1bb019705a985de1947fa)) + +### Tests + +* **auth:** add comprehensive tests for GLM auth persistence fix ([92a79aa](https://github.com/kaitranntt/ccs/commit/92a79aa78ba14aaf2b22f10eaab23f6e04220b17)) + +## [7.6.0](https://github.com/kaitranntt/ccs/compare/v7.5.1...v7.6.0) (2025-12-24) + +### Features + +* **cli:** add config command hints to help and error messages ([e981c39](https://github.com/kaitranntt/ccs/commit/e981c391a26d51de749099ca844915ffc06976e2)) +* **setup:** add first-time setup wizard for config initialization ([cec616d](https://github.com/kaitranntt/ccs/commit/cec616d530d9cf61a3a45032465b01e9a4037558)), closes [#142](https://github.com/kaitranntt/ccs/issues/142) + +### Bug Fixes + +* **cliproxy:** respect enabled:false and use protocol-based port defaults ([a99b6eb](https://github.com/kaitranntt/ccs/commit/a99b6eb93f06c6788bbf13a196bbca908fa06f4c)) +* **config:** improve edge case handling for config initialization ([ca78993](https://github.com/kaitranntt/ccs/commit/ca78993e7612143b3193e3cec3f8976be909e2d6)) +* **ghcp:** display device code during OAuth device code flow ([46f1699](https://github.com/kaitranntt/ccs/commit/46f1699b1c6f716d06c1eaa3dc6aac94dd5761ec)), closes [#189](https://github.com/kaitranntt/ccs/issues/189) + +### Code Refactoring + +* **config:** migrate to config.yaml as primary format ([b34469d](https://github.com/kaitranntt/ccs/commit/b34469d75fd2c2b7fd4f4cc4c0cc28885001649b)), closes [#142](https://github.com/kaitranntt/ccs/issues/142) +* **ghcp:** remove unused device code session management ([5de6ccc](https://github.com/kaitranntt/ccs/commit/5de6cccee08aa06d6533181a1db189a595c5e123)) + ## [7.5.1](https://github.com/kaitranntt/ccs/compare/v7.5.0...v7.5.1) (2025-12-23) ### Bug Fixes diff --git a/CLAUDE.md b/CLAUDE.md index e08ed1df..e85547cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,10 +8,22 @@ CLI wrapper for instant switching between multiple Claude accounts and alternati ## Design Principles (ENFORCE STRICTLY) +### Technical Excellence - **YAGNI**: No features "just in case" - **KISS**: Simple bash/PowerShell/Node.js only -- **DRY**: One source of truth (config.json) -- **CLI-First**: All features must have CLI interface +- **DRY**: One source of truth (config.yaml) + +### User Experience (EQUALLY IMPORTANT) +- **CLI-Complete**: All features MUST have CLI interface +- **Dashboard-Parity**: Configuration features MUST also have Dashboard interface +- **Execution is CLI**: Running profiles happens via terminal, not dashboard buttons +- **UX > Brevity**: Error messages and help text prioritize user success over terseness +- **Progressive Disclosure**: Simple by default, power features accessible but not overwhelming + +### When Principles Conflict +- **UX > YAGNI** for user-facing features (if users need it, it's not "just in case") +- **KISS applies to BOTH** code AND user experience (simple journey, not just simple code) +- **DRY applies to BOTH** code AND interface patterns (consistent behavior across CLI/Dashboard) ## Common Mistakes (AVOID) @@ -89,6 +101,20 @@ bun run validate # Step 3: Final check (must pass) 4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically 5. **CLI documentation** - ALL changes MUST update `--help` in src/ccs.ts, lib/ccs, lib/ccs.ps1 6. **Idempotent** - All install operations safe to run multiple times +7. **Dashboard parity** - Configuration features MUST work in both CLI and Dashboard + +## Feature Interface Requirements + +| Feature Type | CLI | Dashboard | Example | +|--------------|-----|-----------|---------| +| Profile creation | ✓ | ✓ | `ccs auth create`, Dashboard "Add Account" | +| Profile switching | ✓ | ✓ | `ccs ` (execution is CLI-only) | +| API key config | ✓ | ✓ | `ccs api create`, Dashboard API Profiles | +| Health check | ✓ | ✓ | `ccs doctor`, Dashboard Live Monitor | +| OAuth auth flow | ✓ | ✓ | Browser opens from CLI or Dashboard | +| Analytics/monitoring | ✗ | ✓ | Dashboard Analytics (visual by nature) | +| WebSearch config | ✓ | ✓ | CLI flags, Dashboard Settings | +| Remote proxy config | ✓ | ✓ | CLI flags, Dashboard Settings | ## File Structure diff --git a/README.md b/README.md index bcc539aa..a380165f 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,9 @@ The dashboard provides visual management for all account types: | **GLM** | API Key | `ccs glm` | Cost-optimized execution | | **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode | | **Azure Foundry** | API Key | `ccs foundry` | Claude via Microsoft Azure | +| **Minimax** | API Key | `ccs minimax` | M2 series, 1M context | +| **DeepSeek** | API Key | `ccs deepseek` | V3.2 and R1 reasoning | +| **Qwen** | API Key | `ccs qwen` | Alibaba Cloud, qwen3-coder | **OpenRouter Integration** (v7.0.0): CCS v7.0.0 adds OpenRouter with interactive model picker, dynamic discovery, and tier mapping (opus/sonnet/haiku). Create via `ccs api create --preset openrouter` or dashboard. diff --git a/package.json b/package.json index 247f5227..3dee93f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.5.1-dev.1", + "version": "7.9.0", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 60251501..46c6bbf0 100755 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -68,24 +68,13 @@ function validateConfiguration() { errors.push('~/.ccs/ directory not found'); } - // Check required files (GLM/GLMT/Kimi are now optional - created via presets) - const requiredFiles = [ - { path: path.join(ccsDir, 'config.json'), name: 'config.json' } - ]; + // Check for config file - prefer config.yaml, fallback to config.json + const configYaml = path.join(ccsDir, 'config.yaml'); + const configJson = path.join(ccsDir, 'config.json'); + const hasConfig = fs.existsSync(configYaml) || fs.existsSync(configJson); - for (const file of requiredFiles) { - if (!fs.existsSync(file.path)) { - errors.push(`${file.name} not found`); - continue; - } - - // Validate JSON syntax - try { - const content = fs.readFileSync(file.path, 'utf8'); - JSON.parse(content); - } catch (e) { - errors.push(`${file.name} has invalid JSON: ${e.message}`); - } + if (!hasConfig) { + errors.push('config.yaml (or config.json) not found'); } // Check ~/.claude/settings.json (warning only, not critical) @@ -104,7 +93,15 @@ function createConfigFiles() { const ccsDir = path.join(homedir, '.ccs'); // Create ~/.ccs/ directory if missing - if (!fs.existsSync(ccsDir)) { + if (fs.existsSync(ccsDir)) { + // Check if it's a file instead of directory (edge case) + const stats = fs.statSync(ccsDir); + if (!stats.isDirectory()) { + console.error('[X] ~/.ccs exists but is not a directory'); + console.error(' Remove or rename it: mv ~/.ccs ~/.ccs.bak'); + process.exit(1); + } + } else { fs.mkdirSync(ccsDir, { recursive: true, mode: 0o755 }); console.log('[OK] Created directory: ~/.ccs/'); } @@ -150,62 +147,107 @@ function createConfigFiles() { // Users can run "ccs sync" to install CCS commands/skills to ~/.claude/ // This gives users control over when to modify their Claude configuration - // Create config.json if missing + // Create config.yaml if missing (primary format) // NOTE: gemini/codex profiles NOT included - they are added on-demand when user // runs `ccs gemini` or `ccs codex` for first time (requires OAuth auth first) // NOTE: GLM/GLMT/Kimi profiles are now created via UI/CLI presets, not auto-created - const configPath = path.join(ccsDir, 'config.json'); - if (!fs.existsSync(configPath)) { - // NOTE: No 'default' entry - when no profile specified, CCS passes through - // to Claude's native auth without --settings flag. This prevents env var - // pollution from affecting the default profile. - // Profiles are empty by default - users create via `ccs api create --preset` or UI - const config = { - profiles: {} - }; + const configYamlPath = path.join(ccsDir, 'config.yaml'); + const legacyConfigPath = path.join(ccsDir, 'config.json'); - // Atomic write: temp file → rename - const tmpPath = `${configPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tmpPath, configPath); - - console.log('[OK] Created config: ~/.ccs/config.json'); - } else { - // Update existing config (migration for older versions) - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - // Ensure profiles object exists - if (!config.profiles) { - config.profiles = {}; - } - let configUpdated = false; - - // Migration: Add glmt if missing (v3.x) - if (!config.profiles.glmt) { - config.profiles.glmt = '~/.ccs/glmt.settings.json'; - configUpdated = true; - } - - // Migration: Remove 'default' entry pointing to ~/.claude/settings.json (v5.4.0) - // This entry caused the default profile to pass --settings flag, which could - // pick up stale env vars (ANTHROPIC_BASE_URL) from previous profile sessions. - // Fix: Let CCS pass through to Claude's native auth without --settings flag. - if (config.profiles.default === '~/.claude/settings.json') { - delete config.profiles.default; - configUpdated = true; - console.log('[OK] Removed legacy default profile (now uses native Claude auth)'); - } - - // NOTE: gemini/codex profiles added on-demand, not during migration - if (configUpdated) { - const tmpPath = `${configPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tmpPath, configPath); - if (!config.profiles.glmt) { - console.log('[OK] Updated config with glmt profile'); + if (!fs.existsSync(configYamlPath)) { + // Check for legacy config.json - autoMigrate() in ccs.ts will handle migration + if (fs.existsSync(legacyConfigPath)) { + // Validate legacy config.json before assuming migration will work + try { + const content = fs.readFileSync(legacyConfigPath, 'utf8'); + JSON.parse(content); + console.log('[OK] Legacy config.json found - will migrate to config.yaml on first run'); + } catch { + console.warn('[!] Legacy config.json is corrupted/invalid'); + console.warn(' Backup: mv ~/.ccs/config.json ~/.ccs/config.json.bak'); + console.warn(' Creating fresh config.yaml instead'); + // Fall through to create new config.yaml + fs.renameSync(legacyConfigPath, `${legacyConfigPath}.bak`); } - } else { - console.log('[OK] Config exists: ~/.ccs/config.json (preserved)'); } + + // Create config.yaml if it doesn't exist (and legacy wasn't valid) + if (!fs.existsSync(configYamlPath) && !fs.existsSync(legacyConfigPath)) { + // Try to use unified config loader if dist is available + try { + const { saveUnifiedConfig } = require('../dist/config/unified-config-loader'); + const { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } = require('../dist/config/unified-config-types'); + + const config = createEmptyUnifiedConfig(); + config.version = UNIFIED_CONFIG_VERSION; + saveUnifiedConfig(config); + + console.log('[OK] Created config: ~/.ccs/config.yaml'); + } catch (loaderErr) { + // Dist not built yet (fresh clone) - create minimal config.yaml manually + // Wrap js-yaml require in try-catch in case it's not available + let yaml; + try { + yaml = require('js-yaml'); + } catch { + // js-yaml not available - fallback to JSON + console.warn('[!] js-yaml not available, creating legacy config.json'); + const fallbackConfig = { profiles: {} }; + const tmpPath = `${legacyConfigPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); + fs.renameSync(tmpPath, legacyConfigPath); + console.log('[OK] Created config: ~/.ccs/config.json (fallback)'); + yaml = null; + } + + if (yaml) { + const config = { + version: '2.0', + profiles: {}, + accounts: {}, + cliproxy: { + variants: {}, + oauth_accounts: {} + }, + cliproxy_server: { + local: { + port: 8317, + auto_start: true + } + } + }; + + try { + const yamlContent = yaml.dump(config, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false + }); + const tmpPath = `${configYamlPath}.tmp`; + fs.writeFileSync(tmpPath, yamlContent, 'utf8'); + fs.renameSync(tmpPath, configYamlPath); + console.log('[OK] Created config: ~/.ccs/config.yaml'); + } catch (yamlErr) { + // Final fallback: create legacy config.json + console.warn('[!] YAML write failed, creating legacy config.json'); + const fallbackConfig = { profiles: {} }; + const tmpPath = `${legacyConfigPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); + fs.renameSync(tmpPath, legacyConfigPath); + console.log('[OK] Created config: ~/.ccs/config.json (fallback)'); + } + } + } + } + } else { + console.log('[OK] Config exists: ~/.ccs/config.yaml (preserved)'); + } + + // Warn if both config files exist (user may want to clean up) + if (fs.existsSync(legacyConfigPath) && fs.existsSync(configYamlPath)) { + console.log('[!] Both config.yaml and config.json exist'); + console.log(' config.json will be ignored - consider removing it'); } // NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index 7e6dd129..2773e0ad 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir, loadConfig } from '../../utils/config-manager'; +import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; @@ -20,7 +20,7 @@ export function apiProfileExists(name: string): boolean { const config = loadOrCreateUnifiedConfig(); return name in config.profiles; } - const config = loadConfig(); + const config = loadConfigSafe(); return name in config.profiles; } catch { return false; @@ -79,7 +79,7 @@ export function listApiProfiles(): ApiListResult { }); } } else { - const config = loadConfig(); + const config = loadConfigSafe(); for (const [name, settingsPath] of Object.entries(config.profiles)) { // Skip 'default' profile - it's the user's native Claude settings if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) { @@ -116,7 +116,7 @@ export function getApiProfileNames(): string[] { const config = loadOrCreateUnifiedConfig(); return Object.keys(config.profiles); } - const config = loadConfig(); + const config = loadConfigSafe(); return Object.keys(config.profiles); } diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index e48ff6fa..48c34ff1 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -5,7 +5,8 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { getCcsDir, getConfigPath, loadConfig } from '../../utils/config-manager'; +import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig, @@ -144,9 +145,10 @@ function removeApiProfileUnified(name: string): void { throw new Error(`API profile not found: ${name}`); } - // Delete the settings file if it exists + // Delete the settings file if it exists. + // Uses expandPath() for cross-platform path handling. if (profile.settings) { - const settingsPath = profile.settings.replace(/^~/, os.homedir()); + const settingsPath = expandPath(profile.settings); if (fs.existsSync(settingsPath)) { fs.unlinkSync(settingsPath); } @@ -164,7 +166,7 @@ function removeApiProfileUnified(name: string): void { /** Remove API profile from legacy config */ function removeApiProfileLegacy(name: string): void { - const config = loadConfig(); + const config = loadConfigSafe(); delete config.profiles[name]; const configPath = getConfigPath(); diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts index fa34b0e3..85964f0e 100644 --- a/src/api/services/provider-presets.ts +++ b/src/api/services/provider-presets.ts @@ -98,6 +98,39 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ apiKeyHint: 'Create resource at ai.azure.com, get API key from Keys tab', category: 'alternative', }, + { + id: 'minimax', + name: 'Minimax', + description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)', + baseUrl: 'https://api.minimax.io/anthropic', + defaultProfileName: 'minimax', + defaultModel: 'MiniMax-M2.1', + apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY', + apiKeyHint: 'Get your API key at platform.minimax.io', + category: 'alternative', + }, + { + id: 'deepseek', + name: 'DeepSeek', + description: 'V3.2 and R1 reasoning model (128K context)', + baseUrl: 'https://api.deepseek.com/anthropic', + defaultProfileName: 'deepseek', + defaultModel: 'deepseek-chat', + apiKeyPlaceholder: 'sk-...', + apiKeyHint: 'Get your API key at platform.deepseek.com', + category: 'alternative', + }, + { + id: 'qwen', + name: 'Qwen', + description: 'Alibaba Cloud - qwen3-coder-plus (256K context)', + baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic', + defaultProfileName: 'qwen', + defaultModel: 'qwen3-coder-plus', + apiKeyPlaceholder: 'sk-...', + apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio', + category: 'alternative', + }, ]; /** Get preset by ID */ diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 014d5407..16accd5f 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -12,7 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { findSimilarStrings } from '../utils/helpers'; +import { findSimilarStrings, expandPath } from '../utils/helpers'; import { Config, Settings, ProfileMetadata } from '../types'; import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types'; import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; @@ -62,10 +62,11 @@ export interface ProfileNotFoundError extends Error { */ /** * Load env vars from a settings file (*.settings.json). - * Expands ~ to home directory. Returns empty object on error. + * Uses expandPath() for consistent cross-platform path handling. + * Returns empty object on error. */ -function loadSettingsFromFile(settingsPath: string): Record { - const expandedPath = settingsPath.replace(/^~/, os.homedir()); +export function loadSettingsFromFile(settingsPath: string): Record { + const expandedPath = expandPath(settingsPath); try { if (!fs.existsSync(expandedPath)) return {}; const content = fs.readFileSync(expandedPath, 'utf8'); diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index e0013e9c..151781ee 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -332,6 +332,42 @@ export class ProfileRegistry { config.accounts[name].last_used = new Date().toISOString(); saveUnifiedConfig(config); } + + // ========================================== + // DRY Helper Methods (consolidated logic) + // ========================================== + + /** + * Get all profiles merged from both legacy and unified config. + * Unified config takes precedence for duplicate names. + * DRY helper to consolidate merge logic used in multiple places. + */ + getAllProfilesMerged(): Record { + const legacyProfiles = this.getAllProfiles(); + const unifiedAccounts = this.getAllAccountsUnified(); + + // Start with legacy profiles + const merged: Record = { ...legacyProfiles }; + + // Override with unified config accounts (takes precedence) + for (const [name, account] of Object.entries(unifiedAccounts)) { + merged[name] = { + type: 'account', + created: account.created, + last_used: account.last_used, + }; + } + + return merged; + } + + /** + * Get resolved default profile from unified config first, fallback to legacy. + * DRY helper to consolidate default resolution logic. + */ + getDefaultResolved(): string | null { + return this.getDefaultUnified() ?? this.getDefaultProfile(); + } } export default ProfileRegistry; diff --git a/src/ccs.ts b/src/ccs.ts index f347819f..dc2df697 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath, loadSettings } from './utils/config-manager'; +import { validateGlmKey } from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; import { @@ -254,6 +255,13 @@ async function main(): Promise { const args = process.argv.slice(2); const firstArg = args[0]; + // Initialize UI colors early to ensure consistent colored output + // Must happen before any status messages (ok, info, fail, etc.) + if (process.stdout.isTTY && !process.env['CI']) { + const { initUI } = await import('./utils/ui'); + await initUI(); + } + // Trigger update check early for ALL commands except version/help/update // Only if TTY and not CI to avoid noise in automated environments const skipUpdateCheck = [ @@ -283,6 +291,24 @@ async function main(): Promise { await autoMigrate(); } + // Auto-recovery for missing configuration (BEFORE any early-exit commands) + // This ensures ALL commands benefit from auto-recovery, not just profile-switching flow + // Recovery is safe to run early - it only creates missing files with safe defaults + // Wrapped in try-catch to prevent blocking --version/--help on permission errors + try { + const RecoveryManagerModule = await import('./management/recovery-manager'); + const RecoveryManager = RecoveryManagerModule.default; + const recovery = new RecoveryManager(); + const recovered = recovery.recoverAll(); + + if (recovered) { + recovery.showRecoveryHints(); + } + } catch (err) { + // Recovery is best-effort - don't block basic CLI functionality + console.warn('[!] Recovery failed:', (err as Error).message); + } + // Special case: version command (check BEFORE profile detection) if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') { handleVersionCommand(); @@ -404,6 +430,20 @@ async function main(): Promise { return; } + // Special case: tokens command (auth token management) + if (firstArg === 'tokens') { + const { handleTokensCommand } = await import('./commands/tokens-command'); + const exitCode = await handleTokensCommand(args.slice(1)); + process.exit(exitCode); + } + + // Special case: setup command (first-time wizard) + if (firstArg === 'setup' || firstArg === '--setup') { + const { handleSetupCommand } = await import('./commands/setup-command'); + await handleSetupCommand(args.slice(1)); + return; + } + // Special case: copilot command (GitHub Copilot integration) // Only route to command handler for known subcommands, otherwise treat as profile const COPILOT_SUBCOMMANDS = [ @@ -433,14 +473,15 @@ async function main(): Promise { return; } - // Auto-recovery for missing configuration - const RecoveryManagerModule = await import('./management/recovery-manager'); - const RecoveryManager = RecoveryManagerModule.default; - const recovery = new RecoveryManager(); - const recovered = recovery.recoverAll(); - - if (recovered) { - recovery.showRecoveryHints(); + // First-time install: offer setup wizard for interactive users + // Check independently of recovery status (user may have empty config.yaml) + // Skip if headless, CI, or non-TTY environment + const { isFirstTimeInstall } = await import('./commands/setup-command'); + if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) { + console.log(''); + console.log(info('First-time install detected. Run `ccs setup` for guided configuration.')); + console.log(' Or use `ccs config` for the web dashboard.'); + console.log(''); } // Detect profile @@ -490,6 +531,32 @@ async function main(): Promise { // Display WebSearch status (single line, equilibrium UX) displayWebSearchStatus(); + // Pre-flight validation for GLM/GLMT profiles + if (profileInfo.name === 'glm' || profileInfo.name === 'glmt') { + const preflightSettingsPath = getSettingsPath(profileInfo.name); + const preflightSettings = loadSettings(preflightSettingsPath); + const apiKey = preflightSettings.env?.['ANTHROPIC_AUTH_TOKEN']; + + if (apiKey) { + const validation = await validateGlmKey( + apiKey, + preflightSettings.env?.['ANTHROPIC_BASE_URL'] + ); + + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')); + process.exit(1); + } + } + } + // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { // GLMT FLOW: Settings-based with embedded proxy for thinking support diff --git a/src/cliproxy/auth-token-manager.ts b/src/cliproxy/auth-token-manager.ts new file mode 100644 index 00000000..4daebb13 --- /dev/null +++ b/src/cliproxy/auth-token-manager.ts @@ -0,0 +1,203 @@ +/** + * Auth Token Manager for CLIProxyAPI + * + * Manages API key and management secret resolution with inheritance: + * - Per-variant override → Global config → Default constants + * + * Provides secure token generation for user customization. + */ + +import { randomBytes } from 'crypto'; +import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader'; +import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from './config-generator'; + +/** + * Generate a cryptographically secure token. + * Uses CSPRNG (crypto.randomBytes) for proper entropy. + * + * @param length - Number of random bytes (default: 32 = 256-bit entropy) + * @returns Base64URL-encoded token (43 chars for 32 bytes) + */ +export function generateSecureToken(length = 32): string { + return randomBytes(length).toString('base64url'); +} + +/** + * Mask a token for display. + * Shows first 4 and last 4 chars: "ccs_...4f2a" + * Used by CLI commands and API routes. + */ +export function maskToken(token: string): string { + if (token.length <= 8) return '****'; + return `${token.slice(0, 4)}...${token.slice(-4)}`; +} + +/** + * Get effective API key with inheritance chain. + * Priority: variant auth → global cliproxy.auth → default constant + * + * @param variantName - Optional variant name for per-variant override + * @returns Resolved API key + */ +export function getEffectiveApiKey(variantName?: string): string { + const config = loadOrCreateUnifiedConfig(); + + // Priority 1: Per-variant override + if (variantName) { + const variant = config.cliproxy.variants[variantName]; + if (variant?.auth?.api_key) { + return variant.auth.api_key; + } + } + + // Priority 2: Global cliproxy.auth + if (config.cliproxy.auth?.api_key) { + return config.cliproxy.auth.api_key; + } + + // Priority 3: Default constant (backwards compatible) + return CCS_INTERNAL_API_KEY; +} + +/** + * Get effective management secret. + * Priority: global cliproxy.auth → default constant + * + * Note: Management secret is global-only (no per-variant override) + * as it controls the Control Panel access for the entire CLIProxy instance. + * + * @returns Resolved management secret + */ +export function getEffectiveManagementSecret(): string { + const config = loadOrCreateUnifiedConfig(); + + // Priority 1: Global cliproxy.auth + if (config.cliproxy.auth?.management_secret) { + return config.cliproxy.auth.management_secret; + } + + // Priority 2: Default constant (backwards compatible) + return CCS_CONTROL_PANEL_SECRET; +} + +/** + * Set global API key. + * Updates cliproxy.auth.api_key in config.yaml. + * + * @param apiKey - New API key (or undefined to reset to default) + */ +export function setGlobalApiKey(apiKey: string | undefined): void { + const config = loadOrCreateUnifiedConfig(); + + if (!config.cliproxy.auth) { + config.cliproxy.auth = {}; + } + + if (apiKey === undefined) { + delete config.cliproxy.auth.api_key; + } else { + config.cliproxy.auth.api_key = apiKey; + } + + saveUnifiedConfig(config); +} + +/** + * Set global management secret. + * Updates cliproxy.auth.management_secret in config.yaml. + * + * @param secret - New management secret (or undefined to reset to default) + */ +export function setGlobalManagementSecret(secret: string | undefined): void { + const config = loadOrCreateUnifiedConfig(); + + if (!config.cliproxy.auth) { + config.cliproxy.auth = {}; + } + + if (secret === undefined) { + delete config.cliproxy.auth.management_secret; + } else { + config.cliproxy.auth.management_secret = secret; + } + + saveUnifiedConfig(config); +} + +/** + * Set per-variant API key override. + * Updates variants[variantName].auth.api_key in config.yaml. + * + * @param variantName - Variant name + * @param apiKey - New API key (or undefined to remove override) + */ +export function setVariantApiKey(variantName: string, apiKey: string | undefined): void { + const config = loadOrCreateUnifiedConfig(); + const variant = config.cliproxy.variants[variantName]; + + if (!variant) { + throw new Error(`Variant '${variantName}' not found`); + } + + if (!variant.auth) { + variant.auth = {}; + } + + if (apiKey === undefined) { + delete variant.auth.api_key; + // Clean up empty auth object + if (Object.keys(variant.auth).length === 0) { + delete variant.auth; + } + } else { + variant.auth.api_key = apiKey; + } + + saveUnifiedConfig(config); +} + +/** + * Reset all auth settings to defaults. + * Removes cliproxy.auth and all variant auth overrides. + */ +export function resetAuthToDefaults(): void { + const config = loadOrCreateUnifiedConfig(); + + // Remove global auth + delete config.cliproxy.auth; + + // Remove all variant auth overrides + for (const variantName of Object.keys(config.cliproxy.variants)) { + const variant = config.cliproxy.variants[variantName]; + if (variant.auth) { + delete variant.auth; + } + } + + saveUnifiedConfig(config); +} + +/** + * Get auth configuration summary for display. + * Returns current effective values and whether they're customized. + */ +export function getAuthSummary(): { + apiKey: { value: string; isCustom: boolean }; + managementSecret: { value: string; isCustom: boolean }; +} { + const config = loadOrCreateUnifiedConfig(); + + const customApiKey = config.cliproxy.auth?.api_key; + const customSecret = config.cliproxy.auth?.management_secret; + + return { + apiKey: { + value: customApiKey || CCS_INTERNAL_API_KEY, + isCustom: !!customApiKey, + }, + managementSecret: { + value: customSecret || CCS_CONTROL_PANEL_SECRET, + isCustom: !!customSecret, + }, + }; +} diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 4bb06256..927c2e2a 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -171,4 +171,8 @@ export interface OAuthOptions { nickname?: string; /** If true, triggered from Web UI (enables project selection prompt) */ fromUI?: boolean; + /** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */ + noIncognito?: boolean; + /** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */ + import?: boolean; } diff --git a/src/cliproxy/auth/kiro-import.ts b/src/cliproxy/auth/kiro-import.ts new file mode 100644 index 00000000..cc507110 --- /dev/null +++ b/src/cliproxy/auth/kiro-import.ts @@ -0,0 +1,159 @@ +/** + * Kiro Import Helper + * + * Imports Kiro token from Kiro IDE when OAuth callback redirects to IDE instead of CLI. + * Spawns cli-proxy-api-plus --kiro-import to import token from Kiro IDE's storage. + */ + +import { spawn } from 'child_process'; +import { info, ok, fail } from '../../utils/ui'; +import { ensureCLIProxyBinary } from '../binary-manager'; +import { generateConfig } from '../config-generator'; +import { getProviderTokenDir } from './token-manager'; + +export interface KiroImportResult { + success: boolean; + provider?: string; + email?: string; + error?: string; +} + +/** + * Try to import Kiro token from Kiro IDE + * Uses cli-proxy-api-plus --kiro-import flag + */ +export async function tryKiroImport(tokenDir: string, verbose = false): Promise { + const log = (msg: string) => { + if (verbose) console.error(`[kiro-import] ${msg}`); + }; + + try { + log('Ensuring CLIProxy binary is available...'); + const binaryPath = await ensureCLIProxyBinary(verbose); + const configPath = generateConfig('kiro'); + + log(`Binary: ${binaryPath}`); + log(`Config: ${configPath}`); + log(`Token dir: ${tokenDir}`); + + return new Promise((resolve) => { + const args = ['--config', configPath, '--kiro-import']; + + log(`Running: ${binaryPath} ${args.join(' ')}`); + + const proc = spawn(binaryPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir }, + }); + + let stdout = ''; + let stderr = ''; + let resolved = false; + + const safeResolve = (result: KiroImportResult) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result); + }; + + proc.stdout?.on('data', (data: Buffer) => { + const output = data.toString(); + stdout += output; + log(`stdout: ${output.trim()}`); + }); + + proc.stderr?.on('data', (data: Buffer) => { + const output = data.toString(); + stderr += output; + log(`stderr: ${output.trim()}`); + }); + + proc.on('exit', (code) => { + log(`Exit code: ${code}`); + + if (code === 0) { + // Parse output for provider info + const providerMatch = stdout.match(/Provider:\s*(\w+)/i); + const emailMatch = stdout.match(/email[:\s]+([^\s,)]+)/i); + const successMatch = + stdout.includes('Kiro token import successful') || + stdout.includes('Imported Kiro token') || + stdout.includes('Authentication saved'); + + if (successMatch) { + safeResolve({ + success: true, + provider: providerMatch?.[1], + email: emailMatch?.[1], + }); + } else { + safeResolve({ + success: false, + error: 'Import completed but token not confirmed', + }); + } + } else { + const errorLine = stderr.trim().split('\n')[0] || stdout.trim().split('\n')[0]; + safeResolve({ + success: false, + error: errorLine || `Exit code ${code}`, + }); + } + }); + + proc.on('error', (error) => { + log(`Process error: ${error.message}`); + safeResolve({ + success: false, + error: error.message, + }); + }); + + // Timeout after 30 seconds + const timeoutId = setTimeout(() => { + if (!resolved && !proc.killed) { + proc.kill(); + safeResolve({ + success: false, + error: 'Import timed out after 30 seconds', + }); + } + }, 30000); + }); + } catch (error) { + log(`Error: ${(error as Error).message}`); + return { + success: false, + error: (error as Error).message, + }; + } +} + +/** + * Import Kiro token with user-facing output + * Shows progress and result to user + */ +export async function importKiroToken(verbose = false): Promise { + const tokenDir = getProviderTokenDir('kiro'); + + console.log(''); + console.log(info('Importing token from Kiro IDE...')); + + const result = await tryKiroImport(tokenDir, verbose); + + if (result.success) { + const providerInfo = result.provider ? ` (Provider: ${result.provider})` : ''; + console.log(ok(`Imported Kiro token from IDE${providerInfo}`)); + return true; + } + + console.log(fail(`Import failed: ${result.error}`)); + console.log(''); + console.log('Make sure you are logged into Kiro IDE first:'); + console.log(' 1. Open Kiro IDE'); + console.log(' 2. Sign in with your AWS/Google account'); + console.log(' 3. Run: ccs kiro --import'); + + return false; +} diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index f2bb51c2..34ecabbc 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -27,8 +27,9 @@ import { } from '../../management/oauth-port-diagnostics'; import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; -import { getProviderTokenDir, isAuthenticated } from './token-manager'; +import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager'; import { executeOAuthProcess } from './oauth-process'; +import { importKiroToken } from './kiro-import'; /** * Prompt user to add another account @@ -126,7 +127,18 @@ export async function triggerOAuth( options: OAuthOptions = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false, add = false, nickname, fromUI = false } = options; + const { verbose = false, add = false, nickname, fromUI = false, noIncognito = true } = options; + + // Handle --import flag: skip OAuth and import from Kiro IDE directly + if (options.import && provider === 'kiro') { + const tokenDir = getProviderTokenDir(provider); + const success = await importKiroToken(verbose); + if (success) { + return registerAccountFromToken(provider, tokenDir, nickname); + } + return null; + } + const callbackPort = OAUTH_PORTS[provider]; const isCLI = !fromUI; const headless = options.headless ?? isHeadlessEnvironment(); @@ -175,6 +187,10 @@ export async function triggerOAuth( if (headless) { args.push('--no-browser'); } + // Kiro-specific: --no-incognito to use normal browser (saves login credentials) + if (provider === 'kiro' && noIncognito) { + args.push('--no-incognito'); + } // Show step based on flow type if (isDeviceCodeFlow) { @@ -198,7 +214,7 @@ export async function triggerOAuth( } // Execute OAuth process - return executeOAuthProcess({ + const account = await executeOAuthProcess({ provider, binaryPath, args, @@ -210,6 +226,16 @@ export async function triggerOAuth( isCLI, nickname, }); + + // Show hint for Kiro users about --no-incognito option (first-time auth only) + if (account && provider === 'kiro' && !noIncognito) { + console.log(''); + console.log(info('Tip: To save your AWS login credentials for future sessions:')); + console.log(' Use: ccs kiro --no-incognito'); + console.log(' Or enable "Kiro: Use normal browser" in: ccs config'); + } + + return account; } /** diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index f9e0026e..309df84d 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -6,7 +6,8 @@ */ import { spawn, ChildProcess } from 'child_process'; -import { ok, fail, info } from '../../utils/ui'; +import { ok, fail, info, warn } from '../../utils/ui'; +import { tryKiroImport } from './kiro-import'; import { CLIProxyProvider } from '../types'; import { AccountInfo } from '../account-manager'; import { @@ -22,6 +23,8 @@ import { import { ProviderOAuthConfig } from './auth-types'; import { getTimeoutTroubleshooting, showStep } from './environment-detector'; import { isAuthenticated, registerAccountFromToken } from './token-manager'; +import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler'; +import { OAUTH_FLOW_TYPES } from '../../management'; /** Options for OAuth process execution */ export interface OAuthProcessOptions { @@ -46,6 +49,10 @@ interface ProcessState { accumulatedOutput: string; parsedProjects: GCloudProject[]; sessionId: string; + /** Device code displayed to user (for Device Code Flow) */ + deviceCodeDisplayed: boolean; + /** The user code to enter at verification URL */ + userCode: string | null; } /** @@ -99,28 +106,78 @@ async function handleStdout( log(`stdout: ${output.trim()}`); state.accumulatedOutput += output; + // H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check + const flowType = OAUTH_FLOW_TYPES[options.provider] || 'authorization_code'; + const isDeviceCodeFlow = flowType === 'device_code'; + // Parse project list when available if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) { state.parsedProjects = parseProjectList(state.accumulatedOutput); log(`Parsed ${state.parsedProjects.length} projects`); } - // Handle project selection prompt - if (!state.projectPromptHandled && isProjectSelectionPrompt(output)) { + // Handle project selection prompt (Authorization Code flows only - Device Code has no stdin pipe) + if (!isDeviceCodeFlow && !state.projectPromptHandled && isProjectSelectionPrompt(output)) { state.projectPromptHandled = true; await handleProjectSelection(output, state, options, authProcess, log); } - // Detect callback server / browser - if (!state.browserOpened && (output.includes('listening') || output.includes('http'))) { + // Handle Device Code Flow: parse and display user code + if (isDeviceCodeFlow && !state.deviceCodeDisplayed) { + // Parse device/user code from various formats: + // "Enter code: XXXX-YYYY" or "code XXXX-YYYY" or "user code: XXXX-YYYY" + const codeMatch = state.accumulatedOutput.match( + /(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i + ); + const urlMatch = state.accumulatedOutput.match(/(https?:\/\/[^\s]+device[^\s]*)/i); + + if (codeMatch) { + state.userCode = codeMatch[1].toUpperCase(); + state.deviceCodeDisplayed = true; + log(`Parsed device code: ${state.userCode}`); + + const verificationUrl = urlMatch?.[1] || 'https://github.com/login/device'; + + // Emit device code event for WebSocket broadcast to UI + const deviceCodePrompt: DeviceCodePrompt = { + sessionId: state.sessionId, + provider: options.provider, + userCode: state.userCode, + verificationUrl, + expiresAt: Date.now() + 900000, // 15 minutes + }; + deviceCodeEvents.emit('deviceCode:received', deviceCodePrompt); + + // Display device code prominently in CLI + console.log(''); + console.log(' ╔══════════════════════════════════════════════════════╗'); + console.log(` ║ Enter this code: ${state.userCode.padEnd(35)}║`); + console.log(' ╚══════════════════════════════════════════════════════╝'); + console.log(''); + console.log(info(`Open: ${verificationUrl}`)); + console.log(''); + + // Update step display for device code flow + process.stdout.write('\x1b[1A\x1b[2K'); + showStep(2, 4, 'ok', 'Device code received'); + showStep(3, 4, 'progress', 'Waiting for authorization...'); + } + } + + // Detect callback server / browser (for Authorization Code flows only) + if ( + !isDeviceCodeFlow && + !state.browserOpened && + (output.includes('listening') || output.includes('http')) + ) { process.stdout.write('\x1b[1A\x1b[2K'); showStep(2, 4, 'ok', `Callback server listening on port ${options.callbackPort}`); showStep(3, 4, 'progress', 'Opening browser...'); state.browserOpened = true; } - // Display OAuth URLs in headless mode - if (options.headless && !state.urlDisplayed) { + // Display OAuth URL for all modes (enables VS Code terminal URL detection popup) + if (!isDeviceCodeFlow && !state.urlDisplayed) { const urlMatch = output.match(/https?:\/\/[^\s]+/); if (urlMatch) { console.log(''); @@ -149,7 +206,35 @@ function displayUrlFromStderr( } /** Handle token not found after successful process exit */ -function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number | null): void { +async function handleTokenNotFound( + provider: CLIProxyProvider, + callbackPort: number | null, + tokenDir: string, + nickname: string | undefined, + verbose: boolean +): Promise { + // Kiro-specific: Try auto-import from Kiro IDE + if (provider === 'kiro') { + console.log(''); + console.log(warn('Callback redirected to Kiro IDE. Attempting to import token...')); + + const result = await tryKiroImport(tokenDir, verbose); + + if (result.success) { + const providerInfo = result.provider ? ` (Provider: ${result.provider})` : ''; + console.log(ok(`Imported Kiro token from IDE${providerInfo}`)); + return registerAccountFromToken(provider, tokenDir, nickname); + } + + console.log(fail(`Auto-import failed: ${result.error}`)); + console.log(''); + console.log('To manually import from Kiro IDE:'); + console.log(' 1. Ensure you are logged into Kiro IDE'); + console.log(' 2. Run: ccs kiro --import'); + return null; + } + + // Default behavior for other providers console.log(''); console.log(fail('Token not found after authentication')); console.log(''); @@ -169,6 +254,7 @@ function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number | console.log(''); console.log(`Try: ccs ${provider} --auth --verbose`); + return null; } /** Handle process exit with error */ @@ -205,11 +291,29 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise((resolve) => { + // H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check + const flowType = OAUTH_FLOW_TYPES[provider] || 'authorization_code'; + const isDeviceCodeFlow = flowType === 'device_code'; + + // H6: TTY detection - only inherit stdin if TTY available (prevents issues in CI/piped scripts) + // Device Code flows may need interactive stdin for email/prompts + // Authorization Code flows need piped stdin for project selection + const stdinMode = isDeviceCodeFlow && process.stdin.isTTY ? 'inherit' : 'pipe'; + const authProcess = spawn(binaryPath, args, { - stdio: ['pipe', 'pipe', 'pipe'], + stdio: [stdinMode, 'pipe', 'pipe'], env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir }, }); + // H5: Signal handling - properly kill child process on SIGINT/SIGTERM + const cleanup = () => { + if (authProcess && !authProcess.killed) { + authProcess.kill('SIGTERM'); + } + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + const state: ProcessState = { stderrData: '', urlDisplayed: false, @@ -218,6 +322,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { - if (!state.browserOpened) { - process.stdout.write('\x1b[1A\x1b[2K'); - showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`); - showStep(3, 4, 'ok', 'Browser opened'); - state.browserOpened = true; + if (isDeviceCodeFlow) { + // Device Code Flow: show polling message + if (!state.deviceCodeDisplayed) { + // Code not yet displayed, show generic waiting message + showStep(3, 4, 'progress', 'Waiting for device code...'); + } + showStep(4, 4, 'progress', 'Polling for authorization...'); + console.log(''); + console.log( + info('Complete the login in your browser. This page will update automatically.') + ); + } else { + // Authorization Code Flow: show callback server message + if (!state.browserOpened) { + process.stdout.write('\x1b[1A\x1b[2K'); + showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`); + showStep(3, 4, 'ok', 'Browser opened'); + state.browserOpened = true; + } + showStep(4, 4, 'progress', 'Waiting for OAuth callback...'); + console.log(''); + console.log( + info('Complete the login in your browser. This page will update automatically.') + ); } - showStep(4, 4, 'progress', 'Waiting for OAuth callback...'); - console.log(''); - console.log(info('Complete the login in your browser. This page will update automatically.')); if (!verbose) console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose')); }, 2000); // Timeout handling const timeoutMs = headless ? 300000 : 120000; const timeout = setTimeout(() => { + // H5: Remove signal handlers before killing process + process.removeListener('SIGINT', cleanup); + process.removeListener('SIGTERM', cleanup); authProcess.kill(); console.log(''); console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`)); @@ -261,20 +386,52 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + authProcess.on('exit', async (code) => { clearTimeout(timeout); + // H5: Remove signal handlers to prevent memory leaks + process.removeListener('SIGINT', cleanup); + process.removeListener('SIGTERM', cleanup); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); if (code === 0) { if (isAuthenticated(provider)) { console.log(''); console.log(ok(`Authentication successful (${elapsed}s)`)); + + // Emit device code completion event for UI + if (isDeviceCodeFlow && state.deviceCodeDisplayed) { + deviceCodeEvents.emit('deviceCode:completed', state.sessionId); + } + resolve(registerAccountFromToken(provider, tokenDir, nickname)); } else { - handleTokenNotFound(provider, callbackPort); - resolve(null); + // Emit device code failure event for UI + if (isDeviceCodeFlow && state.deviceCodeDisplayed) { + deviceCodeEvents.emit('deviceCode:failed', { + sessionId: state.sessionId, + error: 'Token not found after authentication', + }); + } + + // Try auto-import for Kiro, show error for others + const account = await handleTokenNotFound( + provider, + callbackPort, + tokenDir, + nickname, + verbose + ); + resolve(account); } } else { + // Emit device code failure event for UI + if (isDeviceCodeFlow && state.deviceCodeDisplayed) { + deviceCodeEvents.emit('deviceCode:failed', { + sessionId: state.sessionId, + error: `Auth process exited with code ${code}`, + }); + } + handleProcessError(code, state, headless); resolve(null); } @@ -282,6 +439,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { clearTimeout(timeout); + // H5: Remove signal handlers to prevent memory leaks + process.removeListener('SIGINT', cleanup); + process.removeListener('SIGTERM', cleanup); console.log(''); console.log(fail(`Failed to start auth process: ${error.message}`)); resolve(null); diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 4e23f880..51a33d45 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -5,10 +5,12 @@ * Pattern: Mirrors npm install behavior (fast check, download only when needed) */ -import { info } from '../utils/ui'; -import { getBinDir } from './config-generator'; +import { info, warn } from '../utils/ui'; +import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; import { BinaryInfo, BinaryManagerConfig } from './types'; import { CLIPROXY_FALLBACK_VERSION } from './platform-detector'; +import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service'; +import { waitForPortFree } from '../utils/port-utils'; import { UpdateCheckResult, checkForUpdates, @@ -108,12 +110,32 @@ export function getInstalledCliproxyVersion(): string { /** Install a specific version of CLIProxyAPI */ export async function installCliproxyVersion(version: string, verbose = false): Promise { const manager = new BinaryManager({ version, verbose, forceVersion: true }); + + // Check if proxy is running and stop it first + if (isProxyRunning()) { + if (verbose) console.log(info('Stopping running CLIProxy before update...')); + const result = await stopProxy(); + if (result.stopped) { + // Wait for port to be fully released + const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000); + if (!portFree && verbose) { + console.log(warn('Port did not free up in time, proceeding anyway...')); + } + } else if (verbose && result.error) { + console.log(warn(`Could not stop proxy: ${result.error}`)); + } + } + if (manager.isBinaryInstalled()) { if (verbose) console.log(info(`Removing existing CLIProxy Plus v${getInstalledCliproxyVersion()}`)); manager.deleteBinary(); } await manager.ensureBinary(); + + if (verbose) { + console.log(info('New version will be active on next CLIProxy command')); + } } /** Fetch the latest CLIProxyAPI version from GitHub API */ diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 6152be27..99d31f97 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -17,7 +17,7 @@ import * as net from 'net'; import { ProgressIndicator } from '../utils/progress-indicator'; import { ok, fail, info, warn } from '../utils/ui'; import { escapeShellArg } from '../utils/shell-executor'; -import { ensureCLIProxyBinary } from './binary-manager'; +import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from './binary-manager'; import { generateConfig, getEffectiveEnvVars, @@ -48,7 +48,12 @@ import { installWebSearchHook, displayWebSearchStatus, } from '../utils/websearch-manager'; -import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker'; +import { + registerSession, + unregisterSession, + cleanupOrphanedSessions, + stopProxy, +} from './session-tracker'; import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; @@ -250,6 +255,18 @@ export async function execClaudeWithCLIProxy( const forceConfig = argsWithoutProxy.includes('--config'); const addAccount = argsWithoutProxy.includes('--add'); const showAccounts = argsWithoutProxy.includes('--accounts'); + // Kiro-specific: --import to import token from Kiro IDE directly + const forceImport = argsWithoutProxy.includes('--import'); + // Kiro-specific: browser mode for OAuth + // Default to normal browser (noIncognito=true) for reliability - incognito often fails on Linux + // --incognito flag opts into incognito mode, --no-incognito is legacy (now default) + const incognitoFlag = argsWithoutProxy.includes('--incognito'); + const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito'); + // Config setting (defaults to true = normal browser) + const kiroNoIncognitoConfig = + provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false; + // --incognito flag overrides everything to use incognito + const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig; // Parse --use flag let useAccount: string | undefined; @@ -352,6 +369,38 @@ export async function execClaudeWithCLIProxy( process.exit(0); } + // Handle --import: import token from Kiro IDE directly (Kiro only) + if (forceImport) { + if (provider !== 'kiro') { + console.error(fail('--import is only available for Kiro')); + console.error(` Run "ccs ${provider} --auth" to authenticate`); + process.exit(1); + } + // Validate flag conflicts + if (forceAuth) { + console.error(fail('Cannot use --import with --auth')); + console.error(' --import: Import existing token from Kiro IDE'); + console.error(' --auth: Trigger new OAuth flow in browser'); + process.exit(1); + } + if (forceLogout) { + console.error(fail('Cannot use --import with --logout')); + process.exit(1); + } + const { triggerOAuth } = await import('./auth-handler'); + const authSuccess = await triggerOAuth(provider, { + verbose, + import: true, + ...(setNickname ? { nickname: setNickname } : {}), + }); + if (!authSuccess) { + console.error(fail('Failed to import Kiro token from IDE')); + console.error(' Make sure you are logged into Kiro IDE first'); + process.exit(1); + } + process.exit(0); + } + // 3. Ensure OAuth completed (if provider requires it) if (providerConfig.requiresOAuth) { log(`Checking authentication for ${provider}`); @@ -364,6 +413,7 @@ export async function execClaudeWithCLIProxy( add: addAccount, ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), + ...(noIncognito ? { noIncognito: true } : {}), }); if (!authSuccess) { throw new Error(`Authentication required for ${providerConfig.displayName}`); @@ -418,9 +468,33 @@ export async function execClaudeWithCLIProxy( // 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); + let proxyStatus = await detectRunningProxy(cfg.port); log(`Proxy detection: ${JSON.stringify(proxyStatus)}`); + // Check for version mismatch - restart proxy if installed version differs from running + if (proxyStatus.running && proxyStatus.verified && proxyStatus.version) { + const installedVersion = getInstalledCliproxyVersion(); + if (installedVersion !== proxyStatus.version) { + console.log( + warn( + `Version mismatch: running v${proxyStatus.version}, installed v${installedVersion}. Restarting proxy...` + ) + ); + log(`Stopping outdated proxy (PID: ${proxyStatus.pid ?? 'unknown'})...`); + const stopResult = await stopProxy(cfg.port); + if (stopResult.stopped) { + log(`Stopped outdated proxy successfully`); + } else { + log(`Stop proxy result: ${stopResult.error ?? 'unknown error'}`); + } + // Wait for port to be released + await new Promise((r) => setTimeout(r, 500)); + // Re-detect proxy status (should now be not running) + proxyStatus = await detectRunningProxy(cfg.port); + log(`Re-detection after version mismatch restart: ${JSON.stringify(proxyStatus)}`); + } + } + if (proxyStatus.running && proxyStatus.verified) { // Healthy proxy found - join it if (proxyStatus.pid) { @@ -531,9 +605,12 @@ export async function execClaudeWithCLIProxy( throw new Error(`CLIProxy startup failed: ${err.message}`); } - // 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})`); + // Register this session with the new proxy, including the installed version + const installedVersion = getInstalledCliproxyVersion(); + sessionId = registerSession(cfg.port, proxy.pid as number, installedVersion); + log( + `Registered session ${sessionId} with new proxy (PID ${proxy.pid}, version ${installedVersion})` + ); }); } @@ -591,6 +668,9 @@ export async function execClaudeWithCLIProxy( '--accounts', '--use', '--nickname', + '--incognito', + '--no-incognito', + '--import', // Proxy flags are handled by resolveProxyConfig, but list for documentation ...PROXY_CLI_FLAGS, ]; @@ -625,12 +705,14 @@ export async function execClaudeWithCLIProxy( // 8. Cleanup: unregister session when Claude exits (local mode only) // Proxy persists by default - use 'ccs cliproxy stop' to kill manually + // Capture port for cleanup (avoids closure issues) + const sessionPort = cfg.port; claude.on('exit', (code, signal) => { log(`Claude exited: code=${code}, signal=${signal}`); // Unregister this session (proxy keeps running for persistence) - only for local mode if (sessionId) { - unregisterSession(sessionId); + unregisterSession(sessionId, sessionPort); log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); } @@ -646,7 +728,7 @@ export async function execClaudeWithCLIProxy( // Unregister session, proxy keeps running (local mode only) if (sessionId) { - unregisterSession(sessionId); + unregisterSession(sessionId, sessionPort); } process.exit(1); }); @@ -657,7 +739,7 @@ export async function execClaudeWithCLIProxy( // Unregister session, proxy keeps running (local mode only) if (sessionId) { - unregisterSession(sessionId); + unregisterSession(sessionId, sessionPort); } claude.kill('SIGTERM'); }; diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 7e9f940e..c2d4b83d 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -11,10 +11,12 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; +import { expandPath } from '../utils/helpers'; import { warn } from '../utils/ui'; import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader'; import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader'; +import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager'; /** Settings file structure for user overrides */ interface ProviderSettings { @@ -116,10 +118,22 @@ export function getAuthDir(): string { } /** - * Get config file path + * Get config file path for a specific port. + * Default port uses config.yaml, others use config-{port}.yaml. */ -export function getConfigPath(): string { - return path.join(getCliproxyDir(), 'config.yaml'); +export function getConfigPathForPort(port: number): string { + if (port === CLIPROXY_DEFAULT_PORT) { + return path.join(getCliproxyDir(), 'config.yaml'); + } + return path.join(getCliproxyDir(), `config-${port}.yaml`); +} + +/** + * Get CLIProxy config file path (default port) + * Named distinctly from config-manager's getConfigPath to avoid confusion. + */ +export function getCliproxyConfigPath(): string { + return getConfigPathForPort(CLIPROXY_DEFAULT_PORT); } /** @@ -145,8 +159,14 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } { * Generate UNIFIED config.yaml content for ALL providers * This enables concurrent usage of gemini/codex/agy without config conflicts. * CLIProxyAPI routes requests by model name to the appropriate provider. + * + * @param port - Server port (default: 8317) + * @param userApiKeys - User-added API keys to preserve (default: []) */ -function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string { +function generateUnifiedConfigContent( + port: number = CLIPROXY_DEFAULT_PORT, + userApiKeys: string[] = [] +): string { const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories // Convert Windows backslashes to forward slashes for YAML compatibility const authDirNormalized = authDir.split(path.sep).join('/'); @@ -154,6 +174,14 @@ function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): str // Get logging settings from user config (disabled by default) const { loggingToFile, requestLog } = getLoggingSettings(); + // Get effective auth tokens (respects user customization) + const effectiveApiKey = getEffectiveApiKey(); + const effectiveSecret = getEffectiveManagementSecret(); + + // Build api-keys section with internal key + preserved user keys + const allApiKeys = [effectiveApiKey, ...userApiKeys]; + const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n'); + // Unified config with enhanced CLIProxyAPI features const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} # Supports: gemini, codex, agy, qwen, iflow (concurrent usage) @@ -197,7 +225,7 @@ usage-statistics-enabled: true # Remote management API for CCS dashboard integration remote-management: allow-remote: true - secret-key: "${CCS_CONTROL_PANEL_SECRET}" + secret-key: "${effectiveSecret}" disable-control-panel: false # ============================================================================= @@ -218,9 +246,10 @@ quota-exceeded: # Authentication # ============================================================================= -# API keys for CCS internal requests +# API keys for CCS and user-added external requests +# NOTE: User-added keys are preserved across CCS updates (fix for issue #200) api-keys: - - "${CCS_INTERNAL_API_KEY}" +${apiKeysYaml} # OAuth tokens directory (auto-discovered by CLIProxyAPI) auth-dir: "${authDirNormalized}" @@ -238,7 +267,7 @@ export function generateConfig( provider: CLIProxyProvider, port: number = CLIPROXY_DEFAULT_PORT ): string { - const configPath = getConfigPath(); + const configPath = getConfigPathForPort(port); // Ensure provider auth directory exists const authDir = getProviderAuthDir(provider); @@ -255,24 +284,79 @@ export function generateConfig( } /** - * Force regenerate config.yaml with latest settings - * Deletes existing config and creates fresh one with current port + * Parse user-added API keys from existing config content. + * Extracts all keys except the internal CCS key for preservation. + * + * @param content - Existing config.yaml content + * @returns Array of user-added API keys (excludes CCS_INTERNAL_API_KEY) + */ +export function parseUserApiKeys(content: string): string[] { + const userKeys: string[] = []; + + // Find the api-keys section by looking for lines starting with " - " after "api-keys:" + // Normalize line endings first + const normalizedContent = content.replace(/\r\n/g, '\n'); + + // Find the api-keys: line and extract all subsequent key entries + const lines = normalizedContent.split('\n'); + let inApiKeysSection = false; + + for (const line of lines) { + // Check if this is the start of api-keys section + if (line.match(/^api-keys:\s*$/)) { + inApiKeysSection = true; + continue; + } + + // If we're in the api-keys section, look for key entries + if (inApiKeysSection) { + // Key entries are indented with " - " or similar + const keyMatch = line.match(/^\s+-\s*"([^"]*)"/); + if (keyMatch) { + const key = keyMatch[1]; + // Exclude the internal CCS key and empty strings + if (key && key !== CCS_INTERNAL_API_KEY) { + userKeys.push(key); + } + } else if (line.match(/^\S/) && line.trim().length > 0) { + // Non-indented line that's not empty means we've left the api-keys section + break; + } + // Continue for blank lines or other indented content + } + } + + return userKeys; +} + +/** + * Force regenerate config.yaml with latest settings. + * Preserves user-added API keys and port settings. + * + * @param port - Default port to use if not found in existing config * @returns Path to new config file */ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { - const configPath = getConfigPath(); + const configPath = getConfigPathForPort(port); - // Read existing port if config exists (preserve user's port choice) + // Preserve user settings from existing config let effectivePort = port; + let userApiKeys: string[] = []; + if (fs.existsSync(configPath)) { try { const content = fs.readFileSync(configPath, 'utf-8'); + + // Preserve port setting const portMatch = content.match(/^port:\s*(\d+)/m); if (portMatch) { effectivePort = parseInt(portMatch[1], 10); } + + // Preserve user-added API keys (fix for issue #200) + userApiKeys = parseUserApiKeys(content); } catch { - // Use default port if reading fails + // Use defaults if reading fails } // Delete existing config fs.unlinkSync(configPath); @@ -282,8 +366,8 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 }); - // Generate fresh config - const configContent = generateUnifiedConfigContent(effectivePort); + // Generate fresh config with preserved user API keys + const configContent = generateUnifiedConfigContent(effectivePort, userApiKeys); fs.writeFileSync(configPath, configContent, { mode: 0o600 }); return configPath; @@ -294,7 +378,7 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { * @returns true if config should be regenerated */ export function configNeedsRegeneration(): boolean { - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); if (!fs.existsSync(configPath)) { return false; // Will be created on first use } @@ -316,22 +400,29 @@ export function configNeedsRegeneration(): boolean { } /** - * Check if config exists for provider + * Check if config exists for port */ -export function configExists(): boolean { - return fs.existsSync(getConfigPath()); +export function configExists(port: number = CLIPROXY_DEFAULT_PORT): boolean { + return fs.existsSync(getConfigPathForPort(port)); } /** - * Delete config file + * Delete config file for specific port */ -export function deleteConfig(): void { - const configPath = getConfigPath(); +export function deleteConfigForPort(port: number): void { + const configPath = getConfigPathForPort(port); if (fs.existsSync(configPath)) { fs.unlinkSync(configPath); } } +/** + * Delete config file (default port) + */ +export function deleteConfig(): void { + deleteConfigForPort(CLIPROXY_DEFAULT_PORT); +} + /** * Get path to user settings file for provider * Example: ~/.ccs/gemini.settings.json @@ -358,7 +449,7 @@ export function getClaudeEnvVars( const coreEnvVars = { // Provider-specific endpoint - routes to correct provider via URL path ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`, - ANTHROPIC_AUTH_TOKEN: CCS_INTERNAL_API_KEY, + ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(), ANTHROPIC_MODEL: models.claudeModel, ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, @@ -463,7 +554,7 @@ export function getEffectiveEnvVars( // Priority 1: Custom settings path (for user-defined variants) if (customSettingsPath) { - const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir()); + const expandedPath = expandPath(customSettingsPath); if (fs.existsSync(expandedPath)) { try { const content = fs.readFileSync(expandedPath, 'utf-8'); @@ -569,7 +660,7 @@ export function getRemoteEnvVars( // Priority 1: Custom settings path (for user-defined variants) if (customSettingsPath) { - const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir()); + const expandedPath = expandPath(customSettingsPath); if (fs.existsSync(expandedPath)) { try { const content = fs.readFileSync(expandedPath, 'utf-8'); @@ -625,7 +716,7 @@ export function getRemoteEnvVars( ...userEnvVars, // Always override URL and auth token with remote config ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY, + ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(), }; return env; diff --git a/src/cliproxy/device-code-handler.ts b/src/cliproxy/device-code-handler.ts new file mode 100644 index 00000000..d1ccbc94 --- /dev/null +++ b/src/cliproxy/device-code-handler.ts @@ -0,0 +1,32 @@ +/** + * Device Code Handler + * + * Manages device code display prompts during OAuth Device Code flow. + * Broadcasts device code events via WebSocket to both CLI terminal + * and Web UI (ccs config). + * + * Events emitted by oauth-process.ts: + * - deviceCode:received - When device code is parsed from output + * - deviceCode:completed - When auth succeeds + * - deviceCode:failed - When auth fails + * - deviceCode:expired - When code expires (handled by UI timer) + */ + +import { EventEmitter } from 'events'; + +/** + * Device code prompt data sent to UI + */ +export interface DeviceCodePrompt { + sessionId: string; + provider: string; + userCode: string; + verificationUrl: string; + expiresAt: number; +} + +// Global event emitter for device code events +export const deviceCodeEvents = new EventEmitter(); + +// Default timeout for device code (15 minutes - GitHub's default) +export const DEVICE_CODE_TIMEOUT_MS = 900000; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 5e327180..6858f43d 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -55,6 +55,7 @@ export { generateConfig, regenerateConfig, configNeedsRegeneration, + parseUserApiKeys, getClaudeEnvVars, getEffectiveEnvVars, getProviderSettingsPath, @@ -64,7 +65,7 @@ export { getCliproxyDir, getProviderAuthDir, getAuthDir, - getConfigPath, + getCliproxyConfigPath, getBinDir, configExists, deleteConfig, @@ -138,3 +139,16 @@ export { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '. // Startup lock (prevents race conditions between CCS processes) export type { LockResult } from './startup-lock'; export { acquireStartupLock, withStartupLock } from './startup-lock'; + +// Auth token manager (customizable API key and management secret) +export { + generateSecureToken, + maskToken, + getEffectiveApiKey, + getEffectiveManagementSecret, + setGlobalApiKey, + setGlobalManagementSecret, + setVariantApiKey, + resetAuthToDefaults, + getAuthSummary, +} from './auth-token-manager'; diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index ab9e62b5..ddec180b 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -6,6 +6,7 @@ */ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { InteractivePrompt } from '../utils/prompt'; import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog'; @@ -14,7 +15,7 @@ import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; /** CCS directory */ -const CCS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.ccs'); +const CCS_DIR = path.join(os.homedir(), '.ccs'); /** * Check if provider has user settings configured @@ -34,7 +35,7 @@ export function getCurrentModel( customSettingsPath?: string ): string | undefined { const settingsPath = customSettingsPath - ? customSettingsPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '') + ? customSettingsPath.replace(/^~/, os.homedir()) : getProviderSettingsPath(provider); if (!fs.existsSync(settingsPath)) return undefined; @@ -93,7 +94,7 @@ export async function configureProviderModel( // Use custom settings path for CLIProxy variants, otherwise use default provider path const settingsPath = customSettingsPath - ? customSettingsPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '') + ? customSettingsPath.replace(/^~/, os.homedir()) : getProviderSettingsPath(provider); // Skip if already configured (unless --config flag) diff --git a/src/cliproxy/openai-compat-manager.ts b/src/cliproxy/openai-compat-manager.ts index 5254a156..9bb8dfd4 100644 --- a/src/cliproxy/openai-compat-manager.ts +++ b/src/cliproxy/openai-compat-manager.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as yaml from 'js-yaml'; -import { getConfigPath } from './config-generator'; +import { getCliproxyConfigPath } from './config-generator'; /** Model alias configuration */ export interface OpenAICompatModel { @@ -48,7 +48,7 @@ interface ConfigYaml { * Load current config.yaml */ function loadConfig(): ConfigYaml { - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); if (!fs.existsSync(configPath)) { return {}; } @@ -65,7 +65,7 @@ function loadConfig(): ConfigYaml { * Save config.yaml with proper formatting */ function saveConfig(config: ConfigYaml): void { - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); const content = yaml.dump(config, { lineWidth: -1, // Disable line wrapping quotingType: '"', diff --git a/src/cliproxy/proxy-config-resolver.ts b/src/cliproxy/proxy-config-resolver.ts index b0af403f..aebeb948 100644 --- a/src/cliproxy/proxy-config-resolver.ts +++ b/src/cliproxy/proxy-config-resolver.ts @@ -215,9 +215,12 @@ export function resolveProxyConfig( ...DEFAULT_PROXY_CONFIG, }; - // Determine mode: remote if host is specified anywhere (unless --local-proxy) + // Determine mode: remote if host is specified AND remote is not explicitly disabled + // Priority: CLI flags > ENV > YAML config + // If YAML config has enabled: false, it only blocks YAML-sourced config (CLI/ENV override) + const yamlRemoteEnabled = yamlConfig.remote?.enabled !== false; const hasRemoteHost = - cliFlags.host || envConfig.host || yamlConfig.remote?.host || yamlConfig.remote?.enabled; + cliFlags.host || envConfig.host || (yamlRemoteEnabled && yamlConfig.remote?.host); // --local-proxy forces local mode regardless of remote config if (cliFlags.localProxy) { diff --git a/src/cliproxy/proxy-detector.ts b/src/cliproxy/proxy-detector.ts index 1a7bc812..d1d6afe6 100644 --- a/src/cliproxy/proxy-detector.ts +++ b/src/cliproxy/proxy-detector.ts @@ -15,7 +15,7 @@ * Solves race conditions between cliproxy-executor.ts and service-manager.ts */ -import { getExistingProxy, registerSession } from './session-tracker'; +import { getExistingProxy, registerSession, getRunningProxyVersion } from './session-tracker'; import { isCliproxyRunning } from './stats-fetcher'; import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils'; @@ -38,6 +38,8 @@ export interface ProxyStatus { blocker?: PortProcess; /** Number of active sessions (if session-lock found) */ sessionCount?: number; + /** Version of the running proxy (from session lock) */ + version?: string; } /** Optional logger function for verbose output */ @@ -86,13 +88,19 @@ export async function detectRunningProxy( } } - log(`HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'})`); + // Get version from session lock + const runningVersion = getRunningProxyVersion(port); + + log( + `HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'}, version: ${runningVersion ?? 'unknown'})` + ); return { running: true, verified: true, method: 'http', pid, sessionCount: lock?.sessions?.length, + version: runningVersion ?? undefined, }; } log('HTTP check failed, proxy not responding'); @@ -103,13 +111,17 @@ export async function detectRunningProxy( 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`); + const lockVersion = getRunningProxyVersion(port); + log( + `Session lock found: PID ${lock.pid}, ${lock.sessions.length} sessions, version: ${lockVersion ?? 'unknown'}` + ); return { running: true, verified: false, method: 'session-lock', pid: lock.pid, sessionCount: lock.sessions.length, + version: lockVersion ?? undefined, }; } log('No session lock found'); diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index f4d7c05b..89c70b1a 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -94,6 +94,11 @@ interface FetchAvailableModelsResponse { function readAccessToken(provider: CLIProxyProvider, accountId: string): string | null { const authDir = getAuthDir(); + // Check if auth directory exists + if (!fs.existsSync(authDir)) { + return null; + } + // Account ID format: email with @ and . replaced by _ // Try to find matching token file const files = fs.readdirSync(authDir); @@ -232,10 +237,11 @@ async function fetchAvailableModels(accessToken: string, projectId: string): Pro const remaining = quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining; - if (typeof remaining !== 'number') continue; + // Skip invalid values (NaN, Infinity, non-numbers) + if (typeof remaining !== 'number' || !isFinite(remaining)) continue; - // Convert to percentage (0-100) - const percentage = Math.round(remaining * 100); + // Convert to percentage (0-100) and clamp to valid range + const percentage = Math.max(0, Math.min(100, Math.round(remaining * 100))); // Extract reset time const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null; diff --git a/src/cliproxy/remote-proxy-client.ts b/src/cliproxy/remote-proxy-client.ts index b8ab2c4e..f6b51385 100644 --- a/src/cliproxy/remote-proxy-client.ts +++ b/src/cliproxy/remote-proxy-client.ts @@ -36,7 +36,7 @@ export interface RemoteProxyClientConfig { * Remote proxy port. * Optional - defaults based on protocol: * - HTTPS: 443 - * - HTTP: 80 + * - HTTP: 8317 (CLIProxyAPI default) */ port?: number; /** Protocol to use (http or https) */ @@ -52,8 +52,16 @@ export interface RemoteProxyClientConfig { /** Default timeout for remote proxy requests (aggressive for CLI UX) */ const DEFAULT_TIMEOUT_MS = 2000; -/** Default CLIProxyAPI port */ -const DEFAULT_CLIPROXY_PORT = 8317; +/** + * Get default port for CLIProxyAPI based on protocol. + * - HTTP: 8317 (CLIProxyAPI default for local/dev scenarios) + * - HTTPS: 443 (standard SSL port for production remote servers) + * + * This matches the UI labels shown to users. + */ +function getDefaultPort(protocol: 'http' | 'https'): number { + return protocol === 'https' ? 443 : 8317; +} /** * Get standard web port for protocol (for URL display omission) @@ -65,11 +73,11 @@ function getStandardWebPort(protocol: 'http' | 'https'): number { } /** - * Get effective port for CLIProxyAPI connection - * If port is provided, use it. Otherwise use CLIProxyAPI default (8317). + * Get effective port for CLIProxyAPI connection. + * If port is provided, use it. Otherwise use protocol-based default. */ -function getEffectivePort(port: number | undefined): number { - return port ?? DEFAULT_CLIPROXY_PORT; +function getEffectivePort(port: number | undefined, protocol: 'http' | 'https'): number { + return port ?? getDefaultPort(protocol); } /** @@ -83,7 +91,7 @@ function buildProxyUrl( protocol: 'http' | 'https', path: string ): string { - const effectivePort = getEffectivePort(port); + const effectivePort = getEffectivePort(port, protocol); const standardWebPort = getStandardWebPort(protocol); // Only omit port from URL if it matches the standard web port for the protocol diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts index 69a36ded..4294b956 100644 --- a/src/cliproxy/services/variant-config-adapter.ts +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -5,13 +5,20 @@ */ import * as fs from 'fs'; -import { getConfigPath, loadConfig } from '../../utils/config-manager'; +import { getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { CLIProxyProvider } from '../types'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig, isUnifiedMode, } from '../../config/unified-config-loader'; +import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; + +/** First port for variant profiles (8318 = default + 1) */ +export const VARIANT_PORT_BASE = CLIPROXY_DEFAULT_PORT + 1; + +/** Maximum port offset for variants (100 ports: 8318-8417) */ +export const VARIANT_PORT_MAX_OFFSET = 100; /** Variant configuration structure */ export interface VariantConfig { @@ -19,6 +26,7 @@ export interface VariantConfig { settings?: string; account?: string; model?: string; + port?: number; } /** @@ -30,13 +38,41 @@ export function variantExistsInConfig(name: string): boolean { const config = loadOrCreateUnifiedConfig(); return !!(config.cliproxy?.variants && name in config.cliproxy.variants); } - const config = loadConfig(); + const config = loadConfigSafe(); return !!(config.cliproxy && name in config.cliproxy); } catch { return false; } } +/** + * Get next available port for a new variant. + * Scans existing variants, returns first unused port starting from VARIANT_PORT_BASE. + */ +export function getNextAvailablePort(): number { + const variants = listVariantsFromConfig(); + const usedPorts = new Set(); + + for (const name of Object.keys(variants)) { + const port = variants[name].port; + if (port) usedPorts.add(port); + } + + // Find first available port in range + for (let offset = 0; offset < VARIANT_PORT_MAX_OFFSET; offset++) { + const port = VARIANT_PORT_BASE + offset; + if (!usedPorts.has(port)) { + return port; + } + } + + const variantCount = Object.keys(variants).length; + throw new Error( + `Port limit reached (${variantCount}/${VARIANT_PORT_MAX_OFFSET} variants). ` + + `Delete unused variants with 'ccs cliproxy remove ' to free ports.` + ); +} + /** * List variants from config */ @@ -48,17 +84,32 @@ export function listVariantsFromConfig(): Record { const result: Record = {}; for (const name of Object.keys(variants)) { const v = variants[name]; - result[name] = { provider: v.provider, settings: v.settings, account: v.account }; + result[name] = { + provider: v.provider, + settings: v.settings, + account: v.account, + port: v.port, + }; } return result; } - const config = loadConfig(); + const config = loadConfigSafe(); const variants = config.cliproxy || {}; const result: Record = {}; for (const name of Object.keys(variants)) { - const v = variants[name] as { provider: string; settings: string; account?: string }; - result[name] = { provider: v.provider, settings: v.settings, account: v.account }; + const v = variants[name] as { + provider: string; + settings: string; + account?: string; + port?: number; + }; + result[name] = { + provider: v.provider, + settings: v.settings, + account: v.account, + port: v.port, + }; } return result; } catch { @@ -73,7 +124,8 @@ export function saveVariantUnified( name: string, provider: CLIProxyProvider, settingsPath: string, - account?: string + account?: string, + port?: number ): void { const config = loadOrCreateUnifiedConfig(); @@ -92,6 +144,7 @@ export function saveVariantUnified( provider, account, settings: settingsPath, + port, }; saveUnifiedConfig(config); @@ -104,7 +157,8 @@ export function saveVariantLegacy( name: string, provider: string, settingsPath: string, - account?: string + account?: string, + port?: number ): void { const configPath = getConfigPath(); @@ -119,13 +173,16 @@ export function saveVariantLegacy( config.cliproxy = {}; } - const variantConfig: { provider: string; settings: string; account?: string } = { + const variantConfig: { provider: string; settings: string; account?: string; port?: number } = { provider, settings: settingsPath, }; if (account) { variantConfig.account = account; } + if (port) { + variantConfig.port = port; + } config.cliproxy[name] = variantConfig; const tempPath = configPath + '.tmp'; @@ -147,7 +204,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu delete config.cliproxy.variants[name]; saveUnifiedConfig(config); - return { provider: variant.provider, settings: variant.settings }; + return { provider: variant.provider, settings: variant.settings, port: variant.port }; } /** @@ -167,7 +224,7 @@ export function removeVariantFromLegacyConfig(name: string): VariantConfig | nul return null; } - const variant = config.cliproxy[name] as { provider: string; settings: string }; + const variant = config.cliproxy[name] as { provider: string; settings: string; port?: number }; delete config.cliproxy[name]; if (Object.keys(config.cliproxy).length === 0) { diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 8429ce08..58e4e05d 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -5,16 +5,20 @@ * Supports both unified config (config.yaml) and legacy JSON format. */ +import * as os from 'os'; import * as path from 'path'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { CLIProxyProvider } from '../types'; import { isReservedName } from '../../config/reserved-names'; import { isUnifiedMode } from '../../config/unified-config-loader'; +import { deleteConfigForPort } from '../config-generator'; +import { deleteSessionLockForPort } from '../session-tracker'; import { createSettingsFile, createSettingsFileUnified, deleteSettingsFile, getRelativeSettingsPath, + updateSettingsModel, } from './variant-settings'; import { VariantConfig, @@ -24,6 +28,7 @@ import { saveVariantLegacy, removeVariantFromUnifiedConfig, removeVariantFromLegacyConfig, + getNextAvailablePort, } from './variant-config-adapter'; // Re-export VariantConfig from adapter @@ -80,25 +85,29 @@ export function createVariant( account?: string ): VariantOperationResult { try { + // Allocate unique port for this variant + const port = getNextAvailablePort(); + let settingsPath: string; if (isUnifiedMode()) { - settingsPath = createSettingsFileUnified(name, provider, model); + settingsPath = createSettingsFileUnified(name, provider, model, port); saveVariantUnified( name, provider as CLIProxyProvider, getRelativeSettingsPath(provider, name), - account + account, + port ); } else { - settingsPath = createSettingsFile(name, provider, model); - saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account); + settingsPath = createSettingsFile(name, provider, model, port); + saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account, port); } return { success: true, settingsPath, - variant: { provider, model, account }, + variant: { provider, model, account, port }, }; } catch (error) { return { @@ -120,12 +129,22 @@ export function removeVariant(name: string): VariantOperationResult { if (unifiedVariant?.settings) { deleteSettingsFile(unifiedVariant.settings); } + // Clean up port-specific config and session files + if (unifiedVariant?.port) { + deleteConfigForPort(unifiedVariant.port); + deleteSessionLockForPort(unifiedVariant.port); + } variant = unifiedVariant; } else { variant = removeVariantFromLegacyConfig(name); if (variant?.settings) { deleteSettingsFile(variant.settings); } + // Clean up port-specific config and session files + if (variant?.port) { + deleteConfigForPort(variant.port); + deleteSessionLockForPort(variant.port); + } } if (!variant) { @@ -137,3 +156,67 @@ export function removeVariant(name: string): VariantOperationResult { return { success: false, error: (error as Error).message }; } } + +/** Update options for variant */ +export interface UpdateVariantOptions { + provider?: CLIProxyProfileName; + account?: string; + model?: string; +} + +/** + * Update an existing CLIProxy variant + */ +export function updateVariant(name: string, updates: UpdateVariantOptions): VariantOperationResult { + try { + const variants = listVariantsFromConfig(); + const existing = variants[name]; + + if (!existing) { + return { success: false, error: `Variant '${name}' not found` }; + } + + // Update model in settings file if provided + if (updates.model !== undefined && existing.settings) { + const settingsPath = existing.settings.replace(/^~/, os.homedir()); + updateSettingsModel(settingsPath, updates.model); + } + + // Update config entry if provider or account changed + if (updates.provider !== undefined || updates.account !== undefined) { + const newProvider = updates.provider ?? existing.provider; + const newAccount = updates.account !== undefined ? updates.account : existing.account; + + if (isUnifiedMode()) { + saveVariantUnified( + name, + newProvider as CLIProxyProvider, + existing.settings || '', + newAccount || undefined, + existing.port + ); + } else { + saveVariantLegacy( + name, + newProvider, + existing.settings || '', + newAccount || undefined, + existing.port + ); + } + } + + return { + success: true, + variant: { + provider: updates.provider ?? existing.provider, + model: updates.model ?? existing.model, + account: updates.account !== undefined ? updates.account : existing.account, + port: existing.port, + settings: existing.settings, + }, + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index b3c4b185..646a1a76 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import * as os from 'os'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { getCcsDir } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { CLIProxyProvider } from '../types'; @@ -30,8 +31,12 @@ interface SettingsFile { /** * Build settings env object for a variant */ -function buildSettingsEnv(provider: CLIProxyProfileName, model: string): SettingsEnv { - const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT); +function buildSettingsEnv( + provider: CLIProxyProfileName, + model: string, + port: number = CLIPROXY_DEFAULT_PORT +): SettingsEnv { + const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, port); return { ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', @@ -87,13 +92,14 @@ export function getRelativeSettingsPath(provider: CLIProxyProfileName, name: str export function createSettingsFile( name: string, provider: CLIProxyProfileName, - model: string + model: string, + port: number = CLIPROXY_DEFAULT_PORT ): string { const ccsDir = getCcsDir(); const settingsPath = getSettingsFilePath(provider, name); const settings: SettingsFile = { - env: buildSettingsEnv(provider, model), + env: buildSettingsEnv(provider, model, port), }; ensureDir(ccsDir); @@ -108,13 +114,14 @@ export function createSettingsFile( export function createSettingsFileUnified( name: string, provider: CLIProxyProfileName, - model: string + model: string, + port: number = CLIPROXY_DEFAULT_PORT ): string { - const ccsDir = path.join(os.homedir(), '.ccs'); + const ccsDir = getCcsDir(); // Use centralized function for CCS_HOME support const settingsPath = path.join(ccsDir, getSettingsFileName(provider, name)); const settings: SettingsFile = { - env: buildSettingsEnv(provider, model), + env: buildSettingsEnv(provider, model, port), }; ensureDir(ccsDir); @@ -124,13 +131,43 @@ export function createSettingsFileUnified( } /** - * Delete settings file if it exists + * Delete settings file if it exists. + * Uses expandPath() for cross-platform path handling. */ export function deleteSettingsFile(settingsPath: string): boolean { - const resolvedPath = settingsPath.replace(/^~/, os.homedir()); + const resolvedPath = expandPath(settingsPath); if (fs.existsSync(resolvedPath)) { fs.unlinkSync(resolvedPath); return true; } return false; } + +/** + * Update model in an existing settings file + */ +export function updateSettingsModel(settingsPath: string, model: string): void { + const resolvedPath = settingsPath.replace(/^~/, os.homedir()); + if (!fs.existsSync(resolvedPath)) { + return; + } + + try { + const content = fs.readFileSync(resolvedPath, 'utf8'); + const settings = JSON.parse(content) as SettingsFile; + + if (model) { + settings.env = settings.env || ({} as SettingsEnv); + settings.env.ANTHROPIC_MODEL = model; + settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = model; + settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = model; + } else { + // Clear model settings to use defaults + delete (settings.env as unknown as Record).ANTHROPIC_MODEL; + } + + fs.writeFileSync(resolvedPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + } catch { + // Ignore errors - settings file may be invalid + } +} diff --git a/src/cliproxy/session-tracker.ts b/src/cliproxy/session-tracker.ts index 33b3fe19..2279eeba 100644 --- a/src/cliproxy/session-tracker.ts +++ b/src/cliproxy/session-tracker.ts @@ -27,6 +27,8 @@ interface SessionLock { pid: number; sessions: string[]; startedAt: string; + /** CLIProxy version running (added for version mismatch detection) */ + version?: string; } /** Generate unique session ID */ @@ -34,14 +36,28 @@ function generateSessionId(): string { return crypto.randomBytes(8).toString('hex'); } -/** Get path to session lock file */ -function getSessionLockPath(): string { - return path.join(getCliproxyDir(), 'sessions.json'); +/** Get path to session lock file for specific port */ +function getSessionLockPathForPort(port: number): string { + if (port === CLIPROXY_DEFAULT_PORT) { + return path.join(getCliproxyDir(), 'sessions.json'); + } + return path.join(getCliproxyDir(), `sessions-${port}.json`); } -/** Read session lock file (returns null if not exists or invalid) */ -function readSessionLock(): SessionLock | null { - const lockPath = getSessionLockPath(); +/** Get path to session lock file (default port) - kept for future use */ +function _getSessionLockPath(): string { + return getSessionLockPathForPort(CLIPROXY_DEFAULT_PORT); +} + +// Re-export for external use +export { _getSessionLockPath as getSessionLockPath }; + +// Export deleteSessionLockForPort for cleanup operations +export { deleteSessionLockForPort }; + +/** Read session lock file for specific port (returns null if not exists or invalid) */ +function readSessionLockForPort(port: number): SessionLock | null { + const lockPath = getSessionLockPathForPort(port); try { if (!fs.existsSync(lockPath)) { return null; @@ -62,9 +78,14 @@ function readSessionLock(): SessionLock | null { } } -/** Write session lock file */ -function writeSessionLock(lock: SessionLock): void { - const lockPath = getSessionLockPath(); +/** Read session lock file (default port, returns null if not exists or invalid) */ +function readSessionLock(): SessionLock | null { + return readSessionLockForPort(CLIPROXY_DEFAULT_PORT); +} + +/** Write session lock file for specific port */ +function writeSessionLockForPort(lock: SessionLock): void { + const lockPath = getSessionLockPathForPort(lock.port); const dir = path.dirname(lockPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); @@ -72,9 +93,9 @@ function writeSessionLock(lock: SessionLock): void { fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { mode: 0o600 }); } -/** Delete session lock file */ -function deleteSessionLock(): void { - const lockPath = getSessionLockPath(); +/** Delete session lock file for specific port */ +function deleteSessionLockForPort(port: number): void { + const lockPath = getSessionLockPathForPort(port); try { if (fs.existsSync(lockPath)) { fs.unlinkSync(lockPath); @@ -84,23 +105,51 @@ function deleteSessionLock(): void { } } +/** Delete session lock file (default port) */ +function deleteSessionLock(): void { + deleteSessionLockForPort(CLIPROXY_DEFAULT_PORT); +} + /** Check if a PID is still running */ function isProcessRunning(pid: number): boolean { try { // Sending signal 0 checks if process exists without killing it process.kill(pid, 0); return true; - } catch { + } catch (err) { + const e = err as NodeJS.ErrnoException; + // EPERM means process exists but we don't have permission to signal it + if (e.code === 'EPERM') { + return true; + } + // ESRCH means no such process return false; } } +/** + * Wait for a process to exit within a timeout. + * @param pid Process ID to wait for + * @param timeoutMs Maximum time to wait in milliseconds + * @returns true if process exited, false if timeout + */ +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (!isProcessRunning(pid)) { + return true; // Process exited + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; // Timeout +} + /** * Check if there's an existing proxy running that we can reuse. * Returns the existing lock if proxy is healthy, null otherwise. */ export function getExistingProxy(port: number): SessionLock | null { - const lock = readSessionLock(); + const lock = readSessionLockForPort(port); if (!lock) { return null; } @@ -113,7 +162,7 @@ export function getExistingProxy(port: number): SessionLock | null { // Verify proxy process is still running if (!isProcessRunning(lock.pid)) { // Proxy crashed - clean up stale lock - deleteSessionLock(); + deleteSessionLockForPort(port); return null; } @@ -123,16 +172,19 @@ export function getExistingProxy(port: number): SessionLock | null { /** * Register a new session with the proxy. * Call this when starting a new CCS session that will use an existing proxy. + * @param port Port the proxy is running on + * @param proxyPid PID of the proxy process + * @param version Optional CLIProxy version (stored when spawning new proxy) * @returns Session ID for this session */ -export function registerSession(port: number, proxyPid: number): string { +export function registerSession(port: number, proxyPid: number, version?: string): string { const sessionId = generateSessionId(); - const existingLock = readSessionLock(); + const existingLock = readSessionLockForPort(port); if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) { // Add to existing sessions existingLock.sessions.push(sessionId); - writeSessionLock(existingLock); + writeSessionLockForPort(existingLock); } else { // Create new lock (first session for this proxy) const newLock: SessionLock = { @@ -140,8 +192,9 @@ export function registerSession(port: number, proxyPid: number): string { pid: proxyPid, sessions: [sessionId], startedAt: new Date().toISOString(), + version, }; - writeSessionLock(newLock); + writeSessionLockForPort(newLock); } return sessionId; @@ -149,9 +202,33 @@ export function registerSession(port: number, proxyPid: number): string { /** * Unregister a session from the proxy. + * @param sessionId Session ID to unregister + * @param port Port to unregister from (optional, searches default port if not provided) * @returns true if this was the last session (proxy should be killed) */ -export function unregisterSession(sessionId: string): boolean { +export function unregisterSession(sessionId: string, port?: number): boolean { + // If port provided, use port-specific lookup + if (port !== undefined) { + const lock = readSessionLockForPort(port); + if (!lock) { + return true; + } + + const index = lock.sessions.indexOf(sessionId); + if (index !== -1) { + lock.sessions.splice(index, 1); + } + + if (lock.sessions.length === 0) { + deleteSessionLockForPort(port); + return true; + } + + writeSessionLockForPort(lock); + return false; + } + + // Fallback: search default port (backward compat) const lock = readSessionLock(); if (!lock) { // No lock file - assume we're the only session @@ -172,15 +249,16 @@ export function unregisterSession(sessionId: string): boolean { } // Other sessions still active - keep proxy running - writeSessionLock(lock); + writeSessionLockForPort(lock); return false; } /** * Get current session count for the proxy. + * @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT) */ -export function getSessionCount(): number { - const lock = readSessionLock(); +export function getSessionCount(port: number = CLIPROXY_DEFAULT_PORT): number { + const lock = readSessionLockForPort(port); if (!lock) { return 0; } @@ -190,16 +268,17 @@ export function getSessionCount(): number { /** * Check if proxy has any active sessions. * Used to determine if a "zombie" proxy should be killed. + * @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT) */ -export function hasActiveSessions(): boolean { - const lock = readSessionLock(); +export function hasActiveSessions(port: number = CLIPROXY_DEFAULT_PORT): boolean { + const lock = readSessionLockForPort(port); if (!lock) { return false; } // Verify proxy is still running if (!isProcessRunning(lock.pid)) { - deleteSessionLock(); + deleteSessionLockForPort(port); return false; } @@ -211,38 +290,39 @@ export function hasActiveSessions(): boolean { * Called on startup to ensure clean state. */ export function cleanupOrphanedSessions(port: number): void { - const lock = readSessionLock(); + const lock = readSessionLockForPort(port); if (!lock) { return; } - // If port doesn't match, this lock is for a different proxy + // If port doesn't match, this shouldn't happen with port-specific files if (lock.port !== port) { return; } // If proxy is dead, clean up lock if (!isProcessRunning(lock.pid)) { - deleteSessionLock(); + deleteSessionLockForPort(port); } } /** * Stop the CLIProxy process and clean up session lock. * Falls back to port-based detection if no session lock exists. + * @param port Port to stop (defaults to CLIPROXY_DEFAULT_PORT) * @returns Object with success status and details */ -export async function stopProxy(): Promise<{ +export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{ stopped: boolean; pid?: number; sessionCount?: number; error?: string; }> { - const lock = readSessionLock(); + const lock = readSessionLockForPort(port); if (!lock) { // No session lock - try to find process by port (legacy/untracked proxy) - const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); + const portProcess = await getPortProcess(port); if (!portProcess) { return { stopped: false, error: 'No active CLIProxy session found' }; @@ -251,13 +331,26 @@ export async function stopProxy(): Promise<{ if (!isCLIProxyProcess(portProcess)) { return { stopped: false, - error: `Port ${CLIPROXY_DEFAULT_PORT} is in use by ${portProcess.processName}, not CLIProxy`, + error: `Port ${port} is in use by ${portProcess.processName}, not CLIProxy`, }; } // Found CLIProxy running without session lock - kill it try { process.kill(portProcess.pid, 'SIGTERM'); + + // Wait for graceful shutdown + const exited = await waitForProcessExit(portProcess.pid, 3000); + if (!exited) { + // Escalate to SIGKILL + try { + process.kill(portProcess.pid, 'SIGKILL'); + await waitForProcessExit(portProcess.pid, 1000); + } catch { + // Process may have exited between check and kill + } + } + return { stopped: true, pid: portProcess.pid, sessionCount: 0 }; } catch (err) { const error = err as NodeJS.ErrnoException; @@ -270,7 +363,7 @@ export async function stopProxy(): Promise<{ // Check if proxy is running if (!isProcessRunning(lock.pid)) { - deleteSessionLock(); + deleteSessionLockForPort(port); return { stopped: false, error: 'CLIProxy was not running (cleaned up stale lock)' }; } @@ -281,15 +374,27 @@ export async function stopProxy(): Promise<{ // Kill the proxy process process.kill(pid, 'SIGTERM'); + // Wait for graceful shutdown + const exited = await waitForProcessExit(pid, 3000); + if (!exited) { + // Escalate to SIGKILL + try { + process.kill(pid, 'SIGKILL'); + await waitForProcessExit(pid, 1000); + } catch { + // Process may have exited between check and kill + } + } + // Clean up session lock - deleteSessionLock(); + deleteSessionLockForPort(port); return { stopped: true, pid, sessionCount }; } catch (err) { const error = err as NodeJS.ErrnoException; if (error.code === 'ESRCH') { // Process already gone - deleteSessionLock(); + deleteSessionLockForPort(port); return { stopped: false, error: 'CLIProxy process already terminated' }; } return { stopped: false, pid, error: `Failed to stop: ${error.message}` }; @@ -297,16 +402,17 @@ export async function stopProxy(): Promise<{ } /** - * Get proxy status information. + * Get proxy status information for specific port. */ -export function getProxyStatus(): { +export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): { running: boolean; port?: number; pid?: number; sessionCount?: number; startedAt?: string; + version?: string; } { - const lock = readSessionLock(); + const lock = readSessionLockForPort(port); if (!lock) { return { running: false }; @@ -314,7 +420,7 @@ export function getProxyStatus(): { // Verify proxy is still running if (!isProcessRunning(lock.pid)) { - deleteSessionLock(); + deleteSessionLockForPort(port); return { running: false }; } @@ -324,5 +430,26 @@ export function getProxyStatus(): { pid: lock.pid, sessionCount: lock.sessions.length, startedAt: lock.startedAt, + version: lock.version, }; } + +/** + * Get the version of the running proxy from session lock. + * @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT) + * @returns Version string if available, null otherwise + */ +export function getRunningProxyVersion(port: number = CLIPROXY_DEFAULT_PORT): string | null { + const lock = readSessionLockForPort(port); + if (!lock) { + return null; + } + + // Verify proxy is still running + if (!isProcessRunning(lock.pid)) { + deleteSessionLockForPort(port); + return null; + } + + return lock.version ?? null; +} diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index f66e987d..ee74b8a9 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -5,7 +5,7 @@ * Requires usage-statistics-enabled: true in config.yaml. */ -import { CCS_CONTROL_PANEL_SECRET } from './config-generator'; +import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager'; import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver'; /** Per-account usage statistics */ @@ -112,7 +112,7 @@ export async function fetchCliproxyStats(port?: number): Promise { console.log(''); console.log(subheader('Options')); console.log( - ` ${color('--preset ', 'command')} Use provider preset (openrouter, glm, glmt, kimi, foundry)` + ` ${color('--preset ', 'command')} Use provider preset (openrouter, glm, glmt, kimi, foundry, minimax, deepseek, qwen)` ); console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); @@ -442,6 +442,11 @@ async function showHelp(): Promise { console.log(` ${color('glmt', 'command')} GLMT - GLM with Thinking mode`); console.log(` ${color('kimi', 'command')} Kimi - Moonshot AI reasoning model`); console.log(` ${color('foundry', 'command')} Azure Foundry - Claude via Microsoft Azure`); + console.log(` ${color('minimax', 'command')} Minimax - M2 series with 1M context`); + console.log(` ${color('deepseek', 'command')} DeepSeek - V3.2 and R1 reasoning (128K)`); + console.log( + ` ${color('qwen', 'command')} Qwen - Alibaba Cloud qwen3-coder-plus (256K)` + ); console.log(''); console.log(subheader('Examples')); console.log(` ${dim('# Interactive wizard')}`); diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 2c14804b..e0e93e35 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -256,9 +256,10 @@ async function handleCreate(args: string[]): Promise { const settingsDisplay = isUnifiedMode() ? '~/.ccs/config.yaml' : `~/.ccs/${path.basename(result.settingsPath || '')}`; + const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : ''; console.log( infoBox( - `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`, + `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`, configType ) ); @@ -303,10 +304,11 @@ async function handleList(): Promise { console.log(subheader('Custom Variants')); const rows = variantNames.map((name) => { const variant = variants[name]; - return [name, variant.provider, variant.settings || '-']; + const portStr = variant.port ? String(variant.port) : '-'; + return [name, variant.provider, portStr, variant.settings || '-']; }); console.log( - table(rows, { head: ['Variant', 'Provider', 'Settings'], colWidths: [15, 12, 35] }) + table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] }) ); console.log(''); console.log(dim(`Total: ${variantNames.length} custom variant(s)`)); @@ -357,6 +359,9 @@ async function handleRemove(args: string[]): Promise { console.log(''); console.log(`Variant '${color(name, 'command')}' will be removed.`); console.log(` Provider: ${variant.provider}`); + if (variant.port) { + console.log(` Port: ${variant.port}`); + } console.log(` Settings: ${variant.settings || '-'}`); console.log(''); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 6261dc2a..abb4ee01 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui'; +import { isUnifiedMode } from '../config/unified-config-loader'; // Get version from package.json (same as version-command.ts) const VERSION = JSON.parse( @@ -173,6 +174,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs --config', 'Change model (agy, gemini)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], + ['ccs kiro --import', 'Import token from Kiro IDE'], + ['ccs kiro --incognito', 'Use incognito browser (default: normal)'], ['ccs codex "explain code"', 'Use with prompt'], ] ); @@ -213,6 +216,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Diagnostics printSubSection('Diagnostics', [ + ['ccs setup', 'First-time setup wizard'], ['ccs doctor', 'Run health check and diagnostics'], ['ccs cleanup', 'Remove old CLIProxy logs'], ['ccs config', 'Open web configuration dashboard'], @@ -232,7 +236,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Configuration printConfigSection('Configuration', [ - ['Config File:', '~/.ccs/config.json'], + ['Config File:', isUnifiedMode() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'], ['Profiles:', '~/.ccs/profiles.json'], ['Instances:', '~/.ccs/instances/'], ['Settings:', '~/.ccs/*.settings.json'], diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts new file mode 100644 index 00000000..5b7acf5a --- /dev/null +++ b/src/commands/setup-command.ts @@ -0,0 +1,470 @@ +/** + * Setup Command Handler + * + * Interactive first-time setup wizard for CCS. + * Guides users through initial configuration including: + * - Local vs Remote CLIProxy mode selection + * - Remote proxy configuration (host, port, auth token) + * - Default profile selection + * + * Usage: ccs setup + * + * Related: Issue #142 - remote CLIProxyAPI configuration + */ + +import * as readline from 'readline'; +import * as fs from 'fs'; +import * as path from 'path'; +import { initUI, header, ok, info, warn } from '../utils/ui'; +import { + loadOrCreateUnifiedConfig, + loadUnifiedConfig, + saveUnifiedConfig, + hasUnifiedConfig, +} from '../config/unified-config-loader'; +import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; +import { getCcsDir } from '../utils/config-manager'; + +/** Custom error for user cancellation (Ctrl+C) */ +class UserCancelledError extends Error { + constructor() { + super('Setup cancelled by user'); + this.name = 'UserCancelledError'; + } +} + +/** + * Create readline interface for interactive prompts + */ +function createReadline(): readline.Interface { + return readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); +} + +/** + * Prompt user for input with optional default value + * Handles Ctrl+C gracefully by rejecting with UserCancelledError + */ +async function prompt( + rl: readline.Interface, + question: string, + defaultValue?: string +): Promise { + return new Promise((resolve, reject) => { + const displayQuestion = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `; + + const onClose = () => { + reject(new UserCancelledError()); + }; + + rl.once('close', onClose); + rl.question(displayQuestion, (answer) => { + rl.removeListener('close', onClose); + resolve(answer.trim() || defaultValue || ''); + }); + }); +} + +/** + * Prompt user for yes/no confirmation + */ +async function confirm( + rl: readline.Interface, + question: string, + defaultYes: boolean = true +): Promise { + const hint = defaultYes ? '[Y/n]' : '[y/N]'; + const answer = await prompt(rl, `${question} ${hint}`); + + if (answer === '') return defaultYes; + return answer.toLowerCase().startsWith('y'); +} + +/** + * Prompt user to select from numbered options + */ +async function selectOption( + rl: readline.Interface, + question: string, + options: { label: string; value: string; description?: string }[] +): Promise { + console.log(''); + console.log(question); + console.log(''); + + options.forEach((opt, idx) => { + const desc = opt.description ? ` - ${opt.description}` : ''; + console.log(` ${idx + 1}) ${opt.label}${desc}`); + }); + + console.log(''); + const answer = await prompt(rl, 'Enter choice (number)', '1'); + const idx = parseInt(answer, 10) - 1; + + if (idx >= 0 && idx < options.length) { + return options[idx].value; + } + + // Invalid selection, default to first + console.log(warn(`Invalid selection, using default: ${options[0].label}`)); + return options[0].value; +} + +/** + * Check if this is a first-time install (config exists but is empty/unconfigured) + * Returns true if user should be prompted to run setup wizard + * + * IMPORTANT: Also checks legacy config.json for existing profiles to avoid + * treating users with existing GLM/Kimi setups as "first-time installs" + * (Fix for issue #195 - GLM auth persistence regression) + */ +export function isFirstTimeInstall(): boolean { + // Check unified config first (config.yaml) + if (hasUnifiedConfig()) { + const loaded = loadUnifiedConfig(); + + // Config exists but is corrupted/invalid - don't treat as first-time + if (loaded === null) { + console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted')); + console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose')); + return false; + } + + // Check for any meaningful configuration in unified config + const hasProfiles = Object.keys(loaded.profiles || {}).length > 0; + const hasAccounts = Object.keys(loaded.accounts || {}).length > 0; + const hasVariants = Object.keys(loaded.cliproxy?.variants || {}).length > 0; + const hasOAuthAccounts = Object.keys(loaded.cliproxy?.oauth_accounts || {}).length > 0; + const hasRemoteProxy = + loaded.cliproxy_server?.remote?.enabled && loaded.cliproxy_server?.remote?.host; + + // If any of these exist in unified config, user has configured something + if (hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy) { + return false; + } + } + + // Also check legacy config.json for existing profiles + // This prevents treating users with GLM/Kimi in config.json as "first-time installs" + const ccsDir = getCcsDir(); + const legacyConfigPath = path.join(ccsDir, 'config.json'); + + if (fs.existsSync(legacyConfigPath)) { + try { + const content = fs.readFileSync(legacyConfigPath, 'utf8'); + const legacyConfig = JSON.parse(content) as { profiles?: Record }; + + if (legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0) { + // Has legacy profiles - NOT first time + return false; + } + } catch { + // Legacy config exists but is invalid - ignore and continue + } + } + + // Also check profiles.json for existing accounts + const legacyProfilesPath = path.join(ccsDir, 'profiles.json'); + + if (fs.existsSync(legacyProfilesPath)) { + try { + const content = fs.readFileSync(legacyProfilesPath, 'utf8'); + const legacyProfiles = JSON.parse(content) as { profiles?: Record }; + + if (legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0) { + // Has legacy accounts - NOT first time + return false; + } + } catch { + // Legacy profiles exists but is invalid - ignore and continue + } + } + + // No meaningful configuration found anywhere + return true; +} + +/** + * Configure remote CLIProxy settings interactively + */ +async function configureRemoteProxy(rl: readline.Interface): Promise<{ + host: string; + port?: number; + protocol: 'http' | 'https'; + authToken: string; +}> { + console.log(''); + console.log(info('Configure Remote CLIProxyAPI Connection')); + console.log(''); + console.log(' Enter the details for your remote CLIProxyAPI server.'); + console.log(' Example: your-server.example.com'); + console.log(''); + + // Host - with protocol stripping + let host = await prompt(rl, 'Remote host (hostname or IP)'); + if (!host) { + throw new Error('Host is required for remote proxy mode'); + } + // Strip protocol if user included it (common mistake) + host = host.replace(/^https?:\/\//, ''); + // Strip trailing slashes + host = host.replace(/\/+$/, ''); + + // Protocol + const protocol = (await selectOption(rl, 'Protocol:', [ + { label: 'HTTPS', value: 'https', description: 'Secure connection (recommended)' }, + { label: 'HTTP', value: 'http', description: 'Unencrypted connection' }, + ])) as 'http' | 'https'; + + // Port (optional) - with validation + const defaultPort = protocol === 'https' ? '443' : '80'; + const portStr = await prompt(rl, `Port (leave empty for default ${defaultPort})`); + let port: number | undefined; + if (portStr) { + const parsed = parseInt(portStr, 10); + if (isNaN(parsed) || parsed < 1 || parsed > 65535 || !Number.isInteger(parsed)) { + console.log(warn(`Invalid port "${portStr}", using default: ${defaultPort}`)); + port = undefined; // Use default + } else { + port = parsed; + } + } + + // Auth token + console.log(''); + console.log(info('Authentication')); + console.log(' The auth token is configured in your CLIProxyAPI config.yaml'); + console.log(' under api-keys section. Example: "ccs-internal-managed"'); + console.log(''); + + const authToken = await prompt(rl, 'Auth token', 'ccs-internal-managed'); + + return { host, port, protocol, authToken }; +} + +/** + * Main setup wizard + */ +async function runSetupWizard(force: boolean = false): Promise { + const rl = createReadline(); + + try { + console.log(''); + console.log(header('CCS First-Time Setup')); + console.log(''); + + // Check if already configured + if (!force && !isFirstTimeInstall()) { + console.log(info('CCS is already configured.')); + console.log(' Use --force to reconfigure, or run `ccs config` for the dashboard.'); + console.log(''); + rl.close(); + return; + } + + console.log('Welcome to CCS (Claude Code Switch)!'); + console.log('This wizard will help you configure CCS for first-time use.'); + console.log(''); + + // Step 1: Local vs Remote mode + const proxyMode = await selectOption( + rl, + 'How do you want to use CLIProxy providers (gemini, codex, agy)?', + [ + { + label: 'Local (Recommended)', + value: 'local', + description: 'CCS auto-starts CLIProxyAPI binary on your machine', + }, + { + label: 'Remote Server', + value: 'remote', + description: 'Connect to a remote CLIProxyAPI instance (Issue #142)', + }, + { + label: 'Skip CLIProxy', + value: 'skip', + description: 'Only use API profiles (GLM, Kimi) or Claude accounts', + }, + ] + ); + + // Load or create config + const config = loadOrCreateUnifiedConfig(); + + if (proxyMode === 'remote') { + // Configure remote proxy + const remoteConfig = await configureRemoteProxy(rl); + + config.cliproxy_server = { + remote: { + enabled: true, + host: remoteConfig.host, + port: remoteConfig.port, + protocol: remoteConfig.protocol, + auth_token: remoteConfig.authToken, + }, + fallback: { + enabled: true, + auto_start: false, + }, + local: { + port: 8317, + auto_start: false, // Disable local auto-start when using remote + }, + }; + + console.log(''); + console.log(ok('Remote proxy configured successfully!')); + console.log(''); + console.log( + ` URL: ${remoteConfig.protocol}://${remoteConfig.host}${remoteConfig.port ? `:${remoteConfig.port}` : ''}` + ); + console.log(` Auth: ${remoteConfig.authToken ? '[configured]' : '[none]'}`); + } else if (proxyMode === 'local') { + // Ensure local mode is configured + config.cliproxy_server = { + ...DEFAULT_CLIPROXY_SERVER_CONFIG, + remote: { + enabled: false, + host: '', + protocol: 'http', + auth_token: '', + }, + local: { + port: 8317, + auto_start: true, + }, + }; + + console.log(''); + console.log(ok('Local proxy mode configured!')); + console.log(' CLIProxyAPI will auto-start when you use gemini/codex/agy profiles.'); + } else { + // Skip CLIProxy - just use local config + console.log(''); + console.log(ok('CLIProxy skipped.')); + console.log(' You can still use API profiles (GLM, Kimi) or Claude accounts.'); + } + + // Step 2: Ask about API profiles + console.log(''); + const wantsApiProfile = await confirm( + rl, + 'Do you want to set up an API profile (GLM, Kimi, custom)?', + false + ); + + if (wantsApiProfile) { + console.log(''); + console.log(info('Creating API profiles...')); + console.log(' Use the following commands to create profiles:'); + console.log(''); + console.log(' ccs api create glm --preset glm'); + console.log(' ccs api create kimi --preset kimi'); + console.log(' ccs api create custom --prompt'); + console.log(''); + console.log(' After creating, edit the settings file to add your API key.'); + } + + // Save config + saveUnifiedConfig(config); + + // Final summary + console.log(''); + console.log(header('Setup Complete!')); + console.log(''); + console.log('Quick start commands:'); + console.log(''); + + if (proxyMode !== 'skip') { + console.log(' ccs gemini # Use Gemini via CLIProxy (OAuth)'); + console.log(' ccs codex # Use Codex via CLIProxy (OAuth)'); + console.log(' ccs agy # Use Antigravity via CLIProxy (OAuth)'); + } + + console.log(' ccs # Use default Claude CLI'); + console.log(' ccs config # Open web dashboard'); + console.log(' ccs doctor # Check configuration health'); + console.log(''); + + if (proxyMode === 'remote') { + console.log(info('Remote proxy tip:')); + console.log(' If connection fails, CCS will offer to start local proxy as fallback.'); + console.log(' Edit ~/.ccs/config.yaml to adjust remote settings.'); + console.log(''); + } + + console.log(info('Configuration saved to: ~/.ccs/config.yaml')); + console.log(''); + } catch (err) { + // Handle user cancellation gracefully + if (err instanceof UserCancelledError) { + console.log(''); + console.log(info('Setup cancelled.')); + console.log(' Run `ccs setup` when ready to configure.'); + console.log(''); + return; + } + // Handle other errors with user-friendly message + const message = err instanceof Error ? err.message : String(err); + console.log(''); + console.log(warn(`Setup failed: ${message}`)); + console.log(info(' Run `ccs setup` to try again.')); + console.log(''); + } finally { + rl.close(); + } +} + +/** + * Parse command line arguments + */ +function parseArgs(args: string[]): { force: boolean; help: boolean } { + return { + force: args.includes('--force') || args.includes('-f'), + help: args.includes('--help') || args.includes('-h'), + }; +} + +/** + * Show help message + */ +function showHelp(): void { + console.log(''); + console.log('Usage: ccs setup [options]'); + console.log(''); + console.log('Interactive first-time setup wizard for CCS.'); + console.log(''); + console.log('Options:'); + console.log(' --force, -f Force setup even if already configured'); + console.log(' --help, -h Show this help message'); + console.log(''); + console.log('This wizard helps you configure:'); + console.log(' - Local vs Remote CLIProxy mode'); + console.log(' - Remote proxy connection (host, port, auth token)'); + console.log(' - API profile creation'); + console.log(''); + console.log('Examples:'); + console.log(' ccs setup Run setup wizard'); + console.log(' ccs setup --force Force reconfiguration'); + console.log(''); +} + +/** + * Handle setup command + */ +export async function handleSetupCommand(args: string[]): Promise { + await initUI(); + + const options = parseArgs(args); + + if (options.help) { + showHelp(); + return; + } + + await runSetupWizard(options.force); +} diff --git a/src/commands/tokens-command.ts b/src/commands/tokens-command.ts new file mode 100644 index 00000000..b595c249 --- /dev/null +++ b/src/commands/tokens-command.ts @@ -0,0 +1,199 @@ +/** + * Tokens Command + * + * Manage CLIProxyAPI auth tokens (API key and management secret). + * + * Usage: + * ccs tokens Show current tokens (masked) + * ccs tokens --show Show tokens unmasked + * ccs tokens --api-key Set global API key + * ccs tokens --secret Set management secret + * ccs tokens --regenerate-secret Auto-generate new management secret + * ccs tokens --reset Reset to defaults + * ccs tokens --variant --api-key Set per-variant API key + */ + +import { initUI, ok, info, fail, warn, color, dim, header, subheader } from '../utils/ui'; +import { + generateSecureToken, + maskToken, + setGlobalApiKey, + setGlobalManagementSecret, + setVariantApiKey, + resetAuthToDefaults, + getAuthSummary, +} from '../cliproxy'; +import { regenerateConfig } from '../cliproxy/config-generator'; + +/** + * Display current auth status + */ +async function showAuthStatus(showUnmasked: boolean): Promise { + await initUI(); + + const summary = getAuthSummary(); + + console.log(header('CLIProxy Auth Tokens')); + console.log(''); + + // API Key + const apiKeyDisplay = showUnmasked ? summary.apiKey.value : maskToken(summary.apiKey.value); + const apiKeyStatus = summary.apiKey.isCustom ? color('(custom)', 'warning') : dim('(default)'); + console.log(` API Key: ${apiKeyDisplay} ${apiKeyStatus}`); + + // Management Secret + const secretDisplay = showUnmasked + ? summary.managementSecret.value + : maskToken(summary.managementSecret.value); + const secretStatus = summary.managementSecret.isCustom + ? color('(custom)', 'warning') + : dim('(default)'); + console.log(` Management Secret: ${secretDisplay} ${secretStatus}`); + + console.log(''); + + if (showUnmasked) { + console.log(warn('Tokens shown in plain text. Do not share!')); + console.log(''); + } + + console.log(subheader('Usage')); + console.log(` ${dim('Set API key:')} ccs tokens --api-key `); + console.log(` ${dim('Set secret:')} ccs tokens --secret `); + console.log(` ${dim('Generate secret:')} ccs tokens --regenerate-secret`); + console.log(` ${dim('Reset to defaults:')} ccs tokens --reset`); + console.log(''); +} + +/** + * Handle tokens command + */ +export async function handleTokensCommand(args: string[]): Promise { + await initUI(); + + // Parse flags + const showFlag = args.includes('--show'); + const resetFlag = args.includes('--reset'); + const regenerateSecretFlag = args.includes('--regenerate-secret'); + const helpFlag = args.includes('--help') || args.includes('-h'); + + // Find --api-key value + const apiKeyIndex = args.indexOf('--api-key'); + const apiKeyValue = apiKeyIndex !== -1 ? args[apiKeyIndex + 1] : undefined; + + // Find --secret value + const secretIndex = args.indexOf('--secret'); + const secretValue = secretIndex !== -1 ? args[secretIndex + 1] : undefined; + + // Find --variant value + const variantIndex = args.indexOf('--variant'); + const variantValue = variantIndex !== -1 ? args[variantIndex + 1] : undefined; + + // Help + if (helpFlag) { + console.log(header('CCS Tokens Management')); + console.log(''); + console.log(subheader('Usage')); + console.log(` ${color('ccs tokens', 'command')} [options]`); + console.log(''); + console.log(subheader('Options')); + console.log(` ${color('(no args)', 'command')} Show masked tokens`); + console.log(` ${color('--show', 'command')} Show tokens unmasked`); + console.log(` ${color('--api-key ', 'command')} Set global API key`); + console.log(` ${color('--secret ', 'command')} Set management secret`); + console.log(` ${color('--regenerate-secret', 'command')} Generate new management secret`); + console.log( + ` ${color('--variant ', 'command')} Target specific variant (with --api-key)` + ); + console.log(` ${color('--reset', 'command')} Reset all to defaults`); + console.log(` ${color('--help, -h', 'command')} Show this help`); + console.log(''); + console.log(subheader('Examples')); + console.log(` ${dim('# View current tokens')}`); + console.log(` ccs tokens`); + console.log(''); + console.log(` ${dim('# Set custom API key')}`); + console.log(` ccs tokens --api-key my-custom-key-123`); + console.log(''); + console.log(` ${dim('# Generate secure management secret')}`); + console.log(` ccs tokens --regenerate-secret`); + console.log(''); + console.log(` ${dim('# Set per-variant API key')}`); + console.log(` ccs tokens --variant my-gemini --api-key variant-key-456`); + console.log(''); + return 0; + } + + // Reset to defaults + if (resetFlag) { + resetAuthToDefaults(); + // Regenerate CLIProxy config to apply changes + regenerateConfig(); + console.log(ok('Auth tokens reset to defaults')); + console.log(info('CLIProxy config regenerated')); + return 0; + } + + // Regenerate management secret + if (regenerateSecretFlag) { + const newSecret = generateSecureToken(32); + setGlobalManagementSecret(newSecret); + // Regenerate CLIProxy config to apply changes + regenerateConfig(); + console.log(ok('New management secret generated')); + console.log(` Secret: ${maskToken(newSecret)}`); + console.log(info('CLIProxy config regenerated')); + console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart')); + return 0; + } + + // Set API key + if (apiKeyValue !== undefined) { + if (!apiKeyValue || apiKeyValue.startsWith('-')) { + console.error(fail('Missing value for --api-key')); + return 1; + } + + if (variantValue) { + // Per-variant API key + try { + setVariantApiKey(variantValue, apiKeyValue); + console.log(ok(`API key set for variant '${variantValue}'`)); + } catch (err) { + const error = err instanceof Error ? err.message : 'Unknown error'; + console.error(fail(error)); + return 1; + } + } else { + // Global API key + setGlobalApiKey(apiKeyValue); + console.log(ok('Global API key updated')); + } + + // Regenerate CLIProxy config to apply changes + regenerateConfig(); + console.log(info('CLIProxy config regenerated')); + console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart')); + return 0; + } + + // Set management secret + if (secretValue !== undefined) { + if (!secretValue || secretValue.startsWith('-')) { + console.error(fail('Missing value for --secret')); + return 1; + } + + setGlobalManagementSecret(secretValue); + // Regenerate CLIProxy config to apply changes + regenerateConfig(); + console.log(ok('Management secret updated')); + console.log(info('CLIProxy config regenerated')); + console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart')); + return 0; + } + + // Default: show status + await showAuthStatus(showFlag); + return 0; +} diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 142c57e6..9fa347d4 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -15,9 +15,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; +import { expandPath } from '../utils/helpers'; import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unified-config-types'; import { createEmptyUnifiedConfig } from './unified-config-types'; -import { saveUnifiedConfig, hasUnifiedConfig } from './unified-config-loader'; +import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader'; import { infoBox, warn } from '../utils/ui'; const BACKUP_DIR_PREFIX = 'backup-v1-'; @@ -44,6 +45,58 @@ export function needsMigration(): boolean { return hasOldConfig && !hasNewConfig; } +/** + * Pre-loaded config data for TOCTOU-safe migration checks. + * Read once, use for both check and migration. + */ +export interface MigrationCheckData { + legacyConfig: Record | null; + unifiedConfig: ReturnType; + needsMigration: boolean; +} + +/** + * Load config files once for TOCTOU-safe migration operations. + * Returns data that can be passed to migration functions. + */ +export function loadMigrationCheckData(): MigrationCheckData { + const ccsDir = getCcsDir(); + const configJsonPath = path.join(ccsDir, 'config.json'); + + const legacyConfig = fs.existsSync(configJsonPath) ? readJsonSafe(configJsonPath) : null; + const unifiedConfig = loadUnifiedConfig(); + + // Determine if migration is needed + let needsMigration = false; + + if (legacyConfig?.profiles && typeof legacyConfig.profiles === 'object' && unifiedConfig) { + const legacyProfiles = legacyConfig.profiles as Record; + for (const profileName of Object.keys(legacyProfiles)) { + if (!unifiedConfig.profiles[profileName]) { + needsMigration = true; + break; + } + } + } + + return { legacyConfig, unifiedConfig, needsMigration }; +} + +/** + * Check if there are legacy profiles that haven't been migrated to config.yaml. + * This catches the case where config.yaml exists but is empty/missing profiles + * that are still in config.json. + * + * Used by autoMigrate() to trigger inline migration when needed. + * (Fix for issue #195 - GLM auth persistence regression) + * + * Note: For TOCTOU-safe operations, use loadMigrationCheckData() instead. + */ +export function needsProfileMigration(): boolean { + const data = loadMigrationCheckData(); + return data.needsMigration; +} + /** * Get list of backup directories. */ @@ -167,7 +220,13 @@ export async function migrate(dryRun = false): Promise { } } - // 7. Migrate cache files + // 7. Write new config FIRST (before moving files - H8 fix) + // Note: Settings remain in *.settings.json files, config.yaml only stores references + if (!dryRun) { + saveUnifiedConfig(unifiedConfig); + } + + // 8. Migrate cache files AFTER config is saved (H8: prevents inconsistent state) if (!dryRun) { const cacheDir = path.join(ccsDir, 'cache'); if (!fs.existsSync(cacheDir)) { @@ -188,12 +247,6 @@ export async function migrate(dryRun = false): Promise { } } - // 8. Write new config (unless dry run) - // Note: Settings remain in *.settings.json files, config.yaml only stores references - if (!dryRun) { - saveUnifiedConfig(unifiedConfig); - } - return { success: true, backupPath: dryRun ? undefined : backupDir, @@ -270,13 +323,6 @@ function readJsonSafe(filePath: string): Record | null { } } -/** - * Expand ~ to home directory in path. - */ -function expandPath(p: string): string { - return p.replace(/^~/, process.env.HOME || ''); -} - /** * Move file if it exists. Returns true if moved, false if source didn't exist. */ @@ -326,6 +372,9 @@ async function performBackup(srcDir: string, backupDir: string): Promise { * Auto-migrate on first run after update. * Silent if already migrated or no config exists. * Shows friendly message with backup location on success. + * + * Also handles inline profile migration when config.yaml exists but is missing + * profiles from config.json (Fix for issue #195). */ export async function autoMigrate(): Promise { // Skip in test environment @@ -333,30 +382,122 @@ export async function autoMigrate(): Promise { return; } - // Skip if no migration needed - if (!needsMigration()) { + // Check if full migration is needed (no config.yaml exists) + if (needsMigration()) { + const result = await migrate(false); + + if (result.success) { + console.log(''); + console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS')); + console.log(` Backup: ${result.backupPath}`); + console.log(` Items: ${result.migratedFiles.length} migrated`); + if (result.warnings.length > 0) { + for (const warning of result.warnings) { + console.log(warn(warning)); + } + } + console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`); + console.log(''); + } else { + console.log(''); + console.log(infoBox('Migration failed - using legacy config', 'WARNING')); + console.log(` Error: ${result.error}`); + console.log(' Retry: ccs migrate'); + console.log(''); + } return; } - const result = await migrate(false); - - if (result.success) { - console.log(''); - console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS')); - console.log(` Backup: ${result.backupPath}`); - console.log(` Items: ${result.migratedFiles.length} migrated`); - if (result.warnings.length > 0) { - for (const warning of result.warnings) { - console.log(warn(warning)); + // Check if inline profile migration is needed (config.yaml exists but missing profiles) + // CRIT-2: Load data once and pass to migrateProfilesToUnified to avoid TOCTOU race + const migrationData = loadMigrationCheckData(); + if (migrationData.needsMigration) { + const result = await migrateProfilesToUnified(migrationData); + if (result.success && result.migratedFiles.length > 0) { + console.log(''); + console.log(infoBox('Migrated legacy profiles to config.yaml', 'SUCCESS')); + console.log(` Profiles: ${result.migratedFiles.join(', ')}`); + // H7: Show collision warnings if any + if (result.warnings.length > 0) { + for (const warning of result.warnings) { + console.log(warn(warning)); + } } + console.log(''); } - console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`); - console.log(''); - } else { - console.log(''); - console.log(infoBox('Migration failed - using legacy config', 'WARNING')); - console.log(` Error: ${result.error}`); - console.log(' Retry: ccs migrate'); - console.log(''); + } +} + +/** + * Migrate only profiles from config.json to existing config.yaml. + * Used when config.yaml exists but is missing profiles. + * + * @param preloadedData - Optional pre-loaded config data from loadMigrationCheckData() + * for TOCTOU-safe operations. If not provided, reads configs fresh. + */ +async function migrateProfilesToUnified( + preloadedData?: MigrationCheckData +): Promise { + const ccsDir = getCcsDir(); + const migratedFiles: string[] = []; + const warnings: string[] = []; + + try { + // Use preloaded data if available (CRIT-2: TOCTOU fix) + const oldConfig = preloadedData?.legacyConfig ?? readJsonSafe(path.join(ccsDir, 'config.json')); + const unifiedConfig = preloadedData?.unifiedConfig ?? loadUnifiedConfig(); + + if (!oldConfig?.profiles || !unifiedConfig) { + return { success: true, migratedFiles, warnings }; + } + + let modified = false; + + // Migrate API profiles from config.json + for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) { + const pathStr = settingsPath as string; + + // H7: Detect collision - profile exists in both configs + if (unifiedConfig.profiles[name]) { + // Check if settings differ (potential data loss) + const existingSettings = unifiedConfig.profiles[name].settings; + if (existingSettings && existingSettings !== pathStr) { + warnings.push( + `Profile "${name}" exists in both configs with different settings - keeping existing (${existingSettings}), skipping legacy (${pathStr})` + ); + } + continue; + } + + const expandedPath = expandPath(pathStr); + + // Verify settings file exists + if (!fs.existsSync(expandedPath)) { + warnings.push(`Skipped ${name}: settings file not found at ${pathStr}`); + continue; + } + + // Store reference to settings file + unifiedConfig.profiles[name] = { + type: 'api', + settings: pathStr, + }; + migratedFiles.push(name); + modified = true; + } + + // Save if modified + if (modified) { + saveUnifiedConfig(unifiedConfig); + } + + return { success: true, migratedFiles, warnings }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + migratedFiles, + warnings, + }; } } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 249ecd38..f36cd130 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -131,6 +131,8 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { request_log: partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false, }, + // Auth config - preserve user values, no defaults (uses constants as fallback) + auth: partial.cliproxy?.auth, }, preferences: { ...defaults.preferences, @@ -184,7 +186,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { enabled: partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled, host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host, - // Port is optional - undefined means use protocol default (443/80) + // Port is optional - undefined means use protocol default (443 for HTTPS, 8317 for HTTP) port: partial.cliproxy_server?.remote?.port, protocol: partial.cliproxy_server?.remote?.protocol ?? @@ -231,32 +233,8 @@ export function loadOrCreateUnifiedConfig(): UnifiedConfig { * Generate YAML header with helpful comments. */ function generateYamlHeader(): string { - return `# ============================================================================ -# CCS Unified Configuration (config.yaml) -# ============================================================================ -# Generated by: ccs migrate -# Documentation: https://github.com/kaitranntt/ccs -# -# This file references your settings - actual env vars are in *.settings.json -# files (matching Claude's ~/.claude/settings.json pattern). -# -# To customize a profile: -# 1. Edit the *.settings.json file directly (e.g., ~/.ccs/glm.settings.json) -# 2. The file format matches Claude's settings.json: { "env": { ... } }\n# -# Structure: -# ┌─────────────────────────────────────────────────────────────────────────────┐ -# │ profiles - References to *.settings.json files for API providers │ -# │ cliproxy - References to *.settings.json files for OAuth providers │ -# │ accounts - Isolated Claude instances (managed by 'ccs auth') │ -# │ preferences - User preferences (theme, telemetry, auto-update) │ -# └─────────────────────────────────────────────────────────────────────────────┘ -# -# Usage: -# ccs Switch to profile -# ccs api add Add new API profile -# ccs cliproxy create Create CLIProxy variant -# ccs migrate --rollback Restore from backup -# + return `# CCS Unified Configuration +# Docs: https://github.com/kaitranntt/ccs `; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index a5e2bc4c..b2d7fc6f 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -15,8 +15,9 @@ * Version 3 = WebSearch config with model configuration for Gemini/OpenCode * Version 4 = Copilot API integration (GitHub Copilot proxy) * Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI) + * Version 6 = Customizable auth tokens (API key and management secret) */ -export const UNIFIED_CONFIG_VERSION = 5; +export const UNIFIED_CONFIG_VERSION = 6; /** * Account configuration (formerly in profiles.json). @@ -63,6 +64,21 @@ export interface CLIProxyVariantConfig { account?: string; /** Path to settings file (e.g., "~/.ccs/gemini-custom.settings.json") */ settings?: string; + /** Unique port for variant isolation (8318-8417) */ + port?: number; + /** Per-variant auth override (optional) */ + auth?: CLIProxyAuthConfig; +} + +/** + * CLIProxy authentication configuration. + * Allows customization of API key and management secret for CLIProxyAPI. + */ +export interface CLIProxyAuthConfig { + /** API key for CCS-managed requests (default: 'ccs-internal-managed') */ + api_key?: string; + /** Management secret for Control Panel login (default: 'ccs') */ + management_secret?: string; } /** @@ -89,6 +105,10 @@ export interface CLIProxyConfig { variants: Record; /** Logging configuration (disabled by default) */ logging?: CLIProxyLoggingConfig; + /** Kiro: disable incognito browser mode (use normal browser to save credentials) */ + kiro_no_incognito?: boolean; + /** Global auth configuration for CLIProxyAPI */ + auth?: CLIProxyAuthConfig; } /** @@ -345,7 +365,7 @@ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = { remote: { enabled: false, host: '', - // port is intentionally omitted - will use protocol default (443 for HTTPS, 80 for HTTP) + // port is intentionally omitted - will use protocol default (443 for HTTPS, 8317 for HTTP) protocol: 'http', auth_token: '', }, diff --git a/src/glmt/glmt-proxy.ts b/src/glmt/glmt-proxy.ts index 9edb832d..d1170f6e 100644 --- a/src/glmt/glmt-proxy.ts +++ b/src/glmt/glmt-proxy.ts @@ -587,7 +587,14 @@ export class GlmtProxy { statusCode: 401, type: 'authentication_error', message: - 'Authorization token missing or invalid. Please check your ANTHROPIC_AUTH_TOKEN environment variable in ~/.ccs/config.json or settings.json.', + 'API key rejected by Z.AI. This can happen when:\n' + + ' 1. Key expired on Z.AI server (most common after idle periods)\n' + + ' 2. Key was revoked or regenerated\n' + + ' 3. Key is missing or malformed\n\n' + + 'To fix:\n' + + ' 1. Go to Z.AI dashboard and regenerate your API key\n' + + ' 2. Update ~/.ccs/glm.settings.json with the new key\n' + + ' 3. Or run: ccs config -> API Profiles -> GLM -> Update key', }; } diff --git a/src/management/checks/cliproxy-check.ts b/src/management/checks/cliproxy-check.ts index a4424f30..4a4998b6 100644 --- a/src/management/checks/cliproxy-check.ts +++ b/src/management/checks/cliproxy-check.ts @@ -8,7 +8,7 @@ import { isCLIProxyInstalled, getCLIProxyPath, getAllAuthStatus, - getConfigPath, + getCliproxyConfigPath, getInstalledCliproxyVersion, CLIPROXY_DEFAULT_PORT, configNeedsRegeneration, @@ -60,7 +60,7 @@ export class CLIProxyConfigChecker implements IHealthChecker { run(results: HealthCheck): void { const spinner = ora('Checking CLIProxy config').start(); - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); if (fs.existsSync(configPath)) { // Check if config needs regeneration (version mismatch or missing features) diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts index 4a643814..e56846a8 100644 --- a/src/management/checks/config-check.ts +++ b/src/management/checks/config-check.ts @@ -5,13 +5,15 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { ok, fail, warn } from '../../utils/ui'; +import { ok, fail, warn, info } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; const ora = createSpinner(); /** - * Check CCS config files exist and are valid JSON + * Check CCS config files exist and are valid + * - Prefers config.yaml (v2) over config.json (legacy) + * - Settings files are optional (only checked if profile exists) */ export class ConfigFilesChecker implements IHealthChecker { name = 'Config Files'; @@ -22,63 +24,139 @@ export class ConfigFilesChecker implements IHealthChecker { } run(results: HealthCheck): void { - const files = [ - { path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' }, - { - path: path.join(this.ccsDir, 'glm.settings.json'), - name: 'glm.settings.json', - key: 'GLM Settings', - profile: 'glm', - }, - { - path: path.join(this.ccsDir, 'kimi.settings.json'), - name: 'kimi.settings.json', - key: 'Kimi Settings', - profile: 'kimi', - }, - ]; - const { DelegationValidator } = require('../../utils/delegation-validator'); - for (const file of files) { - const spinner = ora(`Checking ${file.name}`).start(); + // Check main config file (yaml preferred, json fallback) + this.checkMainConfig(results); - if (!fs.existsSync(file.path)) { + // Check optional settings files (only if profile exists in config) + this.checkOptionalSettingsFiles(results, DelegationValidator); + } + + /** + * Check main configuration file (config.yaml preferred, config.json fallback) + */ + private checkMainConfig(results: HealthCheck): void { + const configYamlPath = path.join(this.ccsDir, 'config.yaml'); + const configJsonPath = path.join(this.ccsDir, 'config.json'); + + const yamlExists = fs.existsSync(configYamlPath); + const jsonExists = fs.existsSync(configJsonPath); + + // Check config.yaml first (preferred format) + if (yamlExists) { + const spinner = ora('Checking config.yaml').start(); + try { + const yaml = require('js-yaml'); + const content = fs.readFileSync(configYamlPath, 'utf8'); + yaml.load(content); + spinner.succeed(); + console.log(` ${ok('config.yaml'.padEnd(22))} Valid`); + results.addCheck('config.yaml', 'success', undefined, undefined, { + status: 'OK', + info: 'Valid', + }); + + // Inform if legacy config.json also exists (purely informational, not a check) + if (jsonExists) { + console.log(` ${info('config.json'.padEnd(22))} Legacy (ignored)`); + } + return; + } catch (e) { spinner.fail(); - console.log(` ${fail(file.name.padEnd(22))} Not found`); + console.log(` ${fail('config.yaml'.padEnd(22))} Invalid YAML`); results.addCheck( - file.name, + 'config.yaml', 'error', - `${file.name} not found`, - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Not found' } + `Invalid YAML: ${(e as Error).message}`, + `Backup and recreate: mv ${configYamlPath} ${configYamlPath}.backup && npm install -g @kaitranntt/ccs --force`, + { status: 'ERROR', info: 'Invalid YAML' } ); + return; + } + } + + // Fallback to config.json (legacy format) + if (jsonExists) { + const spinner = ora('Checking config.json').start(); + try { + const content = fs.readFileSync(configJsonPath, 'utf8'); + JSON.parse(content); + spinner.succeed(); + console.log(` ${ok('config.json'.padEnd(22))} Valid (legacy)`); + results.addCheck('config.json', 'success', undefined, undefined, { + status: 'OK', + info: 'Valid (legacy)', + }); + } catch (e) { + spinner.fail(); + console.log(` ${fail('config.json'.padEnd(22))} Invalid JSON`); + results.addCheck( + 'config.json', + 'error', + `Invalid JSON: ${(e as Error).message}`, + `Backup and recreate: mv ${configJsonPath} ${configJsonPath}.backup && npm install -g @kaitranntt/ccs --force`, + { status: 'ERROR', info: 'Invalid JSON' } + ); + } + return; + } + + // Neither exists - error + const spinner = ora('Checking config').start(); + spinner.fail(); + console.log(` ${fail('config.yaml'.padEnd(22))} Not found`); + results.addCheck( + 'config.yaml', + 'error', + 'No configuration file found (config.yaml or config.json)', + 'Run: npm install -g @kaitranntt/ccs --force', + { status: 'ERROR', info: 'Not found' } + ); + } + + /** + * Check optional settings files (only if corresponding profile is configured) + * These files are NOT required - they're only created when user configures a profile + */ + private checkOptionalSettingsFiles( + results: HealthCheck, + DelegationValidator: { validate: (profile: string) => { valid: boolean; error?: string } } + ): void { + // Settings files to check (only if profile exists) + const settingsFiles = [ + { name: 'glm.settings.json', profile: 'glm', displayName: 'GLM Settings' }, + { name: 'kimi.settings.json', profile: 'kimi', displayName: 'Kimi Settings' }, + ]; + + for (const file of settingsFiles) { + const filePath = path.join(this.ccsDir, file.name); + const exists = fs.existsSync(filePath); + + if (!exists) { + // Not an error - these are optional files + // Only show info if user might expect them continue; } - // Validate JSON + // File exists - validate it + const spinner = ora(`Checking ${file.name}`).start(); try { - const content = fs.readFileSync(file.path, 'utf8'); + const content = fs.readFileSync(filePath, 'utf8'); JSON.parse(content); - // Extract useful info based on file type - let fileInfo = 'Valid'; + // Check if API key is properly configured + const validation = DelegationValidator.validate(file.profile); + + let fileInfo = 'Valid JSON'; let status: 'OK' | 'WARN' = 'OK'; - if (file.profile) { - // For settings files, check if API key is configured - const validation = DelegationValidator.validate(file.profile); - - if (validation.valid) { - fileInfo = 'Key configured'; - status = 'OK'; - } else if (validation.error && validation.error.includes('placeholder')) { - fileInfo = 'Placeholder key'; - status = 'WARN'; - } else { - fileInfo = 'Valid JSON'; - status = 'OK'; - } + if (validation.valid) { + fileInfo = 'Key configured'; + status = 'OK'; + } else if (validation.error && validation.error.includes('placeholder')) { + fileInfo = 'Placeholder key'; + status = 'WARN'; } if (status === 'WARN') { @@ -100,7 +178,7 @@ export class ConfigFilesChecker implements IHealthChecker { file.name, 'error', `Invalid JSON: ${(e as Error).message}`, - `Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`, + `Backup and recreate: mv ${filePath} ${filePath}.backup`, { status: 'ERROR', info: 'Invalid JSON' } ); } diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts index cc449042..7d80a4df 100644 --- a/src/management/checks/profile-check.ts +++ b/src/management/checks/profile-check.ts @@ -11,7 +11,7 @@ import { HealthCheck, IHealthChecker, createSpinner } from './types'; const ora = createSpinner(); /** - * Check profile configurations in config.json + * Check profile configurations in config.yaml (preferred) or config.json (legacy) */ export class ProfilesChecker implements IHealthChecker { name = 'Profiles'; @@ -23,47 +23,102 @@ export class ProfilesChecker implements IHealthChecker { run(results: HealthCheck): void { const spinner = ora('Checking profiles').start(); - const configPath = path.join(this.ccsDir, 'config.json'); + const configYamlPath = path.join(this.ccsDir, 'config.yaml'); + const configJsonPath = path.join(this.ccsDir, 'config.json'); - if (!fs.existsSync(configPath)) { - spinner.info(); - console.log(` ${info('Profiles'.padEnd(22))} config.json not found`); - return; - } + const yamlExists = fs.existsSync(configYamlPath); + const jsonExists = fs.existsSync(configJsonPath); - try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - - if (!config.profiles || typeof config.profiles !== 'object') { + // Check config.yaml first (preferred format) + if (yamlExists) { + try { + const yaml = require('js-yaml'); + const content = fs.readFileSync(configYamlPath, 'utf8'); + const config = yaml.load(content) as Record; + this.validateProfiles(config, 'config.yaml', spinner, results); + return; + } catch (e) { spinner.fail(); - console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`); + console.log( + ` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${(e as Error).message}` + ); results.addCheck( 'Profiles', 'error', - 'config.json missing profiles object', - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Missing profiles object' } + `Invalid config.yaml: ${(e as Error).message}`, + undefined, + { + status: 'ERROR', + info: (e as Error).message, + } ); return; } - - const profileCount = Object.keys(config.profiles).length; - const profileNames = Object.keys(config.profiles).join(', '); - - spinner.succeed(); - console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`); - results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, undefined, { - status: 'OK', - info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`, - }); - } catch (e) { - spinner.fail(); - console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`); - results.addCheck('Profiles', 'error', (e as Error).message, undefined, { - status: 'ERROR', - info: (e as Error).message, - }); } + + // Fallback to config.json (legacy format) + if (jsonExists) { + try { + const config = JSON.parse(fs.readFileSync(configJsonPath, 'utf8')); + this.validateProfiles(config, 'config.json', spinner, results); + return; + } catch (e) { + spinner.fail(); + console.log( + ` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${(e as Error).message}` + ); + results.addCheck( + 'Profiles', + 'error', + `Invalid config.json: ${(e as Error).message}`, + undefined, + { + status: 'ERROR', + info: (e as Error).message, + } + ); + return; + } + } + + // Neither exists + spinner.info(); + console.log( + ` ${info('Profiles'.padEnd(22))} No config file found (config.yaml or config.json)` + ); + } + + /** + * Validate profiles object from parsed config + */ + private validateProfiles( + config: Record, + configFileName: string, + spinner: ReturnType['start']>, + results: HealthCheck + ): void { + if (!config.profiles || typeof config.profiles !== 'object') { + spinner.fail(); + console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object in ${configFileName}`); + results.addCheck( + 'Profiles', + 'error', + `${configFileName} missing profiles object`, + 'Run: npm install -g @kaitranntt/ccs --force', + { status: 'ERROR', info: 'Missing profiles object' } + ); + return; + } + + const profileCount = Object.keys(config.profiles as object).length; + const profileNames = Object.keys(config.profiles as object).join(', '); + + spinner.succeed(); + console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`); + results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, undefined, { + status: 'OK', + info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`, + }); } } diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index 9c43dd1e..8977870c 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -8,6 +8,12 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { info } from '../utils/ui'; +import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } from '../config/unified-config-types'; +import { + saveUnifiedConfig, + hasUnifiedConfig, + loadUnifiedConfig, +} from '../config/unified-config-loader'; /** * Get CCS home directory (respects CCS_HOME env for test isolation) @@ -49,37 +55,51 @@ class RecoveryManager { } /** - * Ensure ~/.ccs/config.json exists with defaults + * Ensure ~/.ccs/config.yaml exists with defaults + * This is the primary config format (YAML unified config) */ - ensureConfigJson(): boolean { - const configPath = path.join(this.ccsDir, 'config.json'); - - // Check if exists and valid - if (fs.existsSync(configPath)) { - try { - const content = fs.readFileSync(configPath, 'utf8'); - JSON.parse(content); // Validate JSON - return false; // No recovery needed - } catch (_e) { - // Corrupted - backup and recreate - const backupPath = `${configPath}.backup.${Date.now()}`; - fs.renameSync(configPath, backupPath); - this.recovered.push(`Backed up corrupted config.json to ${path.basename(backupPath)}`); + ensureConfigYaml(): boolean { + // Skip if config.yaml already exists AND is valid + if (hasUnifiedConfig()) { + // Verify it's loadable (not corrupted) + const loaded = loadUnifiedConfig(); + if (loaded !== null) { + return false; // Config exists and is valid } + // Config exists but is corrupted - will be recreated below + this.recovered.push('Detected corrupted ~/.ccs/config.yaml'); } - // Create default config (matches postinstall.js) - // NOTE: Empty profiles - users create profiles via `ccs api create` or UI - const defaultConfig = { - profiles: {}, - }; + // Check for legacy config.json - if exists, let autoMigrate handle it + const legacyConfigPath = path.join(this.ccsDir, 'config.json'); + if (fs.existsSync(legacyConfigPath)) { + // Legacy config exists - autoMigrate() in ccs.ts will handle migration + return false; + } - const tmpPath = `${configPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8'); - fs.renameSync(tmpPath, configPath); + // Create fresh config.yaml with defaults + const config = createEmptyUnifiedConfig(); + config.version = UNIFIED_CONFIG_VERSION; - this.recovered.push('Created ~/.ccs/config.json'); - return true; + try { + saveUnifiedConfig(config); + this.recovered.push('Created ~/.ccs/config.yaml'); + return true; + } catch (_saveErr) { + // Fallback: create minimal config.json for backward compat + try { + const fallbackConfig = { profiles: {} }; + const tmpPath = `${legacyConfigPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); + fs.renameSync(tmpPath, legacyConfigPath); + this.recovered.push('Created ~/.ccs/config.json (fallback)'); + return true; + } catch (_fallbackErr) { + // Both writes failed - log but don't crash + this.recovered.push('Failed to create config file (permission issue?)'); + return false; + } + } } /** @@ -133,89 +153,6 @@ class RecoveryManager { 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 */ @@ -281,8 +218,8 @@ class RecoveryManager { this.ensureSharedDirectories(); this.ensureClaudeSettings(); - // Config files (core only - no GLM/GLMT/Kimi auto-creation) - this.ensureConfigJson(); + // Config files - use YAML as primary format + this.ensureConfigYaml(); // Shell completions this.ensureShellCompletions(); @@ -307,23 +244,6 @@ 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('~/.claude/settings.json'))) { console.log(''); diff --git a/src/types/config.ts b/src/types/config.ts index c5aed9c2..a00195a3 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -17,12 +17,14 @@ export interface ProfilesConfig { * Example: "flash" → gemini provider with gemini-2.5-flash model */ export interface CLIProxyVariantConfig { - /** CLIProxy provider to use (gemini, codex, agy, qwen) */ - provider: 'gemini' | 'codex' | 'agy' | 'qwen'; - /** Path to settings.json with custom model configuration */ - settings: string; + /** CLIProxy provider to use */ + provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp'; + /** Path to settings.json with custom model configuration (optional) */ + settings?: string; /** Account identifier for multi-account support (optional, defaults to 'default') */ account?: string; + /** Unique port for variant isolation (8318-8417) */ + port?: number; } /** diff --git a/src/types/index.ts b/src/types/index.ts index 30fec139..5ed78646 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,6 +12,7 @@ export type { EnvValue, ProfileMetadata, ProfilesRegistry, + CLIProxyVariantsConfig, } from './config'; export { isConfig, isSettings } from './config'; @@ -46,5 +47,5 @@ export type { } from './glmt'; // Utility types -export { ErrorCode, LogLevel } from './utils'; -export type { ColorName, TerminalInfo, Result } from './utils'; +export { LogLevel } from './utils'; +export type { ErrorCode, ColorName, TerminalInfo, Result } from './utils'; diff --git a/src/types/utils.ts b/src/types/utils.ts index a9320d53..51ccc6a7 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -3,7 +3,8 @@ */ // Re-export from error-codes for consistency -export { ERROR_CODES, ErrorCode, getErrorDocUrl, getErrorCategory } from '../utils/error-codes'; +export { ERROR_CODES, getErrorDocUrl, getErrorCategory } from '../utils/error-codes'; +export type { ErrorCode } from '../utils/error-codes'; /** * Log levels diff --git a/src/utils/api-key-validator.ts b/src/utils/api-key-validator.ts new file mode 100644 index 00000000..cb732566 --- /dev/null +++ b/src/utils/api-key-validator.ts @@ -0,0 +1,130 @@ +/** + * API Key Pre-flight Validator + * + * Quick validation of API keys before Claude CLI launch. + * Catches expired keys early with actionable error messages. + */ + +import * as http from 'http'; +import * as https from 'https'; +import { URL } from 'url'; + +export interface ValidationResult { + valid: boolean; + error?: string; + suggestion?: string; +} + +/** Default placeholders that indicate unconfigured keys */ +const DEFAULT_PLACEHOLDERS = [ + 'YOUR_GLM_API_KEY_HERE', + 'YOUR_KIMI_API_KEY_HERE', + 'YOUR_API_KEY_HERE', + 'YOUR-API-KEY-HERE', + 'PLACEHOLDER', + '', +]; + +/** + * Validate GLM API key with quick health check + * + * @param apiKey - The ANTHROPIC_AUTH_TOKEN value + * @param baseUrl - Optional base URL (defaults to Z.AI) + * @param timeoutMs - Timeout in milliseconds (default 2000) + */ +export async function validateGlmKey( + apiKey: string, + baseUrl?: string, + timeoutMs = 2000 +): Promise { + // Skip if disabled + if (process.env.CCS_SKIP_PREFLIGHT === '1') { + return { valid: true }; + } + + // Basic format check - detect placeholders + if (!apiKey || DEFAULT_PLACEHOLDERS.includes(apiKey.toUpperCase())) { + return { + valid: false, + error: 'API key not configured', + suggestion: + 'Set ANTHROPIC_AUTH_TOKEN in ~/.ccs/glm.settings.json\n' + + 'Or run: ccs config -> API Profiles -> GLM', + }; + } + + // Determine validation endpoint + // Z.AI uses /api/anthropic path, we can test with a minimal request + const targetBase = baseUrl || 'https://api.z.ai'; + let url: URL; + try { + url = new URL('/api/anthropic/v1/models', targetBase); + } catch { + // Invalid URL - fail-open + return { valid: true }; + } + + return new Promise((resolve) => { + // Determine protocol - use http module for http:// URLs + const isHttps = url.protocol === 'https:'; + const httpModule = isHttps ? https : http; + const defaultPort = isHttps ? 443 : 80; + + const options: https.RequestOptions = { + hostname: url.hostname, + port: url.port || defaultPort, + path: url.pathname, + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'User-Agent': 'CCS-Preflight/1.0', + }, + }; + + const req = httpModule.request(options, (res) => { + clearTimeout(timeoutId); + + if (res.statusCode === 200) { + resolve({ valid: true }); + } else if (res.statusCode === 401 || res.statusCode === 403) { + resolve({ + valid: false, + error: 'API key rejected by Z.AI', + suggestion: + 'Your key may have expired. To fix:\n' + + ' 1. Go to Z.AI dashboard and regenerate your API key\n' + + ' 2. Update ~/.ccs/glm.settings.json with the new key\n' + + ' 3. Or run: ccs config -> API Profiles -> GLM', + }); + } else { + // Other errors (404, 500, etc.) - fail-open, let Claude CLI handle + // Debug log for diagnostics when CCS_DEBUG is set + if (process.env.CCS_DEBUG === '1') { + console.error( + `[CCS-Preflight] Unexpected status ${res.statusCode} from ${url.href} - fail-open` + ); + } + resolve({ valid: true }); + } + + // Consume response body to free resources + res.resume(); + }); + + req.on('error', () => { + clearTimeout(timeoutId); + // Network error - fail-open + resolve({ valid: true }); + }); + + // Set timeout after request is created so we can destroy it on timeout + const timeoutId = setTimeout(() => { + // Abort request to prevent TCP connection leak + req.destroy(); + // Fail-open on timeout - let Claude CLI handle it + resolve({ valid: true }); + }, timeoutMs); + + req.end(); + }); +} diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 034a47ec..e1169137 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { Config, isConfig, Settings, isSettings } from '../types'; +import { Config, isConfig, Settings, isSettings, CLIProxyVariantsConfig } from '../types'; import { expandPath, error } from './helpers'; import { info } from './ui'; import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; @@ -27,12 +27,26 @@ export function getCcsDir(): string { } /** - * Get config file path + * Get config file path (legacy JSON path) + * @deprecated Use getActiveConfigPath() for mode-aware config path */ export function getConfigPath(): string { return process.env.CCS_CONFIG || path.join(getCcsHome(), '.ccs', 'config.json'); } +/** + * Get the active config file path based on current mode. + * Returns config.yaml in unified mode, config.json in legacy mode. + * @returns Path to the active config file + */ +export function getActiveConfigPath(): string { + const ccsDir = getCcsDir(); + if (isUnifiedMode()) { + return path.join(ccsDir, 'config.yaml'); + } + return path.join(ccsDir, 'config.json'); +} + /** * Load and validate config.json */ @@ -82,6 +96,69 @@ export function readConfig(): Config { return loadConfig(); } +/** + * Load config safely with unified mode support. + * Returns Config with profiles from unified config.yaml or legacy config.json. + * Throws on error (catchable) instead of process.exit. + * Use this in web server routes where try/catch is needed. + */ +export function loadConfigSafe(): Config { + // Unified mode: extract profiles from config.yaml + if (isUnifiedMode()) { + const unifiedConfig = loadOrCreateUnifiedConfig(); + + // Convert unified profiles to legacy format for compatibility + const profiles: Record = {}; + for (const [name, profile] of Object.entries(unifiedConfig.profiles)) { + if (profile.settings) { + profiles[name] = profile.settings; + } + } + + // Convert unified cliproxy variants to legacy format + let cliproxy: CLIProxyVariantsConfig | undefined; + if (unifiedConfig.cliproxy?.variants) { + cliproxy = {}; + for (const [name, variant] of Object.entries(unifiedConfig.cliproxy.variants)) { + cliproxy[name] = { + provider: variant.provider, + settings: variant.settings, + account: variant.account, + port: variant.port, + }; + } + } + + return { + profiles, + cliproxy, + }; + } + + // Legacy mode: read config.json + const configPath = getConfigPath(); + + if (!fs.existsSync(configPath)) { + // Return empty config for graceful degradation (matches unified mode behavior) + return { profiles: {} }; + } + + const raw = fs.readFileSync(configPath, 'utf8'); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error(`Malformed JSON in config: ${configPath} - ${(e as Error).message}`); + } + + if (!isConfig(parsed)) { + throw new Error(`Invalid config format: ${configPath}`); + } + + return parsed; +} + /** * Get settings path for profile. * In unified mode (config.yaml exists), reads from config.yaml first, @@ -106,19 +183,24 @@ export function getSettingsPath(profile: string): string { // If not found in unified config, try legacy config.json as fallback if (!settingsPath) { - try { - const legacyConfig = loadConfig(); - if (legacyConfig.profiles[profile]) { - settingsPath = legacyConfig.profiles[profile]; - // Merge legacy profiles into available list (avoid duplicates) - for (const p of Object.keys(legacyConfig.profiles)) { - if (!availableProfiles.includes(p)) { - availableProfiles.push(p); + // Use inline safe logic - loadConfig() calls process.exit() which cannot be caught + const configPath = getConfigPath(); + if (fs.existsSync(configPath)) { + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const legacyConfig: unknown = JSON.parse(raw); + if (isConfig(legacyConfig) && legacyConfig.profiles[profile]) { + settingsPath = legacyConfig.profiles[profile]; + // Merge legacy profiles into available list (avoid duplicates) + for (const p of Object.keys(legacyConfig.profiles)) { + if (!availableProfiles.includes(p)) { + availableProfiles.push(p); + } } } + } catch { + // Legacy config is invalid JSON - that's OK in unified mode } - } catch { - // Legacy config doesn't exist or is invalid - that's OK in unified mode } } } else { diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 5f1f9031..f281f7e6 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -14,9 +14,14 @@ export function error(message: string): never { * Path expansion (~ and env vars) */ export function expandPath(pathStr: string): string { + // Normalize separators first to handle mixed paths + pathStr = pathStr.replace(/\\/g, '/'); + // Handle tilde expansion - if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) { + if (pathStr.startsWith('~/')) { pathStr = path.join(os.homedir(), pathStr.slice(2)); + } else if (pathStr === '~') { + pathStr = os.homedir(); } // Expand environment variables (Windows and Unix) diff --git a/src/utils/port-utils.ts b/src/utils/port-utils.ts index e3351ac2..31f87208 100644 --- a/src/utils/port-utils.ts +++ b/src/utils/port-utils.ts @@ -38,7 +38,7 @@ export async function getPortProcess(port: number): Promise */ async function getPortProcessUnix(port: number): Promise { try { - const { stdout } = await execAsync(`lsof -i :${port} -sTCP:LISTEN -t -F pcn`, { + const { stdout } = await execAsync(`lsof -i :${port} -sTCP:LISTEN -F pcn`, { timeout: 3000, }); @@ -111,22 +111,17 @@ async function getPortProcessWindows(port: number): Promise /** * Check if process is CLIProxy + * Uses prefix matching to handle Linux kernel's 15-char process name truncation + * (e.g., 'cli-proxy-api-plus' becomes 'cli-proxy-api-p' in lsof/ps output) */ export function isCLIProxyProcess(process: PortProcess | null): boolean { if (!process) { return false; } - // Match cli-proxy, cli-proxy.exe, cliproxy, cliproxy.exe, cli-proxy-api const name = process.processName.toLowerCase(); - return [ - 'cli-proxy', - 'cli-proxy.exe', - 'cliproxy', - 'cliproxy.exe', - 'cli-proxy-api', - 'cli-proxy-api.exe', - ].includes(name); + // All CLIProxy variants start with 'cli-proxy' or 'cliproxy' + return name.startsWith('cli-proxy') || name.startsWith('cliproxy'); } /** @@ -186,6 +181,32 @@ export interface BindingTestResult { message: string; } +/** + * Wait for a port to become free (no process listening) + * @param port Port number to wait for + * @param timeoutMs Maximum time to wait in milliseconds (default: 5000) + * @param pollIntervalMs Interval between checks in milliseconds (default: 200) + * @returns True if port became free, false if timeout + */ +export async function waitForPortFree( + port: number, + timeoutMs = 5000, + pollIntervalMs = 200 +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const process = await getPortProcess(port); + if (!process) { + return true; // Port is free + } + // Wait before next check + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return false; // Timeout reached +} + /** * Test if we can bind to localhost on a specific port * This verifies network stack is working and port is actually available diff --git a/src/web-server/file-watcher.ts b/src/web-server/file-watcher.ts index a57bce39..90de9027 100644 --- a/src/web-server/file-watcher.ts +++ b/src/web-server/file-watcher.ts @@ -22,7 +22,8 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher { const watcher = chokidar.watch( [ - path.join(ccsDir, 'config.json'), + path.join(ccsDir, 'config.json'), // Legacy config + path.join(ccsDir, 'config.yaml'), // Unified config path.join(ccsDir, '*.settings.json'), path.join(ccsDir, 'profiles.json'), path.join(ccsDir, 'cliproxy', 'sessions.json'), // Proxy session tracking @@ -41,7 +42,7 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher { const basename = path.basename(filePath); let type: FileChangeEvent['type']; - if (basename === 'config.json') { + if (basename === 'config.json' || basename === 'config.yaml') { type = 'config-changed'; } else if (basename === 'profiles.json') { type = 'profiles-changed'; diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index 25696d85..21a8723d 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -142,6 +142,17 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st return { success: true, message: 'Created ~/.ccs directory' }; case 'config-file': { + // Use appropriate config based on unified mode + const { isUnifiedMode } = require('../config/unified-config-loader'); + if (isUnifiedMode()) { + const { + loadOrCreateUnifiedConfig, + saveUnifiedConfig, + } = require('../config/unified-config-loader'); + const config = loadOrCreateUnifiedConfig(); + saveUnifiedConfig(config); + return { success: true, message: 'Created/updated config.yaml' }; + } const configPath = getConfigPath(); fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }, null, 2) + '\n'); @@ -149,6 +160,12 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st } case 'profiles-file': { + // Use appropriate storage based on unified mode + const { isUnifiedMode: isUnified } = require('../config/unified-config-loader'); + if (isUnified()) { + // In unified mode, accounts are stored in config.yaml + return { success: true, message: 'Accounts stored in config.yaml (unified mode)' }; + } const profilesPath = path.join(ccsDir, 'profiles.json'); fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync(profilesPath, JSON.stringify({ profiles: {} }, null, 2) + '\n'); diff --git a/src/web-server/health/cliproxy-checks.ts b/src/web-server/health/cliproxy-checks.ts index 80f33e8e..5c1ee2f1 100644 --- a/src/web-server/health/cliproxy-checks.ts +++ b/src/web-server/health/cliproxy-checks.ts @@ -9,7 +9,7 @@ import { isCLIProxyInstalled, getInstalledCliproxyVersion, getCLIProxyPath, - getConfigPath as getCliproxyConfigPath, + getCliproxyConfigPath, getAllAuthStatus, CLIPROXY_DEFAULT_PORT, } from '../../cliproxy'; diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts index 30cc881c..5f7ca34a 100644 --- a/src/web-server/health/config-checks.ts +++ b/src/web-server/health/config-checks.ts @@ -1,18 +1,58 @@ /** * Configuration Health Checks * - * Check config.json, settings files, and Claude settings. + * Check config.json, config.yaml, settings files, and Claude settings. + * Supports both legacy (config.json) and unified (config.yaml) modes. */ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { getConfigPath } from '../../utils/config-manager'; +import { isUnifiedMode, hasUnifiedConfig } from '../../config/unified-config-loader'; import type { HealthCheck } from './types'; /** - * Check config.json file + * Check config file (config.json or config.yaml based on mode) */ export function checkConfigFile(): HealthCheck { + // In unified mode, check config.yaml + if (isUnifiedMode() || hasUnifiedConfig()) { + const ccsDir = path.join(os.homedir(), '.ccs'); + const yamlPath = path.join(ccsDir, 'config.yaml'); + + if (!fs.existsSync(yamlPath)) { + return { + id: 'config-file', + name: 'config.yaml', + status: 'warning', + message: 'Not found (unified mode)', + details: yamlPath, + fixable: true, + }; + } + + try { + fs.readFileSync(yamlPath, 'utf8'); + return { + id: 'config-file', + name: 'config.yaml', + status: 'ok', + message: 'Valid (unified mode)', + details: yamlPath, + }; + } catch { + return { + id: 'config-file', + name: 'config.yaml', + status: 'error', + message: 'Cannot read file', + details: yamlPath, + }; + } + } + + // Legacy mode: check config.json const configPath = getConfigPath(); if (!fs.existsSync(configPath)) { diff --git a/src/web-server/health/profile-checks.ts b/src/web-server/health/profile-checks.ts index 4c2a30e5..83955611 100644 --- a/src/web-server/health/profile-checks.ts +++ b/src/web-server/health/profile-checks.ts @@ -2,16 +2,52 @@ * Profile Health Checks * * Check profiles, instances, and delegation. + * Supports both legacy (config.json) and unified (config.yaml) modes. */ import * as fs from 'fs'; import * as path from 'path'; +import { isUnifiedMode, loadUnifiedConfig } from '../../config/unified-config-loader'; import type { HealthCheck } from './types'; /** - * Check profiles configuration + * Check profiles configuration (API profiles from config.json or config.yaml) */ export function checkProfiles(ccsDir: string): HealthCheck { + // Check unified config first + if (isUnifiedMode()) { + const config = loadUnifiedConfig(); + if (!config) { + return { + id: 'profiles', + name: 'Profiles', + status: 'info', + message: 'config.yaml not loaded', + }; + } + + const profileCount = Object.keys(config.profiles || {}).length; + const profileNames = Object.keys(config.profiles || {}).join(', '); + + if (profileCount === 0) { + return { + id: 'profiles', + name: 'Profiles', + status: 'ok', + message: 'No API profiles (unified mode)', + }; + } + + return { + id: 'profiles', + name: 'Profiles', + status: 'ok', + message: `${profileCount} configured (unified)`, + details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames, + }; + } + + // Legacy mode: check config.json const configPath = path.join(ccsDir, 'config.json'); if (!fs.existsSync(configPath)) { diff --git a/src/web-server/overview-routes.ts b/src/web-server/overview-routes.ts index 0ddaf1da..2b064ce8 100644 --- a/src/web-server/overview-routes.ts +++ b/src/web-server/overview-routes.ts @@ -5,12 +5,11 @@ */ import { Router, Request, Response } from 'express'; -import * as fs from 'fs'; -import * as path from 'path'; -import { getCcsDir, loadConfig } from '../utils/config-manager'; +import { loadConfigSafe } from '../utils/config-manager'; import { runHealthChecks } from './health-service'; import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth-handler'; import { getVersion } from '../utils/version'; +import ProfileRegistry from '../auth/profile-registry'; export const overviewRoutes = Router(); @@ -19,7 +18,7 @@ export const overviewRoutes = Router(); */ overviewRoutes.get('/', async (_req: Request, res: Response) => { try { - const config = loadConfig(); + const config = loadConfigSafe(); const profileCount = Object.keys(config.profiles).length; const cliproxyVariantCount = Object.keys(config.cliproxy || {}).length; @@ -64,12 +63,16 @@ overviewRoutes.get('/', async (_req: Request, res: Response) => { function getAccountCount(): number { try { - const profilesPath = path.join(getCcsDir(), 'profiles.json'); + const registry = new ProfileRegistry(); - if (!fs.existsSync(profilesPath)) return 0; + // Merge legacy (profiles.json) and unified (config.yaml) accounts + const legacyProfiles = registry.getAllProfiles(); + const unifiedAccounts = registry.getAllAccountsUnified(); - const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8')); - return Object.keys(data.profiles || {}).length; + // Count unique accounts (unified takes precedence for duplicates) + const allNames = new Set([...Object.keys(legacyProfiles), ...Object.keys(unifiedAccounts)]); + + return allNames.size; } catch { return 0; } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 848383f5..686c604f 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -1,38 +1,57 @@ /** - * Account Routes - CRUD operations for Claude accounts (profiles.json) + * Account Routes - CRUD operations for Claude accounts * - * Separated from profile-routes.ts to avoid dual-mounting conflicts. + * Uses ProfileRegistry to read from both legacy (profiles.json) + * and unified config (config.yaml) for consistent data with CLI. */ import { Router, Request, Response } from 'express'; -import * as fs from 'fs'; -import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; +import ProfileRegistry from '../../auth/profile-registry'; +import { isUnifiedMode } from '../../config/unified-config-loader'; const router = Router(); +const registry = new ProfileRegistry(); /** - * GET /api/accounts - List accounts from profiles.json + * GET /api/accounts - List accounts from both profiles.json and config.yaml */ router.get('/', (_req: Request, res: Response): void => { try { - const profilesPath = path.join(getCcsDir(), 'profiles.json'); + // Get profiles from both legacy and unified config (same logic as CLI) + const legacyProfiles = registry.getAllProfiles(); + const unifiedAccounts = registry.getAllAccountsUnified(); - if (!fs.existsSync(profilesPath)) { - res.json({ accounts: [], default: null }); - return; + // Merge profiles: unified config takes precedence + const merged: Record = {}; + + // Add legacy profiles first + for (const [name, meta] of Object.entries(legacyProfiles)) { + merged[name] = { + type: meta.type || 'account', + created: meta.created, + last_used: meta.last_used || null, + }; } - const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8')); - const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => { - const metadata = meta as Record; - return { - name, - ...metadata, + // Override with unified config accounts (takes precedence) + for (const [name, account] of Object.entries(unifiedAccounts)) { + merged[name] = { + type: 'account', + created: account.created, + last_used: account.last_used, }; - }); + } - res.json({ accounts, default: data.default || null }); + // Convert to array format + const accounts = Object.entries(merged).map(([name, meta]) => ({ + name, + ...meta, + })); + + // Get default from unified config first, fallback to legacy + const defaultProfile = registry.getDefaultUnified() ?? registry.getDefaultProfile() ?? null; + + res.json({ accounts, default: defaultProfile }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -50,14 +69,12 @@ router.post('/default', (req: Request, res: Response): void => { return; } - const profilesPath = path.join(getCcsDir(), 'profiles.json'); - - const data = fs.existsSync(profilesPath) - ? JSON.parse(fs.readFileSync(profilesPath, 'utf8')) - : { profiles: {} }; - - data.default = name; - fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n'); + // Use unified config if in unified mode, otherwise use legacy + if (isUnifiedMode()) { + registry.setDefaultUnified(name); + } else { + registry.setDefaultProfile(name); + } res.json({ default: name }); } catch (error) { @@ -65,4 +82,50 @@ router.post('/default', (req: Request, res: Response): void => { } }); +/** + * DELETE /api/accounts/reset-default - Reset to CCS default + */ +router.delete('/reset-default', (_req: Request, res: Response): void => { + try { + if (isUnifiedMode()) { + registry.clearDefaultUnified(); + } else { + registry.clearDefaultProfile(); + } + res.json({ success: true, default: null }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * DELETE /api/accounts/:name - Delete an account + */ +router.delete('/:name', (req: Request, res: Response): void => { + try { + const { name } = req.params; + + if (!name) { + res.status(400).json({ error: 'Missing account name' }); + return; + } + + // Check if trying to delete default + const currentDefault = registry.getDefaultUnified() ?? registry.getDefaultProfile(); + if (name === currentDefault) { + res + .status(400) + .json({ error: 'Cannot delete the default account. Set a different default first.' }); + return; + } + + // Delete the profile + registry.deleteProfile(name); + + res.json({ success: true, deleted: name }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 1abf36d2..a4871e38 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -23,6 +23,9 @@ import { } from '../../cliproxy/account-manager'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; +import { getProviderTokenDir } from '../../cliproxy/auth/token-manager'; import type { CLIProxyProvider } from '../../cliproxy/types'; const router = Router(); @@ -268,7 +271,7 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v */ router.post('/:provider/start', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { nickname } = req.body; + const { nickname, noIncognito: noIncognitoBody } = req.body; // Validate provider if (!validProviders.includes(provider as CLIProxyProvider)) { @@ -276,6 +279,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise => { + // Check if remote mode is enabled - import not available remotely + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ + error: 'Kiro import not available in remote mode', + }); + return; + } + + try { + const tokenDir = getProviderTokenDir('kiro'); + const result = await tryKiroImport(tokenDir, false); + + if (result.success) { + // Re-initialize accounts to pick up new token + initializeAccounts(); + + // Get the newly added account + const accounts = getProviderAccounts('kiro'); + const newAccount = accounts.find((a) => a.isDefault) || accounts[0]; + + res.json({ + success: true, + account: newAccount + ? { + id: newAccount.id, + email: newAccount.email, + provider: 'kiro', + isDefault: newAccount.isDefault, + } + : null, + }); + } else { + res.status(400).json({ + success: false, + error: result.error || 'Failed to import Kiro token', + }); + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 729ebd4e..95d65263 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -16,7 +16,7 @@ import { fetchAccountQuota } from '../../cliproxy/quota-fetcher'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { getCliproxyWritablePath, - getConfigPath, + getCliproxyConfigPath, getAuthDir, } from '../../cliproxy/config-generator'; import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker'; @@ -274,7 +274,7 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise => { try { - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); if (!fs.existsSync(configPath)) { res.status(404).json({ error: 'Config file not found' }); return; @@ -301,7 +301,7 @@ router.put('/config.yaml', async (req: Request, res: Response): Promise => return; } - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); // Ensure parent directory exists const configDir = path.dirname(configPath); diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 9a712d50..186ba8bc 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -4,11 +4,9 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../../utils/config-manager'; +import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; import type { Config, Settings } from '../../types/config'; -import type { CLIProxyProvider } from '../../cliproxy/types'; -import { getClaudeEnvVars } from '../../cliproxy/config-generator'; /** Model mapping for API profiles */ export interface ModelMapping { @@ -19,11 +17,12 @@ export interface ModelMapping { } /** - * Read config safely with fallback + * Read config safely with fallback. + * Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json). */ export function readConfigSafe(): Config { try { - return loadConfig(); + return loadConfigSafe(); } catch { return { profiles: {} }; } @@ -156,37 +155,6 @@ export function updateSettingsFile( fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } -/** - * Create cliproxy variant settings - * Includes base URL and auth token for proper Claude CLI integration - */ -export function createCliproxySettings( - name: string, - provider: CLIProxyProvider, - model?: string -): string { - const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); - - // Get base env vars from provider config (includes BASE_URL, AUTH_TOKEN) - const baseEnv = getClaudeEnvVars(provider); - - const settings: Settings = { - env: { - ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', - ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', - ANTHROPIC_MODEL: model || (baseEnv.ANTHROPIC_MODEL as string) || '', - ANTHROPIC_DEFAULT_OPUS_MODEL: model || (baseEnv.ANTHROPIC_DEFAULT_OPUS_MODEL as string) || '', - ANTHROPIC_DEFAULT_SONNET_MODEL: - model || (baseEnv.ANTHROPIC_DEFAULT_SONNET_MODEL as string) || '', - ANTHROPIC_DEFAULT_HAIKU_MODEL: - (baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL as string) || model || '', - }, - }; - - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); - return `~/.ccs/${name}.settings.json`; -} - /** * Security: Validate file path is within allowed directories * - ~/.ccs/ directory: read/write allowed diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 02f03874..12e175aa 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -4,13 +4,43 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { getCcsDir, loadSettings } from '../../utils/config-manager'; import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys'; +import { listVariants } from '../../cliproxy/services/variant-service'; +import { + generateSecureToken, + maskToken, + getAuthSummary, + setGlobalApiKey, + setGlobalManagementSecret, + resetAuthToDefaults, +} from '../../cliproxy'; +import { regenerateConfig } from '../../cliproxy/config-generator'; import type { Settings } from '../../types/config'; const router = Router(); +/** + * Helper: Resolve settings path for profile or variant + * Variants have settings paths in config, regular profiles use {name}.settings.json + */ +function resolveSettingsPath(profileOrVariant: string): string { + const ccsDir = getCcsDir(); + + // Check if this is a variant + const variants = listVariants(); + const variant = variants[profileOrVariant]; + if (variant?.settings) { + // Variant settings path (e.g., ~/.ccs/agy-g3.settings.json) + return variant.settings.replace(/^~/, os.homedir()); + } + + // Regular profile settings + return path.join(ccsDir, `${profileOrVariant}.settings.json`); +} + /** * Helper: Mask API keys in settings */ @@ -34,8 +64,7 @@ function maskApiKeys(settings: Settings): Settings { router.get('/:profile', (req: Request, res: Response): void => { try { const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + const settingsPath = resolveSettingsPath(profile); if (!fs.existsSync(settingsPath)) { res.status(404).json({ error: 'Settings not found' }); @@ -63,8 +92,7 @@ router.get('/:profile', (req: Request, res: Response): void => { router.get('/:profile/raw', (req: Request, res: Response): void => { try { const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + const settingsPath = resolveSettingsPath(profile); if (!fs.existsSync(settingsPath)) { res.status(404).json({ error: 'Settings not found' }); @@ -93,7 +121,7 @@ router.put('/:profile', (req: Request, res: Response): void => { const { profile } = req.params; const { settings, expectedMtime } = req.body; const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + const settingsPath = resolveSettingsPath(profile); const fileExists = fs.existsSync(settingsPath); @@ -151,8 +179,7 @@ router.put('/:profile', (req: Request, res: Response): void => { router.get('/:profile/presets', (req: Request, res: Response): void => { try { const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + const settingsPath = resolveSettingsPath(profile); if (!fs.existsSync(settingsPath)) { res.json({ presets: [] }); @@ -179,11 +206,11 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { return; } - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + const settingsPath = resolveSettingsPath(profile); // Create settings file if it doesn't exist if (!fs.existsSync(settingsPath)) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); } @@ -205,7 +232,11 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { }; settings.presets.push(preset); - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + // Atomic write: temp file + rename + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); res.status(201).json({ preset }); } catch (error) { @@ -219,8 +250,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { router.delete('/:profile/presets/:name', (req: Request, res: Response): void => { try { const { profile, name } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + const settingsPath = resolveSettingsPath(profile); if (!fs.existsSync(settingsPath)) { res.status(404).json({ error: 'Settings not found' }); @@ -234,7 +264,11 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => } settings.presets = settings.presets.filter((p) => p.name !== name); - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + // Atomic write: temp file + rename + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); res.json({ success: true }); } catch (error) { @@ -242,4 +276,142 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => } }); +// ==================== Auth Tokens ==================== + +/** + * GET /api/settings/auth/tokens - Get current auth token status (masked) + */ +router.get('/auth/tokens', (_req: Request, res: Response): void => { + try { + const summary = getAuthSummary(); + + res.json({ + apiKey: { + value: maskToken(summary.apiKey.value), + isCustom: summary.apiKey.isCustom, + }, + managementSecret: { + value: maskToken(summary.managementSecret.value), + isCustom: summary.managementSecret.isCustom, + }, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/settings/auth/tokens/raw - Get current auth tokens unmasked + * NOTE: Sensitive endpoint - no caching, localhost only + */ +router.get('/auth/tokens/raw', (_req: Request, res: Response): void => { + try { + // Prevent caching of sensitive data + res.setHeader('Cache-Control', 'no-store'); + + const summary = getAuthSummary(); + + res.json({ + apiKey: { + value: summary.apiKey.value, + isCustom: summary.apiKey.isCustom, + }, + managementSecret: { + value: summary.managementSecret.value, + isCustom: summary.managementSecret.isCustom, + }, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/settings/auth/tokens - Update auth tokens + */ +router.put('/auth/tokens', (req: Request, res: Response): void => { + try { + const { apiKey, managementSecret } = req.body; + + if (apiKey !== undefined) { + setGlobalApiKey(apiKey || undefined); + } + + if (managementSecret !== undefined) { + setGlobalManagementSecret(managementSecret || undefined); + } + + // Regenerate CLIProxy config to apply changes + regenerateConfig(); + + const summary = getAuthSummary(); + res.json({ + success: true, + apiKey: { + value: maskToken(summary.apiKey.value), + isCustom: summary.apiKey.isCustom, + }, + managementSecret: { + value: maskToken(summary.managementSecret.value), + isCustom: summary.managementSecret.isCustom, + }, + message: 'Restart CLIProxy to apply changes', + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/settings/auth/tokens/regenerate-secret - Generate new management secret + */ +router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): void => { + try { + const newSecret = generateSecureToken(32); + setGlobalManagementSecret(newSecret); + + // Regenerate CLIProxy config to apply changes + regenerateConfig(); + + res.json({ + success: true, + managementSecret: { + value: maskToken(newSecret), + isCustom: true, + }, + message: 'Restart CLIProxy to apply changes', + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/settings/auth/tokens/reset - Reset auth tokens to defaults + */ +router.post('/auth/tokens/reset', (_req: Request, res: Response): void => { + try { + resetAuthToDefaults(); + + // Regenerate CLIProxy config to apply changes + regenerateConfig(); + + const summary = getAuthSummary(); + res.json({ + success: true, + apiKey: { + value: maskToken(summary.apiKey.value), + isCustom: summary.apiKey.isCustom, + }, + managementSecret: { + value: maskToken(summary.managementSecret.value), + isCustom: summary.managementSecret.isCustom, + }, + message: 'Tokens reset to defaults. Restart CLIProxy to apply.', + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index e402bbaa..8c642206 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -1,34 +1,43 @@ /** * Variant Routes - CLIProxy variant management (custom profiles) + * + * Uses variant-service.ts for proper port allocation and cleanup. */ import { Router, Request, Response } from 'express'; -import * as fs from 'fs'; -import * as path from 'path'; -import { getCcsDir, loadSettings } from '../../utils/config-manager'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import type { CLIProxyProvider } from '../../cliproxy/types'; -import { readConfigSafe, writeConfig, createCliproxySettings } from './route-helpers'; +import { + createVariant, + removeVariant, + listVariants, + validateProfileName, + updateVariant, +} from '../../cliproxy/services/variant-service'; const router = Router(); /** * GET /api/cliproxy - List cliproxy variants + * Uses variant-service for consistent behavior with CLI */ router.get('/', (_req: Request, res: Response) => { - const config = readConfigSafe(); - const variants = Object.entries(config.cliproxy || {}).map(([name, variant]) => ({ + const variants = listVariants(); + const variantList = Object.entries(variants).map(([name, variant]) => ({ name, provider: variant.provider, settings: variant.settings, - account: variant.account || 'default', // Include account field + account: variant.account || 'default', + port: variant.port, // Include port for port isolation + model: variant.model, })); - res.json({ variants }); + res.json({ variants: variantList }); }); /** * POST /api/cliproxy - Create cliproxy variant + * Uses variant-service for proper port allocation */ router.post('/', (req: Request, res: Response): void => { const { name, provider, model, account } = req.body; @@ -38,7 +47,14 @@ router.post('/', (req: Request, res: Response): void => { return; } - // Reject reserved names as variant names (prevents collision with built-in providers) + // Validate profile name + const validationError = validateProfileName(name); + if (validationError) { + res.status(400).json({ error: validationError }); + return; + } + + // Reject reserved names (extra safety check) if (isReservedName(name)) { res.status(400).json({ error: `Cannot use reserved name '${name}' as variant name`, @@ -47,84 +63,47 @@ router.post('/', (req: Request, res: Response): void => { return; } - const config = readConfigSafe(); - config.cliproxy = config.cliproxy || {}; + // Use variant-service for proper port allocation + const result = createVariant(name, provider as CLIProxyProvider, model || '', account); - if (config.cliproxy[name]) { - res.status(409).json({ error: 'Variant already exists' }); + if (!result.success) { + res.status(409).json({ error: result.error }); return; } - // Ensure .ccs directory exists - if (!fs.existsSync(getCcsDir())) { - fs.mkdirSync(getCcsDir(), { recursive: true }); - } - - // Create settings file for variant - const settingsPath = createCliproxySettings(name, provider as CLIProxyProvider, model); - - // Include account if specified (defaults to 'default' if not provided) - config.cliproxy[name] = { + res.status(201).json({ + name, provider, - settings: settingsPath, - ...(account && { account }), - }; - writeConfig(config); - - res.status(201).json({ name, provider, settings: settingsPath, account: account || 'default' }); + settings: result.settingsPath, + account: account || 'default', + port: result.variant?.port, + model: result.variant?.model, + }); }); /** * PUT /api/cliproxy/:name - Update cliproxy variant + * Uses variant-service for consistent behavior with CLI */ router.put('/:name', (req: Request, res: Response): void => { try { const { name } = req.params; const { provider, account, model } = req.body; - const config = readConfigSafe(); + // Use variant-service for proper update handling + const result = updateVariant(name, { provider, account, model }); - if (!config.cliproxy?.[name]) { - res.status(404).json({ error: 'Variant not found' }); + if (!result.success) { + res.status(404).json({ error: result.error }); return; } - const variant = config.cliproxy[name]; - - // Update fields if provided - if (provider) { - variant.provider = provider; - } - if (account !== undefined) { - if (account) { - variant.account = account; - } else { - delete variant.account; // Remove account to use default - } - } - - // Update model in settings file if provided - if (model !== undefined) { - const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); - if (fs.existsSync(settingsPath)) { - const settings = loadSettings(settingsPath); - if (model) { - settings.env = settings.env || {}; - settings.env.ANTHROPIC_MODEL = model; - } else if (settings.env) { - delete settings.env.ANTHROPIC_MODEL; - } - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); - } - } - - writeConfig(config); - res.json({ name, - provider: variant.provider, - account: variant.account || 'default', - settings: variant.settings, + provider: result.variant?.provider, + account: result.variant?.account || 'default', + settings: result.variant?.settings, + port: result.variant?.port, updated: true, }); } catch (error) { @@ -134,31 +113,21 @@ router.put('/:name', (req: Request, res: Response): void => { /** * DELETE /api/cliproxy/:name - Delete cliproxy variant + * Uses variant-service for proper port-specific file cleanup */ router.delete('/:name', (req: Request, res: Response): void => { try { const { name } = req.params; - const config = readConfigSafe(); + // Use variant-service for proper cleanup (settings, config, session files) + const result = removeVariant(name); - if (!config.cliproxy?.[name]) { - res.status(404).json({ error: 'Variant not found' }); + if (!result.success) { + res.status(404).json({ error: result.error }); return; } - // 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]; - writeConfig(config); - - res.json({ name, deleted: true }); + res.json({ name, deleted: true, port: result.variant?.port }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } diff --git a/src/web-server/websocket.ts b/src/web-server/websocket.ts index 22de8762..b9ae862e 100644 --- a/src/web-server/websocket.ts +++ b/src/web-server/websocket.ts @@ -12,6 +12,7 @@ import { projectSelectionEvents, type ProjectSelectionPrompt, } from '../cliproxy/project-selection-handler'; +import { deviceCodeEvents, type DeviceCodePrompt } from '../cliproxy/device-code-handler'; export interface WSMessage { type: string; @@ -117,11 +118,54 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { }); }; + // Listen for device code events and broadcast to clients + const handleDeviceCodeReceived = (prompt: DeviceCodePrompt): void => { + console.log(info(`[WS] Broadcasting device code (session: ${prompt.sessionId})`)); + broadcast({ + type: 'deviceCodeReceived', + ...prompt, + timestamp: Date.now(), + }); + }; + + const handleDeviceCodeCompleted = (sessionId: string): void => { + console.log(info(`[WS] Device code auth completed (session: ${sessionId})`)); + broadcast({ + type: 'deviceCodeCompleted', + sessionId, + timestamp: Date.now(), + }); + }; + + const handleDeviceCodeFailed = (data: { sessionId: string; error?: string }): void => { + console.log(info(`[WS] Device code auth failed (session: ${data.sessionId})`)); + broadcast({ + type: 'deviceCodeFailed', + ...data, + timestamp: Date.now(), + }); + }; + + const handleDeviceCodeExpired = (sessionId: string): void => { + console.log(info(`[WS] Device code expired (session: ${sessionId})`)); + broadcast({ + type: 'deviceCodeExpired', + sessionId, + timestamp: Date.now(), + }); + }; + // Subscribe to project selection events projectSelectionEvents.on('selection:required', handleProjectSelectionRequired); projectSelectionEvents.on('selection:timeout', handleProjectSelectionTimeout); projectSelectionEvents.on('selection:submitted', handleProjectSelectionSubmitted); + // Subscribe to device code events + deviceCodeEvents.on('deviceCode:received', handleDeviceCodeReceived); + deviceCodeEvents.on('deviceCode:completed', handleDeviceCodeCompleted); + deviceCodeEvents.on('deviceCode:failed', handleDeviceCodeFailed); + deviceCodeEvents.on('deviceCode:expired', handleDeviceCodeExpired); + // Cleanup function const cleanup = (): void => { watcher.close(); @@ -131,6 +175,12 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { projectSelectionEvents.off('selection:timeout', handleProjectSelectionTimeout); projectSelectionEvents.off('selection:submitted', handleProjectSelectionSubmitted); + // Unsubscribe from device code events + deviceCodeEvents.off('deviceCode:received', handleDeviceCodeReceived); + deviceCodeEvents.off('deviceCode:completed', handleDeviceCodeCompleted); + deviceCodeEvents.off('deviceCode:failed', handleDeviceCodeFailed); + deviceCodeEvents.off('deviceCode:expired', handleDeviceCodeExpired); + clients.forEach((client) => { client.close(1001, 'Server shutting down'); }); diff --git a/tests/npm/postinstall.test.js b/tests/npm/postinstall.test.js index 07572362..136df681 100644 --- a/tests/npm/postinstall.test.js +++ b/tests/npm/postinstall.test.js @@ -19,19 +19,25 @@ describe('npm postinstall', () => { } }); - it('creates config.json', () => { + it('creates config.yaml (primary format)', () => { execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env: { ...process.env, CCS_HOME: testEnv.testHome } }); - assert(testEnv.fileExists('config.json'), 'config.json should be created'); + // config.yaml is now the primary format (v6.x+) + assert(testEnv.fileExists('config.yaml'), 'config.yaml should be created'); - const config = testEnv.readFile('config.json', true); - assert(config.profiles, 'config.json should have profiles'); + // Read YAML config and verify structure + const yaml = require('js-yaml'); + const configContent = testEnv.readFile('config.yaml', false); + const config = yaml.load(configContent); + + assert(config.profiles !== undefined, 'config.yaml should have profiles'); assert(typeof config.profiles === 'object', 'profiles should be an object'); // Profiles are now empty by default - users create via presets assert.deepStrictEqual(config.profiles, {}, 'profiles should be empty by default'); + assert(config.version, 'config.yaml should have version'); }); it('does NOT auto-create glm.settings.json (v6.0 - use presets instead)', () => { @@ -49,24 +55,30 @@ describe('npm postinstall', () => { it('is idempotent', () => { const env = { ...process.env, CCS_HOME: testEnv.testHome }; + const yaml = require('js-yaml'); // Run postinstall first time execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env }); - // Create custom config + // Create custom config.yaml to test preservation const customConfig = { + version: '2.0', profiles: { custom: '~/.custom.json', glm: '~/.ccs/glm.settings.json' - } + }, + accounts: {}, + cliproxy: { variants: {}, oauth_accounts: {} } }; - testEnv.createFile('config.json', customConfig); + const yamlContent = yaml.dump(customConfig, { indent: 2 }); + testEnv.createFile('config.yaml', yamlContent); // Run postinstall again execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env }); // Verify custom config preserved - const config = testEnv.readFile('config.json', true); + const configContent = testEnv.readFile('config.yaml', false); + const config = yaml.load(configContent); assert(config.profiles.custom, 'Custom profile should be preserved'); assert.strictEqual(config.profiles.custom, '~/.custom.json'); }); @@ -97,7 +109,7 @@ describe('npm postinstall', () => { // Verify existing file still exists and new files are created assert(testEnv.fileExists('existing.txt'), 'Existing files should be preserved'); - assert(testEnv.fileExists('config.json'), 'config.json should be created'); + assert(testEnv.fileExists('config.yaml'), 'config.yaml should be created'); // GLM/GLMT/Kimi are no longer auto-created assert(!testEnv.fileExists('glm.settings.json'), 'glm.settings.json should NOT be auto-created'); }); diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts new file mode 100644 index 00000000..52f2ae68 --- /dev/null +++ b/tests/unit/auth/profile-detector.test.ts @@ -0,0 +1,157 @@ +/** + * Unit tests for Profile Detector + */ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import ProfileDetector, { loadSettingsFromFile } from '../../../src/auth/profile-detector'; +import * as unifiedConfigLoader from '../../../src/config/unified-config-loader'; + +describe('ProfileDetector', () => { + const tempDir = path.join(os.tmpdir(), `ccs-test-profile-detector-${process.pid}`); + + beforeEach(() => { + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('loadSettingsFromFile', () => { + it('should load settings from a valid JSON file', () => { + const settingsPath = path.join(tempDir, 'valid.settings.json'); + const settings = { env: { KEY: 'VALUE' } }; + fs.writeFileSync(settingsPath, JSON.stringify(settings)); + + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({ KEY: 'VALUE' }); + }); + + it('should return empty object for non-existent file', () => { + const result = loadSettingsFromFile(path.join(tempDir, 'non-existent.json')); + expect(result).toEqual({}); + }); + + it('should return empty object for invalid JSON', () => { + const settingsPath = path.join(tempDir, 'invalid.json'); + fs.writeFileSync(settingsPath, 'invalid json'); + + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({}); + }); + + it('should handle tilde expansion correctly', () => { + const mockHome = path.join(tempDir, 'home'); + fs.mkdirSync(mockHome, { recursive: true }); + const settingsPath = '~/test.settings.json'; + const actualPath = path.join(mockHome, 'test.settings.json'); + fs.writeFileSync(actualPath, JSON.stringify({ env: { HOME_VAR: 'TRUE' } })); + + // Mock os.homedir + const homedirSpy = spyOn(os, 'homedir').mockReturnValue(mockHome); + + try { + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({ HOME_VAR: 'TRUE' }); + } finally { + homedirSpy.mockRestore(); + } + }); + + it('should handle env var expansion correctly', () => { + const settingsPath = path.join(tempDir, '${TEST_VAR}.json'); + const actualPath = path.join(tempDir, 'actual.json'); + fs.writeFileSync(actualPath, JSON.stringify({ env: { ENV_VAR: 'EXPANDED' } })); + + process.env.TEST_VAR = 'actual'; + try { + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({ ENV_VAR: 'EXPANDED' }); + } finally { + delete process.env.TEST_VAR; + } + }); + }); + + describe('detectProfileType', () => { + let detector: ProfileDetector; + + beforeEach(() => { + detector = new ProfileDetector(); + }); + + it('should detect CLIProxy profiles', () => { + const result = detector.detectProfileType('gemini'); + expect(result.type).toBe('cliproxy'); + expect(result.name).toBe('gemini'); + expect(result.provider).toBe('gemini'); + }); + + it('should detect settings-based profile from unified config', () => { + const settingsPath = path.join(tempDir, 'glm.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } })); + + const mockUnifiedConfig = { + version: 2, + profiles: { + glm: { settings: settingsPath, type: 'api' } + } + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(mockUnifiedConfig as any); + + try { + const result = detector.detectProfileType('glm'); + expect(result.type).toBe('settings'); + expect(result.name).toBe('glm'); + expect(result.env).toEqual({ ANTHROPIC_MODEL: 'glm-4' }); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should detect account-based profile from unified config', () => { + const mockUnifiedConfig = { + version: 2, + accounts: { + work: { created: '2025-01-01', last_used: '2025-01-02' } + } + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(mockUnifiedConfig as any); + + try { + const result = detector.detectProfileType('work'); + expect(result.type).toBe('account'); + expect(result.name).toBe('work'); + expect(result.profile).toBeDefined(); + expect((result.profile as any).type).toBe('account'); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should return null for unknown profile (throws error)', () => { + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + // Mock readConfig/readProfiles to return empty + const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false); + + try { + expect(() => detector.detectProfileType('unknown')).toThrow(/Profile not found/); + } finally { + isUnifiedModeSpy.mockRestore(); + existsSyncSpy.mockRestore(); + } + }); + }); +}); diff --git a/tests/unit/cliproxy/auth-token-manager.test.js b/tests/unit/cliproxy/auth-token-manager.test.js new file mode 100644 index 00000000..00dfd04b --- /dev/null +++ b/tests/unit/cliproxy/auth-token-manager.test.js @@ -0,0 +1,425 @@ +/** + * Auth Token Manager Tests + * + * Comprehensive test suite for CLIProxy auth token management including: + * - Token generation (cryptographic security) + * - Token masking + * - Pure function logic tests + */ + +const assert = require('assert'); +const crypto = require('crypto'); + +describe('Auth Token Manager', () => { + // ========================================================================= + // Token Generation Tests (Pure Functions) + // ========================================================================= + describe('generateSecureToken', () => { + let generateSecureToken; + + beforeEach(() => { + // Clear cache and reload + delete require.cache[require.resolve('../../../dist/cliproxy/auth-token-manager')]; + const authTokenManager = require('../../../dist/cliproxy/auth-token-manager'); + generateSecureToken = authTokenManager.generateSecureToken; + }); + + it('generates token of correct length (base64url encoding)', () => { + const token = generateSecureToken(32); + // 32 bytes = 43 chars in base64url (without padding) + assert.strictEqual(token.length, 43); + }); + + it('generates unique tokens each call', () => { + const tokens = new Set(); + for (let i = 0; i < 100; i++) { + tokens.add(generateSecureToken(32)); + } + assert.strictEqual(tokens.size, 100, 'All 100 tokens should be unique'); + }); + + it('uses base64url encoding (no +/= characters)', () => { + // Generate many tokens to ensure we hit all character ranges + for (let i = 0; i < 50; i++) { + const token = generateSecureToken(32); + assert(!token.includes('+'), 'Should not contain +'); + assert(!token.includes('/'), 'Should not contain /'); + assert(!token.includes('='), 'Should not contain padding ='); + } + }); + + it('accepts custom length parameter', () => { + const short = generateSecureToken(16); + const long = generateSecureToken(64); + + // 16 bytes = 22 chars, 64 bytes = 86 chars in base64url + assert.strictEqual(short.length, 22); + assert.strictEqual(long.length, 86); + }); + + it('handles edge case: length = 0', () => { + const empty = generateSecureToken(0); + assert.strictEqual(empty.length, 0); + }); + + it('handles edge case: very large length', () => { + const large = generateSecureToken(256); + assert.strictEqual(large.length, 342); // 256 bytes = 342 chars in base64url + }); + + it('uses cryptographically secure random bytes', () => { + // Verify the function uses crypto.randomBytes internally + // by checking statistical properties of generated tokens + const tokens = []; + for (let i = 0; i < 1000; i++) { + tokens.push(generateSecureToken(32)); + } + + // All tokens should be unique + const uniqueTokens = new Set(tokens); + assert.strictEqual(uniqueTokens.size, 1000, 'All tokens should be unique'); + + // Check that first character has good distribution (not always same) + const firstChars = new Set(tokens.map((t) => t[0])); + assert(firstChars.size > 10, 'First character should have good distribution'); + }); + }); + + // ========================================================================= + // Token Masking Tests (Pure Function Simulation) + // ========================================================================= + describe('maskToken (logic validation)', () => { + // Simulates the maskToken function from tokens-command.ts and settings-routes.ts + function maskToken(token) { + if (token.length <= 8) return '****'; + return `${token.slice(0, 4)}...${token.slice(-4)}`; + } + + it('masks tokens showing first 4 and last 4 characters', () => { + assert.strictEqual(maskToken('1234567890abcdef'), '1234...cdef'); + }); + + it('returns **** for tokens <= 8 characters', () => { + assert.strictEqual(maskToken('12345678'), '****'); + assert.strictEqual(maskToken('1234567'), '****'); + assert.strictEqual(maskToken('abc'), '****'); + assert.strictEqual(maskToken(''), '****'); + }); + + it('handles exactly 9 character tokens', () => { + assert.strictEqual(maskToken('123456789'), '1234...6789'); + }); + + it('handles long tokens (typical API keys)', () => { + const longToken = 'test_key_1234567890abcdefghijklmnopqrstuvwxyz'; + assert.strictEqual(maskToken(longToken), 'test...wxyz'); + }); + + it('preserves special characters', () => { + assert.strictEqual(maskToken('!@#$abcd____wxyz'), '!@#$...wxyz'); + }); + + it('handles default CCS internal key', () => { + const internalKey = 'ccs-internal-managed'; + assert.strictEqual(maskToken(internalKey), 'ccs-...aged'); + }); + + it('handles default CCS control panel secret', () => { + const secret = 'ccs'; + assert.strictEqual(maskToken(secret), '****'); + }); + }); + + // ========================================================================= + // Inheritance Chain Logic Tests + // ========================================================================= + describe('getEffectiveApiKey logic', () => { + /** + * Simulates the inheritance logic from auth-token-manager.ts + */ + function getEffectiveApiKey(config, variantName, defaultKey) { + // Priority 1: Per-variant override + if (variantName) { + const variant = config.cliproxy?.variants?.[variantName]; + if (variant?.auth?.api_key) { + return variant.auth.api_key; + } + } + + // Priority 2: Global cliproxy.auth + if (config.cliproxy?.auth?.api_key) { + return config.cliproxy.auth.api_key; + } + + // Priority 3: Default constant + return defaultKey; + } + + const DEFAULT_KEY = 'ccs-internal-managed'; + + it('returns default when no custom config', () => { + const config = { cliproxy: { variants: {} } }; + assert.strictEqual(getEffectiveApiKey(config, undefined, DEFAULT_KEY), DEFAULT_KEY); + }); + + it('returns global auth when set', () => { + const config = { + cliproxy: { + variants: {}, + auth: { api_key: 'global-custom' }, + }, + }; + assert.strictEqual(getEffectiveApiKey(config, undefined, DEFAULT_KEY), 'global-custom'); + }); + + it('returns variant auth when set (highest priority)', () => { + const config = { + cliproxy: { + variants: { + gemini: { auth: { api_key: 'variant-key' } }, + }, + auth: { api_key: 'global-custom' }, + }, + }; + assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), 'variant-key'); + }); + + it('falls back to global when variant has no auth', () => { + const config = { + cliproxy: { + variants: { + gemini: { type: 'claude' }, + }, + auth: { api_key: 'global-custom' }, + }, + }; + assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), 'global-custom'); + }); + + it('falls back to default when variant and global missing', () => { + const config = { + cliproxy: { + variants: { + gemini: { type: 'claude' }, + }, + }, + }; + assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), DEFAULT_KEY); + }); + + it('ignores variant name when variant does not exist', () => { + const config = { + cliproxy: { + variants: {}, + auth: { api_key: 'global-custom' }, + }, + }; + assert.strictEqual(getEffectiveApiKey(config, 'non-existent', DEFAULT_KEY), 'global-custom'); + }); + }); + + describe('getEffectiveManagementSecret logic', () => { + /** + * Simulates the management secret logic from auth-token-manager.ts + */ + function getEffectiveManagementSecret(config, defaultSecret) { + // Priority 1: Global cliproxy.auth + if (config.cliproxy?.auth?.management_secret) { + return config.cliproxy.auth.management_secret; + } + + // Priority 2: Default constant + return defaultSecret; + } + + const DEFAULT_SECRET = 'ccs'; + + it('returns default when no custom config', () => { + const config = { cliproxy: { variants: {} } }; + assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), DEFAULT_SECRET); + }); + + it('returns custom secret when set', () => { + const config = { + cliproxy: { + variants: {}, + auth: { management_secret: 'custom-secret' }, + }, + }; + assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), 'custom-secret'); + }); + + it('is global only (no per-variant override)', () => { + // Management secret does not support per-variant override + const config = { + cliproxy: { + variants: { + gemini: { auth: { management_secret: 'should-be-ignored' } }, + }, + auth: { management_secret: 'global-secret' }, + }, + }; + // The function only checks global auth + assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), 'global-secret'); + }); + }); + + // ========================================================================= + // Auth Summary Logic Tests + // ========================================================================= + describe('getAuthSummary logic', () => { + function getAuthSummary(config, defaultApiKey, defaultSecret) { + const customApiKey = config.cliproxy?.auth?.api_key; + const customSecret = config.cliproxy?.auth?.management_secret; + + return { + apiKey: { + value: customApiKey || defaultApiKey, + isCustom: !!customApiKey, + }, + managementSecret: { + value: customSecret || defaultSecret, + isCustom: !!customSecret, + }, + }; + } + + const DEFAULT_KEY = 'ccs-internal-managed'; + const DEFAULT_SECRET = 'ccs'; + + it('returns defaults with isCustom=false when no custom config', () => { + const config = { cliproxy: { variants: {} } }; + const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET); + + assert.strictEqual(summary.apiKey.value, DEFAULT_KEY); + assert.strictEqual(summary.apiKey.isCustom, false); + assert.strictEqual(summary.managementSecret.value, DEFAULT_SECRET); + assert.strictEqual(summary.managementSecret.isCustom, false); + }); + + it('returns custom values with isCustom=true when set', () => { + const config = { + cliproxy: { + variants: {}, + auth: { api_key: 'custom-key', management_secret: 'custom-secret' }, + }, + }; + const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET); + + assert.strictEqual(summary.apiKey.value, 'custom-key'); + assert.strictEqual(summary.apiKey.isCustom, true); + assert.strictEqual(summary.managementSecret.value, 'custom-secret'); + assert.strictEqual(summary.managementSecret.isCustom, true); + }); + + it('handles partial custom config', () => { + const config = { + cliproxy: { + variants: {}, + auth: { api_key: 'custom-key' }, + }, + }; + const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET); + + assert.strictEqual(summary.apiKey.isCustom, true); + assert.strictEqual(summary.managementSecret.isCustom, false); + }); + + it('treats empty string as no custom value', () => { + const config = { + cliproxy: { + variants: {}, + auth: { api_key: '' }, + }, + }; + const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET); + + // Empty string is falsy, so isCustom should be false + assert.strictEqual(summary.apiKey.value, DEFAULT_KEY); + assert.strictEqual(summary.apiKey.isCustom, false); + }); + }); + + // ========================================================================= + // Edge Cases + // ========================================================================= + describe('Edge Cases', () => { + it('handles undefined config gracefully', () => { + function getEffectiveApiKey(config, variantName, defaultKey) { + if (variantName) { + const variant = config?.cliproxy?.variants?.[variantName]; + if (variant?.auth?.api_key) return variant.auth.api_key; + } + if (config?.cliproxy?.auth?.api_key) return config.cliproxy.auth.api_key; + return defaultKey; + } + + assert.strictEqual(getEffectiveApiKey(undefined, undefined, 'default'), 'default'); + assert.strictEqual(getEffectiveApiKey(null, undefined, 'default'), 'default'); + assert.strictEqual(getEffectiveApiKey({}, undefined, 'default'), 'default'); + }); + + it('handles deeply nested missing properties', () => { + function getEffectiveApiKey(config, variantName, defaultKey) { + if (variantName) { + const variant = config?.cliproxy?.variants?.[variantName]; + if (variant?.auth?.api_key) return variant.auth.api_key; + } + if (config?.cliproxy?.auth?.api_key) return config.cliproxy.auth.api_key; + return defaultKey; + } + + const config = { cliproxy: {} }; + assert.strictEqual(getEffectiveApiKey(config, 'test', 'default'), 'default'); + }); + + it('crypto.randomBytes is available and working', () => { + // Ensure crypto module is available + const bytes = crypto.randomBytes(32); + assert.strictEqual(bytes.length, 32); + assert(Buffer.isBuffer(bytes)); + }); + + it('base64url encoding produces valid characters', () => { + const bytes = crypto.randomBytes(32); + const token = bytes.toString('base64url'); + + // Valid base64url characters: A-Z, a-z, 0-9, -, _ + const validChars = /^[A-Za-z0-9_-]+$/; + assert(validChars.test(token), 'Token should only contain base64url characters'); + }); + }); + + // ========================================================================= + // Constants Validation + // ========================================================================= + describe('Default Constants', () => { + let CCS_INTERNAL_API_KEY; + let CCS_CONTROL_PANEL_SECRET; + + beforeEach(() => { + delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')]; + const configGenerator = require('../../../dist/cliproxy/config-generator'); + CCS_INTERNAL_API_KEY = configGenerator.CCS_INTERNAL_API_KEY; + CCS_CONTROL_PANEL_SECRET = configGenerator.CCS_CONTROL_PANEL_SECRET; + }); + + it('CCS_INTERNAL_API_KEY is defined', () => { + assert(CCS_INTERNAL_API_KEY, 'CCS_INTERNAL_API_KEY should be defined'); + assert.strictEqual(typeof CCS_INTERNAL_API_KEY, 'string'); + }); + + it('CCS_CONTROL_PANEL_SECRET is defined', () => { + assert(CCS_CONTROL_PANEL_SECRET, 'CCS_CONTROL_PANEL_SECRET should be defined'); + assert.strictEqual(typeof CCS_CONTROL_PANEL_SECRET, 'string'); + }); + + it('default API key has expected value', () => { + assert.strictEqual(CCS_INTERNAL_API_KEY, 'ccs-internal-managed'); + }); + + it('default secret has expected value', () => { + assert.strictEqual(CCS_CONTROL_PANEL_SECRET, 'ccs'); + }); + }); +}); diff --git a/tests/unit/cliproxy/config-generator-port.test.js b/tests/unit/cliproxy/config-generator-port.test.js new file mode 100644 index 00000000..dfbb76d5 --- /dev/null +++ b/tests/unit/cliproxy/config-generator-port.test.js @@ -0,0 +1,280 @@ +/** + * Config Generator Port Tests + * + * Tests for per-port configuration in config-generator.ts. + * Verifies port-specific config files (config-{port}.yaml) and path generation. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set test isolation environment before importing +const testHome = path.join( + os.tmpdir(), + `ccs-test-config-port-${Date.now()}-${Math.random().toString(36).slice(2)}` +); +process.env.CCS_HOME = testHome; + +const { + getConfigPathForPort, + getCliproxyConfigPath, + generateConfig, + regenerateConfig, + configExists, + deleteConfigForPort, + deleteConfig, + CLIPROXY_DEFAULT_PORT, +} = require('../../../dist/cliproxy/config-generator'); + +describe('Config Generator Port', function () { + let cliproxyDir; + + beforeEach(function () { + // Create test directories + cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + // Clean up any existing config files + try { + const files = fs.readdirSync(cliproxyDir); + for (const file of files) { + if (file.startsWith('config')) { + fs.unlinkSync(path.join(cliproxyDir, file)); + } + } + } catch { + // Directory might not exist yet + } + }); + + afterEach(function () { + // Clean up config files + try { + const files = fs.readdirSync(cliproxyDir); + for (const file of files) { + if (file.startsWith('config')) { + fs.unlinkSync(path.join(cliproxyDir, file)); + } + } + } catch { + // Ignore cleanup errors + } + }); + + afterAll(function () { + // Clean up test directory + try { + fs.rmSync(testHome, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + delete process.env.CCS_HOME; + }); + + describe('getConfigPathForPort', function () { + it('returns config.yaml for default port (8317)', function () { + const configPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT); + const filename = path.basename(configPath); + assert.ok(configPath.endsWith('config.yaml'), `Expected path to end with config.yaml but got: ${configPath}`); + assert.strictEqual(filename, 'config.yaml', `Expected filename to be config.yaml but got: ${filename}`); + }); + + it('returns config-{port}.yaml for variant ports', function () { + const variantPort = 8318; + const configPath = getConfigPathForPort(variantPort); + assert.ok(configPath.endsWith(`config-${variantPort}.yaml`)); + }); + + it('example: port 8318 -> config-8318.yaml', function () { + const configPath = getConfigPathForPort(8318); + assert.ok(configPath.endsWith('config-8318.yaml')); + }); + + it('example: port 8417 -> config-8417.yaml', function () { + const configPath = getConfigPathForPort(8417); + assert.ok(configPath.endsWith('config-8417.yaml')); + }); + }); + + describe('getCliproxyConfigPath', function () { + it('returns path for default port', function () { + const configPath = getCliproxyConfigPath(); + const defaultPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT); + assert.strictEqual(configPath, defaultPath); + }); + }); + + describe('generateConfig', function () { + it('creates config-{port}.yaml for non-default port', function () { + const variantPort = 8318; + generateConfig('gemini', variantPort); + + const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`); + assert.ok(fs.existsSync(configPath), 'Should create config-8318.yaml'); + }); + + it('creates config.yaml for default port', function () { + generateConfig('gemini', CLIPROXY_DEFAULT_PORT); + + const configPath = path.join(cliproxyDir, 'config.yaml'); + assert.ok(fs.existsSync(configPath), 'Should create config.yaml'); + }); + + it('only creates if file does not exist (idempotent)', function () { + const variantPort = 8318; + const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`); + + // Create config first time + generateConfig('gemini', variantPort); + const stat1 = fs.statSync(configPath); + + // Wait a tiny bit and try again + const originalContent = fs.readFileSync(configPath, 'utf-8'); + generateConfig('gemini', variantPort); + const newContent = fs.readFileSync(configPath, 'utf-8'); + + // Content should be the same (not regenerated) + assert.strictEqual(originalContent, newContent); + }); + + it('sets correct port in config content', function () { + const variantPort = 8320; + generateConfig('gemini', variantPort); + + const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`); + const content = fs.readFileSync(configPath, 'utf-8'); + + // Check that port is set correctly + assert.ok(content.includes(`port: ${variantPort}`)); + }); + }); + + describe('regenerateConfig', function () { + it('regenerates config-{port}.yaml with updated version', function () { + const variantPort = 8318; + const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`); + + // Create initial config + generateConfig('gemini', variantPort); + const originalContent = fs.readFileSync(configPath, 'utf-8'); + + // Modify the file + fs.writeFileSync(configPath, '# modified content\n' + originalContent); + + // Regenerate + regenerateConfig(variantPort); + const newContent = fs.readFileSync(configPath, 'utf-8'); + + // Should not contain our modification + assert.ok(!newContent.includes('# modified content')); + }); + + it('preserves port value from existing config', function () { + const variantPort = 8325; + const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`); + + // Create config with specific port + generateConfig('gemini', variantPort); + + // Regenerate + regenerateConfig(variantPort); + const content = fs.readFileSync(configPath, 'utf-8'); + + // Port should still be 8325 + assert.ok(content.includes(`port: ${variantPort}`)); + }); + }); + + describe('deleteConfigForPort', function () { + it('deletes config-{port}.yaml for specified port', function () { + const variantPort = 8318; + generateConfig('gemini', variantPort); + + const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`); + assert.ok(fs.existsSync(configPath)); + + deleteConfigForPort(variantPort); + assert.strictEqual(fs.existsSync(configPath), false); + }); + + it('does nothing if file does not exist', function () { + // Should not throw + deleteConfigForPort(8399); + }); + + it('does not affect other port configs', function () { + const port1 = 8318; + const port2 = 8319; + + generateConfig('gemini', port1); + generateConfig('gemini', port2); + + deleteConfigForPort(port1); + + const config1Path = path.join(cliproxyDir, `config-${port1}.yaml`); + const config2Path = path.join(cliproxyDir, `config-${port2}.yaml`); + + assert.strictEqual(fs.existsSync(config1Path), false); + assert.ok(fs.existsSync(config2Path)); + }); + }); + + describe('deleteConfig', function () { + it('deletes config.yaml for default port', function () { + generateConfig('gemini', CLIPROXY_DEFAULT_PORT); + + const configPath = path.join(cliproxyDir, 'config.yaml'); + assert.ok(fs.existsSync(configPath)); + + deleteConfig(); + assert.strictEqual(fs.existsSync(configPath), false); + }); + }); + + describe('configExists', function () { + it('returns true if config-{port}.yaml exists', function () { + const variantPort = 8318; + generateConfig('gemini', variantPort); + + assert.strictEqual(configExists(variantPort), true); + }); + + it('returns false if config-{port}.yaml missing', function () { + const variantPort = 8399; + assert.strictEqual(configExists(variantPort), false); + }); + + it('returns true for default port config', function () { + generateConfig('gemini', CLIPROXY_DEFAULT_PORT); + assert.strictEqual(configExists(CLIPROXY_DEFAULT_PORT), true); + }); + }); + + describe('Multiple Port Configs', function () { + it('can create configs for multiple ports simultaneously', function () { + const ports = [8318, 8319, 8320, CLIPROXY_DEFAULT_PORT]; + + for (const port of ports) { + generateConfig('gemini', port); + } + + // All should exist + for (const port of ports) { + assert.ok(configExists(port), `Config for port ${port} should exist`); + } + }); + + it('each port config has correct port value', function () { + const ports = [8318, 8319, 8320]; + + for (const port of ports) { + generateConfig('gemini', port); + const configPath = getConfigPathForPort(port); + const content = fs.readFileSync(configPath, 'utf-8'); + assert.ok(content.includes(`port: ${port}`), `Config should have port: ${port}`); + } + }); + }); +}); diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 91dd4b0e..703b5fe6 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -166,6 +166,365 @@ describe('Config Generator', () => { }); }); + // ========================================================================= + // API Key Preservation Tests (Issue #200) + // ========================================================================= + describe('parseUserApiKeys', () => { + let parseUserApiKeys; + + beforeEach(() => { + // Clear cache and reload module + delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')]; + const configGenerator = require('../../../dist/cliproxy/config-generator'); + parseUserApiKeys = configGenerator.parseUserApiKeys; + }); + + it('extracts user-added API keys from config content', () => { + const configContent = ` +# CLIProxyAPI config generated by CCS v4 +port: 8317 + +api-keys: + - "ccs-internal-managed" + - "user-custom-key-12345" + - "another-user-key-abcde" + +auth-dir: "/home/user/.ccs/cliproxy/auth" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-custom-key-12345', 'another-user-key-abcde']); + }); + + it('excludes the internal CCS key', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "my-custom-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.strictEqual(userKeys.length, 1); + assert.strictEqual(userKeys[0], 'my-custom-key'); + assert(!userKeys.includes('ccs-internal-managed'), 'Should not include internal key'); + }); + + it('returns empty array when only internal key exists', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, []); + }); + + it('returns empty array when no api-keys section exists', () => { + const configContent = ` +port: 8317 +debug: false +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, []); + }); + + it('handles single user key', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "single-user-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['single-user-key']); + }); + + it('handles multiple user keys', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "key1" + - "key2" + - "key3" + - "key4" + - "key5" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['key1', 'key2', 'key3', 'key4', 'key5']); + }); + + it('handles keys with special characters', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "sk_live_1234567890abcdef" + - "api-key-with-dashes" + - "key_with_underscores_123" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, [ + 'sk_live_1234567890abcdef', + 'api-key-with-dashes', + 'key_with_underscores_123', + ]); + }); + + it('preserves key order', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "zebra-key" + - "alpha-key" + - "middle-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['zebra-key', 'alpha-key', 'middle-key']); + }); + + it('handles empty string key (edge case)', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "" + - "valid-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + // Empty strings should be filtered out (only truthy values) + assert.deepStrictEqual(userKeys, ['valid-key']); + }); + + it('handles config with Windows line endings', () => { + const configContent = + 'api-keys:\r\n - "ccs-internal-managed"\r\n - "user-key"\r\n\r\nauth-dir: "/test"\r\n'; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-key']); + }); + + it('handles different indentation styles', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "user-key-with-4-spaces" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-key-with-4-spaces']); + }); + + it('handles tabs in indentation', () => { + const configContent = 'api-keys:\n\t- "ccs-internal-managed"\n\t- "user-key-with-tab"\n\nauth-dir: "/test"'; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-key-with-tab']); + }); + }); + + describe('regenerateConfig API key preservation', () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + + let testDir; + let originalCcsHome; + let regenerateConfig; + let getCliproxyConfigPath; + + beforeEach(() => { + // Create a temporary test directory + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = testDir; + + // Clear cache and reload module + delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')]; + delete require.cache[require.resolve('../../../dist/utils/config-manager')]; + const configGenerator = require('../../../dist/cliproxy/config-generator'); + regenerateConfig = configGenerator.regenerateConfig; + getCliproxyConfigPath = configGenerator.getCliproxyConfigPath; + }); + + afterEach(() => { + // Restore environment + process.env.CCS_HOME = originalCcsHome; + + // Clean up test directory + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('preserves user API keys during regeneration', () => { + // Create initial config with user keys + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v3 +port: 8317 + +api-keys: + - "ccs-internal-managed" + - "user-custom-key-12345" + - "another-user-key" + +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + // Regenerate config (simulating CCS update) + regenerateConfig(); + + // Read regenerated config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + + // Verify user keys are preserved + assert(newConfig.includes('user-custom-key-12345'), 'Should preserve first user key'); + assert(newConfig.includes('another-user-key'), 'Should preserve second user key'); + assert(newConfig.includes('ccs-internal-managed'), 'Should include internal key'); + }); + + it('preserves port setting during regeneration', () => { + // Create initial config with custom port + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v3 +port: 9999 + +api-keys: + - "ccs-internal-managed" + +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + // Regenerate config + regenerateConfig(); + + // Read regenerated config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + + // Verify port is preserved + assert(newConfig.includes('port: 9999'), 'Should preserve custom port'); + }); + + it('creates fresh config when none exists', () => { + // Ensure clean state + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + const configPath = path.join(cliproxyDir, 'config.yaml'); + + assert(!fs.existsSync(configPath), 'Config should not exist initially'); + + // Generate config + regenerateConfig(); + + // Verify config was created + assert(fs.existsSync(configPath), 'Config should be created'); + + const config = fs.readFileSync(configPath, 'utf-8'); + assert(config.includes('ccs-internal-managed'), 'Should include internal key'); + assert(config.includes('port: 8317'), 'Should use default port'); + }); + + it('handles corrupted config gracefully', () => { + // Create corrupted config + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), 'invalid yaml content: [[['); + + // Should not throw + assert.doesNotThrow(() => regenerateConfig()); + + // Should create valid config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert(newConfig.includes('ccs-internal-managed'), 'Should create valid config'); + }); + + it('handles empty config file gracefully', () => { + // Create empty config + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), ''); + + // Should not throw + assert.doesNotThrow(() => regenerateConfig()); + + // Should create valid config with defaults + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert(newConfig.includes('ccs-internal-managed'), 'Should create valid config'); + assert(newConfig.includes('port: 8317'), 'Should use default port'); + }); + + it('preserves multiple user keys in correct order', () => { + // Create config with multiple user keys + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v3 +port: 8317 + +api-keys: + - "ccs-internal-managed" + - "key-first" + - "key-second" + - "key-third" + +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + // Regenerate config + regenerateConfig(); + + // Read regenerated config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + + // Verify order is preserved (first > second > third) + const firstPos = newConfig.indexOf('key-first'); + const secondPos = newConfig.indexOf('key-second'); + const thirdPos = newConfig.indexOf('key-third'); + + assert(firstPos < secondPos, 'First key should come before second'); + assert(secondPos < thirdPos, 'Second key should come before third'); + }); + }); + describe('Cross-platform path handling', () => { it('normalizes paths regardless of current platform', () => { // The fix should work consistently on Windows, macOS, and Linux diff --git a/tests/unit/cliproxy/proxy-config-resolver.test.js b/tests/unit/cliproxy/proxy-config-resolver.test.js index 2a7d0851..ce905846 100644 --- a/tests/unit/cliproxy/proxy-config-resolver.test.js +++ b/tests/unit/cliproxy/proxy-config-resolver.test.js @@ -255,6 +255,58 @@ describe('proxy-config-resolver', () => { expect(config.remoteOnly).toBe(true); expect(config.fallbackEnabled).toBe(false); }); + + // Tests for YAML enabled:false handling (Bug fix validation) + describe('YAML remote.enabled handling', () => { + it('should block remote mode when YAML remote.enabled is false', () => { + const { config } = resolveProxyConfig([], { + remote: { enabled: false, host: 'yaml-host.example.com' }, + }); + expect(config.mode).toBe('local'); + // Host from YAML should NOT be used when enabled: false + }); + + it('should allow remote mode when YAML remote.enabled is true', () => { + const { config } = resolveProxyConfig([], { + remote: { enabled: true, host: 'yaml-host.example.com' }, + }); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('yaml-host.example.com'); + }); + + it('should allow remote mode when YAML remote.enabled is undefined (backwards compat)', () => { + const { config } = resolveProxyConfig([], { + remote: { host: 'yaml-host.example.com' }, + }); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('yaml-host.example.com'); + }); + + it('should allow CLI --proxy-host to override YAML enabled:false', () => { + const { config } = resolveProxyConfig(['--proxy-host', 'cli-host'], { + remote: { enabled: false, host: 'yaml-host' }, + }); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('cli-host'); + }); + + it('should allow ENV CCS_PROXY_HOST to override YAML enabled:false', () => { + process.env.CCS_PROXY_HOST = 'env-host'; + const { config } = resolveProxyConfig([], { + remote: { enabled: false, host: 'yaml-host' }, + }); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('env-host'); + }); + + it('should force local mode with --local-proxy even when YAML enabled:true', () => { + const { config } = resolveProxyConfig(['--local-proxy'], { + remote: { enabled: true, host: 'yaml-host' }, + }); + expect(config.mode).toBe('local'); + expect(config.forceLocal).toBe(true); + }); + }); }); describe('hasProxyFlags', () => { diff --git a/tests/unit/cliproxy/remote-proxy-client.test.ts b/tests/unit/cliproxy/remote-proxy-client.test.ts index 536ff1d8..58defd72 100644 --- a/tests/unit/cliproxy/remote-proxy-client.test.ts +++ b/tests/unit/cliproxy/remote-proxy-client.test.ts @@ -137,4 +137,35 @@ describe('remote-proxy-client', () => { expect(expectedUrl).toBe('https://secure.example.com:443/v1/models'); }); }); + + describe('port defaults by protocol', () => { + // Document expected default ports based on protocol + // HTTP: 8317 (CLIProxyAPI default for local/dev scenarios) + // HTTPS: 443 (standard SSL port for production remote servers) + + it('should document HTTP default port as 8317', () => { + // HTTP connections default to CLIProxyAPI port 8317 + // This matches local development scenarios + const expectedHttpDefault = 8317; + expect(expectedHttpDefault).toBe(8317); + }); + + it('should document HTTPS default port as 443', () => { + // HTTPS connections default to standard SSL port 443 + // This matches production remote server scenarios + const expectedHttpsDefault = 443; + expect(expectedHttpsDefault).toBe(443); + }); + + it('should allow port to be optional in config', () => { + // Port is optional - when undefined, defaults based on protocol + const configWithoutPort: RemoteProxyClientConfig = { + host: 'example.com', + protocol: 'https', + // port is intentionally undefined + }; + expect(configWithoutPort.port).toBeUndefined(); + expect(configWithoutPort.protocol).toBe('https'); + }); + }); }); diff --git a/tests/unit/cliproxy/session-tracker-port.test.js b/tests/unit/cliproxy/session-tracker-port.test.js new file mode 100644 index 00000000..1826a2aa --- /dev/null +++ b/tests/unit/cliproxy/session-tracker-port.test.js @@ -0,0 +1,420 @@ +/** + * Session Tracker Port-Specific Tests + * + * Tests for per-port session tracking in session-tracker.ts. + * Verifies port-specific session files (sessions-{port}.json) and cleanup. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set test isolation environment before importing +const testHome = path.join( + os.tmpdir(), + `ccs-test-session-port-${Date.now()}-${Math.random().toString(36).slice(2)}` +); +process.env.CCS_HOME = testHome; + +const { + getExistingProxy, + registerSession, + unregisterSession, + cleanupOrphanedSessions, + stopProxy, + getProxyStatus, + getSessionLockPath, + deleteSessionLockForPort, +} = require('../../../dist/cliproxy/session-tracker'); +const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator'); + +describe('Session Tracker Port-Specific', function () { + const variantPort1 = 8318; + const variantPort2 = 8319; + let cliproxyDir; + + beforeEach(function () { + // Create test directories + cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + // Clean up any existing session files + const files = fs.readdirSync(cliproxyDir); + for (const file of files) { + if (file.startsWith('sessions')) { + fs.unlinkSync(path.join(cliproxyDir, file)); + } + } + }); + + afterEach(function () { + // Clean up session files + try { + const files = fs.readdirSync(cliproxyDir); + for (const file of files) { + if (file.startsWith('sessions')) { + fs.unlinkSync(path.join(cliproxyDir, file)); + } + } + } catch { + // Ignore cleanup errors + } + }); + + afterAll(function () { + // Clean up test directory + try { + fs.rmSync(testHome, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + delete process.env.CCS_HOME; + }); + + describe('Session Lock Path', function () { + it('returns sessions.json for default port', function () { + const lockPath = getSessionLockPath(); + assert.ok(lockPath.endsWith('sessions.json')); + assert.ok(!lockPath.includes('sessions-')); + }); + }); + + describe('Port-Specific Session Files', function () { + it('creates sessions-{port}.json for variant ports', function () { + registerSession(variantPort1, process.pid); + + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + assert.ok(fs.existsSync(lockPath), `Should create sessions-${variantPort1}.json`); + }); + + it('creates sessions.json for default port', function () { + registerSession(CLIPROXY_DEFAULT_PORT, process.pid); + + const lockPath = path.join(cliproxyDir, 'sessions.json'); + assert.ok(fs.existsSync(lockPath), 'Should create sessions.json for default port'); + }); + + it('keeps separate session files for different ports', function () { + // Register sessions on different ports + registerSession(variantPort1, process.pid); + registerSession(variantPort2, process.pid); + registerSession(CLIPROXY_DEFAULT_PORT, process.pid); + + // All three should exist + assert.ok( + fs.existsSync(path.join(cliproxyDir, `sessions-${variantPort1}.json`)), + 'Should have port 8318 sessions' + ); + assert.ok( + fs.existsSync(path.join(cliproxyDir, `sessions-${variantPort2}.json`)), + 'Should have port 8319 sessions' + ); + assert.ok( + fs.existsSync(path.join(cliproxyDir, 'sessions.json')), + 'Should have default port sessions' + ); + }); + }); + + describe('registerSession with Port', function () { + it('stores correct port in session lock file', function () { + registerSession(variantPort1, process.pid); + + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); + + assert.strictEqual(lock.port, variantPort1); + }); + + it('stores correct PID in session lock file', function () { + registerSession(variantPort1, process.pid); + + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); + + assert.strictEqual(lock.pid, process.pid); + }); + + it('appends to existing sessions array for same port', function () { + const session1 = registerSession(variantPort1, process.pid); + const session2 = registerSession(variantPort1, process.pid); + + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); + + assert.strictEqual(lock.sessions.length, 2); + assert.ok(lock.sessions.includes(session1)); + assert.ok(lock.sessions.includes(session2)); + }); + }); + + describe('unregisterSession with Port', function () { + it('removes session from port-specific file', function () { + const session1 = registerSession(variantPort1, process.pid); + const session2 = registerSession(variantPort1, process.pid); + + // Unregister first session with port + unregisterSession(session1, variantPort1); + + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); + + assert.strictEqual(lock.sessions.length, 1); + assert.strictEqual(lock.sessions[0], session2); + }); + + it('deletes lock file when last session removed', function () { + const session = registerSession(variantPort1, process.pid); + + const shouldKill = unregisterSession(session, variantPort1); + + assert.strictEqual(shouldKill, true); + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + assert.strictEqual(fs.existsSync(lockPath), false); + }); + + it('returns true when last session', function () { + const session = registerSession(variantPort1, process.pid); + const shouldKill = unregisterSession(session, variantPort1); + assert.strictEqual(shouldKill, true); + }); + + it('returns false when sessions remain', function () { + const session1 = registerSession(variantPort1, process.pid); + registerSession(variantPort1, process.pid); + + const shouldKill = unregisterSession(session1, variantPort1); + assert.strictEqual(shouldKill, false); + }); + + it('searches default port for backward compat (fallback)', function () { + // Register on default port + const session = registerSession(CLIPROXY_DEFAULT_PORT, process.pid); + + // Unregister without port (should search default) + const shouldKill = unregisterSession(session); + + assert.strictEqual(shouldKill, true); + const lockPath = path.join(cliproxyDir, 'sessions.json'); + assert.strictEqual(fs.existsSync(lockPath), false); + }); + }); + + describe('getExistingProxy with Port', function () { + it('returns lock for running proxy on specified port', function () { + registerSession(variantPort1, process.pid); + + const lock = getExistingProxy(variantPort1); + assert.notStrictEqual(lock, null); + assert.strictEqual(lock.port, variantPort1); + assert.strictEqual(lock.pid, process.pid); + }); + + it('returns null if lock file missing', function () { + const lock = getExistingProxy(variantPort1); + assert.strictEqual(lock, null); + }); + + it('returns null if port mismatch', function () { + // Create lock with different port number in file + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + fs.writeFileSync( + lockPath, + JSON.stringify({ + port: 9999, // Wrong port + pid: process.pid, + sessions: ['session1'], + startedAt: new Date().toISOString(), + }) + ); + + const lock = getExistingProxy(variantPort1); + assert.strictEqual(lock, null); + }); + + it('cleans up stale lock if PID not running', function () { + // Create lock with dead PID + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + fs.writeFileSync( + lockPath, + JSON.stringify({ + port: variantPort1, + pid: 999999999, // Dead PID + sessions: ['session1'], + startedAt: new Date().toISOString(), + }) + ); + + const lock = getExistingProxy(variantPort1); + assert.strictEqual(lock, null); + assert.strictEqual(fs.existsSync(lockPath), false); + }); + }); + + describe('deleteSessionLockForPort', function () { + it('removes sessions-{port}.json for specified port', function () { + registerSession(variantPort1, process.pid); + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + assert.ok(fs.existsSync(lockPath)); + + deleteSessionLockForPort(variantPort1); + assert.strictEqual(fs.existsSync(lockPath), false); + }); + + it('does nothing if file does not exist', function () { + // Should not throw + deleteSessionLockForPort(variantPort1); + }); + + it('does not affect other port sessions', function () { + registerSession(variantPort1, process.pid); + registerSession(variantPort2, process.pid); + + deleteSessionLockForPort(variantPort1); + + const lock1Path = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + const lock2Path = path.join(cliproxyDir, `sessions-${variantPort2}.json`); + + assert.strictEqual(fs.existsSync(lock1Path), false); + assert.ok(fs.existsSync(lock2Path)); + }); + }); + + describe('cleanupOrphanedSessions with Port', function () { + it('deletes lock if PID not running', function () { + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + fs.writeFileSync( + lockPath, + JSON.stringify({ + port: variantPort1, + pid: 999999999, // Dead PID + sessions: ['session1'], + startedAt: new Date().toISOString(), + }) + ); + + cleanupOrphanedSessions(variantPort1); + assert.strictEqual(fs.existsSync(lockPath), false); + }); + + it('keeps lock if PID still running', function () { + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + fs.writeFileSync( + lockPath, + JSON.stringify({ + port: variantPort1, + pid: process.pid, // Our process - running + sessions: ['session1'], + startedAt: new Date().toISOString(), + }) + ); + + cleanupOrphanedSessions(variantPort1); + assert.ok(fs.existsSync(lockPath)); + }); + }); + + describe('stopProxy with Port', function () { + it('stops proxy on specified port', async function () { + // Create lock with dead PID (we can't actually stop a real process in tests) + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + fs.writeFileSync( + lockPath, + JSON.stringify({ + port: variantPort1, + pid: 999999999, // Dead PID + sessions: ['session1'], + startedAt: new Date().toISOString(), + }) + ); + + const result = await stopProxy(variantPort1); + assert.strictEqual(result.stopped, false); + assert.ok(result.error.includes('not running')); + }); + + it('cleans up session lock after stop', async function () { + const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`); + fs.writeFileSync( + lockPath, + JSON.stringify({ + port: variantPort1, + pid: 999999999, // Dead PID + sessions: ['session1'], + startedAt: new Date().toISOString(), + }) + ); + + await stopProxy(variantPort1); + assert.strictEqual(fs.existsSync(lockPath), false); + }); + + it('handles already-stopped proxy gracefully', async function () { + const result = await stopProxy(variantPort1); + assert.strictEqual(result.stopped, false); + assert.strictEqual(result.error, 'No active CLIProxy session found'); + }); + }); + + describe('getProxyStatus with Port', function () { + it('returns correct status for variant port', function () { + registerSession(variantPort1, process.pid); + + const status = getProxyStatus(variantPort1); + assert.strictEqual(status.running, true); + assert.strictEqual(status.port, variantPort1); + assert.strictEqual(status.pid, process.pid); + assert.strictEqual(status.sessionCount, 1); + }); + + it('returns not running for empty variant port', function () { + const status = getProxyStatus(variantPort1); + assert.strictEqual(status.running, false); + }); + }); + + describe('Concurrent Variant Sessions', function () { + it('manages multiple variant ports independently', function () { + // Start sessions on different ports + const session1 = registerSession(variantPort1, process.pid); + const session2 = registerSession(variantPort2, process.pid); + + // Both should be tracked + assert.strictEqual(getProxyStatus(variantPort1).running, true); + assert.strictEqual(getProxyStatus(variantPort2).running, true); + + // Unregister one should not affect other + unregisterSession(session1, variantPort1); + + assert.strictEqual(getProxyStatus(variantPort1).running, false); + assert.strictEqual(getProxyStatus(variantPort2).running, true); + + // Clean up + unregisterSession(session2, variantPort2); + }); + + it('allows same session workflow on different ports', function () { + // Simulate concurrent variant usage + const port1Session1 = registerSession(variantPort1, process.pid); + const port1Session2 = registerSession(variantPort1, process.pid); + const port2Session1 = registerSession(variantPort2, process.pid); + + assert.strictEqual(getProxyStatus(variantPort1).sessionCount, 2); + assert.strictEqual(getProxyStatus(variantPort2).sessionCount, 1); + + // Unregister from port1 + const shouldKill1 = unregisterSession(port1Session1, variantPort1); + assert.strictEqual(shouldKill1, false); // Still has session2 + + const shouldKill2 = unregisterSession(port1Session2, variantPort1); + assert.strictEqual(shouldKill2, true); // Last session on port1 + + // Port2 should still be running + assert.strictEqual(getProxyStatus(variantPort2).running, true); + + // Clean up + unregisterSession(port2Session1, variantPort2); + }); + }); +}); diff --git a/tests/unit/cliproxy/session-tracker.test.js b/tests/unit/cliproxy/session-tracker.test.js index 463a26cd..ac4d7de2 100644 --- a/tests/unit/cliproxy/session-tracker.test.js +++ b/tests/unit/cliproxy/session-tracker.test.js @@ -28,23 +28,39 @@ const { describe('Session Tracker', function () { const testPort = 18317; let sessionLockPath; + let cliproxyDir; beforeEach(function () { // Create test directories - const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); + cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); fs.mkdirSync(cliproxyDir, { recursive: true }); - sessionLockPath = path.join(cliproxyDir, 'sessions.json'); + // Use port-specific session file for non-default ports + sessionLockPath = path.join(cliproxyDir, `sessions-${testPort}.json`); - // Clean up any existing lock file - if (fs.existsSync(sessionLockPath)) { - fs.unlinkSync(sessionLockPath); + // Clean up any existing lock files + try { + const files = fs.readdirSync(cliproxyDir); + for (const file of files) { + if (file.startsWith('sessions')) { + fs.unlinkSync(path.join(cliproxyDir, file)); + } + } + } catch { + // Directory might not exist yet } }); afterEach(function () { - // Clean up lock file - if (fs.existsSync(sessionLockPath)) { - fs.unlinkSync(sessionLockPath); + // Clean up lock files + try { + const files = fs.readdirSync(cliproxyDir); + for (const file of files) { + if (file.startsWith('sessions')) { + fs.unlinkSync(path.join(cliproxyDir, file)); + } + } + } catch { + // Ignore cleanup errors } }); @@ -164,7 +180,7 @@ describe('Session Tracker', function () { describe('unregisterSession', function () { it('should return true when no lock exists', function () { - const result = unregisterSession('nonexistent'); + const result = unregisterSession('nonexistent', testPort); assert.strictEqual(result, true); }); @@ -174,7 +190,7 @@ describe('Session Tracker', function () { const session2 = registerSession(testPort, process.pid); // Unregister first - const shouldKill = unregisterSession(session1); + const shouldKill = unregisterSession(session1, testPort); assert.strictEqual(shouldKill, false, 'should not kill - other sessions active'); @@ -188,7 +204,7 @@ describe('Session Tracker', function () { const session1 = registerSession(testPort, process.pid); // Unregister it - const shouldKill = unregisterSession(session1); + const shouldKill = unregisterSession(session1, testPort); assert.strictEqual(shouldKill, true, 'should kill - last session'); assert.strictEqual(fs.existsSync(sessionLockPath), false, 'should delete lock file'); @@ -199,38 +215,40 @@ describe('Session Tracker', function () { registerSession(testPort, process.pid); // Try to unregister wrong session - const shouldKill = unregisterSession('wrong-session-id'); + const shouldKill = unregisterSession('wrong-session-id', testPort); // Should return false since a session still exists assert.strictEqual(shouldKill, false); }); }); - describe('getSessionCount', function () { + describe('getSessionCount (default port)', function () { it('should return 0 when no lock exists', function () { assert.strictEqual(getSessionCount(), 0); }); - it('should return correct count', function () { + it('should return correct count (uses getProxyStatus for port-specific)', function () { registerSession(testPort, process.pid); - assert.strictEqual(getSessionCount(), 1); + assert.strictEqual(getProxyStatus(testPort).sessionCount, 1); registerSession(testPort, process.pid); - assert.strictEqual(getSessionCount(), 2); + assert.strictEqual(getProxyStatus(testPort).sessionCount, 2); registerSession(testPort, process.pid); - assert.strictEqual(getSessionCount(), 3); + assert.strictEqual(getProxyStatus(testPort).sessionCount, 3); }); }); - describe('hasActiveSessions', function () { + describe('hasActiveSessions (default port)', function () { it('should return false when no lock exists', function () { assert.strictEqual(hasActiveSessions(), false); }); - it('should return true when sessions exist and proxy running', function () { + it('should return true when sessions exist on default port', function () { + // Note: hasActiveSessions() checks default port only + // For port-specific checks, use getProxyStatus(port).running registerSession(testPort, process.pid); - assert.strictEqual(hasActiveSessions(), true); + assert.strictEqual(getProxyStatus(testPort).running, true); }); it('should return false and cleanup when proxy is dead', function () { @@ -243,7 +261,9 @@ describe('Session Tracker', function () { }; fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); - assert.strictEqual(hasActiveSessions(), false); + // getProxyStatus will clean up stale lock + const status = getProxyStatus(testPort); + assert.strictEqual(status.running, false); assert.strictEqual(fs.existsSync(sessionLockPath), false); }); }); @@ -300,7 +320,7 @@ describe('Session Tracker', function () { describe('stopProxy', function () { it('should return error when no lock exists', async function () { - const result = await stopProxy(); + const result = await stopProxy(testPort); assert.strictEqual(result.stopped, false); assert.strictEqual(result.error, 'No active CLIProxy session found'); }); @@ -315,7 +335,7 @@ describe('Session Tracker', function () { }; fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); - const result = await stopProxy(); + const result = await stopProxy(testPort); assert.strictEqual(result.stopped, false); assert.ok(result.error.includes('not running')); assert.strictEqual(fs.existsSync(sessionLockPath), false); @@ -327,7 +347,7 @@ describe('Session Tracker', function () { // Note: We can't actually test killing our own process, // but we can verify the structure is correct before it attempts kill - const status = getProxyStatus(); + const status = getProxyStatus(testPort); assert.strictEqual(status.running, true); assert.strictEqual(status.pid, process.pid); assert.strictEqual(status.sessionCount, 1); @@ -336,7 +356,7 @@ describe('Session Tracker', function () { describe('getProxyStatus', function () { it('should return not running when no lock exists', function () { - const result = getProxyStatus(); + const result = getProxyStatus(testPort); assert.strictEqual(result.running, false); assert.strictEqual(result.port, undefined); assert.strictEqual(result.pid, undefined); @@ -352,7 +372,7 @@ describe('Session Tracker', function () { }; fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); - const result = getProxyStatus(); + const result = getProxyStatus(testPort); assert.strictEqual(result.running, true); assert.strictEqual(result.port, testPort); assert.strictEqual(result.pid, process.pid); @@ -369,22 +389,22 @@ describe('Session Tracker', function () { }; fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); - const result = getProxyStatus(); + const result = getProxyStatus(testPort); assert.strictEqual(result.running, false); assert.strictEqual(fs.existsSync(sessionLockPath), false); }); it('should return correct session count after registrations', function () { registerSession(testPort, process.pid); - let status = getProxyStatus(); + let status = getProxyStatus(testPort); assert.strictEqual(status.sessionCount, 1); registerSession(testPort, process.pid); - status = getProxyStatus(); + status = getProxyStatus(testPort); assert.strictEqual(status.sessionCount, 2); registerSession(testPort, process.pid); - status = getProxyStatus(); + status = getProxyStatus(testPort); assert.strictEqual(status.sessionCount, 3); }); }); @@ -393,30 +413,30 @@ describe('Session Tracker', function () { it('should handle complete multi-terminal workflow', function () { // Terminal 1 starts - first session const session1 = registerSession(testPort, process.pid); - assert.strictEqual(getSessionCount(), 1); + assert.strictEqual(getProxyStatus(testPort).sessionCount, 1); // Terminal 2 starts - joins existing const session2 = registerSession(testPort, process.pid); - assert.strictEqual(getSessionCount(), 2); + assert.strictEqual(getProxyStatus(testPort).sessionCount, 2); // Terminal 3 starts - joins existing const session3 = registerSession(testPort, process.pid); - assert.strictEqual(getSessionCount(), 3); + assert.strictEqual(getProxyStatus(testPort).sessionCount, 3); // Terminal 1 exits - should NOT kill proxy - let shouldKill = unregisterSession(session1); + let shouldKill = unregisterSession(session1, testPort); assert.strictEqual(shouldKill, false); - assert.strictEqual(getSessionCount(), 2); + assert.strictEqual(getProxyStatus(testPort).sessionCount, 2); // Terminal 3 exits - should NOT kill proxy - shouldKill = unregisterSession(session3); + shouldKill = unregisterSession(session3, testPort); assert.strictEqual(shouldKill, false); - assert.strictEqual(getSessionCount(), 1); + assert.strictEqual(getProxyStatus(testPort).sessionCount, 1); // Terminal 2 exits - SHOULD kill proxy (last session) - shouldKill = unregisterSession(session2); + shouldKill = unregisterSession(session2, testPort); assert.strictEqual(shouldKill, true); - assert.strictEqual(getSessionCount(), 0); + assert.strictEqual(getProxyStatus(testPort).running, false); assert.strictEqual(fs.existsSync(sessionLockPath), false); }); }); diff --git a/tests/unit/cliproxy/variant-port-allocation.test.js b/tests/unit/cliproxy/variant-port-allocation.test.js new file mode 100644 index 00000000..363f856d --- /dev/null +++ b/tests/unit/cliproxy/variant-port-allocation.test.js @@ -0,0 +1,299 @@ +/** + * Variant Port Allocation Tests + * + * Tests for port allocation logic in variant-config-adapter.ts. + * Verifies unique port assignment (8318-8417) for CLIProxy variants. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set test isolation environment before importing +const testHome = path.join( + os.tmpdir(), + `ccs-test-port-${Date.now()}-${Math.random().toString(36).slice(2)}` +); +process.env.CCS_HOME = testHome; + +const { + getNextAvailablePort, + VARIANT_PORT_BASE, + VARIANT_PORT_MAX_OFFSET, + listVariantsFromConfig, + saveVariantLegacy, + removeVariantFromLegacyConfig, +} = require('../../../dist/cliproxy/services/variant-config-adapter'); +const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator'); + +describe('Variant Port Allocation', function () { + let configPath; + + beforeEach(function () { + // Create test directories + const ccsDir = path.join(testHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + configPath = path.join(ccsDir, 'config.json'); + + // Start with empty config + fs.writeFileSync(configPath, JSON.stringify({ profiles: {} })); + }); + + afterEach(function () { + // Clean up config file + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + } + }); + + afterAll(function () { + // Clean up test directory + try { + fs.rmSync(testHome, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + delete process.env.CCS_HOME; + }); + + describe('Constants', function () { + it('VARIANT_PORT_BASE equals CLIPROXY_DEFAULT_PORT + 1 (8318)', function () { + assert.strictEqual(VARIANT_PORT_BASE, CLIPROXY_DEFAULT_PORT + 1); + assert.strictEqual(VARIANT_PORT_BASE, 8318); + }); + + it('VARIANT_PORT_MAX_OFFSET equals 100 (ports 8318-8417)', function () { + assert.strictEqual(VARIANT_PORT_MAX_OFFSET, 100); + }); + }); + + describe('getNextAvailablePort - Basic Allocation', function () { + it('returns VARIANT_PORT_BASE (8318) when no variants exist', function () { + const port = getNextAvailablePort(); + assert.strictEqual(port, VARIANT_PORT_BASE); + assert.strictEqual(port, 8318); + }); + + it('returns next available port when some ports used', function () { + // Create variant on first port + const settingsPath = path.join(testHome, '.ccs', 'test1.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy('test1', 'gemini', settingsPath, undefined, 8318); + + const port = getNextAvailablePort(); + assert.strictEqual(port, 8319); + }); + + it('skips used ports and returns first gap', function () { + const ccsDir = path.join(testHome, '.ccs'); + + // Create variants on ports 8318 and 8320 (leaving 8319 as gap) + const settingsPath1 = path.join(ccsDir, 'test1.settings.json'); + const settingsPath2 = path.join(ccsDir, 'test2.settings.json'); + fs.writeFileSync(settingsPath1, JSON.stringify({ env: {} })); + fs.writeFileSync(settingsPath2, JSON.stringify({ env: {} })); + + saveVariantLegacy('test1', 'gemini', settingsPath1, undefined, 8318); + saveVariantLegacy('test2', 'gemini', settingsPath2, undefined, 8320); + + // Should return 8319 (the gap) + const port = getNextAvailablePort(); + assert.strictEqual(port, 8319); + }); + + it('allocates sequential ports for multiple variants', function () { + const ccsDir = path.join(testHome, '.ccs'); + + for (let i = 0; i < 5; i++) { + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + + const port = getNextAvailablePort(); + assert.strictEqual(port, 8318 + i); + + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port); + } + }); + }); + + describe('getNextAvailablePort - Boundary Conditions', function () { + it('returns last port (8417) when 99 ports used', function () { + const ccsDir = path.join(testHome, '.ccs'); + + // Create 99 variants (ports 8318-8416) + for (let i = 0; i < 99; i++) { + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i); + } + + const port = getNextAvailablePort(); + assert.strictEqual(port, 8417); // Last available port + }); + + it('throws when all 100 ports exhausted', function () { + const ccsDir = path.join(testHome, '.ccs'); + + // Create 100 variants (ports 8318-8417) + for (let i = 0; i < 100; i++) { + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i); + } + + assert.throws( + () => getNextAvailablePort(), + /Port limit reached/, + 'Should throw error when all ports exhausted' + ); + }); + + it('error message includes variant count and recovery hint', function () { + const ccsDir = path.join(testHome, '.ccs'); + + // Create 100 variants + for (let i = 0; i < 100; i++) { + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i); + } + + try { + getNextAvailablePort(); + assert.fail('Should have thrown error'); + } catch (err) { + assert.ok(err.message.includes('100/100'), 'Should include variant count'); + assert.ok( + err.message.includes('ccs cliproxy remove'), + 'Should include recovery hint' + ); + } + }); + }); + + describe('getNextAvailablePort - Port Reuse', function () { + it('reuses port after variant deletion', function () { + const ccsDir = path.join(testHome, '.ccs'); + + // Create variant on port 8318 + const settingsPath = path.join(ccsDir, 'test.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy('test', 'gemini', settingsPath, undefined, 8318); + + // Next port should be 8319 + let nextPort = getNextAvailablePort(); + assert.strictEqual(nextPort, 8319); + + // Remove the variant + removeVariantFromLegacyConfig('test'); + + // Now 8318 should be available again + nextPort = getNextAvailablePort(); + assert.strictEqual(nextPort, 8318); + }); + + it('allocates lowest available port (not most recently freed)', function () { + const ccsDir = path.join(testHome, '.ccs'); + + // Create 3 variants on ports 8318, 8319, 8320 + for (let i = 0; i < 3; i++) { + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i); + } + + // Remove middle variant (port 8319) + removeVariantFromLegacyConfig('variant1'); + + // Next allocation should use 8319 (the gap), not 8321 + const nextPort = getNextAvailablePort(); + assert.strictEqual(nextPort, 8319); + }); + }); + + describe('getNextAvailablePort - Legacy Variant Handling', function () { + it('ignores variants without port field in usage calculation', function () { + // Create variant without port (legacy format) + const config = { + profiles: {}, + cliproxy: { + legacy_variant: { + provider: 'gemini', + settings: '/path/to/settings.json', + // No port field + }, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(config)); + + // Should return first port since legacy variant has no port + const port = getNextAvailablePort(); + assert.strictEqual(port, 8318); + }); + + it('handles mixed legacy and modern variants', function () { + const ccsDir = path.join(testHome, '.ccs'); + + // Create legacy variant without port + const config = { + profiles: {}, + cliproxy: { + legacy: { + provider: 'gemini', + settings: path.join(ccsDir, 'legacy.settings.json'), + // No port field + }, + modern: { + provider: 'gemini', + settings: path.join(ccsDir, 'modern.settings.json'), + port: 8318, + }, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(config)); + + // Should skip 8318 (used by modern) and return 8319 + const port = getNextAvailablePort(); + assert.strictEqual(port, 8319); + }); + }); + + describe('listVariantsFromConfig', function () { + it('returns empty object when no variants exist', function () { + const variants = listVariantsFromConfig(); + assert.deepStrictEqual(variants, {}); + }); + + it('includes port field for each variant', function () { + const ccsDir = path.join(testHome, '.ccs'); + const settingsPath = path.join(ccsDir, 'test.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + + saveVariantLegacy('test', 'gemini', settingsPath, undefined, 8318); + + const variants = listVariantsFromConfig(); + assert.strictEqual(variants.test.port, 8318); + assert.strictEqual(variants.test.provider, 'gemini'); + }); + + it('returns undefined port for legacy variants', function () { + // Create legacy variant without port + const config = { + profiles: {}, + cliproxy: { + legacy: { + provider: 'gemini', + settings: '/path/to/settings.json', + // No port field + }, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(config)); + + const variants = listVariantsFromConfig(); + assert.strictEqual(variants.legacy.port, undefined); + assert.strictEqual(variants.legacy.provider, 'gemini'); + }); + }); +}); diff --git a/tests/unit/cliproxy/variant-port-edge-cases.test.js b/tests/unit/cliproxy/variant-port-edge-cases.test.js new file mode 100644 index 00000000..6bad40ec --- /dev/null +++ b/tests/unit/cliproxy/variant-port-edge-cases.test.js @@ -0,0 +1,501 @@ +/** + * Variant Port Edge Case Tests + * + * Tests for edge cases and error handling in variant port isolation. + * Covers port exhaustion, race conditions, stale session cleanup, + * legacy migration, permission errors, and config corruption. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set test isolation environment before importing +const testHome = path.join( + os.tmpdir(), + `ccs-test-edge-${Date.now()}-${Math.random().toString(36).slice(2)}` +); +process.env.CCS_HOME = testHome; + +const { + getNextAvailablePort, + VARIANT_PORT_BASE, + VARIANT_PORT_MAX_OFFSET, + listVariantsFromConfig, + saveVariantLegacy, + removeVariantFromLegacyConfig, +} = require('../../../dist/cliproxy/services/variant-config-adapter'); +const { + getExistingProxy, + registerSession, + unregisterSession, + cleanupOrphanedSessions, + deleteSessionLockForPort, + getProxyStatus, +} = require('../../../dist/cliproxy/session-tracker'); +const { + deleteConfigForPort, + configExists, + generateConfig, +} = require('../../../dist/cliproxy/config-generator'); + +describe('Variant Port Edge Cases', function () { + let configPath; + let cliproxyDir; + + beforeEach(function () { + // Create test directories + const ccsDir = path.join(testHome, '.ccs'); + cliproxyDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + configPath = path.join(ccsDir, 'config.json'); + + // Start with empty config + fs.writeFileSync(configPath, JSON.stringify({ profiles: {} })); + }); + + afterEach(function () { + // Clean up config and session files + try { + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + } + const files = fs.readdirSync(cliproxyDir); + for (const file of files) { + fs.unlinkSync(path.join(cliproxyDir, file)); + } + } catch { + // Ignore cleanup errors + } + }); + + afterAll(function () { + // Clean up test directory + try { + fs.rmSync(testHome, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + delete process.env.CCS_HOME; + }); + + describe('Port Exhaustion', function () { + it('throws after 100 variants created', function () { + const ccsDir = path.join(testHome, '.ccs'); + + // Create 100 variants + for (let i = 0; i < 100; i++) { + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i); + } + + assert.throws(() => getNextAvailablePort(), /Port limit reached/); + }); + + it('error message shows 100/100 and recovery hint', function () { + const ccsDir = path.join(testHome, '.ccs'); + + for (let i = 0; i < 100; i++) { + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i); + } + + try { + getNextAvailablePort(); + assert.fail('Should have thrown'); + } catch (err) { + assert.ok(err.message.includes('100/100')); + assert.ok(err.message.includes('ccs cliproxy remove')); + } + }); + + it('frees port after variant removal', function () { + const ccsDir = path.join(testHome, '.ccs'); + + // Create variant on port 8318 + const settingsPath = path.join(ccsDir, 'test.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy('test', 'gemini', settingsPath, undefined, VARIANT_PORT_BASE); + + // Next should be 8319 + assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE + 1); + + // Remove variant + removeVariantFromLegacyConfig('test'); + + // 8318 should be free again + assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE); + }); + }); + + describe('Stale Session Cleanup', function () { + it('cleans orphaned session lock on variant delete', function () { + const port = 8318; + + // Create session file + registerSession(port, process.pid); + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + assert.ok(fs.existsSync(sessionPath)); + + // Simulate variant delete cleanup + deleteSessionLockForPort(port); + assert.strictEqual(fs.existsSync(sessionPath), false); + }); + + it('cleans stale lock when PID not running', function () { + const port = 8318; + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + + // Create lock with dead PID + fs.writeFileSync( + sessionPath, + JSON.stringify({ + port, + pid: 999999999, // Dead PID + sessions: ['session1'], + startedAt: new Date().toISOString(), + }) + ); + + // getExistingProxy should clean it up + const lock = getExistingProxy(port); + assert.strictEqual(lock, null); + assert.strictEqual(fs.existsSync(sessionPath), false); + }); + }); + + describe('Legacy Variant Migration', function () { + it('variant without port gets undefined in listing', function () { + // Create legacy variant without port + const config = { + profiles: {}, + cliproxy: { + legacy: { + provider: 'gemini', + settings: '/path/to/settings.json', + // No port field + }, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(config)); + + const variants = listVariantsFromConfig(); + assert.strictEqual(variants.legacy.port, undefined); + }); + + it('legacy variant does not block port allocation', function () { + // Create legacy variant without port + const config = { + profiles: {}, + cliproxy: { + legacy: { + provider: 'gemini', + settings: '/path/to/settings.json', + // No port field - doesn't count toward port usage + }, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(config)); + + // First port should still be available + const port = getNextAvailablePort(); + assert.strictEqual(port, VARIANT_PORT_BASE); + }); + }); + + describe('Config Corruption', function () { + it('handles malformed sessions-{port}.json gracefully', function () { + const port = 8318; + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + + // Write invalid JSON + fs.writeFileSync(sessionPath, '{ invalid json }'); + + // getExistingProxy should return null, not throw + const lock = getExistingProxy(port); + assert.strictEqual(lock, null); + }); + + it('handles missing config-{port}.yaml gracefully', function () { + const port = 8318; + // configExists should return false, not throw + assert.strictEqual(configExists(port), false); + }); + + it('handles sessions file with missing required fields', function () { + const port = 8318; + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + + // Write JSON missing required fields + fs.writeFileSync(sessionPath, JSON.stringify({ port: 8318 })); + + // getExistingProxy should return null (invalid structure) + const lock = getExistingProxy(port); + assert.strictEqual(lock, null); + }); + + it('handles sessions file with wrong data types', function () { + const port = 8318; + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + + // Write JSON with wrong types + fs.writeFileSync( + sessionPath, + JSON.stringify({ + port: 'not-a-number', + pid: 'also-not-a-number', + sessions: 'not-an-array', + }) + ); + + const lock = getExistingProxy(port); + assert.strictEqual(lock, null); + }); + }); + + describe('Cleanup on Crash', function () { + it('getExistingProxy cleans stale lock if PID dead', function () { + const port = 8318; + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + + // Create lock with dead PID (simulating crashed proxy) + fs.writeFileSync( + sessionPath, + JSON.stringify({ + port, + pid: 999999999, + sessions: ['session1'], + startedAt: new Date().toISOString(), + }) + ); + + const lock = getExistingProxy(port); + assert.strictEqual(lock, null); + assert.strictEqual(fs.existsSync(sessionPath), false); + }); + + it('cleanupOrphanedSessions removes stale lock', function () { + const port = 8318; + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + + // Create lock with dead PID + fs.writeFileSync( + sessionPath, + JSON.stringify({ + port, + pid: 999999999, + sessions: ['session1'], + startedAt: new Date().toISOString(), + }) + ); + + cleanupOrphanedSessions(port); + assert.strictEqual(fs.existsSync(sessionPath), false); + }); + + it('variant delete cleans session lock even if proxy crashed', function () { + const port = 8318; + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + + // Create lock with dead PID + fs.writeFileSync( + sessionPath, + JSON.stringify({ + port, + pid: 999999999, + sessions: ['session1'], + startedAt: new Date().toISOString(), + }) + ); + + // deleteSessionLockForPort is called during variant removal + deleteSessionLockForPort(port); + assert.strictEqual(fs.existsSync(sessionPath), false); + }); + }); + + describe('Variant Lifecycle Integration', function () { + it('creates variant with unique port and separate files', function () { + const ccsDir = path.join(testHome, '.ccs'); + const port = getNextAvailablePort(); + + // Create variant + const settingsPath = path.join(ccsDir, 'test.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy('test', 'gemini', settingsPath, undefined, port); + + // Generate config + generateConfig('gemini', port); + + // Verify files exist + assert.ok(configExists(port)); + + // Start session + const sessionId = registerSession(port, process.pid); + const status = getProxyStatus(port); + assert.strictEqual(status.running, true); + assert.strictEqual(status.port, port); + + // Clean up session + unregisterSession(sessionId, port); + }); + + it('removes variant and cleans up all port files', function () { + const ccsDir = path.join(testHome, '.ccs'); + const port = 8318; + + // Create variant + const settingsPath = path.join(ccsDir, 'test.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy('test', 'gemini', settingsPath, undefined, port); + + // Generate config and session + generateConfig('gemini', port); + registerSession(port, process.pid); + + // Verify files exist + assert.ok(configExists(port)); + assert.strictEqual(getProxyStatus(port).running, true); + + // Remove variant (simulating full cleanup) + removeVariantFromLegacyConfig('test'); + deleteConfigForPort(port); + deleteSessionLockForPort(port); + + // Verify cleanup + assert.strictEqual(configExists(port), false); + assert.strictEqual(getProxyStatus(port).running, false); + }); + + it('port reuse after deletion does not have stale data', function () { + const ccsDir = path.join(testHome, '.ccs'); + + // Create variant A + const settingsA = path.join(ccsDir, 'variantA.settings.json'); + fs.writeFileSync(settingsA, JSON.stringify({ env: { KEY: 'A' } })); + const portA = getNextAvailablePort(); + saveVariantLegacy('variantA', 'gemini', settingsA, undefined, portA); + generateConfig('gemini', portA); + registerSession(portA, process.pid); + + // Remove variant A with full cleanup + removeVariantFromLegacyConfig('variantA'); + deleteConfigForPort(portA); + deleteSessionLockForPort(portA); + + // Create variant B - should get same port + const settingsB = path.join(ccsDir, 'variantB.settings.json'); + fs.writeFileSync(settingsB, JSON.stringify({ env: { KEY: 'B' } })); + const portB = getNextAvailablePort(); + assert.strictEqual(portB, portA); // Port should be reused + + // New session should start fresh + saveVariantLegacy('variantB', 'gemini', settingsB, undefined, portB); + generateConfig('gemini', portB); + const sessionB = registerSession(portB, process.pid); + + const status = getProxyStatus(portB); + assert.strictEqual(status.running, true); + assert.strictEqual(status.sessionCount, 1); + + // Clean up + unregisterSession(sessionB, portB); + }); + }); + + describe('Multiple Concurrent Variants', function () { + it('creates 3 variants with different ports', function () { + const ccsDir = path.join(testHome, '.ccs'); + const ports = []; + + for (let i = 0; i < 3; i++) { + const port = getNextAvailablePort(); + ports.push(port); + + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port); + } + + // Verify all ports are different + const uniquePorts = new Set(ports); + assert.strictEqual(uniquePorts.size, 3); + + // Verify sequential assignment + assert.strictEqual(ports[0], VARIANT_PORT_BASE); + assert.strictEqual(ports[1], VARIANT_PORT_BASE + 1); + assert.strictEqual(ports[2], VARIANT_PORT_BASE + 2); + }); + + it('each has separate config file', function () { + const ccsDir = path.join(testHome, '.ccs'); + const ports = [8318, 8319, 8320]; + + for (let i = 0; i < 3; i++) { + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]); + generateConfig('gemini', ports[i]); + } + + // Verify separate config files + for (const port of ports) { + assert.ok(configExists(port), `Config for port ${port} should exist`); + } + }); + + it('each has separate sessions file when running', function () { + const ports = [8318, 8319, 8320]; + + for (const port of ports) { + registerSession(port, process.pid); + } + + // Verify separate session files + for (const port of ports) { + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + assert.ok(fs.existsSync(sessionPath), `Session file for port ${port} should exist`); + } + + // Clean up + for (const port of ports) { + deleteSessionLockForPort(port); + } + }); + + it('removing one does not affect others', function () { + const ccsDir = path.join(testHome, '.ccs'); + const ports = [8318, 8319, 8320]; + + // Create 3 variants + for (let i = 0; i < 3; i++) { + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]); + generateConfig('gemini', ports[i]); + registerSession(ports[i], process.pid); + } + + // Remove middle variant + removeVariantFromLegacyConfig('variant1'); + deleteConfigForPort(ports[1]); + deleteSessionLockForPort(ports[1]); + + // Verify others still exist + assert.ok(configExists(ports[0])); + assert.ok(!configExists(ports[1])); // Removed + assert.ok(configExists(ports[2])); + + assert.strictEqual(getProxyStatus(ports[0]).running, true); + assert.strictEqual(getProxyStatus(ports[1]).running, false); // Removed + assert.strictEqual(getProxyStatus(ports[2]).running, true); + + // Clean up remaining + deleteSessionLockForPort(ports[0]); + deleteSessionLockForPort(ports[2]); + }); + }); +}); diff --git a/tests/unit/cliproxy/variant-port-integration.test.js b/tests/unit/cliproxy/variant-port-integration.test.js new file mode 100644 index 00000000..bfb03828 --- /dev/null +++ b/tests/unit/cliproxy/variant-port-integration.test.js @@ -0,0 +1,579 @@ +/** + * Variant Port Isolation Integration Tests + * + * Tests for PR #184: feat(cliproxy): add variant port isolation + * Maps directly to the PR test plan: + * - [x] Create multiple variants with `ccs cliproxy create` + * - [x] Verify each variant gets unique port in config + * - [x] Run multiple variants concurrently + * - [x] Verify `ccs cliproxy list` shows port column + * - [x] Remove variant and verify cleanup of port-specific files + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set test isolation environment before importing +const testHome = path.join( + os.tmpdir(), + `ccs-test-integration-${Date.now()}-${Math.random().toString(36).slice(2)}` +); +process.env.CCS_HOME = testHome; + +const { + getNextAvailablePort, + VARIANT_PORT_BASE, + VARIANT_PORT_MAX_OFFSET, + listVariantsFromConfig, + saveVariantLegacy, + removeVariantFromLegacyConfig, +} = require('../../../dist/cliproxy/services/variant-config-adapter'); +const { + getExistingProxy, + registerSession, + unregisterSession, + getProxyStatus, + deleteSessionLockForPort, +} = require('../../../dist/cliproxy/session-tracker'); +const { + generateConfig, + configExists, + deleteConfigForPort, + getConfigPathForPort, + CLIPROXY_DEFAULT_PORT, +} = require('../../../dist/cliproxy/config-generator'); + +describe('PR #184: Variant Port Isolation Integration', function () { + let configPath; + let cliproxyDir; + let ccsDir; + + beforeEach(function () { + // Create test directories + ccsDir = path.join(testHome, '.ccs'); + cliproxyDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + configPath = path.join(ccsDir, 'config.json'); + + // Start with empty config + fs.writeFileSync(configPath, JSON.stringify({ profiles: {} })); + }); + + afterEach(function () { + // Clean up all test files + try { + if (fs.existsSync(configPath)) fs.unlinkSync(configPath); + const files = fs.readdirSync(cliproxyDir); + for (const file of files) { + fs.unlinkSync(path.join(cliproxyDir, file)); + } + } catch { + // Ignore cleanup errors + } + }); + + afterAll(function () { + try { + fs.rmSync(testHome, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + delete process.env.CCS_HOME; + }); + + // ========================================================================== + // PR Test Plan: Create multiple variants with ccs cliproxy create + // ========================================================================== + describe('Test Plan: Create multiple variants', function () { + it('creates 3 variants with unique ports via createVariant flow', function () { + const variants = []; + + for (let i = 0; i < 3; i++) { + const port = getNextAvailablePort(); + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: { VARIANT_ID: i } })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port); + generateConfig('gemini', port); + variants.push({ name: `variant${i}`, port }); + } + + // Verify all variants created + const storedVariants = listVariantsFromConfig(); + assert.strictEqual(Object.keys(storedVariants).length, 3); + + // Verify unique ports + const ports = variants.map((v) => v.port); + const uniquePorts = new Set(ports); + assert.strictEqual(uniquePorts.size, 3, 'All variants should have unique ports'); + + // Verify sequential port assignment + assert.strictEqual(variants[0].port, VARIANT_PORT_BASE); + assert.strictEqual(variants[1].port, VARIANT_PORT_BASE + 1); + assert.strictEqual(variants[2].port, VARIANT_PORT_BASE + 2); + }); + + it('handles maximum variant creation (100 variants)', function () { + // Create 100 variants + for (let i = 0; i < 100; i++) { + const port = getNextAvailablePort(); + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port); + } + + // Verify 100 variants exist + const storedVariants = listVariantsFromConfig(); + assert.strictEqual(Object.keys(storedVariants).length, 100); + + // Verify port exhaustion error + assert.throws( + () => getNextAvailablePort(), + /Port limit reached.*100\/100/, + 'Should throw port limit error' + ); + }); + }); + + // ========================================================================== + // PR Test Plan: Verify each variant gets unique port in config + // ========================================================================== + describe('Test Plan: Verify unique port in config', function () { + it('each variant has port stored in config', function () { + for (let i = 0; i < 5; i++) { + const port = getNextAvailablePort(); + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port); + } + + const variants = listVariantsFromConfig(); + for (let i = 0; i < 5; i++) { + assert.strictEqual( + variants[`variant${i}`].port, + VARIANT_PORT_BASE + i, + `Variant ${i} should have port ${VARIANT_PORT_BASE + i}` + ); + } + }); + + it('port persists across config reload', function () { + const port = getNextAvailablePort(); + const settingsPath = path.join(ccsDir, 'persistent.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy('persistent', 'gemini', settingsPath, undefined, port); + + // Simulate reload by reading config directly + const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + assert.strictEqual(rawConfig.cliproxy.persistent.port, port); + + // Verify via listVariantsFromConfig + const variants = listVariantsFromConfig(); + assert.strictEqual(variants.persistent.port, port); + }); + + it('port-specific config.yaml has correct port value', function () { + const port = 8320; + generateConfig('gemini', port); + + const configContent = fs.readFileSync(getConfigPathForPort(port), 'utf-8'); + assert.ok(configContent.includes(`port: ${port}`), 'Config should contain correct port'); + }); + + it('different ports have different config files', function () { + const ports = [8318, 8319, 8320]; + + for (const port of ports) { + generateConfig('gemini', port); + } + + // Verify each has separate file + assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8318.yaml'))); + assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8319.yaml'))); + assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8320.yaml'))); + + // Verify each has correct port in content + for (const port of ports) { + const content = fs.readFileSync(getConfigPathForPort(port), 'utf-8'); + assert.ok(content.includes(`port: ${port}`)); + } + }); + }); + + // ========================================================================== + // PR Test Plan: Run multiple variants concurrently + // ========================================================================== + describe('Test Plan: Run multiple variants concurrently', function () { + it('registers sessions on 3 different ports simultaneously', function () { + const ports = [8318, 8319, 8320]; + const sessions = []; + + // Start all 3 variants "concurrently" + for (const port of ports) { + generateConfig('gemini', port); + const sessionId = registerSession(port, process.pid); + sessions.push({ port, sessionId }); + } + + // Verify all 3 are running + for (const { port } of sessions) { + const status = getProxyStatus(port); + assert.strictEqual(status.running, true, `Port ${port} should be running`); + assert.strictEqual(status.port, port); + assert.strictEqual(status.sessionCount, 1); + } + + // Verify separate session files exist + for (const port of ports) { + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + assert.ok(fs.existsSync(sessionPath), `Session file for port ${port} should exist`); + } + + // Clean up + for (const { sessionId, port } of sessions) { + unregisterSession(sessionId, port); + } + }); + + it('concurrent sessions on same variant port accumulate', function () { + const port = 8318; + generateConfig('gemini', port); + + const session1 = registerSession(port, process.pid); + const session2 = registerSession(port, process.pid); + const session3 = registerSession(port, process.pid); + + const status = getProxyStatus(port); + assert.strictEqual(status.sessionCount, 3); + + // Unregister one by one + unregisterSession(session1, port); + assert.strictEqual(getProxyStatus(port).sessionCount, 2); + + unregisterSession(session2, port); + assert.strictEqual(getProxyStatus(port).sessionCount, 1); + + unregisterSession(session3, port); + assert.strictEqual(getProxyStatus(port).running, false); + }); + + it('stopping one variant does not affect others', function () { + const ports = [8318, 8319, 8320]; + + for (const port of ports) { + generateConfig('gemini', port); + registerSession(port, process.pid); + } + + // Stop middle variant + deleteSessionLockForPort(8319); + + // Verify others still running + assert.strictEqual(getProxyStatus(8318).running, true); + assert.strictEqual(getProxyStatus(8319).running, false); + assert.strictEqual(getProxyStatus(8320).running, true); + + // Clean up remaining + deleteSessionLockForPort(8318); + deleteSessionLockForPort(8320); + }); + + it('each variant has isolated session tracking', function () { + // Create 2 variants + const port1 = 8318; + const port2 = 8319; + + generateConfig('gemini', port1); + generateConfig('gemini', port2); + + // Register different session counts + const p1s1 = registerSession(port1, process.pid); + const p1s2 = registerSession(port1, process.pid); + const p2s1 = registerSession(port2, process.pid); + + assert.strictEqual(getProxyStatus(port1).sessionCount, 2); + assert.strictEqual(getProxyStatus(port2).sessionCount, 1); + + // Unregister from port1 - should not affect port2 + unregisterSession(p1s1, port1); + assert.strictEqual(getProxyStatus(port1).sessionCount, 1); + assert.strictEqual(getProxyStatus(port2).sessionCount, 1); + + // Clean up + unregisterSession(p1s2, port1); + unregisterSession(p2s1, port2); + }); + }); + + // ========================================================================== + // PR Test Plan: Verify ccs cliproxy list shows port column + // ========================================================================== + describe('Test Plan: Verify list shows port column', function () { + it('listVariantsFromConfig returns port for each variant', function () { + for (let i = 0; i < 3; i++) { + const port = getNextAvailablePort(); + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port); + } + + const variants = listVariantsFromConfig(); + + // Verify port field exists and is correct for each + assert.strictEqual(variants.variant0.port, 8318); + assert.strictEqual(variants.variant1.port, 8319); + assert.strictEqual(variants.variant2.port, 8320); + }); + + it('legacy variants without port return undefined', function () { + // Create legacy variant without port field + const config = { + profiles: {}, + cliproxy: { + legacy_variant: { + provider: 'gemini', + settings: '/path/to/settings.json', + // No port field + }, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(config)); + + const variants = listVariantsFromConfig(); + assert.strictEqual(variants.legacy_variant.port, undefined); + assert.strictEqual(variants.legacy_variant.provider, 'gemini'); + }); + + it('mixed legacy and modern variants show correct ports', function () { + const config = { + profiles: {}, + cliproxy: { + legacy: { + provider: 'gemini', + settings: '/path/to/legacy.json', + // No port + }, + modern: { + provider: 'codex', + settings: '/path/to/modern.json', + port: 8318, + }, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(config)); + + const variants = listVariantsFromConfig(); + assert.strictEqual(variants.legacy.port, undefined); + assert.strictEqual(variants.modern.port, 8318); + }); + }); + + // ========================================================================== + // PR Test Plan: Remove variant and verify cleanup of port-specific files + // ========================================================================== + describe('Test Plan: Remove variant and verify cleanup', function () { + it('removes variant config entry', function () { + const port = getNextAvailablePort(); + const settingsPath = path.join(ccsDir, 'to-remove.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy('to-remove', 'gemini', settingsPath, undefined, port); + + // Verify exists + let variants = listVariantsFromConfig(); + assert.ok('to-remove' in variants); + + // Remove + const removed = removeVariantFromLegacyConfig('to-remove'); + assert.strictEqual(removed.port, port); + + // Verify gone + variants = listVariantsFromConfig(); + assert.ok(!('to-remove' in variants)); + }); + + it('deleteConfigForPort removes config-{port}.yaml', function () { + const port = 8318; + generateConfig('gemini', port); + assert.ok(configExists(port)); + + deleteConfigForPort(port); + assert.strictEqual(configExists(port), false); + }); + + it('deleteSessionLockForPort removes sessions-{port}.json', function () { + const port = 8318; + registerSession(port, process.pid); + + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + assert.ok(fs.existsSync(sessionPath)); + + deleteSessionLockForPort(port); + assert.strictEqual(fs.existsSync(sessionPath), false); + }); + + it('full variant removal cleans config, session, and config files', function () { + const port = 8318; + const settingsPath = path.join(ccsDir, 'full-cleanup.settings.json'); + + // Create variant with all files + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy('full-cleanup', 'gemini', settingsPath, undefined, port); + generateConfig('gemini', port); + registerSession(port, process.pid); + + // Verify all files exist + assert.ok(listVariantsFromConfig()['full-cleanup']); + assert.ok(configExists(port)); + assert.ok(fs.existsSync(path.join(cliproxyDir, `sessions-${port}.json`))); + + // Full cleanup (simulating removeVariant behavior) + removeVariantFromLegacyConfig('full-cleanup'); + deleteConfigForPort(port); + deleteSessionLockForPort(port); + + // Verify all cleaned + assert.ok(!listVariantsFromConfig()['full-cleanup']); + assert.strictEqual(configExists(port), false); + assert.strictEqual(fs.existsSync(path.join(cliproxyDir, `sessions-${port}.json`)), false); + }); + + it('removing one variant does not affect others', function () { + const ports = [8318, 8319, 8320]; + + // Create 3 variants + for (let i = 0; i < 3; i++) { + const settingsPath = path.join(ccsDir, `variant${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]); + generateConfig('gemini', ports[i]); + registerSession(ports[i], process.pid); + } + + // Remove middle variant + removeVariantFromLegacyConfig('variant1'); + deleteConfigForPort(8319); + deleteSessionLockForPort(8319); + + // Verify others intact + const variants = listVariantsFromConfig(); + assert.ok('variant0' in variants); + assert.ok(!('variant1' in variants)); + assert.ok('variant2' in variants); + + assert.ok(configExists(8318)); + assert.strictEqual(configExists(8319), false); + assert.ok(configExists(8320)); + + assert.strictEqual(getProxyStatus(8318).running, true); + assert.strictEqual(getProxyStatus(8319).running, false); + assert.strictEqual(getProxyStatus(8320).running, true); + + // Clean up remaining + deleteSessionLockForPort(8318); + deleteSessionLockForPort(8320); + }); + + it('freed port can be reused after deletion', function () { + // Create variant on first port + const port1 = getNextAvailablePort(); + const settingsPath1 = path.join(ccsDir, 'first.settings.json'); + fs.writeFileSync(settingsPath1, JSON.stringify({ env: {} })); + saveVariantLegacy('first', 'gemini', settingsPath1, undefined, port1); + generateConfig('gemini', port1); + + // Next port should be port1 + 1 + const port2 = getNextAvailablePort(); + assert.strictEqual(port2, port1 + 1); + + // Remove first variant + removeVariantFromLegacyConfig('first'); + deleteConfigForPort(port1); + + // port1 should now be available again + const reusedPort = getNextAvailablePort(); + assert.strictEqual(reusedPort, port1, 'Freed port should be reusable'); + }); + }); + + // ========================================================================== + // Edge Cases for Port Isolation + // ========================================================================== + describe('Edge Cases', function () { + it('default port (8317) uses sessions.json not sessions-8317.json', function () { + registerSession(CLIPROXY_DEFAULT_PORT, process.pid); + + // Default port uses sessions.json + assert.ok(fs.existsSync(path.join(cliproxyDir, 'sessions.json'))); + assert.strictEqual( + fs.existsSync(path.join(cliproxyDir, `sessions-${CLIPROXY_DEFAULT_PORT}.json`)), + false + ); + + deleteSessionLockForPort(CLIPROXY_DEFAULT_PORT); + }); + + it('default port (8317) uses config.yaml not config-8317.yaml', function () { + generateConfig('gemini', CLIPROXY_DEFAULT_PORT); + + assert.ok(fs.existsSync(path.join(cliproxyDir, 'config.yaml'))); + assert.strictEqual( + fs.existsSync(path.join(cliproxyDir, `config-${CLIPROXY_DEFAULT_PORT}.yaml`)), + false + ); + }); + + it('port range is 8318-8417 (100 ports)', function () { + assert.strictEqual(VARIANT_PORT_BASE, 8318); + assert.strictEqual(VARIANT_PORT_MAX_OFFSET, 100); + + // First variant port + assert.strictEqual(getNextAvailablePort(), 8318); + + // Create all 100 + for (let i = 0; i < 100; i++) { + const port = getNextAvailablePort(); + const settingsPath = path.join(ccsDir, `v${i}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {} })); + saveVariantLegacy(`v${i}`, 'gemini', settingsPath, undefined, port); + } + + // Last port should be 8417 + const lastVariant = listVariantsFromConfig()['v99']; + assert.strictEqual(lastVariant.port, 8417); + }); + + it('handles stale session files from crashed processes', function () { + const port = 8318; + const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`); + + // Write stale session with dead PID + fs.writeFileSync( + sessionPath, + JSON.stringify({ + port, + pid: 999999999, // Non-existent PID + sessions: ['stale-session'], + startedAt: new Date().toISOString(), + }) + ); + + // getExistingProxy should detect and clean up + const lock = getExistingProxy(port); + assert.strictEqual(lock, null); + assert.strictEqual(fs.existsSync(sessionPath), false); + }); + + it('gracefully handles missing session file on unregister', function () { + // Should not throw + const result = unregisterSession('nonexistent-session', 8318); + assert.strictEqual(result, true); // No file = should kill + }); + + it('gracefully handles missing config file on delete', function () { + // Should not throw + deleteConfigForPort(9999); + assert.strictEqual(configExists(9999), false); + }); + }); +}); diff --git a/tests/unit/commands/setup-command.test.ts b/tests/unit/commands/setup-command.test.ts new file mode 100644 index 00000000..e4bb37b4 --- /dev/null +++ b/tests/unit/commands/setup-command.test.ts @@ -0,0 +1,133 @@ +/** + * Unit tests for setup-command.ts - isFirstTimeInstall() function + * + * Issue #195: GLM auth regression - isFirstTimeInstall() was ignoring legacy configs + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Create temp directory for test isolation +let testDir: string; + +beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-')); +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +// Helper to create config files +function createConfigYaml(content: string): void { + fs.writeFileSync(path.join(testDir, 'config.yaml'), content, 'utf8'); +} + +function createConfigJson(content: object): void { + fs.writeFileSync(path.join(testDir, 'config.json'), JSON.stringify(content, null, 2), 'utf8'); +} + +function createProfilesJson(content: object): void { + fs.writeFileSync(path.join(testDir, 'profiles.json'), JSON.stringify(content, null, 2), 'utf8'); +} + +describe('isFirstTimeInstall logic', () => { + describe('legacy config detection', () => { + it('should detect legacy config.json with profiles', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createConfigJson({ profiles: { glm: '~/.ccs/glm.settings.json' } }); + + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + const hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0; + + expect(hasLegacyProfiles).toBe(true); + }); + + it('should detect legacy profiles.json with accounts', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createProfilesJson({ profiles: { work: { path: '/some/path' } } }); + + const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8')); + const hasLegacyAccounts = legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0; + + expect(hasLegacyAccounts).toBe(true); + }); + + it('should return true when no configs exist', () => { + const hasConfigYaml = fs.existsSync(path.join(testDir, 'config.yaml')); + const hasConfigJson = fs.existsSync(path.join(testDir, 'config.json')); + const hasProfilesJson = fs.existsSync(path.join(testDir, 'profiles.json')); + + expect(hasConfigYaml).toBe(false); + expect(hasConfigJson).toBe(false); + expect(hasProfilesJson).toBe(false); + }); + + it('should detect unified config with profiles', () => { + createConfigYaml(` +version: 2 +profiles: + glm: + type: api + settings: ~/.ccs/glm.settings.json +accounts: {} +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + const hasProfiles = content.includes('glm:') && content.includes('type: api'); + + expect(hasProfiles).toBe(true); + }); + + it('should detect unified config with remote proxy', () => { + createConfigYaml(` +version: 2 +profiles: {} +accounts: {} +cliproxy_server: + remote: + enabled: true + host: my-server.example.com +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + const hasRemoteProxy = content.includes('enabled: true') && content.includes('host:'); + + expect(hasRemoteProxy).toBe(true); + }); + }); + + describe('empty config handling', () => { + it('should treat empty configs as first-time', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createConfigJson({ profiles: {} }); + createProfilesJson({ profiles: {} }); + + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8')); + + const hasLegacyProfiles = Object.keys(legacyConfig.profiles || {}).length > 0; + const hasLegacyAccounts = Object.keys(legacyProfiles.profiles || {}).length > 0; + + expect(hasLegacyProfiles).toBe(false); + expect(hasLegacyAccounts).toBe(false); + }); + }); + + describe('corrupted config handling', () => { + it('should handle corrupted config.json gracefully', () => { + fs.writeFileSync(path.join(testDir, 'config.json'), 'not valid json{{{', 'utf8'); + + let hasLegacyProfiles = false; + try { + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0; + } catch { + hasLegacyProfiles = false; + } + + expect(hasLegacyProfiles).toBe(false); + }); + }); +}); diff --git a/tests/unit/commands/tokens-command.test.js b/tests/unit/commands/tokens-command.test.js new file mode 100644 index 00000000..58d81725 --- /dev/null +++ b/tests/unit/commands/tokens-command.test.js @@ -0,0 +1,250 @@ +/** + * Tokens Command Tests + * + * Tests for the `ccs tokens` CLI command including: + * - Argument parsing + * - Token masking + * - Error handling + * - Exit codes + */ + +const assert = require('assert'); + +describe('Tokens Command', () => { + // ========================================================================= + // Token Masking Tests (Unit) + // ========================================================================= + describe('maskToken', () => { + // Extract the maskToken function pattern for testing + function maskToken(token) { + if (token.length <= 8) return '****'; + return `${token.slice(0, 4)}...${token.slice(-4)}`; + } + + it('masks tokens showing first 4 and last 4 characters', () => { + const result = maskToken('1234567890abcdef'); + assert.strictEqual(result, '1234...cdef'); + }); + + it('returns **** for tokens <= 8 characters', () => { + assert.strictEqual(maskToken('12345678'), '****'); + assert.strictEqual(maskToken('1234567'), '****'); + assert.strictEqual(maskToken('abc'), '****'); + assert.strictEqual(maskToken(''), '****'); + }); + + it('handles exactly 9 character tokens', () => { + const result = maskToken('123456789'); + assert.strictEqual(result, '1234...6789'); + }); + + it('handles long tokens (typical API keys)', () => { + const longToken = 'test_key_1234567890abcdefghijklmnopqrstuvwxyz'; + const result = maskToken(longToken); + assert.strictEqual(result, 'test...wxyz'); + }); + + it('preserves special characters in masked output', () => { + const token = '!@#$abcd____wxyz'; + const result = maskToken(token); + assert.strictEqual(result, '!@#$...wxyz'); + }); + }); + + // ========================================================================= + // Argument Parsing Tests (Unit) + // ========================================================================= + describe('Argument Parsing', () => { + /** + * Simulates the argument parsing logic from tokens-command.ts + */ + function parseArgs(args) { + const showFlag = args.includes('--show'); + const resetFlag = args.includes('--reset'); + const regenerateSecretFlag = args.includes('--regenerate-secret'); + const helpFlag = args.includes('--help') || args.includes('-h'); + + const apiKeyIndex = args.indexOf('--api-key'); + const apiKeyValue = apiKeyIndex !== -1 ? args[apiKeyIndex + 1] : undefined; + + const secretIndex = args.indexOf('--secret'); + const secretValue = secretIndex !== -1 ? args[secretIndex + 1] : undefined; + + const variantIndex = args.indexOf('--variant'); + const variantValue = variantIndex !== -1 ? args[variantIndex + 1] : undefined; + + return { + showFlag, + resetFlag, + regenerateSecretFlag, + helpFlag, + apiKeyValue, + secretValue, + variantValue, + }; + } + + it('parses --show flag', () => { + const result = parseArgs(['--show']); + assert.strictEqual(result.showFlag, true); + }); + + it('parses --reset flag', () => { + const result = parseArgs(['--reset']); + assert.strictEqual(result.resetFlag, true); + }); + + it('parses --regenerate-secret flag', () => { + const result = parseArgs(['--regenerate-secret']); + assert.strictEqual(result.regenerateSecretFlag, true); + }); + + it('parses --help and -h flags', () => { + assert.strictEqual(parseArgs(['--help']).helpFlag, true); + assert.strictEqual(parseArgs(['-h']).helpFlag, true); + }); + + it('parses --api-key with value', () => { + const result = parseArgs(['--api-key', 'my-custom-key']); + assert.strictEqual(result.apiKeyValue, 'my-custom-key'); + }); + + it('parses --secret with value', () => { + const result = parseArgs(['--secret', 'my-secret']); + assert.strictEqual(result.secretValue, 'my-secret'); + }); + + it('parses --variant with value', () => { + const result = parseArgs(['--variant', 'gemini']); + assert.strictEqual(result.variantValue, 'gemini'); + }); + + it('parses combined --variant and --api-key', () => { + const result = parseArgs(['--variant', 'gemini', '--api-key', 'variant-key']); + assert.strictEqual(result.variantValue, 'gemini'); + assert.strictEqual(result.apiKeyValue, 'variant-key'); + }); + + it('handles no arguments (default case)', () => { + const result = parseArgs([]); + assert.strictEqual(result.showFlag, false); + assert.strictEqual(result.resetFlag, false); + assert.strictEqual(result.apiKeyValue, undefined); + }); + + it('handles multiple flags', () => { + const result = parseArgs(['--show', '--api-key', 'key', '--variant', 'v1']); + assert.strictEqual(result.showFlag, true); + assert.strictEqual(result.apiKeyValue, 'key'); + assert.strictEqual(result.variantValue, 'v1'); + }); + }); + + // ========================================================================= + // Validation Tests (Edge Cases) + // ========================================================================= + describe('Input Validation', () => { + /** + * Simulates the validation logic from tokens-command.ts + */ + function validateApiKeyValue(apiKeyValue) { + if (apiKeyValue !== undefined) { + if (!apiKeyValue || apiKeyValue.startsWith('-')) { + return { valid: false, error: 'Missing value for --api-key' }; + } + } + return { valid: true }; + } + + function validateSecretValue(secretValue) { + if (secretValue !== undefined) { + if (!secretValue || secretValue.startsWith('-')) { + return { valid: false, error: 'Missing value for --secret' }; + } + } + return { valid: true }; + } + + it('rejects --api-key without value', () => { + // When --api-key is at end of args, value will be undefined + const result = validateApiKeyValue(undefined); + // Note: undefined means flag wasn't provided, empty string means missing value + assert.strictEqual(result.valid, true); // undefined = not provided = valid + }); + + it('rejects --api-key with empty string', () => { + const result = validateApiKeyValue(''); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Missing value for --api-key'); + }); + + it('rejects --api-key followed by another flag', () => { + // e.g., --api-key --reset -> apiKeyValue = '--reset' + const result = validateApiKeyValue('--reset'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Missing value for --api-key'); + }); + + it('rejects --secret with empty string', () => { + const result = validateSecretValue(''); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Missing value for --secret'); + }); + + it('rejects --secret followed by another flag', () => { + const result = validateSecretValue('--show'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.error, 'Missing value for --secret'); + }); + + it('accepts valid api key values', () => { + assert.strictEqual(validateApiKeyValue('my-key').valid, true); + assert.strictEqual(validateApiKeyValue('sk_live_123').valid, true); + assert.strictEqual(validateApiKeyValue('a').valid, true); + }); + + it('accepts valid secret values', () => { + assert.strictEqual(validateSecretValue('my-secret').valid, true); + assert.strictEqual(validateSecretValue('super-secure-123').valid, true); + }); + }); + + // ========================================================================= + // Exit Code Tests + // ========================================================================= + describe('Exit Codes', () => { + it('defines success exit code as 0', () => { + // These are the expected exit codes from the command + const EXIT_SUCCESS = 0; + const EXIT_FAILURE = 1; + + assert.strictEqual(EXIT_SUCCESS, 0); + assert.strictEqual(EXIT_FAILURE, 1); + }); + }); + + // ========================================================================= + // Help Text Tests + // ========================================================================= + describe('Help Text Coverage', () => { + // Verify all documented options are present in help output + const expectedOptions = [ + '--show', + '--api-key', + '--secret', + '--regenerate-secret', + '--variant', + '--reset', + '--help', + '-h', + ]; + + it('documents all CLI options', () => { + // This test ensures we update help when adding new options + expectedOptions.forEach((option) => { + assert(typeof option === 'string', `Option ${option} should be a string`); + assert(option.startsWith('-'), `Option ${option} should start with -`); + }); + }); + }); +}); diff --git a/tests/unit/utils/expand-path.test.ts b/tests/unit/utils/expand-path.test.ts new file mode 100644 index 00000000..42dab0f5 --- /dev/null +++ b/tests/unit/utils/expand-path.test.ts @@ -0,0 +1,104 @@ +import { expect, test, describe, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import * as os from "os"; +import { expandPath } from "../../../src/utils/helpers"; + +describe("expandPath", () => { + const originalEnv = { ...process.env }; + const HOME = os.homedir(); + + beforeEach(() => { + process.env.TEST_HOME = "/custom/home"; + process.env.TEST_VAR = "foo"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + test("1. Tilde expansion: ~/path -> /home/user/path", () => { + expect(expandPath("~/test/file.txt")).toBe(path.join(HOME, "test/file.txt")); + }); + + test("2. Windows tilde with backslash: ~\\path", () => { + // expandPath handles both ~/ and ~\ regardless of platform + expect(expandPath("~\\test\\file.txt")).toBe(path.join(HOME, "test/file.txt")); + }); + + test("3. Environment variable expansion: ${VAR}/path", () => { + expect(expandPath("${TEST_HOME}/file.txt")).toBe(path.normalize("/custom/home/file.txt")); + }); + + test("4. Dollar sign env vars: $VAR/path", () => { + expect(expandPath("$TEST_HOME/file.txt")).toBe(path.normalize("/custom/home/file.txt")); + }); + + test("5. Windows %VAR% expansion: %VAR%\\path (simulated)", () => { + // We can't easily mock process.platform if it's not win32, + // but the function check process.platform === 'win32' + if (process.platform === 'win32') { + expect(expandPath("%TEST_HOME%\\file.txt")).toBe(path.normalize("/custom/home/file.txt")); + } else { + // Should remain unchanged on non-windows (but separators normalized) + expect(expandPath("%TEST_HOME%\\file.txt")).toBe(path.normalize("%TEST_HOME%/file.txt")); + } + }); + + test("6. Mixed path separators normalization", () => { + const result = expandPath("path/to\\some/file"); + expect(result).toBe(path.normalize("path/to/some/file")); + }); + + test("7. Nested env vars: ${HOME}/${VAR}/path", () => { + expect(expandPath("${TEST_HOME}/${TEST_VAR}/file.txt")).toBe(path.normalize("/custom/home/foo/file.txt")); + }); + + test("8. Empty/null path handling", () => { + expect(expandPath("")).toBe("."); + }); + + test("9. Already absolute paths stay unchanged", () => { + const absPath = "/absolute/path"; + expect(expandPath(absPath)).toBe(path.normalize(absPath)); + }); + + test("10. Undefined env vars -> empty string", () => { + expect(expandPath("${UNDEFINED_VAR}/file.txt")).toBe(path.normalize("/file.txt")); + expect(expandPath("$UNDEFINED_VAR/file.txt")).toBe(path.normalize("/file.txt")); + }); + + test("11. Windows drive letters stay intact", () => { + // Windows drive letter paths should be preserved + const result = expandPath("C:\\Users\\test\\file.txt"); + expect(result).toContain("Users"); + expect(result).toContain("test"); + }); + + test("12. Windows UNC paths handled", () => { + // UNC paths start with \\ + const uncPath = "\\\\server\\share\\folder"; + const result = expandPath(uncPath); + // Should normalize but preserve the structure + expect(result).toContain("server"); + expect(result).toContain("share"); + }); + + test("13. Null-like input throws TypeError", () => { + // Function requires string input - documents current behavior + // @ts-ignore - testing runtime edge case + expect(() => expandPath(undefined as unknown as string)).toThrow(TypeError); + // @ts-ignore - testing runtime edge case + expect(() => expandPath(null as unknown as string)).toThrow(TypeError); + }); + + test("14. Path with spaces preserved", () => { + const pathWithSpaces = "~/My Documents/file.txt"; + const result = expandPath(pathWithSpaces); + expect(result).toContain("My Documents"); + }); + + test("15. Multiple consecutive slashes normalized", () => { + const result = expandPath("path//to///file.txt"); + expect(result).toBe(path.normalize("path/to/file.txt")); + }); +}); diff --git a/tests/unit/utils/update-checker-beta-channel.test.js b/tests/unit/utils/update-checker-beta-channel.test.js index 4cca9820..964cfc75 100644 --- a/tests/unit/utils/update-checker-beta-channel.test.js +++ b/tests/unit/utils/update-checker-beta-channel.test.js @@ -27,14 +27,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { let httpsRequests = []; beforeAll(async function () { - // Build the project first - const { execSync } = require('child_process'); - try { - execSync('bun run build', { cwd: path.resolve(__dirname, '../../..'), stdio: 'pipe' }); - } catch (error) { - console.warn('Build failed, tests may not work:', error.message); - } - + // Note: Build is handled by CI before tests run (bun run build:all) // Import the built module updateCheckerModule = await import('../../../dist/utils/update-checker.js'); }); diff --git a/tests/unit/web-server/settings-routes-auth.test.js b/tests/unit/web-server/settings-routes-auth.test.js new file mode 100644 index 00000000..7a0771bb --- /dev/null +++ b/tests/unit/web-server/settings-routes-auth.test.js @@ -0,0 +1,284 @@ +/** + * Settings Routes - Auth Tokens API Tests + * + * Tests for the auth tokens API endpoints: + * - GET /api/settings/auth/tokens (masked) + * - GET /api/settings/auth/tokens/raw (unmasked) + * - PUT /api/settings/auth/tokens (update) + * - POST /api/settings/auth/tokens/regenerate-secret + * - POST /api/settings/auth/tokens/reset + */ + +const assert = require('assert'); + +describe('Settings Routes - Auth Tokens API', () => { + // ========================================================================= + // maskToken Function Tests (same as in routes) + // ========================================================================= + describe('maskToken', () => { + function maskToken(token) { + if (token.length <= 8) return '****'; + return `${token.slice(0, 4)}...${token.slice(-4)}`; + } + + it('masks long tokens correctly', () => { + assert.strictEqual(maskToken('ccs-internal-managed'), 'ccs-...aged'); + }); + + it('fully masks short tokens', () => { + assert.strictEqual(maskToken('ccs'), '****'); + }); + + it('handles exactly 8 character tokens', () => { + assert.strictEqual(maskToken('12345678'), '****'); + }); + + it('handles 9 character tokens', () => { + assert.strictEqual(maskToken('123456789'), '1234...6789'); + }); + }); + + // ========================================================================= + // Response Format Tests + // ========================================================================= + describe('Response Format', () => { + /** + * Expected response format for GET /api/settings/auth/tokens + */ + function createMaskedResponse(apiKey, managementSecret, apiKeyIsCustom, secretIsCustom) { + function maskToken(token) { + if (token.length <= 8) return '****'; + return `${token.slice(0, 4)}...${token.slice(-4)}`; + } + + return { + apiKey: { + value: maskToken(apiKey), + isCustom: apiKeyIsCustom, + }, + managementSecret: { + value: maskToken(managementSecret), + isCustom: secretIsCustom, + }, + }; + } + + it('includes apiKey with value and isCustom', () => { + const response = createMaskedResponse('test-api-key-123', 'test-secret', true, false); + + assert(response.apiKey, 'Should have apiKey'); + assert.strictEqual(typeof response.apiKey.value, 'string'); + assert.strictEqual(typeof response.apiKey.isCustom, 'boolean'); + }); + + it('includes managementSecret with value and isCustom', () => { + const response = createMaskedResponse('key', 'test-secret-456', false, true); + + assert(response.managementSecret, 'Should have managementSecret'); + assert.strictEqual(typeof response.managementSecret.value, 'string'); + assert.strictEqual(typeof response.managementSecret.isCustom, 'boolean'); + }); + + it('masks values in response', () => { + const response = createMaskedResponse( + 'very-long-api-key-12345', + 'very-long-secret-67890', + true, + true + ); + + // Values should be masked (contain ...) + assert(response.apiKey.value.includes('...'), 'API key should be masked'); + assert(response.managementSecret.value.includes('...'), 'Secret should be masked'); + }); + }); + + // ========================================================================= + // PUT /api/settings/auth/tokens - Update Logic Tests + // ========================================================================= + describe('PUT /api/settings/auth/tokens - Update Logic', () => { + /** + * Simulates the update logic + */ + function processUpdate(body, currentState) { + const { apiKey, managementSecret } = body; + const newState = { ...currentState }; + + if (apiKey !== undefined) { + // Empty string -> reset to default + newState.apiKey = apiKey || null; + } + + if (managementSecret !== undefined) { + newState.managementSecret = managementSecret || null; + } + + return newState; + } + + it('updates apiKey when provided', () => { + const current = { apiKey: 'old-key', managementSecret: 'old-secret' }; + const result = processUpdate({ apiKey: 'new-key' }, current); + + assert.strictEqual(result.apiKey, 'new-key'); + assert.strictEqual(result.managementSecret, 'old-secret'); + }); + + it('updates managementSecret when provided', () => { + const current = { apiKey: 'old-key', managementSecret: 'old-secret' }; + const result = processUpdate({ managementSecret: 'new-secret' }, current); + + assert.strictEqual(result.apiKey, 'old-key'); + assert.strictEqual(result.managementSecret, 'new-secret'); + }); + + it('updates both when provided', () => { + const current = { apiKey: 'old-key', managementSecret: 'old-secret' }; + const result = processUpdate({ apiKey: 'new-key', managementSecret: 'new-secret' }, current); + + assert.strictEqual(result.apiKey, 'new-key'); + assert.strictEqual(result.managementSecret, 'new-secret'); + }); + + it('resets to default when empty string provided', () => { + const current = { apiKey: 'custom-key', managementSecret: 'custom-secret' }; + const result = processUpdate({ apiKey: '', managementSecret: '' }, current); + + assert.strictEqual(result.apiKey, null); + assert.strictEqual(result.managementSecret, null); + }); + + it('ignores undefined values (no change)', () => { + const current = { apiKey: 'keep-key', managementSecret: 'keep-secret' }; + const result = processUpdate({}, current); + + assert.strictEqual(result.apiKey, 'keep-key'); + assert.strictEqual(result.managementSecret, 'keep-secret'); + }); + }); + + // ========================================================================= + // POST /api/settings/auth/tokens/regenerate-secret - Tests + // ========================================================================= + describe('POST /api/settings/auth/tokens/regenerate-secret', () => { + /** + * Simulates regenerate secret response + */ + function createRegenerateResponse(newSecret) { + function maskToken(token) { + if (token.length <= 8) return '****'; + return `${token.slice(0, 4)}...${token.slice(-4)}`; + } + + return { + success: true, + managementSecret: { + value: maskToken(newSecret), + isCustom: true, + }, + message: 'Restart CLIProxy to apply changes', + }; + } + + it('returns success: true', () => { + const response = createRegenerateResponse('new-generated-secret-12345'); + assert.strictEqual(response.success, true); + }); + + it('returns masked new secret', () => { + const response = createRegenerateResponse('abcdefghijklmnopqrstuvwxyz'); + assert(response.managementSecret.value.includes('...'), 'Should be masked'); + assert.strictEqual(response.managementSecret.isCustom, true); + }); + + it('includes restart message', () => { + const response = createRegenerateResponse('secret'); + assert(response.message.includes('Restart'), 'Should include restart instruction'); + }); + }); + + // ========================================================================= + // POST /api/settings/auth/tokens/reset - Tests + // ========================================================================= + describe('POST /api/settings/auth/tokens/reset', () => { + /** + * Simulates reset response + */ + function createResetResponse(defaultApiKey, defaultSecret) { + function maskToken(token) { + if (token.length <= 8) return '****'; + return `${token.slice(0, 4)}...${token.slice(-4)}`; + } + + return { + success: true, + apiKey: { + value: maskToken(defaultApiKey), + isCustom: false, + }, + managementSecret: { + value: maskToken(defaultSecret), + isCustom: false, + }, + message: 'Tokens reset to defaults. Restart CLIProxy to apply.', + }; + } + + it('returns success: true', () => { + const response = createResetResponse('ccs-internal-managed', 'ccs'); + assert.strictEqual(response.success, true); + }); + + it('returns isCustom: false for both tokens', () => { + const response = createResetResponse('ccs-internal-managed', 'ccs'); + assert.strictEqual(response.apiKey.isCustom, false); + assert.strictEqual(response.managementSecret.isCustom, false); + }); + + it('includes reset message', () => { + const response = createResetResponse('key', 'secret'); + assert(response.message.includes('reset'), 'Should include reset confirmation'); + }); + }); + + // ========================================================================= + // Error Response Format Tests + // ========================================================================= + describe('Error Response Format', () => { + /** + * Simulates error response + */ + function createErrorResponse(errorMessage) { + return { + error: errorMessage, + }; + } + + it('includes error message', () => { + const response = createErrorResponse('Something went wrong'); + assert.strictEqual(response.error, 'Something went wrong'); + }); + + it('does not include sensitive data in errors', () => { + const response = createErrorResponse('Failed to load config'); + assert(!response.apiKey, 'Should not include apiKey'); + assert(!response.managementSecret, 'Should not include secret'); + }); + }); + + // ========================================================================= + // HTTP Status Code Tests + // ========================================================================= + describe('HTTP Status Codes', () => { + const HTTP_OK = 200; + const HTTP_INTERNAL_ERROR = 500; + + it('uses 200 for successful responses', () => { + assert.strictEqual(HTTP_OK, 200); + }); + + it('uses 500 for internal errors', () => { + assert.strictEqual(HTTP_INTERNAL_ERROR, 500); + }); + }); +}); diff --git a/ui/package.json b/ui/package.json index 5fbb4629..23c50f38 100644 --- a/ui/package.json +++ b/ui/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "typecheck": "tsc -b --noEmit", + "typecheck": "tsc --noEmit", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write src/ tests/", diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx index 65865f03..d90969c9 100644 --- a/ui/src/components/account/accounts-table.tsx +++ b/ui/src/components/account/accounts-table.tsx @@ -1,8 +1,9 @@ /** * Accounts Table Component - * Phase 03: REST API Routes & CRUD + * Dashboard parity: Full CRUD for auth profiles */ +import { useState } from 'react'; import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'; import { Table, @@ -13,17 +14,35 @@ import { TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; -import { Check } from 'lucide-react'; -import { useSetDefaultAccount } from '@/hooks/use-accounts'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Check, Trash2, RotateCcw } from 'lucide-react'; +import { + useSetDefaultAccount, + useDeleteAccount, + useResetDefaultAccount, +} from '@/hooks/use-accounts'; import type { Account } from '@/lib/api-client'; interface AccountsTableProps { data: Account[]; defaultAccount: string | null; + onRefresh?: () => void; } export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { const setDefaultMutation = useSetDefaultAccount(); + const deleteMutation = useDeleteAccount(); + const resetDefaultMutation = useResetDefaultAccount(); + const [deleteTarget, setDeleteTarget] = useState(null); const columns: ColumnDef[] = [ { @@ -71,20 +90,34 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { { id: 'actions', header: 'Actions', - size: 100, + size: 180, cell: ({ row }) => { const isDefault = row.original.name === defaultAccount; + const isPending = setDefaultMutation.isPending || deleteMutation.isPending; + return ( - +
+ + +
); }, }, @@ -100,51 +133,97 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { if (data.length === 0) { return (
- No accounts found. Use ccs login to - add accounts. + No accounts found. Use{' '} + ccs auth create to add accounts.
); } return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const widthClass = - { - name: 'w-[200px]', - type: 'w-[100px]', - created: 'w-[150px]', - last_used: 'w-[150px]', - actions: 'w-[100px]', - }[header.id] || 'w-auto'; + <> +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const widthClass = + { + name: 'w-[200px]', + type: 'w-[100px]', + created: 'w-[150px]', + last_used: 'w-[150px]', + actions: 'w-[180px]', + }[header.id] || 'w-auto'; - return ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + ))} - - ))} - -
-
+ + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + + + + + {/* Reset default button */} + {defaultAccount && ( +
+ +
+ )} + + + {/* Delete confirmation dialog */} + !open && setDeleteTarget(null)}> + + + Delete Account + + Are you sure you want to delete the account "{deleteTarget}"? This will + remove the profile and all its session data. This action cannot be undone. + + + + Cancel + { + if (deleteTarget) { + deleteMutation.mutate(deleteTarget); + setDeleteTarget(null); + } + }} + > + Delete + + + + + ); } diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index fa01fa4e..9d83e9c9 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -2,6 +2,7 @@ * Add Account Dialog Component * Triggers OAuth flow server-side to add another account to a provider * Applies default preset when adding first account + * For Kiro: Also shows "Import from IDE" option as fallback */ import { useState } from 'react'; @@ -15,8 +16,8 @@ import { import { Button } from '@/components/ui/button'; 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 { Loader2, ExternalLink, User, Download } from 'lucide-react'; +import { useStartAuth, useKiroImport } from '@/hooks/use-cliproxy'; import { applyDefaultPreset } from '@/lib/preset-utils'; import { toast } from 'sonner'; @@ -38,6 +39,10 @@ export function AddAccountDialog({ }: AddAccountDialogProps) { const [nickname, setNickname] = useState(''); const startAuthMutation = useStartAuth(); + const kiroImportMutation = useKiroImport(); + + const isKiro = provider === 'kiro'; + const isPending = startAuthMutation.isPending || kiroImportMutation.isPending; const handleStartAuth = () => { startAuthMutation.mutate( @@ -60,8 +65,24 @@ export function AddAccountDialog({ ); }; + const handleKiroImport = () => { + kiroImportMutation.mutate(undefined, { + onSuccess: async () => { + // Apply default preset if this is the first account + if (isFirstAccount) { + const result = await applyDefaultPreset('kiro'); + if (result.success && result.presetName) { + toast.success(`Applied "${result.presetName}" preset`); + } + } + setNickname(''); + onClose(); + }, + }); + }; + const handleOpenChange = (isOpen: boolean) => { - if (!isOpen && !startAuthMutation.isPending) { + if (!isOpen && !isPending) { setNickname(''); onClose(); } @@ -73,8 +94,9 @@ export function AddAccountDialog({ Add {displayName} Account - Click the button below to authenticate a new account. A browser window will open for - OAuth. + {isKiro + ? 'Authenticate via browser or import an existing token from Kiro IDE.' + : 'Click the button below to authenticate a new account. A browser window will open for OAuth.'} @@ -88,7 +110,7 @@ export function AddAccountDialog({ value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder="e.g., work, personal" - disabled={startAuthMutation.isPending} + disabled={isPending} className="flex-1" /> @@ -98,10 +120,25 @@ export function AddAccountDialog({
- - + )} +
diff --git a/ui/src/components/account/create-auth-profile-dialog.tsx b/ui/src/components/account/create-auth-profile-dialog.tsx new file mode 100644 index 00000000..2da65f82 --- /dev/null +++ b/ui/src/components/account/create-auth-profile-dialog.tsx @@ -0,0 +1,124 @@ +/** + * Create Auth Profile Dialog + * Shows CLI command for creating auth profiles (Dashboard cannot spawn Claude login directly) + */ + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Copy, Check, Terminal } from 'lucide-react'; + +interface CreateAuthProfileDialogProps { + open: boolean; + onClose: () => void; +} + +export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) { + const [profileName, setProfileName] = useState(''); + const [copied, setCopied] = useState(false); + + // Validate profile name: alphanumeric, dash, underscore only + const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName); + const command = profileName ? `ccs auth create ${profileName}` : 'ccs auth create '; + + const handleCopy = async () => { + if (!isValidName) return; + await navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleClose = () => { + setProfileName(''); + setCopied(false); + onClose(); + }; + + return ( + !isOpen && handleClose()}> + + + Create New Account + + Auth profiles require Claude CLI login. Run the command below in your terminal. + + + +
+
+ + setProfileName(e.target.value)} + placeholder="e.g., work, personal, client" + autoComplete="off" + /> + {profileName && !isValidName && ( +

+ Name must start with a letter and contain only letters, numbers, dashes, or + underscores. +

+ )} +
+ +
+ +
+ + {command} + +
+
+ +
+

After running the command:

+
    +
  1. Complete the Claude login in your browser
  2. +
  3. Return here and refresh to see the new account
  4. +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/account/index.ts b/ui/src/components/account/index.ts index ba0cf9b4..0b75ccb8 100644 --- a/ui/src/components/account/index.ts +++ b/ui/src/components/account/index.ts @@ -5,6 +5,7 @@ // Main components export { AccountsTable } from './accounts-table'; export { AddAccountDialog } from './add-account-dialog'; +export { CreateAuthProfileDialog } from './create-auth-profile-dialog'; // Flow visualization (from subdirectory) export { AccountFlowViz } from './flow-viz'; diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 2abe97f2..6fcce60f 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -7,7 +7,7 @@ */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { RefreshCw, AlertCircle, Key, X, Gauge, Globe } from 'lucide-react'; +import { RefreshCw, AlertCircle, Key, X, Gauge, Globe, Settings } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { api } from '@/lib/api-client'; import type { CliproxyServerConfig } from '@/lib/api-client'; @@ -15,8 +15,10 @@ import type { CliproxyServerConfig } from '@/lib/api-client'; /** CLIProxyAPI default port */ const CLIPROXY_DEFAULT_PORT = 8317; -/** CCS Control Panel secret - must match config-generator.ts CCS_CONTROL_PANEL_SECRET */ -const CCS_CONTROL_PANEL_SECRET = 'ccs'; +interface AuthTokensResponse { + apiKey: { value: string; isCustom: boolean }; + managementSecret: { value: string; isCustom: boolean }; +} interface ControlPanelEmbedProps { port?: number; @@ -36,6 +38,17 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel staleTime: 30000, // 30 seconds }); + // Fetch auth tokens for local mode (gets effective management secret) + const { data: authTokens } = useQuery({ + queryKey: ['auth-tokens-raw'], + queryFn: async () => { + const response = await fetch('/api/settings/auth/tokens/raw'); + if (!response.ok) throw new Error('Failed to fetch auth tokens'); + return response.json(); + }, + staleTime: 30000, // 30 seconds + }); + // Log config fetch errors (fallback to local mode on error) useEffect(() => { if (configError) { @@ -67,15 +80,16 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel }; } - // Local mode + // Local mode - use effective management secret from auth tokens API + const effectiveSecret = authTokens?.managementSecret?.value || 'ccs'; return { managementUrl: `http://localhost:${port}/management.html`, checkUrl: `http://localhost:${port}/`, - authToken: CCS_CONTROL_PANEL_SECRET, + authToken: effectiveSecret, isRemote: false, displayHost: `localhost:${port}`, }; - }, [cliproxyConfig, port]); + }, [cliproxyConfig, authTokens, port]); // Check if CLIProxy is running useEffect(() => { @@ -219,6 +233,13 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel : authToken || 'ccs'} + + + @@ -260,8 +276,8 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr {/* Show alternative presets when Custom is selected or an alternative is selected */} {(selectedPreset === 'custom' || getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)) && ( -
-