mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-17 18:13:43 +00:00
Merge pull request #217 from kaitranntt/dev
feat: auth profile management, Kiro import, and config recovery
This commit is contained in:
41 files changed
+1090
-162
No files matched your search
@@ -8,10 +8,22 @@ CLI wrapper for instant switching between multiple Claude accounts and alternati
|
|||||||
|
|
||||||
## Design Principles (ENFORCE STRICTLY)
|
## Design Principles (ENFORCE STRICTLY)
|
||||||
|
|
||||||
|
### Technical Excellence
|
||||||
- **YAGNI**: No features "just in case"
|
- **YAGNI**: No features "just in case"
|
||||||
- **KISS**: Simple bash/PowerShell/Node.js only
|
- **KISS**: Simple bash/PowerShell/Node.js only
|
||||||
- **DRY**: One source of truth (config.json)
|
- **DRY**: One source of truth (config.yaml)
|
||||||
- **CLI-First**: All features must have CLI interface
|
|
||||||
|
### 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)
|
## 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
|
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
|
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
|
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
|
## File Structure
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kaitranntt/ccs",
|
"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",
|
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"cli",
|
"cli",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
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 { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
|
||||||
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
|
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export function apiProfileExists(name: string): boolean {
|
|||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
return name in config.profiles;
|
return name in config.profiles;
|
||||||
}
|
}
|
||||||
const config = loadConfig();
|
const config = loadConfigSafe();
|
||||||
return name in config.profiles;
|
return name in config.profiles;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -79,7 +79,7 @@ export function listApiProfiles(): ApiListResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const config = loadConfig();
|
const config = loadConfigSafe();
|
||||||
for (const [name, settingsPath] of Object.entries(config.profiles)) {
|
for (const [name, settingsPath] of Object.entries(config.profiles)) {
|
||||||
// Skip 'default' profile - it's the user's native Claude settings
|
// Skip 'default' profile - it's the user's native Claude settings
|
||||||
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
|
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
|
||||||
@@ -116,7 +116,7 @@ export function getApiProfileNames(): string[] {
|
|||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
return Object.keys(config.profiles);
|
return Object.keys(config.profiles);
|
||||||
}
|
}
|
||||||
const config = loadConfig();
|
const config = loadConfigSafe();
|
||||||
return Object.keys(config.profiles);
|
return Object.keys(config.profiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
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 { expandPath } from '../../utils/helpers';
|
||||||
import {
|
import {
|
||||||
loadOrCreateUnifiedConfig,
|
loadOrCreateUnifiedConfig,
|
||||||
@@ -166,7 +166,7 @@ function removeApiProfileUnified(name: string): void {
|
|||||||
|
|
||||||
/** Remove API profile from legacy config */
|
/** Remove API profile from legacy config */
|
||||||
function removeApiProfileLegacy(name: string): void {
|
function removeApiProfileLegacy(name: string): void {
|
||||||
const config = loadConfig();
|
const config = loadConfigSafe();
|
||||||
delete config.profiles[name];
|
delete config.profiles[name];
|
||||||
|
|
||||||
const configPath = getConfigPath();
|
const configPath = getConfigPath();
|
||||||
|
|||||||
+18
-10
@@ -291,6 +291,24 @@ async function main(): Promise<void> {
|
|||||||
await autoMigrate();
|
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)
|
// Special case: version command (check BEFORE profile detection)
|
||||||
if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') {
|
if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') {
|
||||||
handleVersionCommand();
|
handleVersionCommand();
|
||||||
@@ -455,16 +473,6 @@ async function main(): Promise<void> {
|
|||||||
return;
|
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
|
// First-time install: offer setup wizard for interactive users
|
||||||
// Check independently of recovery status (user may have empty config.yaml)
|
// Check independently of recovery status (user may have empty config.yaml)
|
||||||
// Skip if headless, CI, or non-TTY environment
|
// Skip if headless, CI, or non-TTY environment
|
||||||
|
|||||||
@@ -173,4 +173,6 @@ export interface OAuthOptions {
|
|||||||
fromUI?: boolean;
|
fromUI?: boolean;
|
||||||
/** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */
|
/** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */
|
||||||
noIncognito?: boolean;
|
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';
|
} from '../../management/oauth-port-diagnostics';
|
||||||
import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types';
|
import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types';
|
||||||
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
|
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 { executeOAuthProcess } from './oauth-process';
|
||||||
|
import { importKiroToken } from './kiro-import';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt user to add another account
|
* Prompt user to add another account
|
||||||
@@ -127,6 +128,17 @@ export async function triggerOAuth(
|
|||||||
): Promise<AccountInfo | null> {
|
): Promise<AccountInfo | null> {
|
||||||
const oauthConfig = getOAuthConfig(provider);
|
const oauthConfig = getOAuthConfig(provider);
|
||||||
const { verbose = false, add = false, nickname, fromUI = false, noIncognito = true } = options;
|
const { verbose = false, add = false, nickname, fromUI = false, noIncognito = true } = options;
|
||||||
|
|
||||||
|
// Handle --import flag: skip OAuth and import from Kiro IDE directly
|
||||||
|
if (options.import && provider === 'kiro') {
|
||||||
|
const tokenDir = getProviderTokenDir(provider);
|
||||||
|
const success = await importKiroToken(verbose);
|
||||||
|
if (success) {
|
||||||
|
return registerAccountFromToken(provider, tokenDir, nickname);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const callbackPort = OAUTH_PORTS[provider];
|
const callbackPort = OAUTH_PORTS[provider];
|
||||||
const isCLI = !fromUI;
|
const isCLI = !fromUI;
|
||||||
const headless = options.headless ?? isHeadlessEnvironment();
|
const headless = options.headless ?? isHeadlessEnvironment();
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
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 { CLIProxyProvider } from '../types';
|
||||||
import { AccountInfo } from '../account-manager';
|
import { AccountInfo } from '../account-manager';
|
||||||
import {
|
import {
|
||||||
@@ -205,7 +206,35 @@ function displayUrlFromStderr(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Handle token not found after successful process exit */
|
/** 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('');
|
||||||
console.log(fail('Token not found after authentication'));
|
console.log(fail('Token not found after authentication'));
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -225,6 +254,7 @@ function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number |
|
|||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(`Try: ccs ${provider} --auth --verbose`);
|
console.log(`Try: ccs ${provider} --auth --verbose`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle process exit with error */
|
/** Handle process exit with error */
|
||||||
@@ -356,7 +386,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
resolve(null);
|
resolve(null);
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
authProcess.on('exit', (code) => {
|
authProcess.on('exit', async (code) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
// H5: Remove signal handlers to prevent memory leaks
|
// H5: Remove signal handlers to prevent memory leaks
|
||||||
process.removeListener('SIGINT', cleanup);
|
process.removeListener('SIGINT', cleanup);
|
||||||
@@ -383,8 +413,15 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTokenNotFound(provider, callbackPort);
|
// Try auto-import for Kiro, show error for others
|
||||||
resolve(null);
|
const account = await handleTokenNotFound(
|
||||||
|
provider,
|
||||||
|
callbackPort,
|
||||||
|
tokenDir,
|
||||||
|
nickname,
|
||||||
|
verbose
|
||||||
|
);
|
||||||
|
resolve(account);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Emit device code failure event for UI
|
// Emit device code failure event for UI
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
* Pattern: Mirrors npm install behavior (fast check, download only when needed)
|
* Pattern: Mirrors npm install behavior (fast check, download only when needed)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { info } from '../utils/ui';
|
import { info, warn } from '../utils/ui';
|
||||||
import { getBinDir } from './config-generator';
|
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||||
import { BinaryInfo, BinaryManagerConfig } from './types';
|
import { BinaryInfo, BinaryManagerConfig } from './types';
|
||||||
import { CLIPROXY_FALLBACK_VERSION } from './platform-detector';
|
import { CLIPROXY_FALLBACK_VERSION } from './platform-detector';
|
||||||
|
import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service';
|
||||||
|
import { waitForPortFree } from '../utils/port-utils';
|
||||||
import {
|
import {
|
||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
@@ -108,12 +110,32 @@ export function getInstalledCliproxyVersion(): string {
|
|||||||
/** Install a specific version of CLIProxyAPI */
|
/** Install a specific version of CLIProxyAPI */
|
||||||
export async function installCliproxyVersion(version: string, verbose = false): Promise<void> {
|
export async function installCliproxyVersion(version: string, verbose = false): Promise<void> {
|
||||||
const manager = new BinaryManager({ version, verbose, forceVersion: true });
|
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 (manager.isBinaryInstalled()) {
|
||||||
if (verbose)
|
if (verbose)
|
||||||
console.log(info(`Removing existing CLIProxy Plus v${getInstalledCliproxyVersion()}`));
|
console.log(info(`Removing existing CLIProxy Plus v${getInstalledCliproxyVersion()}`));
|
||||||
manager.deleteBinary();
|
manager.deleteBinary();
|
||||||
}
|
}
|
||||||
await manager.ensureBinary();
|
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 */
|
/** Fetch the latest CLIProxyAPI version from GitHub API */
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import * as net from 'net';
|
|||||||
import { ProgressIndicator } from '../utils/progress-indicator';
|
import { ProgressIndicator } from '../utils/progress-indicator';
|
||||||
import { ok, fail, info, warn } from '../utils/ui';
|
import { ok, fail, info, warn } from '../utils/ui';
|
||||||
import { escapeShellArg } from '../utils/shell-executor';
|
import { escapeShellArg } from '../utils/shell-executor';
|
||||||
import { ensureCLIProxyBinary } from './binary-manager';
|
import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from './binary-manager';
|
||||||
import {
|
import {
|
||||||
generateConfig,
|
generateConfig,
|
||||||
getEffectiveEnvVars,
|
getEffectiveEnvVars,
|
||||||
@@ -48,7 +48,12 @@ import {
|
|||||||
installWebSearchHook,
|
installWebSearchHook,
|
||||||
displayWebSearchStatus,
|
displayWebSearchStatus,
|
||||||
} from '../utils/websearch-manager';
|
} 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 { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
|
||||||
import { withStartupLock } from './startup-lock';
|
import { withStartupLock } from './startup-lock';
|
||||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||||
@@ -250,6 +255,8 @@ export async function execClaudeWithCLIProxy(
|
|||||||
const forceConfig = argsWithoutProxy.includes('--config');
|
const forceConfig = argsWithoutProxy.includes('--config');
|
||||||
const addAccount = argsWithoutProxy.includes('--add');
|
const addAccount = argsWithoutProxy.includes('--add');
|
||||||
const showAccounts = argsWithoutProxy.includes('--accounts');
|
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
|
// Kiro-specific: browser mode for OAuth
|
||||||
// Default to normal browser (noIncognito=true) for reliability - incognito often fails on Linux
|
// 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)
|
// --incognito flag opts into incognito mode, --no-incognito is legacy (now default)
|
||||||
@@ -362,6 +369,38 @@ export async function execClaudeWithCLIProxy(
|
|||||||
process.exit(0);
|
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)
|
// 3. Ensure OAuth completed (if provider requires it)
|
||||||
if (providerConfig.requiresOAuth) {
|
if (providerConfig.requiresOAuth) {
|
||||||
log(`Checking authentication for ${provider}`);
|
log(`Checking authentication for ${provider}`);
|
||||||
@@ -429,9 +468,33 @@ export async function execClaudeWithCLIProxy(
|
|||||||
// Use startup lock to coordinate with other CCS processes
|
// Use startup lock to coordinate with other CCS processes
|
||||||
await withStartupLock(async () => {
|
await withStartupLock(async () => {
|
||||||
// Detect running proxy using multiple methods (HTTP, session-lock, port-process)
|
// 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)}`);
|
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) {
|
if (proxyStatus.running && proxyStatus.verified) {
|
||||||
// Healthy proxy found - join it
|
// Healthy proxy found - join it
|
||||||
if (proxyStatus.pid) {
|
if (proxyStatus.pid) {
|
||||||
@@ -542,9 +605,12 @@ export async function execClaudeWithCLIProxy(
|
|||||||
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register this session with the new proxy
|
// Register this session with the new proxy, including the installed version
|
||||||
sessionId = registerSession(cfg.port, proxy.pid as number);
|
const installedVersion = getInstalledCliproxyVersion();
|
||||||
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`);
|
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',
|
'--nickname',
|
||||||
'--incognito',
|
'--incognito',
|
||||||
'--no-incognito',
|
'--no-incognito',
|
||||||
|
'--import',
|
||||||
// Proxy flags are handled by resolveProxyConfig, but list for documentation
|
// Proxy flags are handled by resolveProxyConfig, but list for documentation
|
||||||
...PROXY_CLI_FLAGS,
|
...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);
|
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
|
* @returns true if config should be regenerated
|
||||||
*/
|
*/
|
||||||
export function configNeedsRegeneration(): boolean {
|
export function configNeedsRegeneration(): boolean {
|
||||||
const configPath = getConfigPath();
|
const configPath = getCliproxyConfigPath();
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
return false; // Will be created on first use
|
return false; // Will be created on first use
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export {
|
|||||||
getCliproxyDir,
|
getCliproxyDir,
|
||||||
getProviderAuthDir,
|
getProviderAuthDir,
|
||||||
getAuthDir,
|
getAuthDir,
|
||||||
getConfigPath,
|
getCliproxyConfigPath,
|
||||||
getBinDir,
|
getBinDir,
|
||||||
configExists,
|
configExists,
|
||||||
deleteConfig,
|
deleteConfig,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as yaml from 'js-yaml';
|
import * as yaml from 'js-yaml';
|
||||||
import { getConfigPath } from './config-generator';
|
import { getCliproxyConfigPath } from './config-generator';
|
||||||
|
|
||||||
/** Model alias configuration */
|
/** Model alias configuration */
|
||||||
export interface OpenAICompatModel {
|
export interface OpenAICompatModel {
|
||||||
@@ -48,7 +48,7 @@ interface ConfigYaml {
|
|||||||
* Load current config.yaml
|
* Load current config.yaml
|
||||||
*/
|
*/
|
||||||
function loadConfig(): ConfigYaml {
|
function loadConfig(): ConfigYaml {
|
||||||
const configPath = getConfigPath();
|
const configPath = getCliproxyConfigPath();
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ function loadConfig(): ConfigYaml {
|
|||||||
* Save config.yaml with proper formatting
|
* Save config.yaml with proper formatting
|
||||||
*/
|
*/
|
||||||
function saveConfig(config: ConfigYaml): void {
|
function saveConfig(config: ConfigYaml): void {
|
||||||
const configPath = getConfigPath();
|
const configPath = getCliproxyConfigPath();
|
||||||
const content = yaml.dump(config, {
|
const content = yaml.dump(config, {
|
||||||
lineWidth: -1, // Disable line wrapping
|
lineWidth: -1, // Disable line wrapping
|
||||||
quotingType: '"',
|
quotingType: '"',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* Solves race conditions between cliproxy-executor.ts and service-manager.ts
|
* 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 { isCliproxyRunning } from './stats-fetcher';
|
||||||
import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils';
|
import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils';
|
||||||
|
|
||||||
@@ -38,6 +38,8 @@ export interface ProxyStatus {
|
|||||||
blocker?: PortProcess;
|
blocker?: PortProcess;
|
||||||
/** Number of active sessions (if session-lock found) */
|
/** Number of active sessions (if session-lock found) */
|
||||||
sessionCount?: number;
|
sessionCount?: number;
|
||||||
|
/** Version of the running proxy (from session lock) */
|
||||||
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Optional logger function for verbose output */
|
/** 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 {
|
return {
|
||||||
running: true,
|
running: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
method: 'http',
|
method: 'http',
|
||||||
pid,
|
pid,
|
||||||
sessionCount: lock?.sessions?.length,
|
sessionCount: lock?.sessions?.length,
|
||||||
|
version: runningVersion ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
log('HTTP check failed, proxy not responding');
|
log('HTTP check failed, proxy not responding');
|
||||||
@@ -103,13 +111,17 @@ export async function detectRunningProxy(
|
|||||||
if (lock) {
|
if (lock) {
|
||||||
// Session lock exists - proxy might be starting up
|
// Session lock exists - proxy might be starting up
|
||||||
// The lock validates PID is running, so proxy exists but not ready
|
// 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 {
|
return {
|
||||||
running: true,
|
running: true,
|
||||||
verified: false,
|
verified: false,
|
||||||
method: 'session-lock',
|
method: 'session-lock',
|
||||||
pid: lock.pid,
|
pid: lock.pid,
|
||||||
sessionCount: lock.sessions.length,
|
sessionCount: lock.sessions.length,
|
||||||
|
version: lockVersion ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
log('No session lock found');
|
log('No session lock found');
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { getConfigPath, loadConfig } from '../../utils/config-manager';
|
import { getConfigPath, loadConfigSafe } from '../../utils/config-manager';
|
||||||
import { CLIProxyProvider } from '../types';
|
import { CLIProxyProvider } from '../types';
|
||||||
import {
|
import {
|
||||||
loadOrCreateUnifiedConfig,
|
loadOrCreateUnifiedConfig,
|
||||||
@@ -38,7 +38,7 @@ export function variantExistsInConfig(name: string): boolean {
|
|||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
return !!(config.cliproxy?.variants && name in config.cliproxy.variants);
|
return !!(config.cliproxy?.variants && name in config.cliproxy.variants);
|
||||||
}
|
}
|
||||||
const config = loadConfig();
|
const config = loadConfigSafe();
|
||||||
return !!(config.cliproxy && name in config.cliproxy);
|
return !!(config.cliproxy && name in config.cliproxy);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -94,7 +94,7 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfigSafe();
|
||||||
const variants = config.cliproxy || {};
|
const variants = config.cliproxy || {};
|
||||||
const result: Record<string, VariantConfig> = {};
|
const result: Record<string, VariantConfig> = {};
|
||||||
for (const name of Object.keys(variants)) {
|
for (const name of Object.keys(variants)) {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface SessionLock {
|
|||||||
pid: number;
|
pid: number;
|
||||||
sessions: string[];
|
sessions: string[];
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
|
/** CLIProxy version running (added for version mismatch detection) */
|
||||||
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate unique session ID */
|
/** 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.
|
* Check if there's an existing proxy running that we can reuse.
|
||||||
* Returns the existing lock if proxy is healthy, null otherwise.
|
* 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.
|
* Register a new session with the proxy.
|
||||||
* Call this when starting a new CCS session that will use an existing 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
|
* @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 sessionId = generateSessionId();
|
||||||
const existingLock = readSessionLockForPort(port);
|
const existingLock = readSessionLockForPort(port);
|
||||||
|
|
||||||
@@ -170,6 +192,7 @@ export function registerSession(port: number, proxyPid: number): string {
|
|||||||
pid: proxyPid,
|
pid: proxyPid,
|
||||||
sessions: [sessionId],
|
sessions: [sessionId],
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
|
version,
|
||||||
};
|
};
|
||||||
writeSessionLockForPort(newLock);
|
writeSessionLockForPort(newLock);
|
||||||
}
|
}
|
||||||
@@ -315,6 +338,19 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
|||||||
// Found CLIProxy running without session lock - kill it
|
// Found CLIProxy running without session lock - kill it
|
||||||
try {
|
try {
|
||||||
process.kill(portProcess.pid, 'SIGTERM');
|
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 };
|
return { stopped: true, pid: portProcess.pid, sessionCount: 0 };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as NodeJS.ErrnoException;
|
const error = err as NodeJS.ErrnoException;
|
||||||
@@ -338,6 +374,18 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
|||||||
// Kill the proxy process
|
// Kill the proxy process
|
||||||
process.kill(pid, 'SIGTERM');
|
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
|
// Clean up session lock
|
||||||
deleteSessionLockForPort(port);
|
deleteSessionLockForPort(port);
|
||||||
|
|
||||||
@@ -362,6 +410,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
|
|||||||
pid?: number;
|
pid?: number;
|
||||||
sessionCount?: number;
|
sessionCount?: number;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
|
version?: string;
|
||||||
} {
|
} {
|
||||||
const lock = readSessionLockForPort(port);
|
const lock = readSessionLockForPort(port);
|
||||||
|
|
||||||
@@ -381,5 +430,26 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
|
|||||||
pid: lock.pid,
|
pid: lock.pid,
|
||||||
sessionCount: lock.sessions.length,
|
sessionCount: lock.sessions.length,
|
||||||
startedAt: lock.startedAt,
|
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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui';
|
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)
|
// Get version from package.json (same as version-command.ts)
|
||||||
const VERSION = JSON.parse(
|
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> --config', 'Change model (agy, gemini)'],
|
||||||
['ccs <provider> --logout', 'Clear authentication'],
|
['ccs <provider> --logout', 'Clear authentication'],
|
||||||
['ccs <provider> --headless', 'Headless auth (for SSH)'],
|
['ccs <provider> --headless', 'Headless auth (for SSH)'],
|
||||||
|
['ccs kiro --import', 'Import token from Kiro IDE'],
|
||||||
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
|
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
|
||||||
['ccs codex "explain code"', 'Use with prompt'],
|
['ccs codex "explain code"', 'Use with prompt'],
|
||||||
]
|
]
|
||||||
@@ -234,7 +236,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
|||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
printConfigSection('Configuration', [
|
printConfigSection('Configuration', [
|
||||||
['Config File:', '~/.ccs/config.json'],
|
['Config File:', isUnifiedMode() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'],
|
||||||
['Profiles:', '~/.ccs/profiles.json'],
|
['Profiles:', '~/.ccs/profiles.json'],
|
||||||
['Instances:', '~/.ccs/instances/'],
|
['Instances:', '~/.ccs/instances/'],
|
||||||
['Settings:', '~/.ccs/*.settings.json'],
|
['Settings:', '~/.ccs/*.settings.json'],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
isCLIProxyInstalled,
|
isCLIProxyInstalled,
|
||||||
getCLIProxyPath,
|
getCLIProxyPath,
|
||||||
getAllAuthStatus,
|
getAllAuthStatus,
|
||||||
getConfigPath,
|
getCliproxyConfigPath,
|
||||||
getInstalledCliproxyVersion,
|
getInstalledCliproxyVersion,
|
||||||
CLIPROXY_DEFAULT_PORT,
|
CLIPROXY_DEFAULT_PORT,
|
||||||
configNeedsRegeneration,
|
configNeedsRegeneration,
|
||||||
@@ -60,7 +60,7 @@ export class CLIProxyConfigChecker implements IHealthChecker {
|
|||||||
|
|
||||||
run(results: HealthCheck): void {
|
run(results: HealthCheck): void {
|
||||||
const spinner = ora('Checking CLIProxy config').start();
|
const spinner = ora('Checking CLIProxy config').start();
|
||||||
const configPath = getConfigPath();
|
const configPath = getCliproxyConfigPath();
|
||||||
|
|
||||||
if (fs.existsSync(configPath)) {
|
if (fs.existsSync(configPath)) {
|
||||||
// Check if config needs regeneration (version mismatch or missing features)
|
// 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
|
* Example: "flash" → gemini provider with gemini-2.5-flash model
|
||||||
*/
|
*/
|
||||||
export interface CLIProxyVariantConfig {
|
export interface CLIProxyVariantConfig {
|
||||||
/** CLIProxy provider to use (gemini, codex, agy, qwen) */
|
/** CLIProxy provider to use */
|
||||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
|
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp';
|
||||||
/** Path to settings.json with custom model configuration */
|
/** Path to settings.json with custom model configuration (optional) */
|
||||||
settings: string;
|
settings?: string;
|
||||||
/** Account identifier for multi-account support (optional, defaults to 'default') */
|
/** Account identifier for multi-account support (optional, defaults to 'default') */
|
||||||
account?: string;
|
account?: string;
|
||||||
/** Unique port for variant isolation (8318-8417) */
|
/** Unique port for variant isolation (8318-8417) */
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type {
|
|||||||
EnvValue,
|
EnvValue,
|
||||||
ProfileMetadata,
|
ProfileMetadata,
|
||||||
ProfilesRegistry,
|
ProfilesRegistry,
|
||||||
|
CLIProxyVariantsConfig,
|
||||||
} from './config';
|
} from './config';
|
||||||
export { isConfig, isSettings } from './config';
|
export { isConfig, isSettings } from './config';
|
||||||
|
|
||||||
|
|||||||
+94
-12
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
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 { expandPath, error } from './helpers';
|
||||||
import { info } from './ui';
|
import { info } from './ui';
|
||||||
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
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 {
|
export function getConfigPath(): string {
|
||||||
return process.env.CCS_CONFIG || path.join(getCcsHome(), '.ccs', 'config.json');
|
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
|
* Load and validate config.json
|
||||||
*/
|
*/
|
||||||
@@ -82,6 +96,69 @@ export function readConfig(): Config {
|
|||||||
return loadConfig();
|
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.
|
* Get settings path for profile.
|
||||||
* In unified mode (config.yaml exists), reads from config.yaml first,
|
* 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 not found in unified config, try legacy config.json as fallback
|
||||||
if (!settingsPath) {
|
if (!settingsPath) {
|
||||||
try {
|
// Use inline safe logic - loadConfig() calls process.exit() which cannot be caught
|
||||||
const legacyConfig = loadConfig();
|
const configPath = getConfigPath();
|
||||||
if (legacyConfig.profiles[profile]) {
|
if (fs.existsSync(configPath)) {
|
||||||
settingsPath = legacyConfig.profiles[profile];
|
try {
|
||||||
// Merge legacy profiles into available list (avoid duplicates)
|
const raw = fs.readFileSync(configPath, 'utf8');
|
||||||
for (const p of Object.keys(legacyConfig.profiles)) {
|
const legacyConfig: unknown = JSON.parse(raw);
|
||||||
if (!availableProfiles.includes(p)) {
|
if (isConfig(legacyConfig) && legacyConfig.profiles[profile]) {
|
||||||
availableProfiles.push(p);
|
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 {
|
} else {
|
||||||
|
|||||||
@@ -181,6 +181,32 @@ export interface BindingTestResult {
|
|||||||
message: string;
|
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
|
* Test if we can bind to localhost on a specific port
|
||||||
* This verifies network stack is working and port is actually available
|
* This verifies network stack is working and port is actually available
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
isCLIProxyInstalled,
|
isCLIProxyInstalled,
|
||||||
getInstalledCliproxyVersion,
|
getInstalledCliproxyVersion,
|
||||||
getCLIProxyPath,
|
getCLIProxyPath,
|
||||||
getConfigPath as getCliproxyConfigPath,
|
getCliproxyConfigPath,
|
||||||
getAllAuthStatus,
|
getAllAuthStatus,
|
||||||
CLIPROXY_DEFAULT_PORT,
|
CLIPROXY_DEFAULT_PORT,
|
||||||
} from '../../cliproxy';
|
} from '../../cliproxy';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { loadConfig } from '../utils/config-manager';
|
import { loadConfigSafe } from '../utils/config-manager';
|
||||||
import { runHealthChecks } from './health-service';
|
import { runHealthChecks } from './health-service';
|
||||||
import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth-handler';
|
import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth-handler';
|
||||||
import { getVersion } from '../utils/version';
|
import { getVersion } from '../utils/version';
|
||||||
@@ -18,7 +18,7 @@ export const overviewRoutes = Router();
|
|||||||
*/
|
*/
|
||||||
overviewRoutes.get('/', async (_req: Request, res: Response) => {
|
overviewRoutes.get('/', async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfigSafe();
|
||||||
|
|
||||||
const profileCount = Object.keys(config.profiles).length;
|
const profileCount = Object.keys(config.profiles).length;
|
||||||
const cliproxyVariantCount = Object.keys(config.cliproxy || {}).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;
|
export default router;
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||||
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
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';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
|
|
||||||
const router = Router();
|
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;
|
export default router;
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from '../../cliproxy/stats-fetcher';
|
} from '../../cliproxy/stats-fetcher';
|
||||||
import {
|
import {
|
||||||
getCliproxyWritablePath,
|
getCliproxyWritablePath,
|
||||||
getConfigPath,
|
getCliproxyConfigPath,
|
||||||
getAuthDir,
|
getAuthDir,
|
||||||
} from '../../cliproxy/config-generator';
|
} from '../../cliproxy/config-generator';
|
||||||
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
|
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> => {
|
router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const configPath = getConfigPath();
|
const configPath = getCliproxyConfigPath();
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
res.status(404).json({ error: 'Config file not found' });
|
res.status(404).json({ error: 'Config file not found' });
|
||||||
return;
|
return;
|
||||||
@@ -299,7 +299,7 @@ router.put('/config.yaml', async (req: Request, res: Response): Promise<void> =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const configPath = getConfigPath();
|
const configPath = getCliproxyConfigPath();
|
||||||
|
|
||||||
// Ensure parent directory exists
|
// Ensure parent directory exists
|
||||||
const configDir = path.dirname(configPath);
|
const configDir = path.dirname(configPath);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
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 { expandPath } from '../../utils/helpers';
|
||||||
import type { Config, Settings } from '../../types/config';
|
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 {
|
export function readConfigSafe(): Config {
|
||||||
try {
|
try {
|
||||||
return loadConfig();
|
return loadConfigSafe();
|
||||||
} catch {
|
} catch {
|
||||||
return { profiles: {} };
|
return { profiles: {} };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ process.env.CCS_HOME = testHome;
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
getConfigPathForPort,
|
getConfigPathForPort,
|
||||||
getConfigPath,
|
getCliproxyConfigPath,
|
||||||
generateConfig,
|
generateConfig,
|
||||||
regenerateConfig,
|
regenerateConfig,
|
||||||
configExists,
|
configExists,
|
||||||
@@ -98,9 +98,9 @@ describe('Config Generator Port', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getConfigPath', function () {
|
describe('getCliproxyConfigPath', function () {
|
||||||
it('returns path for default port', function () {
|
it('returns path for default port', function () {
|
||||||
const configPath = getConfigPath();
|
const configPath = getCliproxyConfigPath();
|
||||||
const defaultPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
|
const defaultPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
|
||||||
assert.strictEqual(configPath, defaultPath);
|
assert.strictEqual(configPath, defaultPath);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ auth-dir: "/test"
|
|||||||
let testDir;
|
let testDir;
|
||||||
let originalCcsHome;
|
let originalCcsHome;
|
||||||
let regenerateConfig;
|
let regenerateConfig;
|
||||||
let getConfigPath;
|
let getCliproxyConfigPath;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Create a temporary test directory
|
// Create a temporary test directory
|
||||||
@@ -375,7 +375,7 @@ auth-dir: "/test"
|
|||||||
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
||||||
const configGenerator = require('../../../dist/cliproxy/config-generator');
|
const configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||||
regenerateConfig = configGenerator.regenerateConfig;
|
regenerateConfig = configGenerator.regenerateConfig;
|
||||||
getConfigPath = configGenerator.getConfigPath;
|
getCliproxyConfigPath = configGenerator.getCliproxyConfigPath;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -27,14 +27,7 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
|||||||
let httpsRequests = [];
|
let httpsRequests = [];
|
||||||
|
|
||||||
beforeAll(async function () {
|
beforeAll(async function () {
|
||||||
// Build the project first
|
// Note: Build is handled by CI before tests run (bun run build:all)
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import the built module
|
// Import the built module
|
||||||
updateCheckerModule = await import('../../../dist/utils/update-checker.js');
|
updateCheckerModule = await import('../../../dist/utils/update-checker.js');
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"typecheck": "tsc -b --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write src/ tests/",
|
"format": "prettier --write src/ tests/",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Accounts Table Component
|
* 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 { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -13,17 +14,35 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Check } from 'lucide-react';
|
import {
|
||||||
import { useSetDefaultAccount } from '@/hooks/use-accounts';
|
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';
|
import type { Account } from '@/lib/api-client';
|
||||||
|
|
||||||
interface AccountsTableProps {
|
interface AccountsTableProps {
|
||||||
data: Account[];
|
data: Account[];
|
||||||
defaultAccount: string | null;
|
defaultAccount: string | null;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||||
const setDefaultMutation = useSetDefaultAccount();
|
const setDefaultMutation = useSetDefaultAccount();
|
||||||
|
const deleteMutation = useDeleteAccount();
|
||||||
|
const resetDefaultMutation = useResetDefaultAccount();
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
|
|
||||||
const columns: ColumnDef<Account>[] = [
|
const columns: ColumnDef<Account>[] = [
|
||||||
{
|
{
|
||||||
@@ -71,20 +90,34 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
size: 100,
|
size: 180,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const isDefault = row.original.name === defaultAccount;
|
const isDefault = row.original.name === defaultAccount;
|
||||||
|
const isPending = setDefaultMutation.isPending || deleteMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant={isDefault ? 'secondary' : 'default'}
|
<Button
|
||||||
size="sm"
|
variant={isDefault ? 'secondary' : 'default'}
|
||||||
className="h-8 px-2 w-full"
|
size="sm"
|
||||||
disabled={isDefault || setDefaultMutation.isPending}
|
className="h-8 px-2"
|
||||||
onClick={() => setDefaultMutation.mutate(row.original.name)}
|
disabled={isDefault || isPending}
|
||||||
>
|
onClick={() => setDefaultMutation.mutate(row.original.name)}
|
||||||
<Check className={`w-3 h-3 mr-1.5 ${isDefault ? 'opacity-50' : ''}`} />
|
>
|
||||||
{isDefault ? 'Active' : 'Set Default'}
|
<Check className={`w-3 h-3 mr-1 ${isDefault ? 'opacity-50' : ''}`} />
|
||||||
</Button>
|
{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) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<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
|
No accounts found. Use{' '}
|
||||||
add accounts.
|
<code className="text-sm bg-muted px-1 rounded">ccs auth create</code> to add accounts.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-md">
|
<>
|
||||||
<Table>
|
<div className="space-y-4">
|
||||||
<TableHeader>
|
<div className="border rounded-md">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<Table>
|
||||||
<TableRow key={headerGroup.id}>
|
<TableHeader>
|
||||||
{headerGroup.headers.map((header) => {
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
const widthClass =
|
<TableRow key={headerGroup.id}>
|
||||||
{
|
{headerGroup.headers.map((header) => {
|
||||||
name: 'w-[200px]',
|
const widthClass =
|
||||||
type: 'w-[100px]',
|
{
|
||||||
created: 'w-[150px]',
|
name: 'w-[200px]',
|
||||||
last_used: 'w-[150px]',
|
type: 'w-[100px]',
|
||||||
actions: 'w-[100px]',
|
created: 'w-[150px]',
|
||||||
}[header.id] || 'w-auto';
|
last_used: 'w-[150px]',
|
||||||
|
actions: 'w-[180px]',
|
||||||
|
}[header.id] || 'w-auto';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableHead key={header.id} className={widthClass}>
|
<TableHead key={header.id} className={widthClass}>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableRow>
|
</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>
|
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableHeader>
|
||||||
))}
|
<TableBody>
|
||||||
</TableBody>
|
{table.getRowModel().rows.map((row) => (
|
||||||
</Table>
|
<TableRow key={row.id}>
|
||||||
</div>
|
{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
|
* Add Account Dialog Component
|
||||||
* Triggers OAuth flow server-side to add another account to a provider
|
* Triggers OAuth flow server-side to add another account to a provider
|
||||||
* Applies default preset when adding first account
|
* Applies default preset when adding first account
|
||||||
|
* For Kiro: Also shows "Import from IDE" option as fallback
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -15,8 +16,8 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Loader2, ExternalLink, User } from 'lucide-react';
|
import { Loader2, ExternalLink, User, Download } from 'lucide-react';
|
||||||
import { useStartAuth } from '@/hooks/use-cliproxy';
|
import { useStartAuth, useKiroImport } from '@/hooks/use-cliproxy';
|
||||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -38,6 +39,10 @@ export function AddAccountDialog({
|
|||||||
}: AddAccountDialogProps) {
|
}: AddAccountDialogProps) {
|
||||||
const [nickname, setNickname] = useState('');
|
const [nickname, setNickname] = useState('');
|
||||||
const startAuthMutation = useStartAuth();
|
const startAuthMutation = useStartAuth();
|
||||||
|
const kiroImportMutation = useKiroImport();
|
||||||
|
|
||||||
|
const isKiro = provider === 'kiro';
|
||||||
|
const isPending = startAuthMutation.isPending || kiroImportMutation.isPending;
|
||||||
|
|
||||||
const handleStartAuth = () => {
|
const handleStartAuth = () => {
|
||||||
startAuthMutation.mutate(
|
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) => {
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
if (!isOpen && !startAuthMutation.isPending) {
|
if (!isOpen && !isPending) {
|
||||||
setNickname('');
|
setNickname('');
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -73,8 +94,9 @@ export function AddAccountDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add {displayName} Account</DialogTitle>
|
<DialogTitle>Add {displayName} Account</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Click the button below to authenticate a new account. A browser window will open for
|
{isKiro
|
||||||
OAuth.
|
? '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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -88,7 +110,7 @@ export function AddAccountDialog({
|
|||||||
value={nickname}
|
value={nickname}
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
placeholder="e.g., work, personal"
|
placeholder="e.g., work, personal"
|
||||||
disabled={startAuthMutation.isPending}
|
disabled={isPending}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +120,25 @@ export function AddAccountDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
<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
|
Cancel
|
||||||
</Button>
|
</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 ? (
|
{startAuthMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
@@ -121,6 +158,11 @@ export function AddAccountDialog({
|
|||||||
Complete the OAuth flow in your browser...
|
Complete the OAuth flow in your browser...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{kiroImportMutation.isPending && (
|
||||||
|
<p className="text-sm text-center text-muted-foreground">
|
||||||
|
Importing token from Kiro IDE...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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
|
// Main components
|
||||||
export { AccountsTable } from './accounts-table';
|
export { AccountsTable } from './accounts-table';
|
||||||
export { AddAccountDialog } from './add-account-dialog';
|
export { AddAccountDialog } from './add-account-dialog';
|
||||||
|
export { CreateAuthProfileDialog } from './create-auth-profile-dialog';
|
||||||
|
|
||||||
// Flow visualization (from subdirectory)
|
// Flow visualization (from subdirectory)
|
||||||
export { AccountFlowViz } from './flow-viz';
|
export { AccountFlowViz } from './flow-viz';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* React Query hooks for accounts (profiles.json)
|
* 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';
|
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
|
// Stats and models hooks for Overview tab
|
||||||
export function useCliproxyStats() {
|
export function useCliproxyStats() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -331,6 +331,12 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ nickname }),
|
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
|
// Error logs
|
||||||
errorLogs: {
|
errorLogs: {
|
||||||
@@ -351,6 +357,8 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
}),
|
}),
|
||||||
|
resetDefault: () => request('/accounts/reset-default', { method: 'DELETE' }),
|
||||||
|
delete: (name: string) => request(`/accounts/${name}`, { method: 'DELETE' }),
|
||||||
},
|
},
|
||||||
// Unified config API
|
// Unified config API
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
+17
-11
@@ -1,13 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Accounts Page
|
* 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 { 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';
|
import { useAccounts } from '@/hooks/use-accounts';
|
||||||
|
|
||||||
export function AccountsPage() {
|
export function AccountsPage() {
|
||||||
const { data, isLoading } = useAccounts();
|
const { data, isLoading, refetch } = useAccounts();
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
<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)
|
Manage multi-account Claude sessions (profiles.json)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-muted-foreground">Loading accounts...</div>
|
<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">
|
<CreateAuthProfileDialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user