mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 00:16:46 +00:00
Merge pull request #217 from kaitranntt/dev
feat: auth profile management, Kiro import, and config recovery
This commit is contained in:
@@ -8,10 +8,22 @@ CLI wrapper for instant switching between multiple Claude accounts and alternati
|
||||
|
||||
## Design Principles (ENFORCE STRICTLY)
|
||||
|
||||
### Technical Excellence
|
||||
- **YAGNI**: No features "just in case"
|
||||
- **KISS**: Simple bash/PowerShell/Node.js only
|
||||
- **DRY**: One source of truth (config.json)
|
||||
- **CLI-First**: All features must have CLI interface
|
||||
- **DRY**: One source of truth (config.yaml)
|
||||
|
||||
### User Experience (EQUALLY IMPORTANT)
|
||||
- **CLI-Complete**: All features MUST have CLI interface
|
||||
- **Dashboard-Parity**: Configuration features MUST also have Dashboard interface
|
||||
- **Execution is CLI**: Running profiles happens via terminal, not dashboard buttons
|
||||
- **UX > Brevity**: Error messages and help text prioritize user success over terseness
|
||||
- **Progressive Disclosure**: Simple by default, power features accessible but not overwhelming
|
||||
|
||||
### When Principles Conflict
|
||||
- **UX > YAGNI** for user-facing features (if users need it, it's not "just in case")
|
||||
- **KISS applies to BOTH** code AND user experience (simple journey, not just simple code)
|
||||
- **DRY applies to BOTH** code AND interface patterns (consistent behavior across CLI/Dashboard)
|
||||
|
||||
## Common Mistakes (AVOID)
|
||||
|
||||
@@ -89,6 +101,20 @@ bun run validate # Step 3: Final check (must pass)
|
||||
4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically
|
||||
5. **CLI documentation** - ALL changes MUST update `--help` in src/ccs.ts, lib/ccs, lib/ccs.ps1
|
||||
6. **Idempotent** - All install operations safe to run multiple times
|
||||
7. **Dashboard parity** - Configuration features MUST work in both CLI and Dashboard
|
||||
|
||||
## Feature Interface Requirements
|
||||
|
||||
| Feature Type | CLI | Dashboard | Example |
|
||||
|--------------|-----|-----------|---------|
|
||||
| Profile creation | ✓ | ✓ | `ccs auth create`, Dashboard "Add Account" |
|
||||
| Profile switching | ✓ | ✓ | `ccs <profile>` (execution is CLI-only) |
|
||||
| API key config | ✓ | ✓ | `ccs api create`, Dashboard API Profiles |
|
||||
| Health check | ✓ | ✓ | `ccs doctor`, Dashboard Live Monitor |
|
||||
| OAuth auth flow | ✓ | ✓ | Browser opens from CLI or Dashboard |
|
||||
| Analytics/monitoring | ✗ | ✓ | Dashboard Analytics (visual by nature) |
|
||||
| WebSearch config | ✓ | ✓ | CLI flags, Dashboard Settings |
|
||||
| Remote proxy config | ✓ | ✓ | CLI flags, Dashboard Settings |
|
||||
|
||||
## File Structure
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.8.0",
|
||||
"version": "7.8.0-dev.4",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, loadConfig } from '../../utils/config-manager';
|
||||
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
|
||||
|
||||
@@ -20,7 +20,7 @@ export function apiProfileExists(name: string): boolean {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return name in config.profiles;
|
||||
}
|
||||
const config = loadConfig();
|
||||
const config = loadConfigSafe();
|
||||
return name in config.profiles;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -79,7 +79,7 @@ export function listApiProfiles(): ApiListResult {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const config = loadConfig();
|
||||
const config = loadConfigSafe();
|
||||
for (const [name, settingsPath] of Object.entries(config.profiles)) {
|
||||
// Skip 'default' profile - it's the user's native Claude settings
|
||||
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
|
||||
@@ -116,7 +116,7 @@ export function getApiProfileNames(): string[] {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return Object.keys(config.profiles);
|
||||
}
|
||||
const config = loadConfig();
|
||||
const config = loadConfigSafe();
|
||||
return Object.keys(config.profiles);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, getConfigPath, loadConfig } from '../../utils/config-manager';
|
||||
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
@@ -166,7 +166,7 @@ function removeApiProfileUnified(name: string): void {
|
||||
|
||||
/** Remove API profile from legacy config */
|
||||
function removeApiProfileLegacy(name: string): void {
|
||||
const config = loadConfig();
|
||||
const config = loadConfigSafe();
|
||||
delete config.profiles[name];
|
||||
|
||||
const configPath = getConfigPath();
|
||||
|
||||
+18
-10
@@ -291,6 +291,24 @@ async function main(): Promise<void> {
|
||||
await autoMigrate();
|
||||
}
|
||||
|
||||
// Auto-recovery for missing configuration (BEFORE any early-exit commands)
|
||||
// This ensures ALL commands benefit from auto-recovery, not just profile-switching flow
|
||||
// Recovery is safe to run early - it only creates missing files with safe defaults
|
||||
// Wrapped in try-catch to prevent blocking --version/--help on permission errors
|
||||
try {
|
||||
const RecoveryManagerModule = await import('./management/recovery-manager');
|
||||
const RecoveryManager = RecoveryManagerModule.default;
|
||||
const recovery = new RecoveryManager();
|
||||
const recovered = recovery.recoverAll();
|
||||
|
||||
if (recovered) {
|
||||
recovery.showRecoveryHints();
|
||||
}
|
||||
} catch (err) {
|
||||
// Recovery is best-effort - don't block basic CLI functionality
|
||||
console.warn('[!] Recovery failed:', (err as Error).message);
|
||||
}
|
||||
|
||||
// Special case: version command (check BEFORE profile detection)
|
||||
if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') {
|
||||
handleVersionCommand();
|
||||
@@ -455,16 +473,6 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-recovery for missing configuration
|
||||
const RecoveryManagerModule = await import('./management/recovery-manager');
|
||||
const RecoveryManager = RecoveryManagerModule.default;
|
||||
const recovery = new RecoveryManager();
|
||||
const recovered = recovery.recoverAll();
|
||||
|
||||
if (recovered) {
|
||||
recovery.showRecoveryHints();
|
||||
}
|
||||
|
||||
// First-time install: offer setup wizard for interactive users
|
||||
// Check independently of recovery status (user may have empty config.yaml)
|
||||
// Skip if headless, CI, or non-TTY environment
|
||||
|
||||
@@ -173,4 +173,6 @@ export interface OAuthOptions {
|
||||
fromUI?: boolean;
|
||||
/** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */
|
||||
noIncognito?: boolean;
|
||||
/** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */
|
||||
import?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Kiro Import Helper
|
||||
*
|
||||
* Imports Kiro token from Kiro IDE when OAuth callback redirects to IDE instead of CLI.
|
||||
* Spawns cli-proxy-api-plus --kiro-import to import token from Kiro IDE's storage.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { info, ok, fail } from '../../utils/ui';
|
||||
import { ensureCLIProxyBinary } from '../binary-manager';
|
||||
import { generateConfig } from '../config-generator';
|
||||
import { getProviderTokenDir } from './token-manager';
|
||||
|
||||
export interface KiroImportResult {
|
||||
success: boolean;
|
||||
provider?: string;
|
||||
email?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to import Kiro token from Kiro IDE
|
||||
* Uses cli-proxy-api-plus --kiro-import flag
|
||||
*/
|
||||
export async function tryKiroImport(tokenDir: string, verbose = false): Promise<KiroImportResult> {
|
||||
const log = (msg: string) => {
|
||||
if (verbose) console.error(`[kiro-import] ${msg}`);
|
||||
};
|
||||
|
||||
try {
|
||||
log('Ensuring CLIProxy binary is available...');
|
||||
const binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
const configPath = generateConfig('kiro');
|
||||
|
||||
log(`Binary: ${binaryPath}`);
|
||||
log(`Config: ${configPath}`);
|
||||
log(`Token dir: ${tokenDir}`);
|
||||
|
||||
return new Promise<KiroImportResult>((resolve) => {
|
||||
const args = ['--config', configPath, '--kiro-import'];
|
||||
|
||||
log(`Running: ${binaryPath} ${args.join(' ')}`);
|
||||
|
||||
const proc = spawn(binaryPath, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let resolved = false;
|
||||
|
||||
const safeResolve = (result: KiroImportResult) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdout += output;
|
||||
log(`stdout: ${output.trim()}`);
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stderr += output;
|
||||
log(`stderr: ${output.trim()}`);
|
||||
});
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
log(`Exit code: ${code}`);
|
||||
|
||||
if (code === 0) {
|
||||
// Parse output for provider info
|
||||
const providerMatch = stdout.match(/Provider:\s*(\w+)/i);
|
||||
const emailMatch = stdout.match(/email[:\s]+([^\s,)]+)/i);
|
||||
const successMatch =
|
||||
stdout.includes('Kiro token import successful') ||
|
||||
stdout.includes('Imported Kiro token') ||
|
||||
stdout.includes('Authentication saved');
|
||||
|
||||
if (successMatch) {
|
||||
safeResolve({
|
||||
success: true,
|
||||
provider: providerMatch?.[1],
|
||||
email: emailMatch?.[1],
|
||||
});
|
||||
} else {
|
||||
safeResolve({
|
||||
success: false,
|
||||
error: 'Import completed but token not confirmed',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const errorLine = stderr.trim().split('\n')[0] || stdout.trim().split('\n')[0];
|
||||
safeResolve({
|
||||
success: false,
|
||||
error: errorLine || `Exit code ${code}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
log(`Process error: ${error.message}`);
|
||||
safeResolve({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
// Timeout after 30 seconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!resolved && !proc.killed) {
|
||||
proc.kill();
|
||||
safeResolve({
|
||||
success: false,
|
||||
error: 'Import timed out after 30 seconds',
|
||||
});
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
} catch (error) {
|
||||
log(`Error: ${(error as Error).message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import Kiro token with user-facing output
|
||||
* Shows progress and result to user
|
||||
*/
|
||||
export async function importKiroToken(verbose = false): Promise<boolean> {
|
||||
const tokenDir = getProviderTokenDir('kiro');
|
||||
|
||||
console.log('');
|
||||
console.log(info('Importing token from Kiro IDE...'));
|
||||
|
||||
const result = await tryKiroImport(tokenDir, verbose);
|
||||
|
||||
if (result.success) {
|
||||
const providerInfo = result.provider ? ` (Provider: ${result.provider})` : '';
|
||||
console.log(ok(`Imported Kiro token from IDE${providerInfo}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(fail(`Import failed: ${result.error}`));
|
||||
console.log('');
|
||||
console.log('Make sure you are logged into Kiro IDE first:');
|
||||
console.log(' 1. Open Kiro IDE');
|
||||
console.log(' 2. Sign in with your AWS/Google account');
|
||||
console.log(' 3. Run: ccs kiro --import');
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -27,8 +27,9 @@ import {
|
||||
} from '../../management/oauth-port-diagnostics';
|
||||
import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types';
|
||||
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
|
||||
import { getProviderTokenDir, isAuthenticated } from './token-manager';
|
||||
import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager';
|
||||
import { executeOAuthProcess } from './oauth-process';
|
||||
import { importKiroToken } from './kiro-import';
|
||||
|
||||
/**
|
||||
* Prompt user to add another account
|
||||
@@ -127,6 +128,17 @@ export async function triggerOAuth(
|
||||
): Promise<AccountInfo | null> {
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
const { verbose = false, add = false, nickname, fromUI = false, noIncognito = true } = options;
|
||||
|
||||
// Handle --import flag: skip OAuth and import from Kiro IDE directly
|
||||
if (options.import && provider === 'kiro') {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
const success = await importKiroToken(verbose);
|
||||
if (success) {
|
||||
return registerAccountFromToken(provider, tokenDir, nickname);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const callbackPort = OAUTH_PORTS[provider];
|
||||
const isCLI = !fromUI;
|
||||
const headless = options.headless ?? isHeadlessEnvironment();
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { ok, fail, info } from '../../utils/ui';
|
||||
import { ok, fail, info, warn } from '../../utils/ui';
|
||||
import { tryKiroImport } from './kiro-import';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { AccountInfo } from '../account-manager';
|
||||
import {
|
||||
@@ -205,7 +206,35 @@ function displayUrlFromStderr(
|
||||
}
|
||||
|
||||
/** Handle token not found after successful process exit */
|
||||
function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number | null): void {
|
||||
async function handleTokenNotFound(
|
||||
provider: CLIProxyProvider,
|
||||
callbackPort: number | null,
|
||||
tokenDir: string,
|
||||
nickname: string | undefined,
|
||||
verbose: boolean
|
||||
): Promise<AccountInfo | null> {
|
||||
// Kiro-specific: Try auto-import from Kiro IDE
|
||||
if (provider === 'kiro') {
|
||||
console.log('');
|
||||
console.log(warn('Callback redirected to Kiro IDE. Attempting to import token...'));
|
||||
|
||||
const result = await tryKiroImport(tokenDir, verbose);
|
||||
|
||||
if (result.success) {
|
||||
const providerInfo = result.provider ? ` (Provider: ${result.provider})` : '';
|
||||
console.log(ok(`Imported Kiro token from IDE${providerInfo}`));
|
||||
return registerAccountFromToken(provider, tokenDir, nickname);
|
||||
}
|
||||
|
||||
console.log(fail(`Auto-import failed: ${result.error}`));
|
||||
console.log('');
|
||||
console.log('To manually import from Kiro IDE:');
|
||||
console.log(' 1. Ensure you are logged into Kiro IDE');
|
||||
console.log(' 2. Run: ccs kiro --import');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Default behavior for other providers
|
||||
console.log('');
|
||||
console.log(fail('Token not found after authentication'));
|
||||
console.log('');
|
||||
@@ -225,6 +254,7 @@ function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number |
|
||||
|
||||
console.log('');
|
||||
console.log(`Try: ccs ${provider} --auth --verbose`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Handle process exit with error */
|
||||
@@ -356,7 +386,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
|
||||
authProcess.on('exit', (code) => {
|
||||
authProcess.on('exit', async (code) => {
|
||||
clearTimeout(timeout);
|
||||
// H5: Remove signal handlers to prevent memory leaks
|
||||
process.removeListener('SIGINT', cleanup);
|
||||
@@ -383,8 +413,15 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
});
|
||||
}
|
||||
|
||||
handleTokenNotFound(provider, callbackPort);
|
||||
resolve(null);
|
||||
// Try auto-import for Kiro, show error for others
|
||||
const account = await handleTokenNotFound(
|
||||
provider,
|
||||
callbackPort,
|
||||
tokenDir,
|
||||
nickname,
|
||||
verbose
|
||||
);
|
||||
resolve(account);
|
||||
}
|
||||
} else {
|
||||
// Emit device code failure event for UI
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
* Pattern: Mirrors npm install behavior (fast check, download only when needed)
|
||||
*/
|
||||
|
||||
import { info } from '../utils/ui';
|
||||
import { getBinDir } from './config-generator';
|
||||
import { info, warn } from '../utils/ui';
|
||||
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
import { BinaryInfo, BinaryManagerConfig } from './types';
|
||||
import { CLIPROXY_FALLBACK_VERSION } from './platform-detector';
|
||||
import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service';
|
||||
import { waitForPortFree } from '../utils/port-utils';
|
||||
import {
|
||||
UpdateCheckResult,
|
||||
checkForUpdates,
|
||||
@@ -108,12 +110,32 @@ export function getInstalledCliproxyVersion(): string {
|
||||
/** Install a specific version of CLIProxyAPI */
|
||||
export async function installCliproxyVersion(version: string, verbose = false): Promise<void> {
|
||||
const manager = new BinaryManager({ version, verbose, forceVersion: true });
|
||||
|
||||
// Check if proxy is running and stop it first
|
||||
if (isProxyRunning()) {
|
||||
if (verbose) console.log(info('Stopping running CLIProxy before update...'));
|
||||
const result = await stopProxy();
|
||||
if (result.stopped) {
|
||||
// Wait for port to be fully released
|
||||
const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000);
|
||||
if (!portFree && verbose) {
|
||||
console.log(warn('Port did not free up in time, proceeding anyway...'));
|
||||
}
|
||||
} else if (verbose && result.error) {
|
||||
console.log(warn(`Could not stop proxy: ${result.error}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (manager.isBinaryInstalled()) {
|
||||
if (verbose)
|
||||
console.log(info(`Removing existing CLIProxy Plus v${getInstalledCliproxyVersion()}`));
|
||||
manager.deleteBinary();
|
||||
}
|
||||
await manager.ensureBinary();
|
||||
|
||||
if (verbose) {
|
||||
console.log(info('New version will be active on next CLIProxy command'));
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the latest CLIProxyAPI version from GitHub API */
|
||||
|
||||
@@ -17,7 +17,7 @@ import * as net from 'net';
|
||||
import { ProgressIndicator } from '../utils/progress-indicator';
|
||||
import { ok, fail, info, warn } from '../utils/ui';
|
||||
import { escapeShellArg } from '../utils/shell-executor';
|
||||
import { ensureCLIProxyBinary } from './binary-manager';
|
||||
import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from './binary-manager';
|
||||
import {
|
||||
generateConfig,
|
||||
getEffectiveEnvVars,
|
||||
@@ -48,7 +48,12 @@ import {
|
||||
installWebSearchHook,
|
||||
displayWebSearchStatus,
|
||||
} from '../utils/websearch-manager';
|
||||
import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker';
|
||||
import {
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
cleanupOrphanedSessions,
|
||||
stopProxy,
|
||||
} from './session-tracker';
|
||||
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
|
||||
import { withStartupLock } from './startup-lock';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
@@ -250,6 +255,8 @@ export async function execClaudeWithCLIProxy(
|
||||
const forceConfig = argsWithoutProxy.includes('--config');
|
||||
const addAccount = argsWithoutProxy.includes('--add');
|
||||
const showAccounts = argsWithoutProxy.includes('--accounts');
|
||||
// Kiro-specific: --import to import token from Kiro IDE directly
|
||||
const forceImport = argsWithoutProxy.includes('--import');
|
||||
// Kiro-specific: browser mode for OAuth
|
||||
// Default to normal browser (noIncognito=true) for reliability - incognito often fails on Linux
|
||||
// --incognito flag opts into incognito mode, --no-incognito is legacy (now default)
|
||||
@@ -362,6 +369,38 @@ export async function execClaudeWithCLIProxy(
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle --import: import token from Kiro IDE directly (Kiro only)
|
||||
if (forceImport) {
|
||||
if (provider !== 'kiro') {
|
||||
console.error(fail('--import is only available for Kiro'));
|
||||
console.error(` Run "ccs ${provider} --auth" to authenticate`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Validate flag conflicts
|
||||
if (forceAuth) {
|
||||
console.error(fail('Cannot use --import with --auth'));
|
||||
console.error(' --import: Import existing token from Kiro IDE');
|
||||
console.error(' --auth: Trigger new OAuth flow in browser');
|
||||
process.exit(1);
|
||||
}
|
||||
if (forceLogout) {
|
||||
console.error(fail('Cannot use --import with --logout'));
|
||||
process.exit(1);
|
||||
}
|
||||
const { triggerOAuth } = await import('./auth-handler');
|
||||
const authSuccess = await triggerOAuth(provider, {
|
||||
verbose,
|
||||
import: true,
|
||||
...(setNickname ? { nickname: setNickname } : {}),
|
||||
});
|
||||
if (!authSuccess) {
|
||||
console.error(fail('Failed to import Kiro token from IDE'));
|
||||
console.error(' Make sure you are logged into Kiro IDE first');
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Ensure OAuth completed (if provider requires it)
|
||||
if (providerConfig.requiresOAuth) {
|
||||
log(`Checking authentication for ${provider}`);
|
||||
@@ -429,9 +468,33 @@ export async function execClaudeWithCLIProxy(
|
||||
// Use startup lock to coordinate with other CCS processes
|
||||
await withStartupLock(async () => {
|
||||
// Detect running proxy using multiple methods (HTTP, session-lock, port-process)
|
||||
const proxyStatus = await detectRunningProxy(cfg.port);
|
||||
let proxyStatus = await detectRunningProxy(cfg.port);
|
||||
log(`Proxy detection: ${JSON.stringify(proxyStatus)}`);
|
||||
|
||||
// Check for version mismatch - restart proxy if installed version differs from running
|
||||
if (proxyStatus.running && proxyStatus.verified && proxyStatus.version) {
|
||||
const installedVersion = getInstalledCliproxyVersion();
|
||||
if (installedVersion !== proxyStatus.version) {
|
||||
console.log(
|
||||
warn(
|
||||
`Version mismatch: running v${proxyStatus.version}, installed v${installedVersion}. Restarting proxy...`
|
||||
)
|
||||
);
|
||||
log(`Stopping outdated proxy (PID: ${proxyStatus.pid ?? 'unknown'})...`);
|
||||
const stopResult = await stopProxy(cfg.port);
|
||||
if (stopResult.stopped) {
|
||||
log(`Stopped outdated proxy successfully`);
|
||||
} else {
|
||||
log(`Stop proxy result: ${stopResult.error ?? 'unknown error'}`);
|
||||
}
|
||||
// Wait for port to be released
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
// Re-detect proxy status (should now be not running)
|
||||
proxyStatus = await detectRunningProxy(cfg.port);
|
||||
log(`Re-detection after version mismatch restart: ${JSON.stringify(proxyStatus)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (proxyStatus.running && proxyStatus.verified) {
|
||||
// Healthy proxy found - join it
|
||||
if (proxyStatus.pid) {
|
||||
@@ -542,9 +605,12 @@ export async function execClaudeWithCLIProxy(
|
||||
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// Register this session with the new proxy
|
||||
sessionId = registerSession(cfg.port, proxy.pid as number);
|
||||
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`);
|
||||
// Register this session with the new proxy, including the installed version
|
||||
const installedVersion = getInstalledCliproxyVersion();
|
||||
sessionId = registerSession(cfg.port, proxy.pid as number, installedVersion);
|
||||
log(
|
||||
`Registered session ${sessionId} with new proxy (PID ${proxy.pid}, version ${installedVersion})`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -604,6 +670,7 @@ export async function execClaudeWithCLIProxy(
|
||||
'--nickname',
|
||||
'--incognito',
|
||||
'--no-incognito',
|
||||
'--import',
|
||||
// Proxy flags are handled by resolveProxyConfig, but list for documentation
|
||||
...PROXY_CLI_FLAGS,
|
||||
];
|
||||
|
||||
@@ -129,9 +129,10 @@ export function getConfigPathForPort(port: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config file path (default port)
|
||||
* Get CLIProxy config file path (default port)
|
||||
* Named distinctly from config-manager's getConfigPath to avoid confusion.
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
export function getCliproxyConfigPath(): string {
|
||||
return getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
|
||||
@@ -377,7 +378,7 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
* @returns true if config should be regenerated
|
||||
*/
|
||||
export function configNeedsRegeneration(): boolean {
|
||||
const configPath = getConfigPath();
|
||||
const configPath = getCliproxyConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return false; // Will be created on first use
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export {
|
||||
getCliproxyDir,
|
||||
getProviderAuthDir,
|
||||
getAuthDir,
|
||||
getConfigPath,
|
||||
getCliproxyConfigPath,
|
||||
getBinDir,
|
||||
configExists,
|
||||
deleteConfig,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getConfigPath } from './config-generator';
|
||||
import { getCliproxyConfigPath } from './config-generator';
|
||||
|
||||
/** Model alias configuration */
|
||||
export interface OpenAICompatModel {
|
||||
@@ -48,7 +48,7 @@ interface ConfigYaml {
|
||||
* Load current config.yaml
|
||||
*/
|
||||
function loadConfig(): ConfigYaml {
|
||||
const configPath = getConfigPath();
|
||||
const configPath = getCliproxyConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
@@ -65,7 +65,7 @@ function loadConfig(): ConfigYaml {
|
||||
* Save config.yaml with proper formatting
|
||||
*/
|
||||
function saveConfig(config: ConfigYaml): void {
|
||||
const configPath = getConfigPath();
|
||||
const configPath = getCliproxyConfigPath();
|
||||
const content = yaml.dump(config, {
|
||||
lineWidth: -1, // Disable line wrapping
|
||||
quotingType: '"',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* Solves race conditions between cliproxy-executor.ts and service-manager.ts
|
||||
*/
|
||||
|
||||
import { getExistingProxy, registerSession } from './session-tracker';
|
||||
import { getExistingProxy, registerSession, getRunningProxyVersion } from './session-tracker';
|
||||
import { isCliproxyRunning } from './stats-fetcher';
|
||||
import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils';
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface ProxyStatus {
|
||||
blocker?: PortProcess;
|
||||
/** Number of active sessions (if session-lock found) */
|
||||
sessionCount?: number;
|
||||
/** Version of the running proxy (from session lock) */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/** Optional logger function for verbose output */
|
||||
@@ -86,13 +88,19 @@ export async function detectRunningProxy(
|
||||
}
|
||||
}
|
||||
|
||||
log(`HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'})`);
|
||||
// Get version from session lock
|
||||
const runningVersion = getRunningProxyVersion(port);
|
||||
|
||||
log(
|
||||
`HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'}, version: ${runningVersion ?? 'unknown'})`
|
||||
);
|
||||
return {
|
||||
running: true,
|
||||
verified: true,
|
||||
method: 'http',
|
||||
pid,
|
||||
sessionCount: lock?.sessions?.length,
|
||||
version: runningVersion ?? undefined,
|
||||
};
|
||||
}
|
||||
log('HTTP check failed, proxy not responding');
|
||||
@@ -103,13 +111,17 @@ export async function detectRunningProxy(
|
||||
if (lock) {
|
||||
// Session lock exists - proxy might be starting up
|
||||
// The lock validates PID is running, so proxy exists but not ready
|
||||
log(`Session lock found: PID ${lock.pid}, ${lock.sessions.length} sessions`);
|
||||
const lockVersion = getRunningProxyVersion(port);
|
||||
log(
|
||||
`Session lock found: PID ${lock.pid}, ${lock.sessions.length} sessions, version: ${lockVersion ?? 'unknown'}`
|
||||
);
|
||||
return {
|
||||
running: true,
|
||||
verified: false,
|
||||
method: 'session-lock',
|
||||
pid: lock.pid,
|
||||
sessionCount: lock.sessions.length,
|
||||
version: lockVersion ?? undefined,
|
||||
};
|
||||
}
|
||||
log('No session lock found');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { getConfigPath, loadConfig } from '../../utils/config-manager';
|
||||
import { getConfigPath, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
@@ -38,7 +38,7 @@ export function variantExistsInConfig(name: string): boolean {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return !!(config.cliproxy?.variants && name in config.cliproxy.variants);
|
||||
}
|
||||
const config = loadConfig();
|
||||
const config = loadConfigSafe();
|
||||
return !!(config.cliproxy && name in config.cliproxy);
|
||||
} catch {
|
||||
return false;
|
||||
@@ -94,7 +94,7 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
|
||||
return result;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const config = loadConfigSafe();
|
||||
const variants = config.cliproxy || {};
|
||||
const result: Record<string, VariantConfig> = {};
|
||||
for (const name of Object.keys(variants)) {
|
||||
|
||||
@@ -27,6 +27,8 @@ interface SessionLock {
|
||||
pid: number;
|
||||
sessions: string[];
|
||||
startedAt: string;
|
||||
/** CLIProxy version running (added for version mismatch detection) */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/** Generate unique session ID */
|
||||
@@ -125,6 +127,23 @@ function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a process to exit within a timeout.
|
||||
* @param pid Process ID to wait for
|
||||
* @param timeoutMs Maximum time to wait in milliseconds
|
||||
* @returns true if process exited, false if timeout
|
||||
*/
|
||||
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return true; // Process exited
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return false; // Timeout
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's an existing proxy running that we can reuse.
|
||||
* Returns the existing lock if proxy is healthy, null otherwise.
|
||||
@@ -153,9 +172,12 @@ export function getExistingProxy(port: number): SessionLock | null {
|
||||
/**
|
||||
* Register a new session with the proxy.
|
||||
* Call this when starting a new CCS session that will use an existing proxy.
|
||||
* @param port Port the proxy is running on
|
||||
* @param proxyPid PID of the proxy process
|
||||
* @param version Optional CLIProxy version (stored when spawning new proxy)
|
||||
* @returns Session ID for this session
|
||||
*/
|
||||
export function registerSession(port: number, proxyPid: number): string {
|
||||
export function registerSession(port: number, proxyPid: number, version?: string): string {
|
||||
const sessionId = generateSessionId();
|
||||
const existingLock = readSessionLockForPort(port);
|
||||
|
||||
@@ -170,6 +192,7 @@ export function registerSession(port: number, proxyPid: number): string {
|
||||
pid: proxyPid,
|
||||
sessions: [sessionId],
|
||||
startedAt: new Date().toISOString(),
|
||||
version,
|
||||
};
|
||||
writeSessionLockForPort(newLock);
|
||||
}
|
||||
@@ -315,6 +338,19 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
||||
// Found CLIProxy running without session lock - kill it
|
||||
try {
|
||||
process.kill(portProcess.pid, 'SIGTERM');
|
||||
|
||||
// Wait for graceful shutdown
|
||||
const exited = await waitForProcessExit(portProcess.pid, 3000);
|
||||
if (!exited) {
|
||||
// Escalate to SIGKILL
|
||||
try {
|
||||
process.kill(portProcess.pid, 'SIGKILL');
|
||||
await waitForProcessExit(portProcess.pid, 1000);
|
||||
} catch {
|
||||
// Process may have exited between check and kill
|
||||
}
|
||||
}
|
||||
|
||||
return { stopped: true, pid: portProcess.pid, sessionCount: 0 };
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
@@ -338,6 +374,18 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
||||
// Kill the proxy process
|
||||
process.kill(pid, 'SIGTERM');
|
||||
|
||||
// Wait for graceful shutdown
|
||||
const exited = await waitForProcessExit(pid, 3000);
|
||||
if (!exited) {
|
||||
// Escalate to SIGKILL
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
await waitForProcessExit(pid, 1000);
|
||||
} catch {
|
||||
// Process may have exited between check and kill
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up session lock
|
||||
deleteSessionLockForPort(port);
|
||||
|
||||
@@ -362,6 +410,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
|
||||
pid?: number;
|
||||
sessionCount?: number;
|
||||
startedAt?: string;
|
||||
version?: string;
|
||||
} {
|
||||
const lock = readSessionLockForPort(port);
|
||||
|
||||
@@ -381,5 +430,26 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
|
||||
pid: lock.pid,
|
||||
sessionCount: lock.sessions.length,
|
||||
startedAt: lock.startedAt,
|
||||
version: lock.version,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version of the running proxy from session lock.
|
||||
* @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT)
|
||||
* @returns Version string if available, null otherwise
|
||||
*/
|
||||
export function getRunningProxyVersion(port: number = CLIPROXY_DEFAULT_PORT): string | null {
|
||||
const lock = readSessionLockForPort(port);
|
||||
if (!lock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify proxy is still running
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
deleteSessionLockForPort(port);
|
||||
return null;
|
||||
}
|
||||
|
||||
return lock.version ?? null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui';
|
||||
import { isUnifiedMode } from '../config/unified-config-loader';
|
||||
|
||||
// Get version from package.json (same as version-command.ts)
|
||||
const VERSION = JSON.parse(
|
||||
@@ -173,6 +174,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs <provider> --config', 'Change model (agy, gemini)'],
|
||||
['ccs <provider> --logout', 'Clear authentication'],
|
||||
['ccs <provider> --headless', 'Headless auth (for SSH)'],
|
||||
['ccs kiro --import', 'Import token from Kiro IDE'],
|
||||
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
|
||||
['ccs codex "explain code"', 'Use with prompt'],
|
||||
]
|
||||
@@ -234,7 +236,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
|
||||
// Configuration
|
||||
printConfigSection('Configuration', [
|
||||
['Config File:', '~/.ccs/config.json'],
|
||||
['Config File:', isUnifiedMode() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'],
|
||||
['Profiles:', '~/.ccs/profiles.json'],
|
||||
['Instances:', '~/.ccs/instances/'],
|
||||
['Settings:', '~/.ccs/*.settings.json'],
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isCLIProxyInstalled,
|
||||
getCLIProxyPath,
|
||||
getAllAuthStatus,
|
||||
getConfigPath,
|
||||
getCliproxyConfigPath,
|
||||
getInstalledCliproxyVersion,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
configNeedsRegeneration,
|
||||
@@ -60,7 +60,7 @@ export class CLIProxyConfigChecker implements IHealthChecker {
|
||||
|
||||
run(results: HealthCheck): void {
|
||||
const spinner = ora('Checking CLIProxy config').start();
|
||||
const configPath = getConfigPath();
|
||||
const configPath = getCliproxyConfigPath();
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
// Check if config needs regeneration (version mismatch or missing features)
|
||||
|
||||
+4
-4
@@ -17,10 +17,10 @@ export interface ProfilesConfig {
|
||||
* Example: "flash" → gemini provider with gemini-2.5-flash model
|
||||
*/
|
||||
export interface CLIProxyVariantConfig {
|
||||
/** CLIProxy provider to use (gemini, codex, agy, qwen) */
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
|
||||
/** Path to settings.json with custom model configuration */
|
||||
settings: string;
|
||||
/** CLIProxy provider to use */
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp';
|
||||
/** Path to settings.json with custom model configuration (optional) */
|
||||
settings?: string;
|
||||
/** Account identifier for multi-account support (optional, defaults to 'default') */
|
||||
account?: string;
|
||||
/** Unique port for variant isolation (8318-8417) */
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
EnvValue,
|
||||
ProfileMetadata,
|
||||
ProfilesRegistry,
|
||||
CLIProxyVariantsConfig,
|
||||
} from './config';
|
||||
export { isConfig, isSettings } from './config';
|
||||
|
||||
|
||||
+94
-12
@@ -1,7 +1,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { Config, isConfig, Settings, isSettings } from '../types';
|
||||
import { Config, isConfig, Settings, isSettings, CLIProxyVariantsConfig } from '../types';
|
||||
import { expandPath, error } from './helpers';
|
||||
import { info } from './ui';
|
||||
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
@@ -27,12 +27,26 @@ export function getCcsDir(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config file path
|
||||
* Get config file path (legacy JSON path)
|
||||
* @deprecated Use getActiveConfigPath() for mode-aware config path
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
return process.env.CCS_CONFIG || path.join(getCcsHome(), '.ccs', 'config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active config file path based on current mode.
|
||||
* Returns config.yaml in unified mode, config.json in legacy mode.
|
||||
* @returns Path to the active config file
|
||||
*/
|
||||
export function getActiveConfigPath(): string {
|
||||
const ccsDir = getCcsDir();
|
||||
if (isUnifiedMode()) {
|
||||
return path.join(ccsDir, 'config.yaml');
|
||||
}
|
||||
return path.join(ccsDir, 'config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate config.json
|
||||
*/
|
||||
@@ -82,6 +96,69 @@ export function readConfig(): Config {
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config safely with unified mode support.
|
||||
* Returns Config with profiles from unified config.yaml or legacy config.json.
|
||||
* Throws on error (catchable) instead of process.exit.
|
||||
* Use this in web server routes where try/catch is needed.
|
||||
*/
|
||||
export function loadConfigSafe(): Config {
|
||||
// Unified mode: extract profiles from config.yaml
|
||||
if (isUnifiedMode()) {
|
||||
const unifiedConfig = loadOrCreateUnifiedConfig();
|
||||
|
||||
// Convert unified profiles to legacy format for compatibility
|
||||
const profiles: Record<string, string> = {};
|
||||
for (const [name, profile] of Object.entries(unifiedConfig.profiles)) {
|
||||
if (profile.settings) {
|
||||
profiles[name] = profile.settings;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert unified cliproxy variants to legacy format
|
||||
let cliproxy: CLIProxyVariantsConfig | undefined;
|
||||
if (unifiedConfig.cliproxy?.variants) {
|
||||
cliproxy = {};
|
||||
for (const [name, variant] of Object.entries(unifiedConfig.cliproxy.variants)) {
|
||||
cliproxy[name] = {
|
||||
provider: variant.provider,
|
||||
settings: variant.settings,
|
||||
account: variant.account,
|
||||
port: variant.port,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
profiles,
|
||||
cliproxy,
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy mode: read config.json
|
||||
const configPath = getConfigPath();
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
// Return empty config for graceful degradation (matches unified mode behavior)
|
||||
return { profiles: {} };
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error(`Malformed JSON in config: ${configPath} - ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
if (!isConfig(parsed)) {
|
||||
throw new Error(`Invalid config format: ${configPath}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings path for profile.
|
||||
* In unified mode (config.yaml exists), reads from config.yaml first,
|
||||
@@ -106,19 +183,24 @@ export function getSettingsPath(profile: string): string {
|
||||
|
||||
// If not found in unified config, try legacy config.json as fallback
|
||||
if (!settingsPath) {
|
||||
try {
|
||||
const legacyConfig = loadConfig();
|
||||
if (legacyConfig.profiles[profile]) {
|
||||
settingsPath = legacyConfig.profiles[profile];
|
||||
// Merge legacy profiles into available list (avoid duplicates)
|
||||
for (const p of Object.keys(legacyConfig.profiles)) {
|
||||
if (!availableProfiles.includes(p)) {
|
||||
availableProfiles.push(p);
|
||||
// Use inline safe logic - loadConfig() calls process.exit() which cannot be caught
|
||||
const configPath = getConfigPath();
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const legacyConfig: unknown = JSON.parse(raw);
|
||||
if (isConfig(legacyConfig) && legacyConfig.profiles[profile]) {
|
||||
settingsPath = legacyConfig.profiles[profile];
|
||||
// Merge legacy profiles into available list (avoid duplicates)
|
||||
for (const p of Object.keys(legacyConfig.profiles)) {
|
||||
if (!availableProfiles.includes(p)) {
|
||||
availableProfiles.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Legacy config is invalid JSON - that's OK in unified mode
|
||||
}
|
||||
} catch {
|
||||
// Legacy config doesn't exist or is invalid - that's OK in unified mode
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -181,6 +181,32 @@ export interface BindingTestResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a port to become free (no process listening)
|
||||
* @param port Port number to wait for
|
||||
* @param timeoutMs Maximum time to wait in milliseconds (default: 5000)
|
||||
* @param pollIntervalMs Interval between checks in milliseconds (default: 200)
|
||||
* @returns True if port became free, false if timeout
|
||||
*/
|
||||
export async function waitForPortFree(
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
pollIntervalMs = 200
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const process = await getPortProcess(port);
|
||||
if (!process) {
|
||||
return true; // Port is free
|
||||
}
|
||||
// Wait before next check
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
return false; // Timeout reached
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if we can bind to localhost on a specific port
|
||||
* This verifies network stack is working and port is actually available
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
isCLIProxyInstalled,
|
||||
getInstalledCliproxyVersion,
|
||||
getCLIProxyPath,
|
||||
getConfigPath as getCliproxyConfigPath,
|
||||
getCliproxyConfigPath,
|
||||
getAllAuthStatus,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from '../../cliproxy';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { loadConfig } from '../utils/config-manager';
|
||||
import { loadConfigSafe } from '../utils/config-manager';
|
||||
import { runHealthChecks } from './health-service';
|
||||
import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth-handler';
|
||||
import { getVersion } from '../utils/version';
|
||||
@@ -18,7 +18,7 @@ export const overviewRoutes = Router();
|
||||
*/
|
||||
overviewRoutes.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const config = loadConfigSafe();
|
||||
|
||||
const profileCount = Object.keys(config.profiles).length;
|
||||
const cliproxyVariantCount = Object.keys(config.cliproxy || {}).length;
|
||||
|
||||
@@ -82,4 +82,50 @@ router.post('/default', (req: Request, res: Response): void => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/accounts/reset-default - Reset to CCS default
|
||||
*/
|
||||
router.delete('/reset-default', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
if (isUnifiedMode()) {
|
||||
registry.clearDefaultUnified();
|
||||
} else {
|
||||
registry.clearDefaultProfile();
|
||||
}
|
||||
res.json({ success: true, default: null });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/accounts/:name - Delete an account
|
||||
*/
|
||||
router.delete('/:name', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Missing account name' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if trying to delete default
|
||||
const currentDefault = registry.getDefaultUnified() ?? registry.getDefaultProfile();
|
||||
if (name === currentDefault) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: 'Cannot delete the default account. Set a different default first.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the profile
|
||||
registry.deleteProfile(name);
|
||||
|
||||
res.json({ success: true, deleted: name });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
|
||||
import { getProviderTokenDir } from '../../cliproxy/auth/token-manager';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
|
||||
const router = Router();
|
||||
@@ -350,4 +352,52 @@ router.post('/project-selection/:sessionId', (req: Request, res: Response): void
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/auth/kiro/import - Import Kiro token from Kiro IDE
|
||||
* Alternative auth path when OAuth callback fails to redirect properly
|
||||
*/
|
||||
router.post('/kiro/import', async (_req: Request, res: Response): Promise<void> => {
|
||||
// Check if remote mode is enabled - import not available remotely
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(501).json({
|
||||
error: 'Kiro import not available in remote mode',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenDir = getProviderTokenDir('kiro');
|
||||
const result = await tryKiroImport(tokenDir, false);
|
||||
|
||||
if (result.success) {
|
||||
// Re-initialize accounts to pick up new token
|
||||
initializeAccounts();
|
||||
|
||||
// Get the newly added account
|
||||
const accounts = getProviderAccounts('kiro');
|
||||
const newAccount = accounts.find((a) => a.isDefault) || accounts[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
account: newAccount
|
||||
? {
|
||||
id: newAccount.id,
|
||||
email: newAccount.email,
|
||||
provider: 'kiro',
|
||||
isDefault: newAccount.isDefault,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error || 'Failed to import Kiro token',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '../../cliproxy/stats-fetcher';
|
||||
import {
|
||||
getCliproxyWritablePath,
|
||||
getConfigPath,
|
||||
getCliproxyConfigPath,
|
||||
getAuthDir,
|
||||
} from '../../cliproxy/config-generator';
|
||||
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
|
||||
@@ -272,7 +272,7 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise<voi
|
||||
*/
|
||||
router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const configPath = getConfigPath();
|
||||
const configPath = getCliproxyConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
res.status(404).json({ error: 'Config file not found' });
|
||||
return;
|
||||
@@ -299,7 +299,7 @@ router.put('/config.yaml', async (req: Request, res: Response): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = getConfigPath();
|
||||
const configPath = getCliproxyConfigPath();
|
||||
|
||||
// Ensure parent directory exists
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../../utils/config-manager';
|
||||
import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import type { Config, Settings } from '../../types/config';
|
||||
|
||||
@@ -17,11 +17,12 @@ export interface ModelMapping {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read config safely with fallback
|
||||
* Read config safely with fallback.
|
||||
* Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json).
|
||||
*/
|
||||
export function readConfigSafe(): Config {
|
||||
try {
|
||||
return loadConfig();
|
||||
return loadConfigSafe();
|
||||
} catch {
|
||||
return { profiles: {} };
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ process.env.CCS_HOME = testHome;
|
||||
|
||||
const {
|
||||
getConfigPathForPort,
|
||||
getConfigPath,
|
||||
getCliproxyConfigPath,
|
||||
generateConfig,
|
||||
regenerateConfig,
|
||||
configExists,
|
||||
@@ -98,9 +98,9 @@ describe('Config Generator Port', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfigPath', function () {
|
||||
describe('getCliproxyConfigPath', function () {
|
||||
it('returns path for default port', function () {
|
||||
const configPath = getConfigPath();
|
||||
const configPath = getCliproxyConfigPath();
|
||||
const defaultPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
|
||||
assert.strictEqual(configPath, defaultPath);
|
||||
});
|
||||
|
||||
@@ -362,7 +362,7 @@ auth-dir: "/test"
|
||||
let testDir;
|
||||
let originalCcsHome;
|
||||
let regenerateConfig;
|
||||
let getConfigPath;
|
||||
let getCliproxyConfigPath;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temporary test directory
|
||||
@@ -375,7 +375,7 @@ auth-dir: "/test"
|
||||
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
||||
const configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||
regenerateConfig = configGenerator.regenerateConfig;
|
||||
getConfigPath = configGenerator.getConfigPath;
|
||||
getCliproxyConfigPath = configGenerator.getCliproxyConfigPath;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -27,14 +27,7 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
let httpsRequests = [];
|
||||
|
||||
beforeAll(async function () {
|
||||
// Build the project first
|
||||
const { execSync } = require('child_process');
|
||||
try {
|
||||
execSync('bun run build', { cwd: path.resolve(__dirname, '../../..'), stdio: 'pipe' });
|
||||
} catch (error) {
|
||||
console.warn('Build failed, tests may not work:', error.message);
|
||||
}
|
||||
|
||||
// Note: Build is handled by CI before tests run (bun run build:all)
|
||||
// Import the built module
|
||||
updateCheckerModule = await import('../../../dist/utils/update-checker.js');
|
||||
});
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write src/ tests/",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Accounts Table Component
|
||||
* Phase 03: REST API Routes & CRUD
|
||||
* Dashboard parity: Full CRUD for auth profiles
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
|
||||
import {
|
||||
Table,
|
||||
@@ -13,17 +14,35 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useSetDefaultAccount } from '@/hooks/use-accounts';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Check, Trash2, RotateCcw } from 'lucide-react';
|
||||
import {
|
||||
useSetDefaultAccount,
|
||||
useDeleteAccount,
|
||||
useResetDefaultAccount,
|
||||
} from '@/hooks/use-accounts';
|
||||
import type { Account } from '@/lib/api-client';
|
||||
|
||||
interface AccountsTableProps {
|
||||
data: Account[];
|
||||
defaultAccount: string | null;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
const setDefaultMutation = useSetDefaultAccount();
|
||||
const deleteMutation = useDeleteAccount();
|
||||
const resetDefaultMutation = useResetDefaultAccount();
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
|
||||
const columns: ColumnDef<Account>[] = [
|
||||
{
|
||||
@@ -71,20 +90,34 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
size: 100,
|
||||
size: 180,
|
||||
cell: ({ row }) => {
|
||||
const isDefault = row.original.name === defaultAccount;
|
||||
const isPending = setDefaultMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={isDefault ? 'secondary' : 'default'}
|
||||
size="sm"
|
||||
className="h-8 px-2 w-full"
|
||||
disabled={isDefault || setDefaultMutation.isPending}
|
||||
onClick={() => setDefaultMutation.mutate(row.original.name)}
|
||||
>
|
||||
<Check className={`w-3 h-3 mr-1.5 ${isDefault ? 'opacity-50' : ''}`} />
|
||||
{isDefault ? 'Active' : 'Set Default'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={isDefault ? 'secondary' : 'default'}
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
disabled={isDefault || isPending}
|
||||
onClick={() => setDefaultMutation.mutate(row.original.name)}
|
||||
>
|
||||
<Check className={`w-3 h-3 mr-1 ${isDefault ? 'opacity-50' : ''}`} />
|
||||
{isDefault ? 'Active' : 'Set Default'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
disabled={isDefault || isPending}
|
||||
onClick={() => setDeleteTarget(row.original.name)}
|
||||
title={isDefault ? 'Cannot delete default account' : 'Delete account'}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -100,51 +133,97 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No accounts found. Use <code className="text-sm bg-muted px-1 rounded">ccs login</code> to
|
||||
add accounts.
|
||||
No accounts found. Use{' '}
|
||||
<code className="text-sm bg-muted px-1 rounded">ccs auth create</code> to add accounts.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const widthClass =
|
||||
{
|
||||
name: 'w-[200px]',
|
||||
type: 'w-[100px]',
|
||||
created: 'w-[150px]',
|
||||
last_used: 'w-[150px]',
|
||||
actions: 'w-[100px]',
|
||||
}[header.id] || 'w-auto';
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const widthClass =
|
||||
{
|
||||
name: 'w-[200px]',
|
||||
type: 'w-[100px]',
|
||||
created: 'w-[150px]',
|
||||
last_used: 'w-[150px]',
|
||||
actions: 'w-[180px]',
|
||||
}[header.id] || 'w-auto';
|
||||
|
||||
return (
|
||||
<TableHead key={header.id} className={widthClass}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
return (
|
||||
<TableHead key={header.id} className={widthClass}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Reset default button */}
|
||||
{defaultAccount && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetDefaultMutation.mutate()}
|
||||
disabled={resetDefaultMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reset to CCS Default
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Account</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the account "{deleteTarget}"? This will
|
||||
remove the profile and all its session data. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
if (deleteTarget) {
|
||||
deleteMutation.mutate(deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Add Account Dialog Component
|
||||
* Triggers OAuth flow server-side to add another account to a provider
|
||||
* Applies default preset when adding first account
|
||||
* For Kiro: Also shows "Import from IDE" option as fallback
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
@@ -15,8 +16,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, ExternalLink, User } from 'lucide-react';
|
||||
import { useStartAuth } from '@/hooks/use-cliproxy';
|
||||
import { Loader2, ExternalLink, User, Download } from 'lucide-react';
|
||||
import { useStartAuth, useKiroImport } from '@/hooks/use-cliproxy';
|
||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -38,6 +39,10 @@ export function AddAccountDialog({
|
||||
}: AddAccountDialogProps) {
|
||||
const [nickname, setNickname] = useState('');
|
||||
const startAuthMutation = useStartAuth();
|
||||
const kiroImportMutation = useKiroImport();
|
||||
|
||||
const isKiro = provider === 'kiro';
|
||||
const isPending = startAuthMutation.isPending || kiroImportMutation.isPending;
|
||||
|
||||
const handleStartAuth = () => {
|
||||
startAuthMutation.mutate(
|
||||
@@ -60,8 +65,24 @@ export function AddAccountDialog({
|
||||
);
|
||||
};
|
||||
|
||||
const handleKiroImport = () => {
|
||||
kiroImportMutation.mutate(undefined, {
|
||||
onSuccess: async () => {
|
||||
// Apply default preset if this is the first account
|
||||
if (isFirstAccount) {
|
||||
const result = await applyDefaultPreset('kiro');
|
||||
if (result.success && result.presetName) {
|
||||
toast.success(`Applied "${result.presetName}" preset`);
|
||||
}
|
||||
}
|
||||
setNickname('');
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen && !startAuthMutation.isPending) {
|
||||
if (!isOpen && !isPending) {
|
||||
setNickname('');
|
||||
onClose();
|
||||
}
|
||||
@@ -73,8 +94,9 @@ export function AddAccountDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add {displayName} Account</DialogTitle>
|
||||
<DialogDescription>
|
||||
Click the button below to authenticate a new account. A browser window will open for
|
||||
OAuth.
|
||||
{isKiro
|
||||
? 'Authenticate via browser or import an existing token from Kiro IDE.'
|
||||
: 'Click the button below to authenticate a new account. A browser window will open for OAuth.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -88,7 +110,7 @@ export function AddAccountDialog({
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
placeholder="e.g., work, personal"
|
||||
disabled={startAuthMutation.isPending}
|
||||
disabled={isPending}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,10 +120,25 @@ export function AddAccountDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={startAuthMutation.isPending}>
|
||||
<Button variant="ghost" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleStartAuth} disabled={startAuthMutation.isPending}>
|
||||
{isKiro && (
|
||||
<Button variant="outline" onClick={handleKiroImport} disabled={isPending}>
|
||||
{kiroImportMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Import from IDE
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleStartAuth} disabled={isPending}>
|
||||
{startAuthMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
@@ -121,6 +158,11 @@ export function AddAccountDialog({
|
||||
Complete the OAuth flow in your browser...
|
||||
</p>
|
||||
)}
|
||||
{kiroImportMutation.isPending && (
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Importing token from Kiro IDE...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Create Auth Profile Dialog
|
||||
* Shows CLI command for creating auth profiles (Dashboard cannot spawn Claude login directly)
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Copy, Check, Terminal } from 'lucide-react';
|
||||
|
||||
interface CreateAuthProfileDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) {
|
||||
const [profileName, setProfileName] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Validate profile name: alphanumeric, dash, underscore only
|
||||
const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName);
|
||||
const command = profileName ? `ccs auth create ${profileName}` : 'ccs auth create <name>';
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!isValidName) return;
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setProfileName('');
|
||||
setCopied(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Account</DialogTitle>
|
||||
<DialogDescription>
|
||||
Auth profiles require Claude CLI login. Run the command below in your terminal.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
placeholder="e.g., work, personal, client"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{profileName && !isValidName && (
|
||||
<p className="text-xs text-destructive">
|
||||
Name must start with a letter and contain only letters, numbers, dashes, or
|
||||
underscores.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Command</Label>
|
||||
<div className="flex items-center gap-2 p-3 bg-muted rounded-md font-mono text-sm">
|
||||
<Terminal className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<code className="flex-1 break-all">{command}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 h-8 px-2"
|
||||
onClick={handleCopy}
|
||||
disabled={!isValidName}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>After running the command:</p>
|
||||
<ol className="list-decimal list-inside pl-2 space-y-0.5">
|
||||
<li>Complete the Claude login in your browser</li>
|
||||
<li>Return here and refresh to see the new account</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleCopy} disabled={!isValidName}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy Command
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
// Main components
|
||||
export { AccountsTable } from './accounts-table';
|
||||
export { AddAccountDialog } from './add-account-dialog';
|
||||
export { CreateAuthProfileDialog } from './create-auth-profile-dialog';
|
||||
|
||||
// Flow visualization (from subdirectory)
|
||||
export { AccountFlowViz } from './flow-viz';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* React Query hooks for accounts (profiles.json)
|
||||
* Phase 03: REST API Routes & CRUD
|
||||
* Dashboard parity: Full CRUD operations for auth profiles
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -28,3 +28,33 @@ export function useSetDefaultAccount() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetDefaultAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.accounts.resetDefault(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
toast.success('Default account reset to CCS');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => api.accounts.delete(name),
|
||||
onSuccess: (_data, name) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
toast.success(`Account "${name}" deleted`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,6 +136,27 @@ export function useStartAuth() {
|
||||
});
|
||||
}
|
||||
|
||||
// Kiro IDE import hook (alternative auth path when OAuth callback fails)
|
||||
export function useKiroImport() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.cliproxy.auth.kiroImport(),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
if (data.account) {
|
||||
toast.success(`Imported Kiro account: ${data.account.email || data.account.id}`);
|
||||
} else {
|
||||
toast.success('Kiro token imported');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Stats and models hooks for Overview tab
|
||||
export function useCliproxyStats() {
|
||||
return useQuery({
|
||||
|
||||
@@ -331,6 +331,12 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ nickname }),
|
||||
}),
|
||||
/** Import Kiro token from Kiro IDE (Kiro only) */
|
||||
kiroImport: () =>
|
||||
request<{ success: boolean; account: OAuthAccount | null; error?: string }>(
|
||||
'/cliproxy/auth/kiro/import',
|
||||
{ method: 'POST' }
|
||||
),
|
||||
},
|
||||
// Error logs
|
||||
errorLogs: {
|
||||
@@ -351,6 +357,8 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
resetDefault: () => request('/accounts/reset-default', { method: 'DELETE' }),
|
||||
delete: (name: string) => request(`/accounts/${name}`, { method: 'DELETE' }),
|
||||
},
|
||||
// Unified config API
|
||||
config: {
|
||||
|
||||
+17
-11
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* Accounts Page
|
||||
* Phase 03: REST API Routes & CRUD
|
||||
* Dashboard parity: Auth profile CRUD operations
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { AccountsTable } from '@/components/account/accounts-table';
|
||||
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAccounts } from '@/hooks/use-accounts';
|
||||
|
||||
export function AccountsPage() {
|
||||
const { data, isLoading } = useAccounts();
|
||||
const { data, isLoading, refetch } = useAccounts();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
||||
@@ -18,22 +23,23 @@ export function AccountsPage() {
|
||||
Manage multi-account Claude sessions (profiles.json)
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground">Loading accounts...</div>
|
||||
) : (
|
||||
<AccountsTable data={data?.accounts || []} defaultAccount={data?.default ?? null} />
|
||||
<AccountsTable
|
||||
data={data?.accounts || []}
|
||||
defaultAccount={data?.default ?? null}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-muted-foreground border-t pt-4 mt-4">
|
||||
<p>
|
||||
Accounts are isolated Claude instances with separate sessions.
|
||||
<br />
|
||||
Use <code className="bg-muted px-1 rounded">ccs auth create <name></code> to add new
|
||||
accounts via CLI.
|
||||
</p>
|
||||
</div>
|
||||
<CreateAuthProfileDialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user