From e981c391a26d51de749099ca844915ffc06976e2 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 23 Dec 2025 00:55:17 -0500 Subject: [PATCH 01/11] feat(cli): add config command hints to help and error messages Enhance discoverability of the 'ccs config' command by adding hints to the help menu, post-update/doctor messages, and relevant error states. --- src/commands/help-command.ts | 7 +++++-- src/commands/update-command.ts | 1 + src/management/doctor.ts | 6 +++++- src/utils/error-manager.ts | 8 +++++++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 2333c729..6261dc2a 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -89,14 +89,16 @@ export async function handleHelpCommand(): Promise { // Initialize UI (if not already) await initUI(); - // Hero box with ASCII art logo + // Hero box with ASCII art logo and config hint // Each letter: C=╔═╗/║ /╚═╝, C=╔═╗/║ /╚═╝, S=╔═╗/╚═╗/╚═╝ const logo = ` ╔═╗ ╔═╗ ╔═╗ ║ ║ ╚═╗ v${VERSION} ╚═╝ ╚═╝ ╚═╝ -Claude Code Profile & Model Switcher`.trim(); +Claude Code Profile & Model Switcher + +Run ${color('ccs config', 'command')} for web dashboard`.trim(); console.log( box(logo, { @@ -286,6 +288,7 @@ Claude Code Profile & Model Switcher`.trim(); ` $ ${color('ccs gemini', 'command')} ${dim('# OAuth (browser opens first time)')}` ); console.log(` $ ${color('ccs glm "implement API"', 'command')} ${dim('# API key model')}`); + console.log(` $ ${color('ccs config', 'command')} ${dim('# Open web dashboard')}`); console.log(''); // Update examples diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index a172e4da..3a025ff7 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -221,6 +221,7 @@ async function performNpmUpdate( console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`)); console.log(''); console.log(`Run ${color('ccs --version', 'command')} to verify`); + console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); console.log(''); } else { console.log(''); diff --git a/src/management/doctor.ts b/src/management/doctor.ts index b19dd4f4..1ec0abd3 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -2,7 +2,7 @@ * CCS Health Check and Diagnostics - Main Orchestrator */ -import { initUI, header, box, table, color, ok, fail, warn } from '../utils/ui'; +import { initUI, header, box, table, color, ok, fail, warn, info } from '../utils/ui'; import packageJson from '../../package.json'; import { HealthCheck, @@ -135,6 +135,8 @@ class Doctor { // Final status if (this.results.isHealthy() && !this.results.hasWarnings()) { console.log(ok('All checks passed! Installation is healthy.')); + console.log(''); + console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); } else if (this.results.hasErrors()) { console.log(fail('Installation has errors. Run suggested fixes above.')); } else { @@ -143,6 +145,8 @@ class Doctor { `Installation healthy (${this.results.warnings.length} warning${this.results.warnings.length !== 1 ? 's' : ''})` ) ); + console.log(''); + console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); } console.log(''); diff --git a/src/utils/error-manager.ts b/src/utils/error-manager.ts index 98bb4acf..65d364b8 100644 --- a/src/utils/error-manager.ts +++ b/src/utils/error-manager.ts @@ -1,4 +1,4 @@ -import { initUI, header, color, dim, errorBox } from './ui'; +import { initUI, header, color, dim, info, errorBox } from './ui'; import { ERROR_CODES, getErrorDocUrl, ErrorCode } from './error-codes'; import { getPortCheckCommand, getKillPidCommand } from './platform-commands'; @@ -155,6 +155,8 @@ export class ErrorManager { console.error(' Create a new account profile:'); console.error(` ${color('ccs auth create ', 'command')}`); console.error(''); + console.error(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); + console.error(''); this.showErrorCode(ERROR_CODES.PROFILE_NOT_FOUND); } @@ -208,6 +210,8 @@ export class ErrorManager { console.error(''); console.error(dim('This displays manual authentication steps.')); console.error(''); + console.error(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); + console.error(''); } /** @@ -283,5 +287,7 @@ export class ErrorManager { console.error(dim('This will open a browser for OAuth login.')); console.error(dim('After login, you can use the profile normally.')); console.error(''); + console.error(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); + console.error(''); } } From 9d8268b0f6b65eb8a2a3b4fd3fa68f68d596f2d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 23 Dec 2025 05:56:23 +0000 Subject: [PATCH 02/11] chore(release): 7.5.1-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6299668d..247f5227 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.5.1", + "version": "7.5.1-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From cec616d530d9cf61a3a45032465b01e9a4037558 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 23 Dec 2025 21:28:38 -0500 Subject: [PATCH 03/11] feat(setup): add first-time setup wizard for config initialization Addresses Issue #142 - remote CLIProxyAPI configuration for Docker/server deployments. Changes: - Add `ccs setup` command with interactive wizard - Support local vs remote CLIProxy mode selection - Guide users through remote proxy config (host, port, auth token) - Auto-detect first-time install and suggest setup wizard - Update help command to include setup Scenarios covered: 1. New users - wizard guides through local/remote/skip choice 2. Remote proxy users - configure host/port/protocol/auth_token 3. Local users - default auto-start behavior 4. Skip CLIProxy - only API profiles or Claude accounts --- src/ccs.ts | 17 ++ src/commands/help-command.ts | 1 + src/commands/setup-command.ts | 355 ++++++++++++++++++++++++++++++++++ 3 files changed, 373 insertions(+) create mode 100644 src/commands/setup-command.ts diff --git a/src/ccs.ts b/src/ccs.ts index f347819f..25dd7171 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -404,6 +404,13 @@ async function main(): Promise { return; } + // 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 = [ @@ -441,6 +448,16 @@ async function main(): Promise { if (recovered) { recovery.showRecoveryHints(); + + // First-time install: offer setup wizard for interactive users + // 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 diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 6261dc2a..224230ae 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -213,6 +213,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'], diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts new file mode 100644 index 00000000..ec990812 --- /dev/null +++ b/src/commands/setup-command.ts @@ -0,0 +1,355 @@ +/** + * 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 { initUI, header, ok, info, warn } from '../utils/ui'; +import { + loadOrCreateUnifiedConfig, + saveUnifiedConfig, + hasUnifiedConfig, +} from '../config/unified-config-loader'; +import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; + +/** + * 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 + */ +async function prompt( + rl: readline.Interface, + question: string, + defaultValue?: string +): Promise { + return new Promise((resolve) => { + const displayQuestion = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `; + rl.question(displayQuestion, (answer) => { + 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 (no config exists) + */ +export function isFirstTimeInstall(): boolean { + return !hasUnifiedConfig(); +} + +/** + * 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 + const host = await prompt(rl, 'Remote host (hostname or IP)'); + if (!host) { + throw new Error('Host is required for remote proxy mode'); + } + + // 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) + const defaultPort = protocol === 'https' ? '443' : '80'; + const portStr = await prompt(rl, `Port (leave empty for default ${defaultPort})`); + const port = portStr ? parseInt(portStr, 10) : undefined; + + // 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(''); + } 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); +} From b34469d75fd2c2b7fd4f4cc4c0cc28885001649b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 23 Dec 2025 21:54:55 -0500 Subject: [PATCH 04/11] refactor(config): migrate to config.yaml as primary format - Update postinstall.js to create config.yaml instead of config.json - Update recovery-manager to create config.yaml as primary config - Fix isFirstTimeInstall() to check for meaningful config content (profiles, accounts, variants, oauth_accounts, remote proxy) - Update validation to accept config.yaml OR config.json - Preserve backward compatibility: legacy config.json is migrated to config.yaml on first run via autoMigrate() - Update postinstall tests to verify config.yaml creation Fixes #142 - remote CLIProxyAPI configuration --- scripts/postinstall.js | 138 +++++++++++++++-------------- src/commands/setup-command.ts | 25 +++++- src/management/recovery-manager.ts | 60 +++++++------ tests/npm/postinstall.test.js | 30 +++++-- 4 files changed, 147 insertions(+), 106 deletions(-) diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 60251501..0da1a18b 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) @@ -150,62 +139,77 @@ 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)) { + console.log('[OK] Legacy config.json found - will migrate to config.yaml on first run'); } else { - console.log('[OK] Config exists: ~/.ccs/config.json (preserved)'); + // 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 + const yaml = require('js-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)'); + } + + // Handle legacy config.json migrations (for users upgrading) + if (fs.existsSync(legacyConfigPath) && !fs.existsSync(configYamlPath)) { + // Migration will happen via autoMigrate() in ccs.ts on first run + console.log('[i] Legacy config.json will be migrated to config.yaml on first run'); } // NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index ec990812..ff867eaf 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -93,10 +93,31 @@ async function selectOption( } /** - * Check if this is a first-time install (no config exists) + * 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 */ export function isFirstTimeInstall(): boolean { - return !hasUnifiedConfig(); + // No config at all → definitely first time + if (!hasUnifiedConfig()) { + return true; + } + + // Config exists - check if it's meaningfully configured + const config = loadOrCreateUnifiedConfig(); + + // Check for any meaningful configuration + const hasProfiles = Object.keys(config.profiles || {}).length > 0; + const hasAccounts = Object.keys(config.accounts || {}).length > 0; + const hasVariants = Object.keys(config.cliproxy?.variants || {}).length > 0; + const hasOAuthAccounts = Object.keys(config.cliproxy?.oauth_accounts || {}).length > 0; + const hasRemoteProxy = + config.cliproxy_server?.remote?.enabled && config.cliproxy_server?.remote?.host; + + // If any of these exist, user has configured something + const isConfigured = + hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy; + + return !isConfigured; } /** diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index 9c43dd1e..b93621d3 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -8,6 +8,8 @@ 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 } from '../config/unified-config-loader'; /** * Get CCS home directory (respects CCS_HOME env for test isolation) @@ -49,37 +51,39 @@ 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 + if (hasUnifiedConfig()) { + return false; } - // 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 (_e) { + // Fallback: create minimal config.json for backward compat + 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; + } } /** @@ -281,8 +285,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(); 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'); }); From ca78993e7612143b3193e3cec3f8976be909e2d6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 23 Dec 2025 22:34:32 -0500 Subject: [PATCH 05/11] fix(config): improve edge case handling for config initialization - setup-command.ts: Add Ctrl+C handling (UserCancelledError), port validation (1-65535), protocol stripping from host, try-catch with user-friendly error messages - postinstall.js: Add ~/.ccs file check (not directory), wrap js-yaml require in try-catch with JSON fallback, validate config.json before migration, warn when both config files exist - recovery-manager.ts: Verify config is loadable (not just exists), add nested error handling for fallback write - ccs.ts: Make first-time install hint independent of recovery status (shows even when user manually created empty config.yaml) All edge cases identified by code review addressed. --- scripts/postinstall.js | 112 +++++++++++++++++++---------- src/ccs.ts | 19 ++--- src/commands/setup-command.ts | 69 ++++++++++++++++-- src/management/recovery-manager.ts | 36 +++++++--- 4 files changed, 173 insertions(+), 63 deletions(-) diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 0da1a18b..46c6bbf0 100755 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -93,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/'); } @@ -149,8 +157,22 @@ function createConfigFiles() { if (!fs.existsSync(configYamlPath)) { // Check for legacy config.json - autoMigrate() in ccs.ts will handle migration if (fs.existsSync(legacyConfigPath)) { - console.log('[OK] Legacy config.json found - will migrate to config.yaml on first run'); - } else { + // 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`); + } + } + + // 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'); @@ -163,42 +185,58 @@ function createConfigFiles() { console.log('[OK] Created config: ~/.ccs/config.yaml'); } catch (loaderErr) { // Dist not built yet (fresh clone) - create minimal config.yaml manually - const yaml = require('js-yaml'); - const config = { - version: '2.0', - profiles: {}, - accounts: {}, - cliproxy: { - variants: {}, - oauth_accounts: {} - }, - cliproxy_server: { - local: { - port: 8317, - auto_start: true - } - } - }; - + // Wrap js-yaml require in try-catch in case it's not available + let yaml; 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'); + 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)'); + } } } } @@ -206,10 +244,10 @@ function createConfigFiles() { console.log('[OK] Config exists: ~/.ccs/config.yaml (preserved)'); } - // Handle legacy config.json migrations (for users upgrading) - if (fs.existsSync(legacyConfigPath) && !fs.existsSync(configYamlPath)) { - // Migration will happen via autoMigrate() in ccs.ts on first run - console.log('[i] Legacy config.json will be migrated to config.yaml on first run'); + // 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/ccs.ts b/src/ccs.ts index 25dd7171..ced3fc28 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -448,16 +448,17 @@ async function main(): Promise { if (recovered) { recovery.showRecoveryHints(); + } - // First-time install: offer setup wizard for interactive users - // 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(''); - } + // 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 diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index ff867eaf..dc0c5801 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -16,11 +16,20 @@ import * as readline from 'readline'; 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'; +/** 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 */ @@ -33,15 +42,23 @@ function createReadline(): readline.Interface { /** * 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) => { + 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 || ''); }); }); @@ -102,8 +119,18 @@ export function isFirstTimeInstall(): boolean { return true; } - // Config exists - check if it's meaningfully configured - const config = loadOrCreateUnifiedConfig(); + // Try loading config directly to detect corruption + const loaded = loadUnifiedConfig(); + if (loaded === null) { + // Config exists but is corrupted/invalid - don't treat as first-time + // User should fix or delete the file, or use --force + 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; + } + + // Config exists and is valid - check if it's meaningfully configured + const config = loaded; // Check for any meaningful configuration const hasProfiles = Object.keys(config.profiles || {}).length > 0; @@ -136,11 +163,15 @@ async function configureRemoteProxy(rl: readline.Interface): Promise<{ console.log(' Example: your-server.example.com'); console.log(''); - // Host - const host = await prompt(rl, 'Remote host (hostname or IP)'); + // 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:', [ @@ -148,10 +179,19 @@ async function configureRemoteProxy(rl: readline.Interface): Promise<{ { label: 'HTTP', value: 'http', description: 'Unencrypted connection' }, ])) as 'http' | 'https'; - // Port (optional) + // Port (optional) - with validation const defaultPort = protocol === 'https' ? '443' : '80'; const portStr = await prompt(rl, `Port (leave empty for default ${defaultPort})`); - const port = portStr ? parseInt(portStr, 10) : undefined; + 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(''); @@ -320,6 +360,21 @@ async function runSetupWizard(force: boolean = false): Promise { 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(); } diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index b93621d3..ec2169a2 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -9,7 +9,11 @@ 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 } from '../config/unified-config-loader'; +import { + saveUnifiedConfig, + hasUnifiedConfig, + loadUnifiedConfig, +} from '../config/unified-config-loader'; /** * Get CCS home directory (respects CCS_HOME env for test isolation) @@ -55,9 +59,15 @@ class RecoveryManager { * This is the primary config format (YAML unified config) */ ensureConfigYaml(): boolean { - // Skip if config.yaml already exists + // Skip if config.yaml already exists AND is valid if (hasUnifiedConfig()) { - return false; + // 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'); } // Check for legacy config.json - if exists, let autoMigrate handle it @@ -75,14 +85,20 @@ class RecoveryManager { saveUnifiedConfig(config); this.recovered.push('Created ~/.ccs/config.yaml'); return true; - } catch (_e) { + } catch (_saveErr) { // Fallback: create minimal config.json for backward compat - 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; + 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; + } } } From 8d1f1a6a941dc4d5460fa145432616c116534a4f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Dec 2025 04:03:58 +0000 Subject: [PATCH 06/11] chore(release): 7.5.1-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 247f5227..f6d393cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.5.1-dev.1", + "version": "7.5.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 46f1699b1c6f716d06c1eaa3dc6aac94dd5761ec Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 03:02:36 -0500 Subject: [PATCH 07/11] fix(ghcp): display device code during OAuth device code flow When authenticating with GitHub Copilot using device code flow, the user code was not being displayed. This fix: - Parses device codes from CLIProxy output in oauth-process.ts - Displays code prominently in CLI terminal with box formatting - Broadcasts device code events via WebSocket for UI display - Creates DeviceCodeDialog component for web UI (ccs config) - Follows same pattern as Gemini project selection dialog Closes #189 --- src/cliproxy/auth/oauth-process.ts | 118 ++++++++++-- src/cliproxy/device-code-handler.ts | 167 +++++++++++++++++ src/web-server/websocket.ts | 50 +++++ ui/src/components/layout/layout.tsx | 16 ++ .../components/shared/device-code-dialog.tsx | 174 ++++++++++++++++++ ui/src/components/shared/index.ts | 1 + ui/src/hooks/use-device-code.ts | 134 ++++++++++++++ 7 files changed, 648 insertions(+), 12 deletions(-) create mode 100644 src/cliproxy/device-code-handler.ts create mode 100644 ui/src/components/shared/device-code-dialog.tsx create mode 100644 ui/src/hooks/use-device-code.ts diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index f9e0026e..51848279 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -22,6 +22,7 @@ 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'; /** Options for OAuth process execution */ export interface OAuthProcessOptions { @@ -46,6 +47,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,6 +104,8 @@ async function handleStdout( log(`stdout: ${output.trim()}`); state.accumulatedOutput += output; + const isDeviceCodeFlow = options.callbackPort === null; + // Parse project list when available if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) { state.parsedProjects = parseProjectList(state.accumulatedOutput); @@ -111,16 +118,62 @@ async function handleStdout( 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 URLs in headless mode (for non-device-code flows) + if (!isDeviceCodeFlow && options.headless && !state.urlDisplayed) { const urlMatch = output.match(/https?:\/\/[^\s]+/); if (urlMatch) { console.log(''); @@ -218,6 +271,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); @@ -269,12 +341,34 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise void; + timeout: NodeJS.Timeout; +} + +// Global event emitter for device code events +export const deviceCodeEvents = new EventEmitter(); + +// Active sessions by session ID +const activeSessions = new Map(); + +// Default timeout for device code (15 minutes - GitHub's default) +const DEVICE_CODE_TIMEOUT_MS = 900000; + +/** + * Parse device/user code from CLIProxy output + * + * Supports various formats: + * - "Enter code: XXXX-YYYY" + * - "code XXXX-YYYY" + * - "user code: XXXX-YYYY" + * - "code \"XXXX-YYYY\"" + */ +export function parseDeviceCode(output: string): string | null { + const codeMatch = output.match( + /(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i + ); + return codeMatch ? codeMatch[1].toUpperCase() : null; +} + +/** + * Parse verification URL from CLIProxy output + */ +export function parseVerificationUrl(output: string): string | null { + const urlMatch = output.match(/(https?:\/\/[^\s]+device[^\s]*)/i); + return urlMatch ? urlMatch[1] : null; +} + +/** + * Register a device code session and emit event for UI broadcast + * + * @param prompt - Device code prompt data + * @returns Promise that resolves when auth completes or times out + */ +export function registerDeviceCode(prompt: DeviceCodePrompt): Promise { + return new Promise((resolve) => { + // Set timeout for session expiry + const timeout = setTimeout(() => { + const session = activeSessions.get(prompt.sessionId); + if (session) { + activeSessions.delete(prompt.sessionId); + deviceCodeEvents.emit('deviceCode:expired', prompt.sessionId); + resolve(false); + } + }, DEVICE_CODE_TIMEOUT_MS); + + // Store active session + activeSessions.set(prompt.sessionId, { + prompt, + resolve, + timeout, + }); + + // Emit event for WebSocket broadcast to UI + deviceCodeEvents.emit('deviceCode:received', prompt); + }); +} + +/** + * Mark device code session as completed (auth successful) + */ +export function completeDeviceCode(sessionId: string): boolean { + const session = activeSessions.get(sessionId); + + if (!session) { + return false; + } + + clearTimeout(session.timeout); + activeSessions.delete(sessionId); + + session.resolve(true); + deviceCodeEvents.emit('deviceCode:completed', sessionId); + + return true; +} + +/** + * Mark device code session as failed + */ +export function failDeviceCode(sessionId: string, error?: string): boolean { + const session = activeSessions.get(sessionId); + + if (!session) { + return false; + } + + clearTimeout(session.timeout); + activeSessions.delete(sessionId); + + session.resolve(false); + deviceCodeEvents.emit('deviceCode:failed', { sessionId, error }); + + return true; +} + +/** + * Get active device code prompt + */ +export function getActiveDeviceCode(sessionId: string): DeviceCodePrompt | null { + const session = activeSessions.get(sessionId); + return session ? session.prompt : null; +} + +/** + * Check if there's an active device code session + */ +export function hasActiveDeviceCode(sessionId: string): boolean { + return activeSessions.has(sessionId); +} + +/** + * Get all active device code sessions (for debugging/status) + */ +export function getAllActiveDeviceCodes(): DeviceCodePrompt[] { + return Array.from(activeSessions.values()).map((s) => s.prompt); +} + +export default { + parseDeviceCode, + parseVerificationUrl, + registerDeviceCode, + completeDeviceCode, + failDeviceCode, + getActiveDeviceCode, + hasActiveDeviceCode, + getAllActiveDeviceCodes, + deviceCodeEvents, +}; 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/ui/src/components/layout/layout.tsx b/ui/src/components/layout/layout.tsx index d04ec791..313beac3 100644 --- a/ui/src/components/layout/layout.tsx +++ b/ui/src/components/layout/layout.tsx @@ -12,7 +12,9 @@ import { Skeleton } from '@/components/ui/skeleton'; import { ClaudeKitBadge } from '@/components/shared/claudekit-badge'; import { SponsorButton } from '@/components/shared/sponsor-button'; import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog'; +import { DeviceCodeDialog } from '@/components/shared/device-code-dialog'; import { useProjectSelection } from '@/hooks/use-project-selection'; +import { useDeviceCode } from '@/hooks/use-device-code'; function PageLoader() { return ( @@ -25,6 +27,7 @@ function PageLoader() { export function Layout() { const { isOpen, prompt, onSelect, onClose } = useProjectSelection(); + const deviceCode = useDeviceCode(); return ( @@ -64,6 +67,19 @@ export function Layout() { onSelect={onSelect} /> )} + + {/* Global device code dialog for Device Code OAuth flows (GitHub Copilot, Qwen) */} + {deviceCode.prompt && ( + + )} ); } diff --git a/ui/src/components/shared/device-code-dialog.tsx b/ui/src/components/shared/device-code-dialog.tsx new file mode 100644 index 00000000..a7224f3e --- /dev/null +++ b/ui/src/components/shared/device-code-dialog.tsx @@ -0,0 +1,174 @@ +/** + * Device Code Dialog Component + * + * Displays during OAuth Device Code flow when user needs to enter + * a verification code at the provider's website (e.g., GitHub, Qwen). + * Shows prominently formatted code with copy and open URL buttons. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { ExternalLink, Copy, Check, Loader2, KeyRound } from 'lucide-react'; +import { toast } from 'sonner'; + +interface DeviceCodeDialogProps { + open: boolean; + onClose: () => void; + sessionId: string; + provider: string; + userCode: string; + verificationUrl: string; + expiresAt: number; +} + +/** Provider display names */ +const PROVIDER_DISPLAY_NAMES: Record = { + ghcp: 'GitHub Copilot', + qwen: 'Qwen Code', +}; + +/** Provider specific instructions */ +const PROVIDER_INSTRUCTIONS: Record = { + ghcp: 'Sign in with your GitHub account that has Copilot access.', + qwen: 'Sign in with your Qwen account to authorize access.', +}; + +export function DeviceCodeDialog({ + open, + onClose, + sessionId, + provider, + userCode, + verificationUrl, + expiresAt, +}: DeviceCodeDialogProps) { + const [hasCopied, setHasCopied] = useState(false); + const [timeRemaining, setTimeRemaining] = useState(null); + + // Calculate and update remaining time + useEffect(() => { + if (!open) return; + + const updateTime = () => { + const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); + setTimeRemaining(remaining); + }; + + updateTime(); + const timer = setInterval(updateTime, 1000); + + return () => clearInterval(timer); + }, [open, expiresAt]); + + const handleCopyCode = useCallback(async () => { + try { + await navigator.clipboard.writeText(userCode); + setHasCopied(true); + toast.success('Code copied to clipboard'); + setTimeout(() => setHasCopied(false), 2000); + } catch { + toast.error('Failed to copy code'); + } + }, [userCode]); + + const handleOpenUrl = useCallback(() => { + window.open(verificationUrl, '_blank', 'noopener,noreferrer'); + }, [verificationUrl]); + + const providerDisplay = PROVIDER_DISPLAY_NAMES[provider] || provider; + const instructions = + PROVIDER_INSTRUCTIONS[provider] || 'Complete the authorization in your browser.'; + + // Format remaining time + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + // Suppress unused variable warning - sessionId used for identification + void sessionId; + + return ( + !isOpen && onClose()}> + + + + + Authorize {providerDisplay} + + + Enter the code below at the authorization page. + {timeRemaining !== null && timeRemaining > 0 && ( + + (Expires in {formatTime(timeRemaining)}) + + )} + + + +
+ {/* Large code display */} +
+
+
+ {userCode} +
+
+ +
+ + {/* Instructions */} +
+

{instructions}

+
+ + {/* Action buttons */} +
+ + +
+ + {/* Waiting indicator */} +
+ + Waiting for authorization... +
+
+
+
+ ); +} diff --git a/ui/src/components/shared/index.ts b/ui/src/components/shared/index.ts index 2a181ec4..602a9712 100644 --- a/ui/src/components/shared/index.ts +++ b/ui/src/components/shared/index.ts @@ -8,6 +8,7 @@ export { CodeEditor } from './code-editor'; export { CommandBuilder } from './command-builder'; export { ConfirmDialog } from './confirm-dialog'; export { ConnectionIndicator } from './connection-indicator'; +export { DeviceCodeDialog } from './device-code-dialog'; export { DocsLink } from './docs-link'; export { GitHubLink } from './github-link'; export { GlobalEnvIndicator } from './global-env-indicator'; diff --git a/ui/src/hooks/use-device-code.ts b/ui/src/hooks/use-device-code.ts new file mode 100644 index 00000000..a8492068 --- /dev/null +++ b/ui/src/hooks/use-device-code.ts @@ -0,0 +1,134 @@ +/** + * Device Code Hook + * + * Listens for WebSocket device code events and manages dialog state. + * Similar to useProjectSelection but for Device Code OAuth flows + * (GitHub Copilot, Qwen, etc.) + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { toast } from 'sonner'; + +export interface DeviceCodePrompt { + sessionId: string; + provider: string; + userCode: string; + verificationUrl: string; + expiresAt: number; +} + +interface DeviceCodeState { + isOpen: boolean; + prompt: DeviceCodePrompt | null; + error: string | null; +} + +/** Provider display names for user-friendly messages */ +const PROVIDER_DISPLAY_NAMES: Record = { + ghcp: 'GitHub Copilot', + qwen: 'Qwen Code', +}; + +export function useDeviceCode() { + const [state, setState] = useState({ + isOpen: false, + prompt: null, + error: null, + }); + + // Listen for WebSocket messages via custom events + useEffect(() => { + const handleMessage = (event: CustomEvent<{ type: string; [key: string]: unknown }>) => { + const data = event.detail; + + if (data.type === 'deviceCodeReceived') { + console.log('[DeviceCode] Received prompt:', data.sessionId); + const displayName = PROVIDER_DISPLAY_NAMES[data.provider as string] || data.provider; + toast.info(`${displayName} authorization required`); + + setState({ + isOpen: true, + prompt: { + sessionId: data.sessionId as string, + provider: data.provider as string, + userCode: data.userCode as string, + verificationUrl: data.verificationUrl as string, + expiresAt: data.expiresAt as number, + }, + error: null, + }); + } else if (data.type === 'deviceCodeCompleted') { + console.log('[DeviceCode] Auth completed:', data.sessionId); + setState((prev) => { + if (prev.prompt && prev.prompt.sessionId === data.sessionId) { + const displayName = + PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider; + toast.success(`${displayName} authentication successful!`); + return { isOpen: false, prompt: null, error: null }; + } + return prev; + }); + } else if (data.type === 'deviceCodeFailed') { + console.log('[DeviceCode] Auth failed:', data.sessionId, data.error); + setState((prev) => { + if (prev.prompt && prev.prompt.sessionId === data.sessionId) { + const displayName = + PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider; + toast.error(`${displayName} authentication failed`); + return { isOpen: false, prompt: null, error: data.error as string }; + } + return prev; + }); + } else if (data.type === 'deviceCodeExpired') { + console.log('[DeviceCode] Code expired:', data.sessionId); + setState((prev) => { + if (prev.prompt?.sessionId === data.sessionId) { + toast.error('Device code expired. Please try again.'); + return { isOpen: false, prompt: null, error: 'Device code expired' }; + } + return prev; + }); + } + }; + + // Listen for custom ws-message events dispatched by useWebSocket + window.addEventListener('ws-message', handleMessage as EventListener); + + return () => { + window.removeEventListener('ws-message', handleMessage as EventListener); + }; + }, []); + + const handleClose = useCallback(() => { + setState({ isOpen: false, prompt: null, error: null }); + }, []); + + const handleOpenUrl = useCallback(() => { + if (state.prompt?.verificationUrl) { + window.open(state.prompt.verificationUrl, '_blank', 'noopener,noreferrer'); + } + }, [state.prompt]); + + const handleCopyCode = useCallback(async () => { + if (state.prompt?.userCode) { + try { + await navigator.clipboard.writeText(state.prompt.userCode); + toast.success('Code copied to clipboard'); + } catch { + toast.error('Failed to copy code'); + } + } + }, [state.prompt]); + + return useMemo( + () => ({ + isOpen: state.isOpen, + prompt: state.prompt, + error: state.error, + onClose: handleClose, + onOpenUrl: handleOpenUrl, + onCopyCode: handleCopyCode, + }), + [state.isOpen, state.prompt, state.error, handleClose, handleOpenUrl, handleCopyCode] + ); +} From 5de6cccee08aa06d6533181a1db189a595c5e123 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 03:15:57 -0500 Subject: [PATCH 08/11] refactor(ghcp): remove unused device code session management - Remove unused functions: registerDeviceCode, completeDeviceCode, failDeviceCode, getActiveDeviceCode, hasActiveDeviceCode, getAllActiveDeviceCodes, ActiveSession interface, activeSessions Map - Reduce device-code-handler.ts from 168 to 33 lines (YAGNI) - Add ARIA labels to copy button for accessibility - Fix timer to stop at 0 seconds instead of continuing negative - Add "(Code expired)" visual indicator with destructive styling - Change void sessionId to data-session-id attribute for debugging Code review findings applied. Gemini OAuth unaffected (uses separate project-selection-handler.ts with Authorization Code Flow). --- src/cliproxy/device-code-handler.ts | 151 +----------------- .../components/shared/device-code-dialog.tsx | 22 ++- 2 files changed, 24 insertions(+), 149 deletions(-) diff --git a/src/cliproxy/device-code-handler.ts b/src/cliproxy/device-code-handler.ts index a2dccb78..d1ccbc94 100644 --- a/src/cliproxy/device-code-handler.ts +++ b/src/cliproxy/device-code-handler.ts @@ -2,11 +2,14 @@ * Device Code Handler * * Manages device code display prompts during OAuth Device Code flow. - * Parses CLIProxyAPI stdout for device codes and broadcasts via WebSocket - * to both CLI terminal and Web UI (ccs config). + * Broadcasts device code events via WebSocket to both CLI terminal + * and Web UI (ccs config). * - * Similar pattern to project-selection-handler.ts but for device code flows - * (GitHub Copilot, Qwen, etc.) + * 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'; @@ -22,146 +25,8 @@ export interface DeviceCodePrompt { expiresAt: number; } -/** - * Active device code session with completion resolver - */ -interface ActiveSession { - prompt: DeviceCodePrompt; - resolve: (success: boolean) => void; - timeout: NodeJS.Timeout; -} - // Global event emitter for device code events export const deviceCodeEvents = new EventEmitter(); -// Active sessions by session ID -const activeSessions = new Map(); - // Default timeout for device code (15 minutes - GitHub's default) -const DEVICE_CODE_TIMEOUT_MS = 900000; - -/** - * Parse device/user code from CLIProxy output - * - * Supports various formats: - * - "Enter code: XXXX-YYYY" - * - "code XXXX-YYYY" - * - "user code: XXXX-YYYY" - * - "code \"XXXX-YYYY\"" - */ -export function parseDeviceCode(output: string): string | null { - const codeMatch = output.match( - /(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i - ); - return codeMatch ? codeMatch[1].toUpperCase() : null; -} - -/** - * Parse verification URL from CLIProxy output - */ -export function parseVerificationUrl(output: string): string | null { - const urlMatch = output.match(/(https?:\/\/[^\s]+device[^\s]*)/i); - return urlMatch ? urlMatch[1] : null; -} - -/** - * Register a device code session and emit event for UI broadcast - * - * @param prompt - Device code prompt data - * @returns Promise that resolves when auth completes or times out - */ -export function registerDeviceCode(prompt: DeviceCodePrompt): Promise { - return new Promise((resolve) => { - // Set timeout for session expiry - const timeout = setTimeout(() => { - const session = activeSessions.get(prompt.sessionId); - if (session) { - activeSessions.delete(prompt.sessionId); - deviceCodeEvents.emit('deviceCode:expired', prompt.sessionId); - resolve(false); - } - }, DEVICE_CODE_TIMEOUT_MS); - - // Store active session - activeSessions.set(prompt.sessionId, { - prompt, - resolve, - timeout, - }); - - // Emit event for WebSocket broadcast to UI - deviceCodeEvents.emit('deviceCode:received', prompt); - }); -} - -/** - * Mark device code session as completed (auth successful) - */ -export function completeDeviceCode(sessionId: string): boolean { - const session = activeSessions.get(sessionId); - - if (!session) { - return false; - } - - clearTimeout(session.timeout); - activeSessions.delete(sessionId); - - session.resolve(true); - deviceCodeEvents.emit('deviceCode:completed', sessionId); - - return true; -} - -/** - * Mark device code session as failed - */ -export function failDeviceCode(sessionId: string, error?: string): boolean { - const session = activeSessions.get(sessionId); - - if (!session) { - return false; - } - - clearTimeout(session.timeout); - activeSessions.delete(sessionId); - - session.resolve(false); - deviceCodeEvents.emit('deviceCode:failed', { sessionId, error }); - - return true; -} - -/** - * Get active device code prompt - */ -export function getActiveDeviceCode(sessionId: string): DeviceCodePrompt | null { - const session = activeSessions.get(sessionId); - return session ? session.prompt : null; -} - -/** - * Check if there's an active device code session - */ -export function hasActiveDeviceCode(sessionId: string): boolean { - return activeSessions.has(sessionId); -} - -/** - * Get all active device code sessions (for debugging/status) - */ -export function getAllActiveDeviceCodes(): DeviceCodePrompt[] { - return Array.from(activeSessions.values()).map((s) => s.prompt); -} - -export default { - parseDeviceCode, - parseVerificationUrl, - registerDeviceCode, - completeDeviceCode, - failDeviceCode, - getActiveDeviceCode, - hasActiveDeviceCode, - getAllActiveDeviceCodes, - deviceCodeEvents, -}; +export const DEVICE_CODE_TIMEOUT_MS = 900000; diff --git a/ui/src/components/shared/device-code-dialog.tsx b/ui/src/components/shared/device-code-dialog.tsx index a7224f3e..5f21cd50 100644 --- a/ui/src/components/shared/device-code-dialog.tsx +++ b/ui/src/components/shared/device-code-dialog.tsx @@ -52,19 +52,28 @@ export function DeviceCodeDialog({ const [hasCopied, setHasCopied] = useState(false); const [timeRemaining, setTimeRemaining] = useState(null); - // Calculate and update remaining time + // Calculate and update remaining time, stop at 0 useEffect(() => { if (!open) return; + let timer: ReturnType | null = null; + const updateTime = () => { const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); setTimeRemaining(remaining); + // Stop timer when expired + if (remaining === 0 && timer) { + clearInterval(timer); + timer = null; + } }; updateTime(); - const timer = setInterval(updateTime, 1000); + timer = setInterval(updateTime, 1000); - return () => clearInterval(timer); + return () => { + if (timer) clearInterval(timer); + }; }, [open, expiresAt]); const handleCopyCode = useCallback(async () => { @@ -93,12 +102,11 @@ export function DeviceCodeDialog({ return `${mins}:${secs.toString().padStart(2, '0')}`; }; - // Suppress unused variable warning - sessionId used for identification - void sessionId; + const isExpired = timeRemaining === 0; return ( !isOpen && onClose()}> - + @@ -111,6 +119,7 @@ export function DeviceCodeDialog({ (Expires in {formatTime(timeRemaining)}) )} + {isExpired && (Code expired)} @@ -127,6 +136,7 @@ export function DeviceCodeDialog({ size="icon" className="absolute top-2 right-2" onClick={handleCopyCode} + aria-label={hasCopied ? 'Code copied' : 'Copy verification code'} > {hasCopied ? ( From 659761f0192f68a10c51bd217d2fc1eb89ab8aa4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Dec 2025 08:24:13 +0000 Subject: [PATCH 09/11] chore(release): 7.5.1-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f6d393cc..3c291e05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.5.1-dev.2", + "version": "7.5.1-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From a99b6eb93f06c6788bbf13a196bbca908fa06f4c Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 03:36:48 -0500 Subject: [PATCH 10/11] fix(cliproxy): respect enabled:false and use protocol-based port defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix port default logic: HTTP→8317, HTTPS→443 (was always 8317) - Fix enabled:false being ignored in proxy config resolution - Update stale comments referencing HTTP:80 to HTTP:8317 - Add 9 unit tests for edge cases (enabled handling, port defaults) --- src/cliproxy/proxy-config-resolver.ts | 7 ++- src/cliproxy/remote-proxy-client.ts | 24 ++++++--- src/config/unified-config-loader.ts | 2 +- src/config/unified-config-types.ts | 2 +- .../cliproxy/proxy-config-resolver.test.js | 52 +++++++++++++++++++ .../unit/cliproxy/remote-proxy-client.test.ts | 31 +++++++++++ 6 files changed, 106 insertions(+), 12 deletions(-) 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/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/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 249ecd38..3817eedb 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -184,7 +184,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 ?? diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index a5e2bc4c..333ef307 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -345,7 +345,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/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'); + }); + }); }); From 91a079ad203edcb088567d63435a6633456b2bed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Dec 2025 08:38:07 +0000 Subject: [PATCH 11/11] chore(release): 7.5.1-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3c291e05..a813b8a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.5.1-dev.3", + "version": "7.5.1-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",