mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 10:15:40 +00:00
Merge pull request #191 from kaitranntt/dev
feat(release): config setup wizard, device code display, and proxy bug fixes
This commit is contained in:
23 files changed
+1276
-133
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.5.1",
|
||||
"version": "7.5.1-dev.4",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
+110
-68
@@ -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
|
||||
|
||||
+18
@@ -404,6 +404,13 @@ async function main(): Promise<void> {
|
||||
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 = [
|
||||
@@ -443,6 +450,17 @@ async function main(): Promise<void> {
|
||||
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
|
||||
const { profile, remainingArgs } = detectProfile(args);
|
||||
|
||||
|
||||
@@ -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<Accou
|
||||
accumulatedOutput: '',
|
||||
parsedProjects: [],
|
||||
sessionId: generateSessionId(),
|
||||
deviceCodeDisplayed: false,
|
||||
userCode: null,
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
@@ -236,16 +291,33 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
});
|
||||
|
||||
// Show waiting message after delay
|
||||
const isDeviceCodeFlow = callbackPort === null;
|
||||
setTimeout(() => {
|
||||
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<Accou
|
||||
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 {
|
||||
// Emit device code failure event for UI
|
||||
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
|
||||
deviceCodeEvents.emit('deviceCode:failed', {
|
||||
sessionId: state.sessionId,
|
||||
error: 'Token not found after authentication',
|
||||
});
|
||||
}
|
||||
|
||||
handleTokenNotFound(provider, callbackPort);
|
||||
resolve(null);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -89,14 +89,16 @@ export async function handleHelpCommand(): Promise<void> {
|
||||
// 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, {
|
||||
@@ -211,6 +213,7 @@ Claude Code Profile & Model Switcher`.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'],
|
||||
@@ -286,6 +289,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
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 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,
|
||||
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
|
||||
*/
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
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
|
||||
*/
|
||||
export function isFirstTimeInstall(): boolean {
|
||||
// No config at all → definitely first time
|
||||
if (!hasUnifiedConfig()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
await initUI();
|
||||
|
||||
const options = parseArgs(args);
|
||||
|
||||
if (options.help) {
|
||||
showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
await runSetupWizard(options.force);
|
||||
}
|
||||
@@ -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('');
|
||||
|
||||
@@ -184,7 +184,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
enabled:
|
||||
partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled,
|
||||
host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host,
|
||||
// Port 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 ??
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,8 +301,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();
|
||||
|
||||
@@ -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 <name>', '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('');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<SidebarProvider>
|
||||
@@ -64,6 +67,19 @@ export function Layout() {
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Global device code dialog for Device Code OAuth flows (GitHub Copilot, Qwen) */}
|
||||
{deviceCode.prompt && (
|
||||
<DeviceCodeDialog
|
||||
open={deviceCode.isOpen}
|
||||
onClose={deviceCode.onClose}
|
||||
sessionId={deviceCode.prompt.sessionId}
|
||||
provider={deviceCode.prompt.provider}
|
||||
userCode={deviceCode.prompt.userCode}
|
||||
verificationUrl={deviceCode.prompt.verificationUrl}
|
||||
expiresAt={deviceCode.prompt.expiresAt}
|
||||
/>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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<string, string> = {
|
||||
ghcp: 'GitHub Copilot',
|
||||
qwen: 'Qwen Code',
|
||||
};
|
||||
|
||||
/** Provider specific instructions */
|
||||
const PROVIDER_INSTRUCTIONS: Record<string, string> = {
|
||||
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<number | null>(null);
|
||||
|
||||
// Calculate and update remaining time, stop at 0
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | 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();
|
||||
timer = setInterval(updateTime, 1000);
|
||||
|
||||
return () => {
|
||||
if (timer) 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')}`;
|
||||
};
|
||||
|
||||
const isExpired = timeRemaining === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-md" data-session-id={sessionId}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5" />
|
||||
Authorize {providerDisplay}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the code below at the authorization page.
|
||||
{timeRemaining !== null && timeRemaining > 0 && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
(Expires in {formatTime(timeRemaining)})
|
||||
</span>
|
||||
)}
|
||||
{isExpired && <span className="text-destructive ml-1 font-medium">(Code expired)</span>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Large code display */}
|
||||
<div className="relative">
|
||||
<div className="bg-muted rounded-lg p-6 text-center">
|
||||
<div className="text-4xl font-mono font-bold tracking-[0.3em] text-foreground select-all">
|
||||
{userCode}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={handleCopyCode}
|
||||
aria-label={hasCopied ? 'Code copied' : 'Copy verification code'}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="text-sm text-muted-foreground text-center">
|
||||
<p>{instructions}</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button onClick={handleOpenUrl} className="w-full">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Open {providerDisplay.split(' ')[0]}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleCopyCode} className="w-full">
|
||||
{hasCopied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2 text-green-500" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy Code
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Waiting indicator */}
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Waiting for authorization...</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, string> = {
|
||||
ghcp: 'GitHub Copilot',
|
||||
qwen: 'Qwen Code',
|
||||
};
|
||||
|
||||
export function useDeviceCode() {
|
||||
const [state, setState] = useState<DeviceCodeState>({
|
||||
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]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user