mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 02:16:43 +00:00
Merge pull request #209 from kaitranntt/dev
feat: auth tokens, variant port isolation, and health check improvements
This commit is contained in:
@@ -332,6 +332,42 @@ export class ProfileRegistry {
|
||||
config.accounts[name].last_used = new Date().toISOString();
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DRY Helper Methods (consolidated logic)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Get all profiles merged from both legacy and unified config.
|
||||
* Unified config takes precedence for duplicate names.
|
||||
* DRY helper to consolidate merge logic used in multiple places.
|
||||
*/
|
||||
getAllProfilesMerged(): Record<string, ProfileMetadata> {
|
||||
const legacyProfiles = this.getAllProfiles();
|
||||
const unifiedAccounts = this.getAllAccountsUnified();
|
||||
|
||||
// Start with legacy profiles
|
||||
const merged: Record<string, ProfileMetadata> = { ...legacyProfiles };
|
||||
|
||||
// Override with unified config accounts (takes precedence)
|
||||
for (const [name, account] of Object.entries(unifiedAccounts)) {
|
||||
merged[name] = {
|
||||
type: 'account',
|
||||
created: account.created,
|
||||
last_used: account.last_used,
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resolved default profile from unified config first, fallback to legacy.
|
||||
* DRY helper to consolidate default resolution logic.
|
||||
*/
|
||||
getDefaultResolved(): string | null {
|
||||
return this.getDefaultUnified() ?? this.getDefaultProfile();
|
||||
}
|
||||
}
|
||||
|
||||
export default ProfileRegistry;
|
||||
|
||||
+14
@@ -255,6 +255,13 @@ async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const firstArg = args[0];
|
||||
|
||||
// Initialize UI colors early to ensure consistent colored output
|
||||
// Must happen before any status messages (ok, info, fail, etc.)
|
||||
if (process.stdout.isTTY && !process.env['CI']) {
|
||||
const { initUI } = await import('./utils/ui');
|
||||
await initUI();
|
||||
}
|
||||
|
||||
// Trigger update check early for ALL commands except version/help/update
|
||||
// Only if TTY and not CI to avoid noise in automated environments
|
||||
const skipUpdateCheck = [
|
||||
@@ -405,6 +412,13 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: tokens command (auth token management)
|
||||
if (firstArg === 'tokens') {
|
||||
const { handleTokensCommand } = await import('./commands/tokens-command');
|
||||
const exitCode = await handleTokensCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
// Special case: setup command (first-time wizard)
|
||||
if (firstArg === 'setup' || firstArg === '--setup') {
|
||||
const { handleSetupCommand } = await import('./commands/setup-command');
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Auth Token Manager for CLIProxyAPI
|
||||
*
|
||||
* Manages API key and management secret resolution with inheritance:
|
||||
* - Per-variant override → Global config → Default constants
|
||||
*
|
||||
* Provides secure token generation for user customization.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from './config-generator';
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure token.
|
||||
* Uses CSPRNG (crypto.randomBytes) for proper entropy.
|
||||
*
|
||||
* @param length - Number of random bytes (default: 32 = 256-bit entropy)
|
||||
* @returns Base64URL-encoded token (43 chars for 32 bytes)
|
||||
*/
|
||||
export function generateSecureToken(length = 32): string {
|
||||
return randomBytes(length).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a token for display.
|
||||
* Shows first 4 and last 4 chars: "ccs_...4f2a"
|
||||
* Used by CLI commands and API routes.
|
||||
*/
|
||||
export function maskToken(token: string): string {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective API key with inheritance chain.
|
||||
* Priority: variant auth → global cliproxy.auth → default constant
|
||||
*
|
||||
* @param variantName - Optional variant name for per-variant override
|
||||
* @returns Resolved API key
|
||||
*/
|
||||
export function getEffectiveApiKey(variantName?: string): string {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
// Priority 1: Per-variant override
|
||||
if (variantName) {
|
||||
const variant = config.cliproxy.variants[variantName];
|
||||
if (variant?.auth?.api_key) {
|
||||
return variant.auth.api_key;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Global cliproxy.auth
|
||||
if (config.cliproxy.auth?.api_key) {
|
||||
return config.cliproxy.auth.api_key;
|
||||
}
|
||||
|
||||
// Priority 3: Default constant (backwards compatible)
|
||||
return CCS_INTERNAL_API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective management secret.
|
||||
* Priority: global cliproxy.auth → default constant
|
||||
*
|
||||
* Note: Management secret is global-only (no per-variant override)
|
||||
* as it controls the Control Panel access for the entire CLIProxy instance.
|
||||
*
|
||||
* @returns Resolved management secret
|
||||
*/
|
||||
export function getEffectiveManagementSecret(): string {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
// Priority 1: Global cliproxy.auth
|
||||
if (config.cliproxy.auth?.management_secret) {
|
||||
return config.cliproxy.auth.management_secret;
|
||||
}
|
||||
|
||||
// Priority 2: Default constant (backwards compatible)
|
||||
return CCS_CONTROL_PANEL_SECRET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set global API key.
|
||||
* Updates cliproxy.auth.api_key in config.yaml.
|
||||
*
|
||||
* @param apiKey - New API key (or undefined to reset to default)
|
||||
*/
|
||||
export function setGlobalApiKey(apiKey: string | undefined): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
if (!config.cliproxy.auth) {
|
||||
config.cliproxy.auth = {};
|
||||
}
|
||||
|
||||
if (apiKey === undefined) {
|
||||
delete config.cliproxy.auth.api_key;
|
||||
} else {
|
||||
config.cliproxy.auth.api_key = apiKey;
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set global management secret.
|
||||
* Updates cliproxy.auth.management_secret in config.yaml.
|
||||
*
|
||||
* @param secret - New management secret (or undefined to reset to default)
|
||||
*/
|
||||
export function setGlobalManagementSecret(secret: string | undefined): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
if (!config.cliproxy.auth) {
|
||||
config.cliproxy.auth = {};
|
||||
}
|
||||
|
||||
if (secret === undefined) {
|
||||
delete config.cliproxy.auth.management_secret;
|
||||
} else {
|
||||
config.cliproxy.auth.management_secret = secret;
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set per-variant API key override.
|
||||
* Updates variants[variantName].auth.api_key in config.yaml.
|
||||
*
|
||||
* @param variantName - Variant name
|
||||
* @param apiKey - New API key (or undefined to remove override)
|
||||
*/
|
||||
export function setVariantApiKey(variantName: string, apiKey: string | undefined): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const variant = config.cliproxy.variants[variantName];
|
||||
|
||||
if (!variant) {
|
||||
throw new Error(`Variant '${variantName}' not found`);
|
||||
}
|
||||
|
||||
if (!variant.auth) {
|
||||
variant.auth = {};
|
||||
}
|
||||
|
||||
if (apiKey === undefined) {
|
||||
delete variant.auth.api_key;
|
||||
// Clean up empty auth object
|
||||
if (Object.keys(variant.auth).length === 0) {
|
||||
delete variant.auth;
|
||||
}
|
||||
} else {
|
||||
variant.auth.api_key = apiKey;
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all auth settings to defaults.
|
||||
* Removes cliproxy.auth and all variant auth overrides.
|
||||
*/
|
||||
export function resetAuthToDefaults(): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
// Remove global auth
|
||||
delete config.cliproxy.auth;
|
||||
|
||||
// Remove all variant auth overrides
|
||||
for (const variantName of Object.keys(config.cliproxy.variants)) {
|
||||
const variant = config.cliproxy.variants[variantName];
|
||||
if (variant.auth) {
|
||||
delete variant.auth;
|
||||
}
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth configuration summary for display.
|
||||
* Returns current effective values and whether they're customized.
|
||||
*/
|
||||
export function getAuthSummary(): {
|
||||
apiKey: { value: string; isCustom: boolean };
|
||||
managementSecret: { value: string; isCustom: boolean };
|
||||
} {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
const customApiKey = config.cliproxy.auth?.api_key;
|
||||
const customSecret = config.cliproxy.auth?.management_secret;
|
||||
|
||||
return {
|
||||
apiKey: {
|
||||
value: customApiKey || CCS_INTERNAL_API_KEY,
|
||||
isCustom: !!customApiKey,
|
||||
},
|
||||
managementSecret: {
|
||||
value: customSecret || CCS_CONTROL_PANEL_SECRET,
|
||||
isCustom: !!customSecret,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -638,12 +638,14 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// 8. Cleanup: unregister session when Claude exits (local mode only)
|
||||
// Proxy persists by default - use 'ccs cliproxy stop' to kill manually
|
||||
// Capture port for cleanup (avoids closure issues)
|
||||
const sessionPort = cfg.port;
|
||||
claude.on('exit', (code, signal) => {
|
||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||
|
||||
// Unregister this session (proxy keeps running for persistence) - only for local mode
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId);
|
||||
unregisterSession(sessionId, sessionPort);
|
||||
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
|
||||
}
|
||||
|
||||
@@ -659,7 +661,7 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// Unregister session, proxy keeps running (local mode only)
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId);
|
||||
unregisterSession(sessionId, sessionPort);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -670,7 +672,7 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// Unregister session, proxy keeps running (local mode only)
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId);
|
||||
unregisterSession(sessionId, sessionPort);
|
||||
}
|
||||
claude.kill('SIGTERM');
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { warn } from '../utils/ui';
|
||||
import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types';
|
||||
import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader';
|
||||
import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader';
|
||||
import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager';
|
||||
|
||||
/** Settings file structure for user overrides */
|
||||
interface ProviderSettings {
|
||||
@@ -117,10 +118,21 @@ export function getAuthDir(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config file path
|
||||
* Get config file path for a specific port.
|
||||
* Default port uses config.yaml, others use config-{port}.yaml.
|
||||
*/
|
||||
export function getConfigPathForPort(port: number): string {
|
||||
if (port === CLIPROXY_DEFAULT_PORT) {
|
||||
return path.join(getCliproxyDir(), 'config.yaml');
|
||||
}
|
||||
return path.join(getCliproxyDir(), `config-${port}.yaml`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config file path (default port)
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
return path.join(getCliproxyDir(), 'config.yaml');
|
||||
return getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,8 +173,12 @@ function generateUnifiedConfigContent(
|
||||
// Get logging settings from user config (disabled by default)
|
||||
const { loggingToFile, requestLog } = getLoggingSettings();
|
||||
|
||||
// Get effective auth tokens (respects user customization)
|
||||
const effectiveApiKey = getEffectiveApiKey();
|
||||
const effectiveSecret = getEffectiveManagementSecret();
|
||||
|
||||
// Build api-keys section with internal key + preserved user keys
|
||||
const allApiKeys = [CCS_INTERNAL_API_KEY, ...userApiKeys];
|
||||
const allApiKeys = [effectiveApiKey, ...userApiKeys];
|
||||
const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n');
|
||||
|
||||
// Unified config with enhanced CLIProxyAPI features
|
||||
@@ -208,7 +224,7 @@ usage-statistics-enabled: true
|
||||
# Remote management API for CCS dashboard integration
|
||||
remote-management:
|
||||
allow-remote: true
|
||||
secret-key: "${CCS_CONTROL_PANEL_SECRET}"
|
||||
secret-key: "${effectiveSecret}"
|
||||
disable-control-panel: false
|
||||
|
||||
# =============================================================================
|
||||
@@ -250,7 +266,7 @@ export function generateConfig(
|
||||
provider: CLIProxyProvider,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): string {
|
||||
const configPath = getConfigPath();
|
||||
const configPath = getConfigPathForPort(port);
|
||||
|
||||
// Ensure provider auth directory exists
|
||||
const authDir = getProviderAuthDir(provider);
|
||||
@@ -320,7 +336,7 @@ export function parseUserApiKeys(content: string): string[] {
|
||||
* @returns Path to new config file
|
||||
*/
|
||||
export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
const configPath = getConfigPath();
|
||||
const configPath = getConfigPathForPort(port);
|
||||
|
||||
// Preserve user settings from existing config
|
||||
let effectivePort = port;
|
||||
@@ -383,22 +399,29 @@ export function configNeedsRegeneration(): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config exists for provider
|
||||
* Check if config exists for port
|
||||
*/
|
||||
export function configExists(): boolean {
|
||||
return fs.existsSync(getConfigPath());
|
||||
export function configExists(port: number = CLIPROXY_DEFAULT_PORT): boolean {
|
||||
return fs.existsSync(getConfigPathForPort(port));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete config file
|
||||
* Delete config file for specific port
|
||||
*/
|
||||
export function deleteConfig(): void {
|
||||
const configPath = getConfigPath();
|
||||
export function deleteConfigForPort(port: number): void {
|
||||
const configPath = getConfigPathForPort(port);
|
||||
if (fs.existsSync(configPath)) {
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete config file (default port)
|
||||
*/
|
||||
export function deleteConfig(): void {
|
||||
deleteConfigForPort(CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to user settings file for provider
|
||||
* Example: ~/.ccs/gemini.settings.json
|
||||
@@ -425,7 +448,7 @@ export function getClaudeEnvVars(
|
||||
const coreEnvVars = {
|
||||
// Provider-specific endpoint - routes to correct provider via URL path
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
|
||||
ANTHROPIC_AUTH_TOKEN: CCS_INTERNAL_API_KEY,
|
||||
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
@@ -692,7 +715,7 @@ export function getRemoteEnvVars(
|
||||
...userEnvVars,
|
||||
// Always override URL and auth token with remote config
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY,
|
||||
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(),
|
||||
};
|
||||
|
||||
return env;
|
||||
|
||||
@@ -135,3 +135,16 @@ export { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '.
|
||||
// Startup lock (prevents race conditions between CCS processes)
|
||||
export type { LockResult } from './startup-lock';
|
||||
export { acquireStartupLock, withStartupLock } from './startup-lock';
|
||||
|
||||
// Auth token manager (customizable API key and management secret)
|
||||
export {
|
||||
generateSecureToken,
|
||||
maskToken,
|
||||
getEffectiveApiKey,
|
||||
getEffectiveManagementSecret,
|
||||
setGlobalApiKey,
|
||||
setGlobalManagementSecret,
|
||||
setVariantApiKey,
|
||||
resetAuthToDefaults,
|
||||
getAuthSummary,
|
||||
} from './auth-token-manager';
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { InteractivePrompt } from '../utils/prompt';
|
||||
import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog';
|
||||
@@ -14,7 +15,7 @@ import { CLIProxyProvider } from './types';
|
||||
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
|
||||
|
||||
/** CCS directory */
|
||||
const CCS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.ccs');
|
||||
const CCS_DIR = path.join(os.homedir(), '.ccs');
|
||||
|
||||
/**
|
||||
* Check if provider has user settings configured
|
||||
@@ -34,7 +35,7 @@ export function getCurrentModel(
|
||||
customSettingsPath?: string
|
||||
): string | undefined {
|
||||
const settingsPath = customSettingsPath
|
||||
? customSettingsPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '')
|
||||
? customSettingsPath.replace(/^~/, os.homedir())
|
||||
: getProviderSettingsPath(provider);
|
||||
if (!fs.existsSync(settingsPath)) return undefined;
|
||||
|
||||
@@ -93,7 +94,7 @@ export async function configureProviderModel(
|
||||
|
||||
// Use custom settings path for CLIProxy variants, otherwise use default provider path
|
||||
const settingsPath = customSettingsPath
|
||||
? customSettingsPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '')
|
||||
? customSettingsPath.replace(/^~/, os.homedir())
|
||||
: getProviderSettingsPath(provider);
|
||||
|
||||
// Skip if already configured (unless --config flag)
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
saveUnifiedConfig,
|
||||
isUnifiedMode,
|
||||
} from '../../config/unified-config-loader';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../config-generator';
|
||||
|
||||
/** First port for variant profiles (8318 = default + 1) */
|
||||
export const VARIANT_PORT_BASE = CLIPROXY_DEFAULT_PORT + 1;
|
||||
|
||||
/** Maximum port offset for variants (100 ports: 8318-8417) */
|
||||
export const VARIANT_PORT_MAX_OFFSET = 100;
|
||||
|
||||
/** Variant configuration structure */
|
||||
export interface VariantConfig {
|
||||
@@ -19,6 +26,7 @@ export interface VariantConfig {
|
||||
settings?: string;
|
||||
account?: string;
|
||||
model?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +45,34 @@ export function variantExistsInConfig(name: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next available port for a new variant.
|
||||
* Scans existing variants, returns first unused port starting from VARIANT_PORT_BASE.
|
||||
*/
|
||||
export function getNextAvailablePort(): number {
|
||||
const variants = listVariantsFromConfig();
|
||||
const usedPorts = new Set<number>();
|
||||
|
||||
for (const name of Object.keys(variants)) {
|
||||
const port = variants[name].port;
|
||||
if (port) usedPorts.add(port);
|
||||
}
|
||||
|
||||
// Find first available port in range
|
||||
for (let offset = 0; offset < VARIANT_PORT_MAX_OFFSET; offset++) {
|
||||
const port = VARIANT_PORT_BASE + offset;
|
||||
if (!usedPorts.has(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
const variantCount = Object.keys(variants).length;
|
||||
throw new Error(
|
||||
`Port limit reached (${variantCount}/${VARIANT_PORT_MAX_OFFSET} variants). ` +
|
||||
`Delete unused variants with 'ccs cliproxy remove <name>' to free ports.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List variants from config
|
||||
*/
|
||||
@@ -48,7 +84,12 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
|
||||
const result: Record<string, VariantConfig> = {};
|
||||
for (const name of Object.keys(variants)) {
|
||||
const v = variants[name];
|
||||
result[name] = { provider: v.provider, settings: v.settings, account: v.account };
|
||||
result[name] = {
|
||||
provider: v.provider,
|
||||
settings: v.settings,
|
||||
account: v.account,
|
||||
port: v.port,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -57,8 +98,18 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
|
||||
const variants = config.cliproxy || {};
|
||||
const result: Record<string, VariantConfig> = {};
|
||||
for (const name of Object.keys(variants)) {
|
||||
const v = variants[name] as { provider: string; settings: string; account?: string };
|
||||
result[name] = { provider: v.provider, settings: v.settings, account: v.account };
|
||||
const v = variants[name] as {
|
||||
provider: string;
|
||||
settings: string;
|
||||
account?: string;
|
||||
port?: number;
|
||||
};
|
||||
result[name] = {
|
||||
provider: v.provider,
|
||||
settings: v.settings,
|
||||
account: v.account,
|
||||
port: v.port,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
@@ -73,7 +124,8 @@ export function saveVariantUnified(
|
||||
name: string,
|
||||
provider: CLIProxyProvider,
|
||||
settingsPath: string,
|
||||
account?: string
|
||||
account?: string,
|
||||
port?: number
|
||||
): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
@@ -92,6 +144,7 @@ export function saveVariantUnified(
|
||||
provider,
|
||||
account,
|
||||
settings: settingsPath,
|
||||
port,
|
||||
};
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
@@ -104,7 +157,8 @@ export function saveVariantLegacy(
|
||||
name: string,
|
||||
provider: string,
|
||||
settingsPath: string,
|
||||
account?: string
|
||||
account?: string,
|
||||
port?: number
|
||||
): void {
|
||||
const configPath = getConfigPath();
|
||||
|
||||
@@ -119,13 +173,16 @@ export function saveVariantLegacy(
|
||||
config.cliproxy = {};
|
||||
}
|
||||
|
||||
const variantConfig: { provider: string; settings: string; account?: string } = {
|
||||
const variantConfig: { provider: string; settings: string; account?: string; port?: number } = {
|
||||
provider,
|
||||
settings: settingsPath,
|
||||
};
|
||||
if (account) {
|
||||
variantConfig.account = account;
|
||||
}
|
||||
if (port) {
|
||||
variantConfig.port = port;
|
||||
}
|
||||
config.cliproxy[name] = variantConfig;
|
||||
|
||||
const tempPath = configPath + '.tmp';
|
||||
@@ -147,7 +204,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu
|
||||
delete config.cliproxy.variants[name];
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
return { provider: variant.provider, settings: variant.settings };
|
||||
return { provider: variant.provider, settings: variant.settings, port: variant.port };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,7 +224,7 @@ export function removeVariantFromLegacyConfig(name: string): VariantConfig | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
const variant = config.cliproxy[name] as { provider: string; settings: string };
|
||||
const variant = config.cliproxy[name] as { provider: string; settings: string; port?: number };
|
||||
delete config.cliproxy[name];
|
||||
|
||||
if (Object.keys(config.cliproxy).length === 0) {
|
||||
|
||||
@@ -5,16 +5,20 @@
|
||||
* Supports both unified config (config.yaml) and legacy JSON format.
|
||||
*/
|
||||
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CLIProxyProfileName } from '../../auth/profile-detector';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { isReservedName } from '../../config/reserved-names';
|
||||
import { isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { deleteConfigForPort } from '../config-generator';
|
||||
import { deleteSessionLockForPort } from '../session-tracker';
|
||||
import {
|
||||
createSettingsFile,
|
||||
createSettingsFileUnified,
|
||||
deleteSettingsFile,
|
||||
getRelativeSettingsPath,
|
||||
updateSettingsModel,
|
||||
} from './variant-settings';
|
||||
import {
|
||||
VariantConfig,
|
||||
@@ -24,6 +28,7 @@ import {
|
||||
saveVariantLegacy,
|
||||
removeVariantFromUnifiedConfig,
|
||||
removeVariantFromLegacyConfig,
|
||||
getNextAvailablePort,
|
||||
} from './variant-config-adapter';
|
||||
|
||||
// Re-export VariantConfig from adapter
|
||||
@@ -80,25 +85,29 @@ export function createVariant(
|
||||
account?: string
|
||||
): VariantOperationResult {
|
||||
try {
|
||||
// Allocate unique port for this variant
|
||||
const port = getNextAvailablePort();
|
||||
|
||||
let settingsPath: string;
|
||||
|
||||
if (isUnifiedMode()) {
|
||||
settingsPath = createSettingsFileUnified(name, provider, model);
|
||||
settingsPath = createSettingsFileUnified(name, provider, model, port);
|
||||
saveVariantUnified(
|
||||
name,
|
||||
provider as CLIProxyProvider,
|
||||
getRelativeSettingsPath(provider, name),
|
||||
account
|
||||
account,
|
||||
port
|
||||
);
|
||||
} else {
|
||||
settingsPath = createSettingsFile(name, provider, model);
|
||||
saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account);
|
||||
settingsPath = createSettingsFile(name, provider, model, port);
|
||||
saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account, port);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
settingsPath,
|
||||
variant: { provider, model, account },
|
||||
variant: { provider, model, account, port },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -120,12 +129,22 @@ export function removeVariant(name: string): VariantOperationResult {
|
||||
if (unifiedVariant?.settings) {
|
||||
deleteSettingsFile(unifiedVariant.settings);
|
||||
}
|
||||
// Clean up port-specific config and session files
|
||||
if (unifiedVariant?.port) {
|
||||
deleteConfigForPort(unifiedVariant.port);
|
||||
deleteSessionLockForPort(unifiedVariant.port);
|
||||
}
|
||||
variant = unifiedVariant;
|
||||
} else {
|
||||
variant = removeVariantFromLegacyConfig(name);
|
||||
if (variant?.settings) {
|
||||
deleteSettingsFile(variant.settings);
|
||||
}
|
||||
// Clean up port-specific config and session files
|
||||
if (variant?.port) {
|
||||
deleteConfigForPort(variant.port);
|
||||
deleteSessionLockForPort(variant.port);
|
||||
}
|
||||
}
|
||||
|
||||
if (!variant) {
|
||||
@@ -137,3 +156,67 @@ export function removeVariant(name: string): VariantOperationResult {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Update options for variant */
|
||||
export interface UpdateVariantOptions {
|
||||
provider?: CLIProxyProfileName;
|
||||
account?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing CLIProxy variant
|
||||
*/
|
||||
export function updateVariant(name: string, updates: UpdateVariantOptions): VariantOperationResult {
|
||||
try {
|
||||
const variants = listVariantsFromConfig();
|
||||
const existing = variants[name];
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: `Variant '${name}' not found` };
|
||||
}
|
||||
|
||||
// Update model in settings file if provided
|
||||
if (updates.model !== undefined && existing.settings) {
|
||||
const settingsPath = existing.settings.replace(/^~/, os.homedir());
|
||||
updateSettingsModel(settingsPath, updates.model);
|
||||
}
|
||||
|
||||
// Update config entry if provider or account changed
|
||||
if (updates.provider !== undefined || updates.account !== undefined) {
|
||||
const newProvider = updates.provider ?? existing.provider;
|
||||
const newAccount = updates.account !== undefined ? updates.account : existing.account;
|
||||
|
||||
if (isUnifiedMode()) {
|
||||
saveVariantUnified(
|
||||
name,
|
||||
newProvider as CLIProxyProvider,
|
||||
existing.settings || '',
|
||||
newAccount || undefined,
|
||||
existing.port
|
||||
);
|
||||
} else {
|
||||
saveVariantLegacy(
|
||||
name,
|
||||
newProvider,
|
||||
existing.settings || '',
|
||||
newAccount || undefined,
|
||||
existing.port
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
variant: {
|
||||
provider: updates.provider ?? existing.provider,
|
||||
model: updates.model ?? existing.model,
|
||||
account: updates.account !== undefined ? updates.account : existing.account,
|
||||
port: existing.port,
|
||||
settings: existing.settings,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,12 @@ interface SettingsFile {
|
||||
/**
|
||||
* Build settings env object for a variant
|
||||
*/
|
||||
function buildSettingsEnv(provider: CLIProxyProfileName, model: string): SettingsEnv {
|
||||
const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT);
|
||||
function buildSettingsEnv(
|
||||
provider: CLIProxyProfileName,
|
||||
model: string,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): SettingsEnv {
|
||||
const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, port);
|
||||
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
|
||||
@@ -88,13 +92,14 @@ export function getRelativeSettingsPath(provider: CLIProxyProfileName, name: str
|
||||
export function createSettingsFile(
|
||||
name: string,
|
||||
provider: CLIProxyProfileName,
|
||||
model: string
|
||||
model: string,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): string {
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = getSettingsFilePath(provider, name);
|
||||
|
||||
const settings: SettingsFile = {
|
||||
env: buildSettingsEnv(provider, model),
|
||||
env: buildSettingsEnv(provider, model, port),
|
||||
};
|
||||
|
||||
ensureDir(ccsDir);
|
||||
@@ -109,13 +114,14 @@ export function createSettingsFile(
|
||||
export function createSettingsFileUnified(
|
||||
name: string,
|
||||
provider: CLIProxyProfileName,
|
||||
model: string
|
||||
model: string,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): string {
|
||||
const ccsDir = path.join(os.homedir(), '.ccs');
|
||||
const ccsDir = getCcsDir(); // Use centralized function for CCS_HOME support
|
||||
const settingsPath = path.join(ccsDir, getSettingsFileName(provider, name));
|
||||
|
||||
const settings: SettingsFile = {
|
||||
env: buildSettingsEnv(provider, model),
|
||||
env: buildSettingsEnv(provider, model, port),
|
||||
};
|
||||
|
||||
ensureDir(ccsDir);
|
||||
@@ -136,3 +142,32 @@ export function deleteSettingsFile(settingsPath: string): boolean {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update model in an existing settings file
|
||||
*/
|
||||
export function updateSettingsModel(settingsPath: string, model: string): void {
|
||||
const resolvedPath = settingsPath.replace(/^~/, os.homedir());
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(resolvedPath, 'utf8');
|
||||
const settings = JSON.parse(content) as SettingsFile;
|
||||
|
||||
if (model) {
|
||||
settings.env = settings.env || ({} as SettingsEnv);
|
||||
settings.env.ANTHROPIC_MODEL = model;
|
||||
settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
|
||||
settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
|
||||
} else {
|
||||
// Clear model settings to use defaults
|
||||
delete (settings.env as unknown as Record<string, string>).ANTHROPIC_MODEL;
|
||||
}
|
||||
|
||||
fs.writeFileSync(resolvedPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||
} catch {
|
||||
// Ignore errors - settings file may be invalid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,14 +34,28 @@ function generateSessionId(): string {
|
||||
return crypto.randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
/** Get path to session lock file */
|
||||
function getSessionLockPath(): string {
|
||||
return path.join(getCliproxyDir(), 'sessions.json');
|
||||
/** Get path to session lock file for specific port */
|
||||
function getSessionLockPathForPort(port: number): string {
|
||||
if (port === CLIPROXY_DEFAULT_PORT) {
|
||||
return path.join(getCliproxyDir(), 'sessions.json');
|
||||
}
|
||||
return path.join(getCliproxyDir(), `sessions-${port}.json`);
|
||||
}
|
||||
|
||||
/** Read session lock file (returns null if not exists or invalid) */
|
||||
function readSessionLock(): SessionLock | null {
|
||||
const lockPath = getSessionLockPath();
|
||||
/** Get path to session lock file (default port) - kept for future use */
|
||||
function _getSessionLockPath(): string {
|
||||
return getSessionLockPathForPort(CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
|
||||
// Re-export for external use
|
||||
export { _getSessionLockPath as getSessionLockPath };
|
||||
|
||||
// Export deleteSessionLockForPort for cleanup operations
|
||||
export { deleteSessionLockForPort };
|
||||
|
||||
/** Read session lock file for specific port (returns null if not exists or invalid) */
|
||||
function readSessionLockForPort(port: number): SessionLock | null {
|
||||
const lockPath = getSessionLockPathForPort(port);
|
||||
try {
|
||||
if (!fs.existsSync(lockPath)) {
|
||||
return null;
|
||||
@@ -62,9 +76,14 @@ function readSessionLock(): SessionLock | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Write session lock file */
|
||||
function writeSessionLock(lock: SessionLock): void {
|
||||
const lockPath = getSessionLockPath();
|
||||
/** Read session lock file (default port, returns null if not exists or invalid) */
|
||||
function readSessionLock(): SessionLock | null {
|
||||
return readSessionLockForPort(CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
|
||||
/** Write session lock file for specific port */
|
||||
function writeSessionLockForPort(lock: SessionLock): void {
|
||||
const lockPath = getSessionLockPathForPort(lock.port);
|
||||
const dir = path.dirname(lockPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
@@ -72,9 +91,9 @@ function writeSessionLock(lock: SessionLock): void {
|
||||
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Delete session lock file */
|
||||
function deleteSessionLock(): void {
|
||||
const lockPath = getSessionLockPath();
|
||||
/** Delete session lock file for specific port */
|
||||
function deleteSessionLockForPort(port: number): void {
|
||||
const lockPath = getSessionLockPathForPort(port);
|
||||
try {
|
||||
if (fs.existsSync(lockPath)) {
|
||||
fs.unlinkSync(lockPath);
|
||||
@@ -84,13 +103,24 @@ function deleteSessionLock(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete session lock file (default port) */
|
||||
function deleteSessionLock(): void {
|
||||
deleteSessionLockForPort(CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
|
||||
/** Check if a PID is still running */
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
// Sending signal 0 checks if process exists without killing it
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
// EPERM means process exists but we don't have permission to signal it
|
||||
if (e.code === 'EPERM') {
|
||||
return true;
|
||||
}
|
||||
// ESRCH means no such process
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -100,7 +130,7 @@ function isProcessRunning(pid: number): boolean {
|
||||
* Returns the existing lock if proxy is healthy, null otherwise.
|
||||
*/
|
||||
export function getExistingProxy(port: number): SessionLock | null {
|
||||
const lock = readSessionLock();
|
||||
const lock = readSessionLockForPort(port);
|
||||
if (!lock) {
|
||||
return null;
|
||||
}
|
||||
@@ -113,7 +143,7 @@ export function getExistingProxy(port: number): SessionLock | null {
|
||||
// Verify proxy process is still running
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
// Proxy crashed - clean up stale lock
|
||||
deleteSessionLock();
|
||||
deleteSessionLockForPort(port);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -127,12 +157,12 @@ export function getExistingProxy(port: number): SessionLock | null {
|
||||
*/
|
||||
export function registerSession(port: number, proxyPid: number): string {
|
||||
const sessionId = generateSessionId();
|
||||
const existingLock = readSessionLock();
|
||||
const existingLock = readSessionLockForPort(port);
|
||||
|
||||
if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) {
|
||||
// Add to existing sessions
|
||||
existingLock.sessions.push(sessionId);
|
||||
writeSessionLock(existingLock);
|
||||
writeSessionLockForPort(existingLock);
|
||||
} else {
|
||||
// Create new lock (first session for this proxy)
|
||||
const newLock: SessionLock = {
|
||||
@@ -141,7 +171,7 @@ export function registerSession(port: number, proxyPid: number): string {
|
||||
sessions: [sessionId],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
writeSessionLock(newLock);
|
||||
writeSessionLockForPort(newLock);
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
@@ -149,9 +179,33 @@ export function registerSession(port: number, proxyPid: number): string {
|
||||
|
||||
/**
|
||||
* Unregister a session from the proxy.
|
||||
* @param sessionId Session ID to unregister
|
||||
* @param port Port to unregister from (optional, searches default port if not provided)
|
||||
* @returns true if this was the last session (proxy should be killed)
|
||||
*/
|
||||
export function unregisterSession(sessionId: string): boolean {
|
||||
export function unregisterSession(sessionId: string, port?: number): boolean {
|
||||
// If port provided, use port-specific lookup
|
||||
if (port !== undefined) {
|
||||
const lock = readSessionLockForPort(port);
|
||||
if (!lock) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const index = lock.sessions.indexOf(sessionId);
|
||||
if (index !== -1) {
|
||||
lock.sessions.splice(index, 1);
|
||||
}
|
||||
|
||||
if (lock.sessions.length === 0) {
|
||||
deleteSessionLockForPort(port);
|
||||
return true;
|
||||
}
|
||||
|
||||
writeSessionLockForPort(lock);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fallback: search default port (backward compat)
|
||||
const lock = readSessionLock();
|
||||
if (!lock) {
|
||||
// No lock file - assume we're the only session
|
||||
@@ -172,15 +226,16 @@ export function unregisterSession(sessionId: string): boolean {
|
||||
}
|
||||
|
||||
// Other sessions still active - keep proxy running
|
||||
writeSessionLock(lock);
|
||||
writeSessionLockForPort(lock);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session count for the proxy.
|
||||
* @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT)
|
||||
*/
|
||||
export function getSessionCount(): number {
|
||||
const lock = readSessionLock();
|
||||
export function getSessionCount(port: number = CLIPROXY_DEFAULT_PORT): number {
|
||||
const lock = readSessionLockForPort(port);
|
||||
if (!lock) {
|
||||
return 0;
|
||||
}
|
||||
@@ -190,16 +245,17 @@ export function getSessionCount(): number {
|
||||
/**
|
||||
* Check if proxy has any active sessions.
|
||||
* Used to determine if a "zombie" proxy should be killed.
|
||||
* @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT)
|
||||
*/
|
||||
export function hasActiveSessions(): boolean {
|
||||
const lock = readSessionLock();
|
||||
export function hasActiveSessions(port: number = CLIPROXY_DEFAULT_PORT): boolean {
|
||||
const lock = readSessionLockForPort(port);
|
||||
if (!lock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify proxy is still running
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
deleteSessionLock();
|
||||
deleteSessionLockForPort(port);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -211,38 +267,39 @@ export function hasActiveSessions(): boolean {
|
||||
* Called on startup to ensure clean state.
|
||||
*/
|
||||
export function cleanupOrphanedSessions(port: number): void {
|
||||
const lock = readSessionLock();
|
||||
const lock = readSessionLockForPort(port);
|
||||
if (!lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If port doesn't match, this lock is for a different proxy
|
||||
// If port doesn't match, this shouldn't happen with port-specific files
|
||||
if (lock.port !== port) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If proxy is dead, clean up lock
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
deleteSessionLock();
|
||||
deleteSessionLockForPort(port);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the CLIProxy process and clean up session lock.
|
||||
* Falls back to port-based detection if no session lock exists.
|
||||
* @param port Port to stop (defaults to CLIPROXY_DEFAULT_PORT)
|
||||
* @returns Object with success status and details
|
||||
*/
|
||||
export async function stopProxy(): Promise<{
|
||||
export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
||||
stopped: boolean;
|
||||
pid?: number;
|
||||
sessionCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const lock = readSessionLock();
|
||||
const lock = readSessionLockForPort(port);
|
||||
|
||||
if (!lock) {
|
||||
// No session lock - try to find process by port (legacy/untracked proxy)
|
||||
const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT);
|
||||
const portProcess = await getPortProcess(port);
|
||||
|
||||
if (!portProcess) {
|
||||
return { stopped: false, error: 'No active CLIProxy session found' };
|
||||
@@ -251,7 +308,7 @@ export async function stopProxy(): Promise<{
|
||||
if (!isCLIProxyProcess(portProcess)) {
|
||||
return {
|
||||
stopped: false,
|
||||
error: `Port ${CLIPROXY_DEFAULT_PORT} is in use by ${portProcess.processName}, not CLIProxy`,
|
||||
error: `Port ${port} is in use by ${portProcess.processName}, not CLIProxy`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,7 +327,7 @@ export async function stopProxy(): Promise<{
|
||||
|
||||
// Check if proxy is running
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
deleteSessionLock();
|
||||
deleteSessionLockForPort(port);
|
||||
return { stopped: false, error: 'CLIProxy was not running (cleaned up stale lock)' };
|
||||
}
|
||||
|
||||
@@ -282,14 +339,14 @@ export async function stopProxy(): Promise<{
|
||||
process.kill(pid, 'SIGTERM');
|
||||
|
||||
// Clean up session lock
|
||||
deleteSessionLock();
|
||||
deleteSessionLockForPort(port);
|
||||
|
||||
return { stopped: true, pid, sessionCount };
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === 'ESRCH') {
|
||||
// Process already gone
|
||||
deleteSessionLock();
|
||||
deleteSessionLockForPort(port);
|
||||
return { stopped: false, error: 'CLIProxy process already terminated' };
|
||||
}
|
||||
return { stopped: false, pid, error: `Failed to stop: ${error.message}` };
|
||||
@@ -297,16 +354,16 @@ export async function stopProxy(): Promise<{
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy status information.
|
||||
* Get proxy status information for specific port.
|
||||
*/
|
||||
export function getProxyStatus(): {
|
||||
export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
|
||||
running: boolean;
|
||||
port?: number;
|
||||
pid?: number;
|
||||
sessionCount?: number;
|
||||
startedAt?: string;
|
||||
} {
|
||||
const lock = readSessionLock();
|
||||
const lock = readSessionLockForPort(port);
|
||||
|
||||
if (!lock) {
|
||||
return { running: false };
|
||||
@@ -314,7 +371,7 @@ export function getProxyStatus(): {
|
||||
|
||||
// Verify proxy is still running
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
deleteSessionLock();
|
||||
deleteSessionLockForPort(port);
|
||||
return { running: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Requires usage-statistics-enabled: true in config.yaml.
|
||||
*/
|
||||
|
||||
import { CCS_CONTROL_PANEL_SECRET } from './config-generator';
|
||||
import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager';
|
||||
import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver';
|
||||
|
||||
/** Per-account usage statistics */
|
||||
@@ -112,7 +112,7 @@ export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats |
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
@@ -244,10 +244,10 @@ export async function fetchCliproxyModels(port?: number): Promise<CliproxyModels
|
||||
}
|
||||
const url = buildProxyUrl(target, '/v1/models');
|
||||
|
||||
// For /v1 endpoints: use remote auth token for remote, ccs-internal-managed for local
|
||||
// For /v1 endpoints: use remote auth token for remote, effective API key for local
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: 'Bearer ccs-internal-managed' };
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveApiKey()}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
@@ -325,7 +325,7 @@ export async function fetchCliproxyErrorLogs(port?: number): Promise<CliproxyErr
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
@@ -373,7 +373,7 @@ export async function fetchCliproxyErrorLogContent(
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
: { Authorization: `Bearer ${getEffectiveManagementSecret()}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -256,9 +256,10 @@ async function handleCreate(args: string[]): Promise<void> {
|
||||
const settingsDisplay = isUnifiedMode()
|
||||
? '~/.ccs/config.yaml'
|
||||
: `~/.ccs/${path.basename(result.settingsPath || '')}`;
|
||||
const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : '';
|
||||
console.log(
|
||||
infoBox(
|
||||
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
|
||||
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
|
||||
configType
|
||||
)
|
||||
);
|
||||
@@ -303,10 +304,11 @@ async function handleList(): Promise<void> {
|
||||
console.log(subheader('Custom Variants'));
|
||||
const rows = variantNames.map((name) => {
|
||||
const variant = variants[name];
|
||||
return [name, variant.provider, variant.settings || '-'];
|
||||
const portStr = variant.port ? String(variant.port) : '-';
|
||||
return [name, variant.provider, portStr, variant.settings || '-'];
|
||||
});
|
||||
console.log(
|
||||
table(rows, { head: ['Variant', 'Provider', 'Settings'], colWidths: [15, 12, 35] })
|
||||
table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] })
|
||||
);
|
||||
console.log('');
|
||||
console.log(dim(`Total: ${variantNames.length} custom variant(s)`));
|
||||
@@ -357,6 +359,9 @@ async function handleRemove(args: string[]): Promise<void> {
|
||||
console.log('');
|
||||
console.log(`Variant '${color(name, 'command')}' will be removed.`);
|
||||
console.log(` Provider: ${variant.provider}`);
|
||||
if (variant.port) {
|
||||
console.log(` Port: ${variant.port}`);
|
||||
}
|
||||
console.log(` Settings: ${variant.settings || '-'}`);
|
||||
console.log('');
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Tokens Command
|
||||
*
|
||||
* Manage CLIProxyAPI auth tokens (API key and management secret).
|
||||
*
|
||||
* Usage:
|
||||
* ccs tokens Show current tokens (masked)
|
||||
* ccs tokens --show Show tokens unmasked
|
||||
* ccs tokens --api-key <key> Set global API key
|
||||
* ccs tokens --secret <key> Set management secret
|
||||
* ccs tokens --regenerate-secret Auto-generate new management secret
|
||||
* ccs tokens --reset Reset to defaults
|
||||
* ccs tokens --variant <name> --api-key <key> Set per-variant API key
|
||||
*/
|
||||
|
||||
import { initUI, ok, info, fail, warn, color, dim, header, subheader } from '../utils/ui';
|
||||
import {
|
||||
generateSecureToken,
|
||||
maskToken,
|
||||
setGlobalApiKey,
|
||||
setGlobalManagementSecret,
|
||||
setVariantApiKey,
|
||||
resetAuthToDefaults,
|
||||
getAuthSummary,
|
||||
} from '../cliproxy';
|
||||
import { regenerateConfig } from '../cliproxy/config-generator';
|
||||
|
||||
/**
|
||||
* Display current auth status
|
||||
*/
|
||||
async function showAuthStatus(showUnmasked: boolean): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
const summary = getAuthSummary();
|
||||
|
||||
console.log(header('CLIProxy Auth Tokens'));
|
||||
console.log('');
|
||||
|
||||
// API Key
|
||||
const apiKeyDisplay = showUnmasked ? summary.apiKey.value : maskToken(summary.apiKey.value);
|
||||
const apiKeyStatus = summary.apiKey.isCustom ? color('(custom)', 'warning') : dim('(default)');
|
||||
console.log(` API Key: ${apiKeyDisplay} ${apiKeyStatus}`);
|
||||
|
||||
// Management Secret
|
||||
const secretDisplay = showUnmasked
|
||||
? summary.managementSecret.value
|
||||
: maskToken(summary.managementSecret.value);
|
||||
const secretStatus = summary.managementSecret.isCustom
|
||||
? color('(custom)', 'warning')
|
||||
: dim('(default)');
|
||||
console.log(` Management Secret: ${secretDisplay} ${secretStatus}`);
|
||||
|
||||
console.log('');
|
||||
|
||||
if (showUnmasked) {
|
||||
console.log(warn('Tokens shown in plain text. Do not share!'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(subheader('Usage'));
|
||||
console.log(` ${dim('Set API key:')} ccs tokens --api-key <key>`);
|
||||
console.log(` ${dim('Set secret:')} ccs tokens --secret <key>`);
|
||||
console.log(` ${dim('Generate secret:')} ccs tokens --regenerate-secret`);
|
||||
console.log(` ${dim('Reset to defaults:')} ccs tokens --reset`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tokens command
|
||||
*/
|
||||
export async function handleTokensCommand(args: string[]): Promise<number> {
|
||||
await initUI();
|
||||
|
||||
// Parse flags
|
||||
const showFlag = args.includes('--show');
|
||||
const resetFlag = args.includes('--reset');
|
||||
const regenerateSecretFlag = args.includes('--regenerate-secret');
|
||||
const helpFlag = args.includes('--help') || args.includes('-h');
|
||||
|
||||
// Find --api-key value
|
||||
const apiKeyIndex = args.indexOf('--api-key');
|
||||
const apiKeyValue = apiKeyIndex !== -1 ? args[apiKeyIndex + 1] : undefined;
|
||||
|
||||
// Find --secret value
|
||||
const secretIndex = args.indexOf('--secret');
|
||||
const secretValue = secretIndex !== -1 ? args[secretIndex + 1] : undefined;
|
||||
|
||||
// Find --variant value
|
||||
const variantIndex = args.indexOf('--variant');
|
||||
const variantValue = variantIndex !== -1 ? args[variantIndex + 1] : undefined;
|
||||
|
||||
// Help
|
||||
if (helpFlag) {
|
||||
console.log(header('CCS Tokens Management'));
|
||||
console.log('');
|
||||
console.log(subheader('Usage'));
|
||||
console.log(` ${color('ccs tokens', 'command')} [options]`);
|
||||
console.log('');
|
||||
console.log(subheader('Options'));
|
||||
console.log(` ${color('(no args)', 'command')} Show masked tokens`);
|
||||
console.log(` ${color('--show', 'command')} Show tokens unmasked`);
|
||||
console.log(` ${color('--api-key <key>', 'command')} Set global API key`);
|
||||
console.log(` ${color('--secret <key>', 'command')} Set management secret`);
|
||||
console.log(` ${color('--regenerate-secret', 'command')} Generate new management secret`);
|
||||
console.log(
|
||||
` ${color('--variant <name>', 'command')} Target specific variant (with --api-key)`
|
||||
);
|
||||
console.log(` ${color('--reset', 'command')} Reset all to defaults`);
|
||||
console.log(` ${color('--help, -h', 'command')} Show this help`);
|
||||
console.log('');
|
||||
console.log(subheader('Examples'));
|
||||
console.log(` ${dim('# View current tokens')}`);
|
||||
console.log(` ccs tokens`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Set custom API key')}`);
|
||||
console.log(` ccs tokens --api-key my-custom-key-123`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Generate secure management secret')}`);
|
||||
console.log(` ccs tokens --regenerate-secret`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Set per-variant API key')}`);
|
||||
console.log(` ccs tokens --variant my-gemini --api-key variant-key-456`);
|
||||
console.log('');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Reset to defaults
|
||||
if (resetFlag) {
|
||||
resetAuthToDefaults();
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
console.log(ok('Auth tokens reset to defaults'));
|
||||
console.log(info('CLIProxy config regenerated'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Regenerate management secret
|
||||
if (regenerateSecretFlag) {
|
||||
const newSecret = generateSecureToken(32);
|
||||
setGlobalManagementSecret(newSecret);
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
console.log(ok('New management secret generated'));
|
||||
console.log(` Secret: ${maskToken(newSecret)}`);
|
||||
console.log(info('CLIProxy config regenerated'));
|
||||
console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Set API key
|
||||
if (apiKeyValue !== undefined) {
|
||||
if (!apiKeyValue || apiKeyValue.startsWith('-')) {
|
||||
console.error(fail('Missing value for --api-key'));
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (variantValue) {
|
||||
// Per-variant API key
|
||||
try {
|
||||
setVariantApiKey(variantValue, apiKeyValue);
|
||||
console.log(ok(`API key set for variant '${variantValue}'`));
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error(fail(error));
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// Global API key
|
||||
setGlobalApiKey(apiKeyValue);
|
||||
console.log(ok('Global API key updated'));
|
||||
}
|
||||
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
console.log(info('CLIProxy config regenerated'));
|
||||
console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Set management secret
|
||||
if (secretValue !== undefined) {
|
||||
if (!secretValue || secretValue.startsWith('-')) {
|
||||
console.error(fail('Missing value for --secret'));
|
||||
return 1;
|
||||
}
|
||||
|
||||
setGlobalManagementSecret(secretValue);
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
console.log(ok('Management secret updated'));
|
||||
console.log(info('CLIProxy config regenerated'));
|
||||
console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Default: show status
|
||||
await showAuthStatus(showFlag);
|
||||
return 0;
|
||||
}
|
||||
@@ -131,6 +131,8 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
request_log:
|
||||
partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false,
|
||||
},
|
||||
// Auth config - preserve user values, no defaults (uses constants as fallback)
|
||||
auth: partial.cliproxy?.auth,
|
||||
},
|
||||
preferences: {
|
||||
...defaults.preferences,
|
||||
@@ -231,32 +233,8 @@ export function loadOrCreateUnifiedConfig(): UnifiedConfig {
|
||||
* Generate YAML header with helpful comments.
|
||||
*/
|
||||
function generateYamlHeader(): string {
|
||||
return `# ============================================================================
|
||||
# CCS Unified Configuration (config.yaml)
|
||||
# ============================================================================
|
||||
# Generated by: ccs migrate
|
||||
# Documentation: https://github.com/kaitranntt/ccs
|
||||
#
|
||||
# This file references your settings - actual env vars are in *.settings.json
|
||||
# files (matching Claude's ~/.claude/settings.json pattern).
|
||||
#
|
||||
# To customize a profile:
|
||||
# 1. Edit the *.settings.json file directly (e.g., ~/.ccs/glm.settings.json)
|
||||
# 2. The file format matches Claude's settings.json: { "env": { ... } }\n#
|
||||
# Structure:
|
||||
# ┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
# │ profiles - References to *.settings.json files for API providers │
|
||||
# │ cliproxy - References to *.settings.json files for OAuth providers │
|
||||
# │ accounts - Isolated Claude instances (managed by 'ccs auth') │
|
||||
# │ preferences - User preferences (theme, telemetry, auto-update) │
|
||||
# └─────────────────────────────────────────────────────────────────────────────┘
|
||||
#
|
||||
# Usage:
|
||||
# ccs <profile> Switch to profile
|
||||
# ccs api add <name> Add new API profile
|
||||
# ccs cliproxy create Create CLIProxy variant
|
||||
# ccs migrate --rollback Restore from backup
|
||||
#
|
||||
return `# CCS Unified Configuration
|
||||
# Docs: https://github.com/kaitranntt/ccs
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
* Version 3 = WebSearch config with model configuration for Gemini/OpenCode
|
||||
* Version 4 = Copilot API integration (GitHub Copilot proxy)
|
||||
* Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI)
|
||||
* Version 6 = Customizable auth tokens (API key and management secret)
|
||||
*/
|
||||
export const UNIFIED_CONFIG_VERSION = 5;
|
||||
export const UNIFIED_CONFIG_VERSION = 6;
|
||||
|
||||
/**
|
||||
* Account configuration (formerly in profiles.json).
|
||||
@@ -63,6 +64,21 @@ export interface CLIProxyVariantConfig {
|
||||
account?: string;
|
||||
/** Path to settings file (e.g., "~/.ccs/gemini-custom.settings.json") */
|
||||
settings?: string;
|
||||
/** Unique port for variant isolation (8318-8417) */
|
||||
port?: number;
|
||||
/** Per-variant auth override (optional) */
|
||||
auth?: CLIProxyAuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy authentication configuration.
|
||||
* Allows customization of API key and management secret for CLIProxyAPI.
|
||||
*/
|
||||
export interface CLIProxyAuthConfig {
|
||||
/** API key for CCS-managed requests (default: 'ccs-internal-managed') */
|
||||
api_key?: string;
|
||||
/** Management secret for Control Panel login (default: 'ccs') */
|
||||
management_secret?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,6 +107,8 @@ export interface CLIProxyConfig {
|
||||
logging?: CLIProxyLoggingConfig;
|
||||
/** Kiro: disable incognito browser mode (use normal browser to save credentials) */
|
||||
kiro_no_incognito?: boolean;
|
||||
/** Global auth configuration for CLIProxyAPI */
|
||||
auth?: CLIProxyAuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ok, fail, warn } from '../../utils/ui';
|
||||
import { ok, fail, warn, info } from '../../utils/ui';
|
||||
import { HealthCheck, IHealthChecker, createSpinner } from './types';
|
||||
|
||||
const ora = createSpinner();
|
||||
|
||||
/**
|
||||
* Check CCS config files exist and are valid JSON
|
||||
* Check CCS config files exist and are valid
|
||||
* - Prefers config.yaml (v2) over config.json (legacy)
|
||||
* - Settings files are optional (only checked if profile exists)
|
||||
*/
|
||||
export class ConfigFilesChecker implements IHealthChecker {
|
||||
name = 'Config Files';
|
||||
@@ -22,63 +24,139 @@ export class ConfigFilesChecker implements IHealthChecker {
|
||||
}
|
||||
|
||||
run(results: HealthCheck): void {
|
||||
const files = [
|
||||
{ path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' },
|
||||
{
|
||||
path: path.join(this.ccsDir, 'glm.settings.json'),
|
||||
name: 'glm.settings.json',
|
||||
key: 'GLM Settings',
|
||||
profile: 'glm',
|
||||
},
|
||||
{
|
||||
path: path.join(this.ccsDir, 'kimi.settings.json'),
|
||||
name: 'kimi.settings.json',
|
||||
key: 'Kimi Settings',
|
||||
profile: 'kimi',
|
||||
},
|
||||
];
|
||||
|
||||
const { DelegationValidator } = require('../../utils/delegation-validator');
|
||||
|
||||
for (const file of files) {
|
||||
const spinner = ora(`Checking ${file.name}`).start();
|
||||
// Check main config file (yaml preferred, json fallback)
|
||||
this.checkMainConfig(results);
|
||||
|
||||
if (!fs.existsSync(file.path)) {
|
||||
// Check optional settings files (only if profile exists in config)
|
||||
this.checkOptionalSettingsFiles(results, DelegationValidator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check main configuration file (config.yaml preferred, config.json fallback)
|
||||
*/
|
||||
private checkMainConfig(results: HealthCheck): void {
|
||||
const configYamlPath = path.join(this.ccsDir, 'config.yaml');
|
||||
const configJsonPath = path.join(this.ccsDir, 'config.json');
|
||||
|
||||
const yamlExists = fs.existsSync(configYamlPath);
|
||||
const jsonExists = fs.existsSync(configJsonPath);
|
||||
|
||||
// Check config.yaml first (preferred format)
|
||||
if (yamlExists) {
|
||||
const spinner = ora('Checking config.yaml').start();
|
||||
try {
|
||||
const yaml = require('js-yaml');
|
||||
const content = fs.readFileSync(configYamlPath, 'utf8');
|
||||
yaml.load(content);
|
||||
spinner.succeed();
|
||||
console.log(` ${ok('config.yaml'.padEnd(22))} Valid`);
|
||||
results.addCheck('config.yaml', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: 'Valid',
|
||||
});
|
||||
|
||||
// Inform if legacy config.json also exists (purely informational, not a check)
|
||||
if (jsonExists) {
|
||||
console.log(` ${info('config.json'.padEnd(22))} Legacy (ignored)`);
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
spinner.fail();
|
||||
console.log(` ${fail(file.name.padEnd(22))} Not found`);
|
||||
console.log(` ${fail('config.yaml'.padEnd(22))} Invalid YAML`);
|
||||
results.addCheck(
|
||||
file.name,
|
||||
'config.yaml',
|
||||
'error',
|
||||
`${file.name} not found`,
|
||||
'Run: npm install -g @kaitranntt/ccs --force',
|
||||
{ status: 'ERROR', info: 'Not found' }
|
||||
`Invalid YAML: ${(e as Error).message}`,
|
||||
`Backup and recreate: mv ${configYamlPath} ${configYamlPath}.backup && npm install -g @kaitranntt/ccs --force`,
|
||||
{ status: 'ERROR', info: 'Invalid YAML' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to config.json (legacy format)
|
||||
if (jsonExists) {
|
||||
const spinner = ora('Checking config.json').start();
|
||||
try {
|
||||
const content = fs.readFileSync(configJsonPath, 'utf8');
|
||||
JSON.parse(content);
|
||||
spinner.succeed();
|
||||
console.log(` ${ok('config.json'.padEnd(22))} Valid (legacy)`);
|
||||
results.addCheck('config.json', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: 'Valid (legacy)',
|
||||
});
|
||||
} catch (e) {
|
||||
spinner.fail();
|
||||
console.log(` ${fail('config.json'.padEnd(22))} Invalid JSON`);
|
||||
results.addCheck(
|
||||
'config.json',
|
||||
'error',
|
||||
`Invalid JSON: ${(e as Error).message}`,
|
||||
`Backup and recreate: mv ${configJsonPath} ${configJsonPath}.backup && npm install -g @kaitranntt/ccs --force`,
|
||||
{ status: 'ERROR', info: 'Invalid JSON' }
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither exists - error
|
||||
const spinner = ora('Checking config').start();
|
||||
spinner.fail();
|
||||
console.log(` ${fail('config.yaml'.padEnd(22))} Not found`);
|
||||
results.addCheck(
|
||||
'config.yaml',
|
||||
'error',
|
||||
'No configuration file found (config.yaml or config.json)',
|
||||
'Run: npm install -g @kaitranntt/ccs --force',
|
||||
{ status: 'ERROR', info: 'Not found' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check optional settings files (only if corresponding profile is configured)
|
||||
* These files are NOT required - they're only created when user configures a profile
|
||||
*/
|
||||
private checkOptionalSettingsFiles(
|
||||
results: HealthCheck,
|
||||
DelegationValidator: { validate: (profile: string) => { valid: boolean; error?: string } }
|
||||
): void {
|
||||
// Settings files to check (only if profile exists)
|
||||
const settingsFiles = [
|
||||
{ name: 'glm.settings.json', profile: 'glm', displayName: 'GLM Settings' },
|
||||
{ name: 'kimi.settings.json', profile: 'kimi', displayName: 'Kimi Settings' },
|
||||
];
|
||||
|
||||
for (const file of settingsFiles) {
|
||||
const filePath = path.join(this.ccsDir, file.name);
|
||||
const exists = fs.existsSync(filePath);
|
||||
|
||||
if (!exists) {
|
||||
// Not an error - these are optional files
|
||||
// Only show info if user might expect them
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate JSON
|
||||
// File exists - validate it
|
||||
const spinner = ora(`Checking ${file.name}`).start();
|
||||
try {
|
||||
const content = fs.readFileSync(file.path, 'utf8');
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
JSON.parse(content);
|
||||
|
||||
// Extract useful info based on file type
|
||||
let fileInfo = 'Valid';
|
||||
// Check if API key is properly configured
|
||||
const validation = DelegationValidator.validate(file.profile);
|
||||
|
||||
let fileInfo = 'Valid JSON';
|
||||
let status: 'OK' | 'WARN' = 'OK';
|
||||
|
||||
if (file.profile) {
|
||||
// For settings files, check if API key is configured
|
||||
const validation = DelegationValidator.validate(file.profile);
|
||||
|
||||
if (validation.valid) {
|
||||
fileInfo = 'Key configured';
|
||||
status = 'OK';
|
||||
} else if (validation.error && validation.error.includes('placeholder')) {
|
||||
fileInfo = 'Placeholder key';
|
||||
status = 'WARN';
|
||||
} else {
|
||||
fileInfo = 'Valid JSON';
|
||||
status = 'OK';
|
||||
}
|
||||
if (validation.valid) {
|
||||
fileInfo = 'Key configured';
|
||||
status = 'OK';
|
||||
} else if (validation.error && validation.error.includes('placeholder')) {
|
||||
fileInfo = 'Placeholder key';
|
||||
status = 'WARN';
|
||||
}
|
||||
|
||||
if (status === 'WARN') {
|
||||
@@ -100,7 +178,7 @@ export class ConfigFilesChecker implements IHealthChecker {
|
||||
file.name,
|
||||
'error',
|
||||
`Invalid JSON: ${(e as Error).message}`,
|
||||
`Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`,
|
||||
`Backup and recreate: mv ${filePath} ${filePath}.backup`,
|
||||
{ status: 'ERROR', info: 'Invalid JSON' }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { HealthCheck, IHealthChecker, createSpinner } from './types';
|
||||
const ora = createSpinner();
|
||||
|
||||
/**
|
||||
* Check profile configurations in config.json
|
||||
* Check profile configurations in config.yaml (preferred) or config.json (legacy)
|
||||
*/
|
||||
export class ProfilesChecker implements IHealthChecker {
|
||||
name = 'Profiles';
|
||||
@@ -23,47 +23,102 @@ export class ProfilesChecker implements IHealthChecker {
|
||||
|
||||
run(results: HealthCheck): void {
|
||||
const spinner = ora('Checking profiles').start();
|
||||
const configPath = path.join(this.ccsDir, 'config.json');
|
||||
const configYamlPath = path.join(this.ccsDir, 'config.yaml');
|
||||
const configJsonPath = path.join(this.ccsDir, 'config.json');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
spinner.info();
|
||||
console.log(` ${info('Profiles'.padEnd(22))} config.json not found`);
|
||||
return;
|
||||
}
|
||||
const yamlExists = fs.existsSync(configYamlPath);
|
||||
const jsonExists = fs.existsSync(configJsonPath);
|
||||
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
if (!config.profiles || typeof config.profiles !== 'object') {
|
||||
// Check config.yaml first (preferred format)
|
||||
if (yamlExists) {
|
||||
try {
|
||||
const yaml = require('js-yaml');
|
||||
const content = fs.readFileSync(configYamlPath, 'utf8');
|
||||
const config = yaml.load(content) as Record<string, unknown>;
|
||||
this.validateProfiles(config, 'config.yaml', spinner, results);
|
||||
return;
|
||||
} catch (e) {
|
||||
spinner.fail();
|
||||
console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`);
|
||||
console.log(
|
||||
` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${(e as Error).message}`
|
||||
);
|
||||
results.addCheck(
|
||||
'Profiles',
|
||||
'error',
|
||||
'config.json missing profiles object',
|
||||
'Run: npm install -g @kaitranntt/ccs --force',
|
||||
{ status: 'ERROR', info: 'Missing profiles object' }
|
||||
`Invalid config.yaml: ${(e as Error).message}`,
|
||||
undefined,
|
||||
{
|
||||
status: 'ERROR',
|
||||
info: (e as Error).message,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const profileCount = Object.keys(config.profiles).length;
|
||||
const profileNames = Object.keys(config.profiles).join(', ');
|
||||
|
||||
spinner.succeed();
|
||||
console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`);
|
||||
results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, undefined, {
|
||||
status: 'OK',
|
||||
info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`,
|
||||
});
|
||||
} catch (e) {
|
||||
spinner.fail();
|
||||
console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`);
|
||||
results.addCheck('Profiles', 'error', (e as Error).message, undefined, {
|
||||
status: 'ERROR',
|
||||
info: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to config.json (legacy format)
|
||||
if (jsonExists) {
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(configJsonPath, 'utf8'));
|
||||
this.validateProfiles(config, 'config.json', spinner, results);
|
||||
return;
|
||||
} catch (e) {
|
||||
spinner.fail();
|
||||
console.log(
|
||||
` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${(e as Error).message}`
|
||||
);
|
||||
results.addCheck(
|
||||
'Profiles',
|
||||
'error',
|
||||
`Invalid config.json: ${(e as Error).message}`,
|
||||
undefined,
|
||||
{
|
||||
status: 'ERROR',
|
||||
info: (e as Error).message,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Neither exists
|
||||
spinner.info();
|
||||
console.log(
|
||||
` ${info('Profiles'.padEnd(22))} No config file found (config.yaml or config.json)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate profiles object from parsed config
|
||||
*/
|
||||
private validateProfiles(
|
||||
config: Record<string, unknown>,
|
||||
configFileName: string,
|
||||
spinner: ReturnType<ReturnType<typeof ora>['start']>,
|
||||
results: HealthCheck
|
||||
): void {
|
||||
if (!config.profiles || typeof config.profiles !== 'object') {
|
||||
spinner.fail();
|
||||
console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object in ${configFileName}`);
|
||||
results.addCheck(
|
||||
'Profiles',
|
||||
'error',
|
||||
`${configFileName} missing profiles object`,
|
||||
'Run: npm install -g @kaitranntt/ccs --force',
|
||||
{ status: 'ERROR', info: 'Missing profiles object' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const profileCount = Object.keys(config.profiles as object).length;
|
||||
const profileNames = Object.keys(config.profiles as object).join(', ');
|
||||
|
||||
spinner.succeed();
|
||||
console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`);
|
||||
results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, undefined, {
|
||||
status: 'OK',
|
||||
info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,89 +153,6 @@ class RecoveryManager {
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure GLM settings file exists
|
||||
*/
|
||||
ensureGlmSettings(): boolean {
|
||||
const settingsPath = path.join(this.ccsDir, 'glm.settings.json');
|
||||
if (fs.existsSync(settingsPath)) return false;
|
||||
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
|
||||
ANTHROPIC_AUTH_TOKEN: 'YOUR_GLM_API_KEY_HERE',
|
||||
ANTHROPIC_MODEL: 'glm-4.6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-4.6',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-4.6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.6',
|
||||
},
|
||||
};
|
||||
|
||||
const tmpPath = `${settingsPath}.tmp`;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(tmpPath, settingsPath);
|
||||
this.recovered.push('Created ~/.ccs/glm.settings.json');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure GLMT settings file exists
|
||||
*/
|
||||
ensureGlmtSettings(): boolean {
|
||||
const settingsPath = path.join(this.ccsDir, 'glmt.settings.json');
|
||||
if (fs.existsSync(settingsPath)) return false;
|
||||
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
|
||||
ANTHROPIC_AUTH_TOKEN: 'YOUR_GLM_API_KEY_HERE',
|
||||
ANTHROPIC_MODEL: 'glm-4.6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-4.6',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-4.6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.6',
|
||||
ANTHROPIC_TEMPERATURE: '0.2',
|
||||
ANTHROPIC_MAX_TOKENS: '65536',
|
||||
MAX_THINKING_TOKENS: '32768',
|
||||
ENABLE_STREAMING: 'true',
|
||||
ANTHROPIC_SAFE_MODE: 'false',
|
||||
API_TIMEOUT_MS: '3000000',
|
||||
},
|
||||
alwaysThinkingEnabled: true,
|
||||
};
|
||||
|
||||
const tmpPath = `${settingsPath}.tmp`;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(tmpPath, settingsPath);
|
||||
this.recovered.push('Created ~/.ccs/glmt.settings.json');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Kimi settings file exists
|
||||
*/
|
||||
ensureKimiSettings(): boolean {
|
||||
const settingsPath = path.join(this.ccsDir, 'kimi.settings.json');
|
||||
if (fs.existsSync(settingsPath)) return false;
|
||||
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/',
|
||||
ANTHROPIC_AUTH_TOKEN: 'YOUR_KIMI_API_KEY_HERE',
|
||||
ANTHROPIC_MODEL: 'kimi-k2-thinking-turbo',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'kimi-k2-thinking-turbo',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'kimi-k2-thinking-turbo',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'kimi-k2-thinking-turbo',
|
||||
},
|
||||
alwaysThinkingEnabled: true,
|
||||
};
|
||||
|
||||
const tmpPath = `${settingsPath}.tmp`;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(tmpPath, settingsPath);
|
||||
this.recovered.push('Created ~/.ccs/kimi.settings.json');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install shell completion files
|
||||
*/
|
||||
@@ -327,23 +244,6 @@ class RecoveryManager {
|
||||
console.log(info('Auto-recovery completed:'));
|
||||
this.recovered.forEach((msg) => console.log(` - ${msg}`));
|
||||
|
||||
// Show API key hints if created profile settings
|
||||
const createdGlm = this.recovered.some((msg) => msg.includes('glm.settings.json'));
|
||||
const createdKimi = this.recovered.some((msg) => msg.includes('kimi.settings.json'));
|
||||
|
||||
if (createdGlm || createdKimi) {
|
||||
console.log('');
|
||||
console.log(info('Configure API keys:'));
|
||||
if (createdGlm) {
|
||||
console.log(' GLM: Edit ~/.ccs/glm.settings.json');
|
||||
console.log(' Get key from: https://api.z.ai');
|
||||
}
|
||||
if (createdKimi) {
|
||||
console.log(' Kimi: Edit ~/.ccs/kimi.settings.json');
|
||||
console.log(' Get key from: https://www.kimi.com/coding');
|
||||
}
|
||||
}
|
||||
|
||||
// Show login hint if created Claude settings
|
||||
if (this.recovered.some((msg) => msg.includes('~/.claude/settings.json'))) {
|
||||
console.log('');
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface CLIProxyVariantConfig {
|
||||
settings: string;
|
||||
/** Account identifier for multi-account support (optional, defaults to 'default') */
|
||||
account?: string;
|
||||
/** Unique port for variant isolation (8318-8417) */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,8 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher {
|
||||
|
||||
const watcher = chokidar.watch(
|
||||
[
|
||||
path.join(ccsDir, 'config.json'),
|
||||
path.join(ccsDir, 'config.json'), // Legacy config
|
||||
path.join(ccsDir, 'config.yaml'), // Unified config
|
||||
path.join(ccsDir, '*.settings.json'),
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
path.join(ccsDir, 'cliproxy', 'sessions.json'), // Proxy session tracking
|
||||
@@ -41,7 +42,7 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher {
|
||||
const basename = path.basename(filePath);
|
||||
let type: FileChangeEvent['type'];
|
||||
|
||||
if (basename === 'config.json') {
|
||||
if (basename === 'config.json' || basename === 'config.yaml') {
|
||||
type = 'config-changed';
|
||||
} else if (basename === 'profiles.json') {
|
||||
type = 'profiles-changed';
|
||||
|
||||
@@ -142,6 +142,17 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st
|
||||
return { success: true, message: 'Created ~/.ccs directory' };
|
||||
|
||||
case 'config-file': {
|
||||
// Use appropriate config based on unified mode
|
||||
const { isUnifiedMode } = require('../config/unified-config-loader');
|
||||
if (isUnifiedMode()) {
|
||||
const {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
} = require('../config/unified-config-loader');
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
saveUnifiedConfig(config);
|
||||
return { success: true, message: 'Created/updated config.yaml' };
|
||||
}
|
||||
const configPath = getConfigPath();
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }, null, 2) + '\n');
|
||||
@@ -149,6 +160,12 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st
|
||||
}
|
||||
|
||||
case 'profiles-file': {
|
||||
// Use appropriate storage based on unified mode
|
||||
const { isUnifiedMode: isUnified } = require('../config/unified-config-loader');
|
||||
if (isUnified()) {
|
||||
// In unified mode, accounts are stored in config.yaml
|
||||
return { success: true, message: 'Accounts stored in config.yaml (unified mode)' };
|
||||
}
|
||||
const profilesPath = path.join(ccsDir, 'profiles.json');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(profilesPath, JSON.stringify({ profiles: {} }, null, 2) + '\n');
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
/**
|
||||
* Configuration Health Checks
|
||||
*
|
||||
* Check config.json, settings files, and Claude settings.
|
||||
* Check config.json, config.yaml, settings files, and Claude settings.
|
||||
* Supports both legacy (config.json) and unified (config.yaml) modes.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getConfigPath } from '../../utils/config-manager';
|
||||
import { isUnifiedMode, hasUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import type { HealthCheck } from './types';
|
||||
|
||||
/**
|
||||
* Check config.json file
|
||||
* Check config file (config.json or config.yaml based on mode)
|
||||
*/
|
||||
export function checkConfigFile(): HealthCheck {
|
||||
// In unified mode, check config.yaml
|
||||
if (isUnifiedMode() || hasUnifiedConfig()) {
|
||||
const ccsDir = path.join(os.homedir(), '.ccs');
|
||||
const yamlPath = path.join(ccsDir, 'config.yaml');
|
||||
|
||||
if (!fs.existsSync(yamlPath)) {
|
||||
return {
|
||||
id: 'config-file',
|
||||
name: 'config.yaml',
|
||||
status: 'warning',
|
||||
message: 'Not found (unified mode)',
|
||||
details: yamlPath,
|
||||
fixable: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
fs.readFileSync(yamlPath, 'utf8');
|
||||
return {
|
||||
id: 'config-file',
|
||||
name: 'config.yaml',
|
||||
status: 'ok',
|
||||
message: 'Valid (unified mode)',
|
||||
details: yamlPath,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: 'config-file',
|
||||
name: 'config.yaml',
|
||||
status: 'error',
|
||||
message: 'Cannot read file',
|
||||
details: yamlPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy mode: check config.json
|
||||
const configPath = getConfigPath();
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
|
||||
@@ -2,16 +2,52 @@
|
||||
* Profile Health Checks
|
||||
*
|
||||
* Check profiles, instances, and delegation.
|
||||
* Supports both legacy (config.json) and unified (config.yaml) modes.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { isUnifiedMode, loadUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import type { HealthCheck } from './types';
|
||||
|
||||
/**
|
||||
* Check profiles configuration
|
||||
* Check profiles configuration (API profiles from config.json or config.yaml)
|
||||
*/
|
||||
export function checkProfiles(ccsDir: string): HealthCheck {
|
||||
// Check unified config first
|
||||
if (isUnifiedMode()) {
|
||||
const config = loadUnifiedConfig();
|
||||
if (!config) {
|
||||
return {
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
status: 'info',
|
||||
message: 'config.yaml not loaded',
|
||||
};
|
||||
}
|
||||
|
||||
const profileCount = Object.keys(config.profiles || {}).length;
|
||||
const profileNames = Object.keys(config.profiles || {}).join(', ');
|
||||
|
||||
if (profileCount === 0) {
|
||||
return {
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
status: 'ok',
|
||||
message: 'No API profiles (unified mode)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
status: 'ok',
|
||||
message: `${profileCount} configured (unified)`,
|
||||
details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames,
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy mode: check config.json
|
||||
const configPath = path.join(ccsDir, 'config.json');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
|
||||
@@ -5,12 +5,11 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, loadConfig } from '../utils/config-manager';
|
||||
import { loadConfig } from '../utils/config-manager';
|
||||
import { runHealthChecks } from './health-service';
|
||||
import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth-handler';
|
||||
import { getVersion } from '../utils/version';
|
||||
import ProfileRegistry from '../auth/profile-registry';
|
||||
|
||||
export const overviewRoutes = Router();
|
||||
|
||||
@@ -64,12 +63,16 @@ overviewRoutes.get('/', async (_req: Request, res: Response) => {
|
||||
|
||||
function getAccountCount(): number {
|
||||
try {
|
||||
const profilesPath = path.join(getCcsDir(), 'profiles.json');
|
||||
const registry = new ProfileRegistry();
|
||||
|
||||
if (!fs.existsSync(profilesPath)) return 0;
|
||||
// Merge legacy (profiles.json) and unified (config.yaml) accounts
|
||||
const legacyProfiles = registry.getAllProfiles();
|
||||
const unifiedAccounts = registry.getAllAccountsUnified();
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
|
||||
return Object.keys(data.profiles || {}).length;
|
||||
// Count unique accounts (unified takes precedence for duplicates)
|
||||
const allNames = new Set([...Object.keys(legacyProfiles), ...Object.keys(unifiedAccounts)]);
|
||||
|
||||
return allNames.size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,57 @@
|
||||
/**
|
||||
* Account Routes - CRUD operations for Claude accounts (profiles.json)
|
||||
* Account Routes - CRUD operations for Claude accounts
|
||||
*
|
||||
* Separated from profile-routes.ts to avoid dual-mounting conflicts.
|
||||
* Uses ProfileRegistry to read from both legacy (profiles.json)
|
||||
* and unified config (config.yaml) for consistent data with CLI.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import ProfileRegistry from '../../auth/profile-registry';
|
||||
import { isUnifiedMode } from '../../config/unified-config-loader';
|
||||
|
||||
const router = Router();
|
||||
const registry = new ProfileRegistry();
|
||||
|
||||
/**
|
||||
* GET /api/accounts - List accounts from profiles.json
|
||||
* GET /api/accounts - List accounts from both profiles.json and config.yaml
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const profilesPath = path.join(getCcsDir(), 'profiles.json');
|
||||
// Get profiles from both legacy and unified config (same logic as CLI)
|
||||
const legacyProfiles = registry.getAllProfiles();
|
||||
const unifiedAccounts = registry.getAllAccountsUnified();
|
||||
|
||||
if (!fs.existsSync(profilesPath)) {
|
||||
res.json({ accounts: [], default: null });
|
||||
return;
|
||||
// Merge profiles: unified config takes precedence
|
||||
const merged: Record<string, { type: string; created: string; last_used: string | null }> = {};
|
||||
|
||||
// Add legacy profiles first
|
||||
for (const [name, meta] of Object.entries(legacyProfiles)) {
|
||||
merged[name] = {
|
||||
type: meta.type || 'account',
|
||||
created: meta.created,
|
||||
last_used: meta.last_used || null,
|
||||
};
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
|
||||
const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => {
|
||||
const metadata = meta as Record<string, unknown>;
|
||||
return {
|
||||
name,
|
||||
...metadata,
|
||||
// Override with unified config accounts (takes precedence)
|
||||
for (const [name, account] of Object.entries(unifiedAccounts)) {
|
||||
merged[name] = {
|
||||
type: 'account',
|
||||
created: account.created,
|
||||
last_used: account.last_used,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ accounts, default: data.default || null });
|
||||
// Convert to array format
|
||||
const accounts = Object.entries(merged).map(([name, meta]) => ({
|
||||
name,
|
||||
...meta,
|
||||
}));
|
||||
|
||||
// Get default from unified config first, fallback to legacy
|
||||
const defaultProfile = registry.getDefaultUnified() ?? registry.getDefaultProfile() ?? null;
|
||||
|
||||
res.json({ accounts, default: defaultProfile });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
@@ -50,14 +69,12 @@ router.post('/default', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const profilesPath = path.join(getCcsDir(), 'profiles.json');
|
||||
|
||||
const data = fs.existsSync(profilesPath)
|
||||
? JSON.parse(fs.readFileSync(profilesPath, 'utf8'))
|
||||
: { profiles: {} };
|
||||
|
||||
data.default = name;
|
||||
fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n');
|
||||
// Use unified config if in unified mode, otherwise use legacy
|
||||
if (isUnifiedMode()) {
|
||||
registry.setDefaultUnified(name);
|
||||
} else {
|
||||
registry.setDefaultProfile(name);
|
||||
}
|
||||
|
||||
res.json({ default: name });
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,8 +7,6 @@ import * as path from 'path';
|
||||
import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import type { Config, Settings } from '../../types/config';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import { getClaudeEnvVars } from '../../cliproxy/config-generator';
|
||||
|
||||
/** Model mapping for API profiles */
|
||||
export interface ModelMapping {
|
||||
@@ -156,37 +154,6 @@ export function updateSettingsFile(
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create cliproxy variant settings
|
||||
* Includes base URL and auth token for proper Claude CLI integration
|
||||
*/
|
||||
export function createCliproxySettings(
|
||||
name: string,
|
||||
provider: CLIProxyProvider,
|
||||
model?: string
|
||||
): string {
|
||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||
|
||||
// Get base env vars from provider config (includes BASE_URL, AUTH_TOKEN)
|
||||
const baseEnv = getClaudeEnvVars(provider);
|
||||
|
||||
const settings: Settings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
|
||||
ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
|
||||
ANTHROPIC_MODEL: model || (baseEnv.ANTHROPIC_MODEL as string) || '',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: model || (baseEnv.ANTHROPIC_DEFAULT_OPUS_MODEL as string) || '',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL:
|
||||
model || (baseEnv.ANTHROPIC_DEFAULT_SONNET_MODEL as string) || '',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL:
|
||||
(baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL as string) || model || '',
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
return `~/.ccs/${name}.settings.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security: Validate file path is within allowed directories
|
||||
* - ~/.ccs/ directory: read/write allowed
|
||||
|
||||
@@ -4,13 +4,43 @@
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, loadSettings } from '../../utils/config-manager';
|
||||
import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys';
|
||||
import { listVariants } from '../../cliproxy/services/variant-service';
|
||||
import {
|
||||
generateSecureToken,
|
||||
maskToken,
|
||||
getAuthSummary,
|
||||
setGlobalApiKey,
|
||||
setGlobalManagementSecret,
|
||||
resetAuthToDefaults,
|
||||
} from '../../cliproxy';
|
||||
import { regenerateConfig } from '../../cliproxy/config-generator';
|
||||
import type { Settings } from '../../types/config';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Helper: Resolve settings path for profile or variant
|
||||
* Variants have settings paths in config, regular profiles use {name}.settings.json
|
||||
*/
|
||||
function resolveSettingsPath(profileOrVariant: string): string {
|
||||
const ccsDir = getCcsDir();
|
||||
|
||||
// Check if this is a variant
|
||||
const variants = listVariants();
|
||||
const variant = variants[profileOrVariant];
|
||||
if (variant?.settings) {
|
||||
// Variant settings path (e.g., ~/.ccs/agy-g3.settings.json)
|
||||
return variant.settings.replace(/^~/, os.homedir());
|
||||
}
|
||||
|
||||
// Regular profile settings
|
||||
return path.join(ccsDir, `${profileOrVariant}.settings.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Mask API keys in settings
|
||||
*/
|
||||
@@ -34,8 +64,7 @@ function maskApiKeys(settings: Settings): Settings {
|
||||
router.get('/:profile', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
const settingsPath = resolveSettingsPath(profile);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
@@ -63,8 +92,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
|
||||
router.get('/:profile/raw', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
const settingsPath = resolveSettingsPath(profile);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
@@ -93,7 +121,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
||||
const { profile } = req.params;
|
||||
const { settings, expectedMtime } = req.body;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
const settingsPath = resolveSettingsPath(profile);
|
||||
|
||||
const fileExists = fs.existsSync(settingsPath);
|
||||
|
||||
@@ -151,8 +179,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
||||
router.get('/:profile/presets', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
const settingsPath = resolveSettingsPath(profile);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.json({ presets: [] });
|
||||
@@ -179,11 +206,11 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
const settingsPath = resolveSettingsPath(profile);
|
||||
|
||||
// Create settings file if it doesn't exist
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
|
||||
}
|
||||
|
||||
@@ -205,7 +232,11 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
settings.presets.push(preset);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
|
||||
// Atomic write: temp file + rename
|
||||
const tempPath = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
|
||||
res.status(201).json({ preset });
|
||||
} catch (error) {
|
||||
@@ -219,8 +250,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
|
||||
router.delete('/:profile/presets/:name', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { profile, name } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
const settingsPath = resolveSettingsPath(profile);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
@@ -234,7 +264,11 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void =>
|
||||
}
|
||||
|
||||
settings.presets = settings.presets.filter((p) => p.name !== name);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
|
||||
// Atomic write: temp file + rename
|
||||
const tempPath = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -242,4 +276,142 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void =>
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Auth Tokens ====================
|
||||
|
||||
/**
|
||||
* GET /api/settings/auth/tokens - Get current auth token status (masked)
|
||||
*/
|
||||
router.get('/auth/tokens', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const summary = getAuthSummary();
|
||||
|
||||
res.json({
|
||||
apiKey: {
|
||||
value: maskToken(summary.apiKey.value),
|
||||
isCustom: summary.apiKey.isCustom,
|
||||
},
|
||||
managementSecret: {
|
||||
value: maskToken(summary.managementSecret.value),
|
||||
isCustom: summary.managementSecret.isCustom,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/auth/tokens/raw - Get current auth tokens unmasked
|
||||
* NOTE: Sensitive endpoint - no caching, localhost only
|
||||
*/
|
||||
router.get('/auth/tokens/raw', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
// Prevent caching of sensitive data
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
const summary = getAuthSummary();
|
||||
|
||||
res.json({
|
||||
apiKey: {
|
||||
value: summary.apiKey.value,
|
||||
isCustom: summary.apiKey.isCustom,
|
||||
},
|
||||
managementSecret: {
|
||||
value: summary.managementSecret.value,
|
||||
isCustom: summary.managementSecret.isCustom,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/settings/auth/tokens - Update auth tokens
|
||||
*/
|
||||
router.put('/auth/tokens', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { apiKey, managementSecret } = req.body;
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
setGlobalApiKey(apiKey || undefined);
|
||||
}
|
||||
|
||||
if (managementSecret !== undefined) {
|
||||
setGlobalManagementSecret(managementSecret || undefined);
|
||||
}
|
||||
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
|
||||
const summary = getAuthSummary();
|
||||
res.json({
|
||||
success: true,
|
||||
apiKey: {
|
||||
value: maskToken(summary.apiKey.value),
|
||||
isCustom: summary.apiKey.isCustom,
|
||||
},
|
||||
managementSecret: {
|
||||
value: maskToken(summary.managementSecret.value),
|
||||
isCustom: summary.managementSecret.isCustom,
|
||||
},
|
||||
message: 'Restart CLIProxy to apply changes',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/auth/tokens/regenerate-secret - Generate new management secret
|
||||
*/
|
||||
router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const newSecret = generateSecureToken(32);
|
||||
setGlobalManagementSecret(newSecret);
|
||||
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
managementSecret: {
|
||||
value: maskToken(newSecret),
|
||||
isCustom: true,
|
||||
},
|
||||
message: 'Restart CLIProxy to apply changes',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/auth/tokens/reset - Reset auth tokens to defaults
|
||||
*/
|
||||
router.post('/auth/tokens/reset', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
resetAuthToDefaults();
|
||||
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
|
||||
const summary = getAuthSummary();
|
||||
res.json({
|
||||
success: true,
|
||||
apiKey: {
|
||||
value: maskToken(summary.apiKey.value),
|
||||
isCustom: summary.apiKey.isCustom,
|
||||
},
|
||||
managementSecret: {
|
||||
value: maskToken(summary.managementSecret.value),
|
||||
isCustom: summary.managementSecret.isCustom,
|
||||
},
|
||||
message: 'Tokens reset to defaults. Restart CLIProxy to apply.',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
/**
|
||||
* Variant Routes - CLIProxy variant management (custom profiles)
|
||||
*
|
||||
* Uses variant-service.ts for proper port allocation and cleanup.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, loadSettings } from '../../utils/config-manager';
|
||||
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import { readConfigSafe, writeConfig, createCliproxySettings } from './route-helpers';
|
||||
import {
|
||||
createVariant,
|
||||
removeVariant,
|
||||
listVariants,
|
||||
validateProfileName,
|
||||
updateVariant,
|
||||
} from '../../cliproxy/services/variant-service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy - List cliproxy variants
|
||||
* Uses variant-service for consistent behavior with CLI
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
const config = readConfigSafe();
|
||||
const variants = Object.entries(config.cliproxy || {}).map(([name, variant]) => ({
|
||||
const variants = listVariants();
|
||||
const variantList = Object.entries(variants).map(([name, variant]) => ({
|
||||
name,
|
||||
provider: variant.provider,
|
||||
settings: variant.settings,
|
||||
account: variant.account || 'default', // Include account field
|
||||
account: variant.account || 'default',
|
||||
port: variant.port, // Include port for port isolation
|
||||
model: variant.model,
|
||||
}));
|
||||
|
||||
res.json({ variants });
|
||||
res.json({ variants: variantList });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy - Create cliproxy variant
|
||||
* Uses variant-service for proper port allocation
|
||||
*/
|
||||
router.post('/', (req: Request, res: Response): void => {
|
||||
const { name, provider, model, account } = req.body;
|
||||
@@ -38,7 +47,14 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject reserved names as variant names (prevents collision with built-in providers)
|
||||
// Validate profile name
|
||||
const validationError = validateProfileName(name);
|
||||
if (validationError) {
|
||||
res.status(400).json({ error: validationError });
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject reserved names (extra safety check)
|
||||
if (isReservedName(name)) {
|
||||
res.status(400).json({
|
||||
error: `Cannot use reserved name '${name}' as variant name`,
|
||||
@@ -47,84 +63,47 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = readConfigSafe();
|
||||
config.cliproxy = config.cliproxy || {};
|
||||
// Use variant-service for proper port allocation
|
||||
const result = createVariant(name, provider as CLIProxyProvider, model || '', account);
|
||||
|
||||
if (config.cliproxy[name]) {
|
||||
res.status(409).json({ error: 'Variant already exists' });
|
||||
if (!result.success) {
|
||||
res.status(409).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure .ccs directory exists
|
||||
if (!fs.existsSync(getCcsDir())) {
|
||||
fs.mkdirSync(getCcsDir(), { recursive: true });
|
||||
}
|
||||
|
||||
// Create settings file for variant
|
||||
const settingsPath = createCliproxySettings(name, provider as CLIProxyProvider, model);
|
||||
|
||||
// Include account if specified (defaults to 'default' if not provided)
|
||||
config.cliproxy[name] = {
|
||||
res.status(201).json({
|
||||
name,
|
||||
provider,
|
||||
settings: settingsPath,
|
||||
...(account && { account }),
|
||||
};
|
||||
writeConfig(config);
|
||||
|
||||
res.status(201).json({ name, provider, settings: settingsPath, account: account || 'default' });
|
||||
settings: result.settingsPath,
|
||||
account: account || 'default',
|
||||
port: result.variant?.port,
|
||||
model: result.variant?.model,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/cliproxy/:name - Update cliproxy variant
|
||||
* Uses variant-service for consistent behavior with CLI
|
||||
*/
|
||||
router.put('/:name', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const { provider, account, model } = req.body;
|
||||
|
||||
const config = readConfigSafe();
|
||||
// Use variant-service for proper update handling
|
||||
const result = updateVariant(name, { provider, account, model });
|
||||
|
||||
if (!config.cliproxy?.[name]) {
|
||||
res.status(404).json({ error: 'Variant not found' });
|
||||
if (!result.success) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const variant = config.cliproxy[name];
|
||||
|
||||
// Update fields if provided
|
||||
if (provider) {
|
||||
variant.provider = provider;
|
||||
}
|
||||
if (account !== undefined) {
|
||||
if (account) {
|
||||
variant.account = account;
|
||||
} else {
|
||||
delete variant.account; // Remove account to use default
|
||||
}
|
||||
}
|
||||
|
||||
// Update model in settings file if provided
|
||||
if (model !== undefined) {
|
||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settings = loadSettings(settingsPath);
|
||||
if (model) {
|
||||
settings.env = settings.env || {};
|
||||
settings.env.ANTHROPIC_MODEL = model;
|
||||
} else if (settings.env) {
|
||||
delete settings.env.ANTHROPIC_MODEL;
|
||||
}
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
writeConfig(config);
|
||||
|
||||
res.json({
|
||||
name,
|
||||
provider: variant.provider,
|
||||
account: variant.account || 'default',
|
||||
settings: variant.settings,
|
||||
provider: result.variant?.provider,
|
||||
account: result.variant?.account || 'default',
|
||||
settings: result.variant?.settings,
|
||||
port: result.variant?.port,
|
||||
updated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -134,31 +113,21 @@ router.put('/:name', (req: Request, res: Response): void => {
|
||||
|
||||
/**
|
||||
* DELETE /api/cliproxy/:name - Delete cliproxy variant
|
||||
* Uses variant-service for proper port-specific file cleanup
|
||||
*/
|
||||
router.delete('/:name', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const config = readConfigSafe();
|
||||
// Use variant-service for proper cleanup (settings, config, session files)
|
||||
const result = removeVariant(name);
|
||||
|
||||
if (!config.cliproxy?.[name]) {
|
||||
res.status(404).json({ error: 'Variant not found' });
|
||||
if (!result.success) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Never delete settings files for reserved provider names (safety guard)
|
||||
if (!isReservedName(name)) {
|
||||
// Only delete settings file for non-reserved variant names
|
||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
fs.unlinkSync(settingsPath);
|
||||
}
|
||||
}
|
||||
|
||||
delete config.cliproxy[name];
|
||||
writeConfig(config);
|
||||
|
||||
res.json({ name, deleted: true });
|
||||
res.json({ name, deleted: true, port: result.variant?.port });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* Auth Token Manager Tests
|
||||
*
|
||||
* Comprehensive test suite for CLIProxy auth token management including:
|
||||
* - Token generation (cryptographic security)
|
||||
* - Token masking
|
||||
* - Pure function logic tests
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
|
||||
describe('Auth Token Manager', () => {
|
||||
// =========================================================================
|
||||
// Token Generation Tests (Pure Functions)
|
||||
// =========================================================================
|
||||
describe('generateSecureToken', () => {
|
||||
let generateSecureToken;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear cache and reload
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/auth-token-manager')];
|
||||
const authTokenManager = require('../../../dist/cliproxy/auth-token-manager');
|
||||
generateSecureToken = authTokenManager.generateSecureToken;
|
||||
});
|
||||
|
||||
it('generates token of correct length (base64url encoding)', () => {
|
||||
const token = generateSecureToken(32);
|
||||
// 32 bytes = 43 chars in base64url (without padding)
|
||||
assert.strictEqual(token.length, 43);
|
||||
});
|
||||
|
||||
it('generates unique tokens each call', () => {
|
||||
const tokens = new Set();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tokens.add(generateSecureToken(32));
|
||||
}
|
||||
assert.strictEqual(tokens.size, 100, 'All 100 tokens should be unique');
|
||||
});
|
||||
|
||||
it('uses base64url encoding (no +/= characters)', () => {
|
||||
// Generate many tokens to ensure we hit all character ranges
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const token = generateSecureToken(32);
|
||||
assert(!token.includes('+'), 'Should not contain +');
|
||||
assert(!token.includes('/'), 'Should not contain /');
|
||||
assert(!token.includes('='), 'Should not contain padding =');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts custom length parameter', () => {
|
||||
const short = generateSecureToken(16);
|
||||
const long = generateSecureToken(64);
|
||||
|
||||
// 16 bytes = 22 chars, 64 bytes = 86 chars in base64url
|
||||
assert.strictEqual(short.length, 22);
|
||||
assert.strictEqual(long.length, 86);
|
||||
});
|
||||
|
||||
it('handles edge case: length = 0', () => {
|
||||
const empty = generateSecureToken(0);
|
||||
assert.strictEqual(empty.length, 0);
|
||||
});
|
||||
|
||||
it('handles edge case: very large length', () => {
|
||||
const large = generateSecureToken(256);
|
||||
assert.strictEqual(large.length, 342); // 256 bytes = 342 chars in base64url
|
||||
});
|
||||
|
||||
it('uses cryptographically secure random bytes', () => {
|
||||
// Verify the function uses crypto.randomBytes internally
|
||||
// by checking statistical properties of generated tokens
|
||||
const tokens = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
tokens.push(generateSecureToken(32));
|
||||
}
|
||||
|
||||
// All tokens should be unique
|
||||
const uniqueTokens = new Set(tokens);
|
||||
assert.strictEqual(uniqueTokens.size, 1000, 'All tokens should be unique');
|
||||
|
||||
// Check that first character has good distribution (not always same)
|
||||
const firstChars = new Set(tokens.map((t) => t[0]));
|
||||
assert(firstChars.size > 10, 'First character should have good distribution');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Token Masking Tests (Pure Function Simulation)
|
||||
// =========================================================================
|
||||
describe('maskToken (logic validation)', () => {
|
||||
// Simulates the maskToken function from tokens-command.ts and settings-routes.ts
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
it('masks tokens showing first 4 and last 4 characters', () => {
|
||||
assert.strictEqual(maskToken('1234567890abcdef'), '1234...cdef');
|
||||
});
|
||||
|
||||
it('returns **** for tokens <= 8 characters', () => {
|
||||
assert.strictEqual(maskToken('12345678'), '****');
|
||||
assert.strictEqual(maskToken('1234567'), '****');
|
||||
assert.strictEqual(maskToken('abc'), '****');
|
||||
assert.strictEqual(maskToken(''), '****');
|
||||
});
|
||||
|
||||
it('handles exactly 9 character tokens', () => {
|
||||
assert.strictEqual(maskToken('123456789'), '1234...6789');
|
||||
});
|
||||
|
||||
it('handles long tokens (typical API keys)', () => {
|
||||
const longToken = 'test_key_1234567890abcdefghijklmnopqrstuvwxyz';
|
||||
assert.strictEqual(maskToken(longToken), 'test...wxyz');
|
||||
});
|
||||
|
||||
it('preserves special characters', () => {
|
||||
assert.strictEqual(maskToken('!@#$abcd____wxyz'), '!@#$...wxyz');
|
||||
});
|
||||
|
||||
it('handles default CCS internal key', () => {
|
||||
const internalKey = 'ccs-internal-managed';
|
||||
assert.strictEqual(maskToken(internalKey), 'ccs-...aged');
|
||||
});
|
||||
|
||||
it('handles default CCS control panel secret', () => {
|
||||
const secret = 'ccs';
|
||||
assert.strictEqual(maskToken(secret), '****');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Inheritance Chain Logic Tests
|
||||
// =========================================================================
|
||||
describe('getEffectiveApiKey logic', () => {
|
||||
/**
|
||||
* Simulates the inheritance logic from auth-token-manager.ts
|
||||
*/
|
||||
function getEffectiveApiKey(config, variantName, defaultKey) {
|
||||
// Priority 1: Per-variant override
|
||||
if (variantName) {
|
||||
const variant = config.cliproxy?.variants?.[variantName];
|
||||
if (variant?.auth?.api_key) {
|
||||
return variant.auth.api_key;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Global cliproxy.auth
|
||||
if (config.cliproxy?.auth?.api_key) {
|
||||
return config.cliproxy.auth.api_key;
|
||||
}
|
||||
|
||||
// Priority 3: Default constant
|
||||
return defaultKey;
|
||||
}
|
||||
|
||||
const DEFAULT_KEY = 'ccs-internal-managed';
|
||||
|
||||
it('returns default when no custom config', () => {
|
||||
const config = { cliproxy: { variants: {} } };
|
||||
assert.strictEqual(getEffectiveApiKey(config, undefined, DEFAULT_KEY), DEFAULT_KEY);
|
||||
});
|
||||
|
||||
it('returns global auth when set', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, undefined, DEFAULT_KEY), 'global-custom');
|
||||
});
|
||||
|
||||
it('returns variant auth when set (highest priority)', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { auth: { api_key: 'variant-key' } },
|
||||
},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), 'variant-key');
|
||||
});
|
||||
|
||||
it('falls back to global when variant has no auth', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { type: 'claude' },
|
||||
},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), 'global-custom');
|
||||
});
|
||||
|
||||
it('falls back to default when variant and global missing', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { type: 'claude' },
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), DEFAULT_KEY);
|
||||
});
|
||||
|
||||
it('ignores variant name when variant does not exist', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'non-existent', DEFAULT_KEY), 'global-custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffectiveManagementSecret logic', () => {
|
||||
/**
|
||||
* Simulates the management secret logic from auth-token-manager.ts
|
||||
*/
|
||||
function getEffectiveManagementSecret(config, defaultSecret) {
|
||||
// Priority 1: Global cliproxy.auth
|
||||
if (config.cliproxy?.auth?.management_secret) {
|
||||
return config.cliproxy.auth.management_secret;
|
||||
}
|
||||
|
||||
// Priority 2: Default constant
|
||||
return defaultSecret;
|
||||
}
|
||||
|
||||
const DEFAULT_SECRET = 'ccs';
|
||||
|
||||
it('returns default when no custom config', () => {
|
||||
const config = { cliproxy: { variants: {} } };
|
||||
assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), DEFAULT_SECRET);
|
||||
});
|
||||
|
||||
it('returns custom secret when set', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { management_secret: 'custom-secret' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), 'custom-secret');
|
||||
});
|
||||
|
||||
it('is global only (no per-variant override)', () => {
|
||||
// Management secret does not support per-variant override
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { auth: { management_secret: 'should-be-ignored' } },
|
||||
},
|
||||
auth: { management_secret: 'global-secret' },
|
||||
},
|
||||
};
|
||||
// The function only checks global auth
|
||||
assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), 'global-secret');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Auth Summary Logic Tests
|
||||
// =========================================================================
|
||||
describe('getAuthSummary logic', () => {
|
||||
function getAuthSummary(config, defaultApiKey, defaultSecret) {
|
||||
const customApiKey = config.cliproxy?.auth?.api_key;
|
||||
const customSecret = config.cliproxy?.auth?.management_secret;
|
||||
|
||||
return {
|
||||
apiKey: {
|
||||
value: customApiKey || defaultApiKey,
|
||||
isCustom: !!customApiKey,
|
||||
},
|
||||
managementSecret: {
|
||||
value: customSecret || defaultSecret,
|
||||
isCustom: !!customSecret,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_KEY = 'ccs-internal-managed';
|
||||
const DEFAULT_SECRET = 'ccs';
|
||||
|
||||
it('returns defaults with isCustom=false when no custom config', () => {
|
||||
const config = { cliproxy: { variants: {} } };
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
assert.strictEqual(summary.apiKey.value, DEFAULT_KEY);
|
||||
assert.strictEqual(summary.apiKey.isCustom, false);
|
||||
assert.strictEqual(summary.managementSecret.value, DEFAULT_SECRET);
|
||||
assert.strictEqual(summary.managementSecret.isCustom, false);
|
||||
});
|
||||
|
||||
it('returns custom values with isCustom=true when set', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'custom-key', management_secret: 'custom-secret' },
|
||||
},
|
||||
};
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
assert.strictEqual(summary.apiKey.value, 'custom-key');
|
||||
assert.strictEqual(summary.apiKey.isCustom, true);
|
||||
assert.strictEqual(summary.managementSecret.value, 'custom-secret');
|
||||
assert.strictEqual(summary.managementSecret.isCustom, true);
|
||||
});
|
||||
|
||||
it('handles partial custom config', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'custom-key' },
|
||||
},
|
||||
};
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
assert.strictEqual(summary.apiKey.isCustom, true);
|
||||
assert.strictEqual(summary.managementSecret.isCustom, false);
|
||||
});
|
||||
|
||||
it('treats empty string as no custom value', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: '' },
|
||||
},
|
||||
};
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
// Empty string is falsy, so isCustom should be false
|
||||
assert.strictEqual(summary.apiKey.value, DEFAULT_KEY);
|
||||
assert.strictEqual(summary.apiKey.isCustom, false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Edge Cases
|
||||
// =========================================================================
|
||||
describe('Edge Cases', () => {
|
||||
it('handles undefined config gracefully', () => {
|
||||
function getEffectiveApiKey(config, variantName, defaultKey) {
|
||||
if (variantName) {
|
||||
const variant = config?.cliproxy?.variants?.[variantName];
|
||||
if (variant?.auth?.api_key) return variant.auth.api_key;
|
||||
}
|
||||
if (config?.cliproxy?.auth?.api_key) return config.cliproxy.auth.api_key;
|
||||
return defaultKey;
|
||||
}
|
||||
|
||||
assert.strictEqual(getEffectiveApiKey(undefined, undefined, 'default'), 'default');
|
||||
assert.strictEqual(getEffectiveApiKey(null, undefined, 'default'), 'default');
|
||||
assert.strictEqual(getEffectiveApiKey({}, undefined, 'default'), 'default');
|
||||
});
|
||||
|
||||
it('handles deeply nested missing properties', () => {
|
||||
function getEffectiveApiKey(config, variantName, defaultKey) {
|
||||
if (variantName) {
|
||||
const variant = config?.cliproxy?.variants?.[variantName];
|
||||
if (variant?.auth?.api_key) return variant.auth.api_key;
|
||||
}
|
||||
if (config?.cliproxy?.auth?.api_key) return config.cliproxy.auth.api_key;
|
||||
return defaultKey;
|
||||
}
|
||||
|
||||
const config = { cliproxy: {} };
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'test', 'default'), 'default');
|
||||
});
|
||||
|
||||
it('crypto.randomBytes is available and working', () => {
|
||||
// Ensure crypto module is available
|
||||
const bytes = crypto.randomBytes(32);
|
||||
assert.strictEqual(bytes.length, 32);
|
||||
assert(Buffer.isBuffer(bytes));
|
||||
});
|
||||
|
||||
it('base64url encoding produces valid characters', () => {
|
||||
const bytes = crypto.randomBytes(32);
|
||||
const token = bytes.toString('base64url');
|
||||
|
||||
// Valid base64url characters: A-Z, a-z, 0-9, -, _
|
||||
const validChars = /^[A-Za-z0-9_-]+$/;
|
||||
assert(validChars.test(token), 'Token should only contain base64url characters');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Constants Validation
|
||||
// =========================================================================
|
||||
describe('Default Constants', () => {
|
||||
let CCS_INTERNAL_API_KEY;
|
||||
let CCS_CONTROL_PANEL_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
const configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||
CCS_INTERNAL_API_KEY = configGenerator.CCS_INTERNAL_API_KEY;
|
||||
CCS_CONTROL_PANEL_SECRET = configGenerator.CCS_CONTROL_PANEL_SECRET;
|
||||
});
|
||||
|
||||
it('CCS_INTERNAL_API_KEY is defined', () => {
|
||||
assert(CCS_INTERNAL_API_KEY, 'CCS_INTERNAL_API_KEY should be defined');
|
||||
assert.strictEqual(typeof CCS_INTERNAL_API_KEY, 'string');
|
||||
});
|
||||
|
||||
it('CCS_CONTROL_PANEL_SECRET is defined', () => {
|
||||
assert(CCS_CONTROL_PANEL_SECRET, 'CCS_CONTROL_PANEL_SECRET should be defined');
|
||||
assert.strictEqual(typeof CCS_CONTROL_PANEL_SECRET, 'string');
|
||||
});
|
||||
|
||||
it('default API key has expected value', () => {
|
||||
assert.strictEqual(CCS_INTERNAL_API_KEY, 'ccs-internal-managed');
|
||||
});
|
||||
|
||||
it('default secret has expected value', () => {
|
||||
assert.strictEqual(CCS_CONTROL_PANEL_SECRET, 'ccs');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Config Generator Port Tests
|
||||
*
|
||||
* Tests for per-port configuration in config-generator.ts.
|
||||
* Verifies port-specific config files (config-{port}.yaml) and path generation.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(
|
||||
os.tmpdir(),
|
||||
`ccs-test-config-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
process.env.CCS_HOME = testHome;
|
||||
|
||||
const {
|
||||
getConfigPathForPort,
|
||||
getConfigPath,
|
||||
generateConfig,
|
||||
regenerateConfig,
|
||||
configExists,
|
||||
deleteConfigForPort,
|
||||
deleteConfig,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} = require('../../../dist/cliproxy/config-generator');
|
||||
|
||||
describe('Config Generator Port', function () {
|
||||
let cliproxyDir;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test directories
|
||||
cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
|
||||
// Clean up any existing config files
|
||||
try {
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('config')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up config files
|
||||
try {
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('config')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
// Clean up test directory
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
delete process.env.CCS_HOME;
|
||||
});
|
||||
|
||||
describe('getConfigPathForPort', function () {
|
||||
it('returns config.yaml for default port (8317)', function () {
|
||||
const configPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
|
||||
const filename = path.basename(configPath);
|
||||
assert.ok(configPath.endsWith('config.yaml'), `Expected path to end with config.yaml but got: ${configPath}`);
|
||||
assert.strictEqual(filename, 'config.yaml', `Expected filename to be config.yaml but got: ${filename}`);
|
||||
});
|
||||
|
||||
it('returns config-{port}.yaml for variant ports', function () {
|
||||
const variantPort = 8318;
|
||||
const configPath = getConfigPathForPort(variantPort);
|
||||
assert.ok(configPath.endsWith(`config-${variantPort}.yaml`));
|
||||
});
|
||||
|
||||
it('example: port 8318 -> config-8318.yaml', function () {
|
||||
const configPath = getConfigPathForPort(8318);
|
||||
assert.ok(configPath.endsWith('config-8318.yaml'));
|
||||
});
|
||||
|
||||
it('example: port 8417 -> config-8417.yaml', function () {
|
||||
const configPath = getConfigPathForPort(8417);
|
||||
assert.ok(configPath.endsWith('config-8417.yaml'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfigPath', function () {
|
||||
it('returns path for default port', function () {
|
||||
const configPath = getConfigPath();
|
||||
const defaultPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
|
||||
assert.strictEqual(configPath, defaultPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateConfig', function () {
|
||||
it('creates config-{port}.yaml for non-default port', function () {
|
||||
const variantPort = 8318;
|
||||
generateConfig('gemini', variantPort);
|
||||
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
assert.ok(fs.existsSync(configPath), 'Should create config-8318.yaml');
|
||||
});
|
||||
|
||||
it('creates config.yaml for default port', function () {
|
||||
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
|
||||
|
||||
const configPath = path.join(cliproxyDir, 'config.yaml');
|
||||
assert.ok(fs.existsSync(configPath), 'Should create config.yaml');
|
||||
});
|
||||
|
||||
it('only creates if file does not exist (idempotent)', function () {
|
||||
const variantPort = 8318;
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
|
||||
// Create config first time
|
||||
generateConfig('gemini', variantPort);
|
||||
const stat1 = fs.statSync(configPath);
|
||||
|
||||
// Wait a tiny bit and try again
|
||||
const originalContent = fs.readFileSync(configPath, 'utf-8');
|
||||
generateConfig('gemini', variantPort);
|
||||
const newContent = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Content should be the same (not regenerated)
|
||||
assert.strictEqual(originalContent, newContent);
|
||||
});
|
||||
|
||||
it('sets correct port in config content', function () {
|
||||
const variantPort = 8320;
|
||||
generateConfig('gemini', variantPort);
|
||||
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Check that port is set correctly
|
||||
assert.ok(content.includes(`port: ${variantPort}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('regenerateConfig', function () {
|
||||
it('regenerates config-{port}.yaml with updated version', function () {
|
||||
const variantPort = 8318;
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
|
||||
// Create initial config
|
||||
generateConfig('gemini', variantPort);
|
||||
const originalContent = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Modify the file
|
||||
fs.writeFileSync(configPath, '# modified content\n' + originalContent);
|
||||
|
||||
// Regenerate
|
||||
regenerateConfig(variantPort);
|
||||
const newContent = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Should not contain our modification
|
||||
assert.ok(!newContent.includes('# modified content'));
|
||||
});
|
||||
|
||||
it('preserves port value from existing config', function () {
|
||||
const variantPort = 8325;
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
|
||||
// Create config with specific port
|
||||
generateConfig('gemini', variantPort);
|
||||
|
||||
// Regenerate
|
||||
regenerateConfig(variantPort);
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Port should still be 8325
|
||||
assert.ok(content.includes(`port: ${variantPort}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConfigForPort', function () {
|
||||
it('deletes config-{port}.yaml for specified port', function () {
|
||||
const variantPort = 8318;
|
||||
generateConfig('gemini', variantPort);
|
||||
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
assert.ok(fs.existsSync(configPath));
|
||||
|
||||
deleteConfigForPort(variantPort);
|
||||
assert.strictEqual(fs.existsSync(configPath), false);
|
||||
});
|
||||
|
||||
it('does nothing if file does not exist', function () {
|
||||
// Should not throw
|
||||
deleteConfigForPort(8399);
|
||||
});
|
||||
|
||||
it('does not affect other port configs', function () {
|
||||
const port1 = 8318;
|
||||
const port2 = 8319;
|
||||
|
||||
generateConfig('gemini', port1);
|
||||
generateConfig('gemini', port2);
|
||||
|
||||
deleteConfigForPort(port1);
|
||||
|
||||
const config1Path = path.join(cliproxyDir, `config-${port1}.yaml`);
|
||||
const config2Path = path.join(cliproxyDir, `config-${port2}.yaml`);
|
||||
|
||||
assert.strictEqual(fs.existsSync(config1Path), false);
|
||||
assert.ok(fs.existsSync(config2Path));
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConfig', function () {
|
||||
it('deletes config.yaml for default port', function () {
|
||||
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
|
||||
|
||||
const configPath = path.join(cliproxyDir, 'config.yaml');
|
||||
assert.ok(fs.existsSync(configPath));
|
||||
|
||||
deleteConfig();
|
||||
assert.strictEqual(fs.existsSync(configPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configExists', function () {
|
||||
it('returns true if config-{port}.yaml exists', function () {
|
||||
const variantPort = 8318;
|
||||
generateConfig('gemini', variantPort);
|
||||
|
||||
assert.strictEqual(configExists(variantPort), true);
|
||||
});
|
||||
|
||||
it('returns false if config-{port}.yaml missing', function () {
|
||||
const variantPort = 8399;
|
||||
assert.strictEqual(configExists(variantPort), false);
|
||||
});
|
||||
|
||||
it('returns true for default port config', function () {
|
||||
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
|
||||
assert.strictEqual(configExists(CLIPROXY_DEFAULT_PORT), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Port Configs', function () {
|
||||
it('can create configs for multiple ports simultaneously', function () {
|
||||
const ports = [8318, 8319, 8320, CLIPROXY_DEFAULT_PORT];
|
||||
|
||||
for (const port of ports) {
|
||||
generateConfig('gemini', port);
|
||||
}
|
||||
|
||||
// All should exist
|
||||
for (const port of ports) {
|
||||
assert.ok(configExists(port), `Config for port ${port} should exist`);
|
||||
}
|
||||
});
|
||||
|
||||
it('each port config has correct port value', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
for (const port of ports) {
|
||||
generateConfig('gemini', port);
|
||||
const configPath = getConfigPathForPort(port);
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
assert.ok(content.includes(`port: ${port}`), `Config should have port: ${port}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Session Tracker Port-Specific Tests
|
||||
*
|
||||
* Tests for per-port session tracking in session-tracker.ts.
|
||||
* Verifies port-specific session files (sessions-{port}.json) and cleanup.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(
|
||||
os.tmpdir(),
|
||||
`ccs-test-session-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
process.env.CCS_HOME = testHome;
|
||||
|
||||
const {
|
||||
getExistingProxy,
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
cleanupOrphanedSessions,
|
||||
stopProxy,
|
||||
getProxyStatus,
|
||||
getSessionLockPath,
|
||||
deleteSessionLockForPort,
|
||||
} = require('../../../dist/cliproxy/session-tracker');
|
||||
const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator');
|
||||
|
||||
describe('Session Tracker Port-Specific', function () {
|
||||
const variantPort1 = 8318;
|
||||
const variantPort2 = 8319;
|
||||
let cliproxyDir;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test directories
|
||||
cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
|
||||
// Clean up any existing session files
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('sessions')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up session files
|
||||
try {
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('sessions')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
// Clean up test directory
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
delete process.env.CCS_HOME;
|
||||
});
|
||||
|
||||
describe('Session Lock Path', function () {
|
||||
it('returns sessions.json for default port', function () {
|
||||
const lockPath = getSessionLockPath();
|
||||
assert.ok(lockPath.endsWith('sessions.json'));
|
||||
assert.ok(!lockPath.includes('sessions-'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Port-Specific Session Files', function () {
|
||||
it('creates sessions-{port}.json for variant ports', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
assert.ok(fs.existsSync(lockPath), `Should create sessions-${variantPort1}.json`);
|
||||
});
|
||||
|
||||
it('creates sessions.json for default port', function () {
|
||||
registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, 'sessions.json');
|
||||
assert.ok(fs.existsSync(lockPath), 'Should create sessions.json for default port');
|
||||
});
|
||||
|
||||
it('keeps separate session files for different ports', function () {
|
||||
// Register sessions on different ports
|
||||
registerSession(variantPort1, process.pid);
|
||||
registerSession(variantPort2, process.pid);
|
||||
registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
|
||||
|
||||
// All three should exist
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(cliproxyDir, `sessions-${variantPort1}.json`)),
|
||||
'Should have port 8318 sessions'
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(cliproxyDir, `sessions-${variantPort2}.json`)),
|
||||
'Should have port 8319 sessions'
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(cliproxyDir, 'sessions.json')),
|
||||
'Should have default port sessions'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerSession with Port', function () {
|
||||
it('stores correct port in session lock file', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
|
||||
assert.strictEqual(lock.port, variantPort1);
|
||||
});
|
||||
|
||||
it('stores correct PID in session lock file', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
|
||||
assert.strictEqual(lock.pid, process.pid);
|
||||
});
|
||||
|
||||
it('appends to existing sessions array for same port', function () {
|
||||
const session1 = registerSession(variantPort1, process.pid);
|
||||
const session2 = registerSession(variantPort1, process.pid);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
|
||||
assert.strictEqual(lock.sessions.length, 2);
|
||||
assert.ok(lock.sessions.includes(session1));
|
||||
assert.ok(lock.sessions.includes(session2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterSession with Port', function () {
|
||||
it('removes session from port-specific file', function () {
|
||||
const session1 = registerSession(variantPort1, process.pid);
|
||||
const session2 = registerSession(variantPort1, process.pid);
|
||||
|
||||
// Unregister first session with port
|
||||
unregisterSession(session1, variantPort1);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
|
||||
assert.strictEqual(lock.sessions.length, 1);
|
||||
assert.strictEqual(lock.sessions[0], session2);
|
||||
});
|
||||
|
||||
it('deletes lock file when last session removed', function () {
|
||||
const session = registerSession(variantPort1, process.pid);
|
||||
|
||||
const shouldKill = unregisterSession(session, variantPort1);
|
||||
|
||||
assert.strictEqual(shouldKill, true);
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
it('returns true when last session', function () {
|
||||
const session = registerSession(variantPort1, process.pid);
|
||||
const shouldKill = unregisterSession(session, variantPort1);
|
||||
assert.strictEqual(shouldKill, true);
|
||||
});
|
||||
|
||||
it('returns false when sessions remain', function () {
|
||||
const session1 = registerSession(variantPort1, process.pid);
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const shouldKill = unregisterSession(session1, variantPort1);
|
||||
assert.strictEqual(shouldKill, false);
|
||||
});
|
||||
|
||||
it('searches default port for backward compat (fallback)', function () {
|
||||
// Register on default port
|
||||
const session = registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
|
||||
|
||||
// Unregister without port (should search default)
|
||||
const shouldKill = unregisterSession(session);
|
||||
|
||||
assert.strictEqual(shouldKill, true);
|
||||
const lockPath = path.join(cliproxyDir, 'sessions.json');
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExistingProxy with Port', function () {
|
||||
it('returns lock for running proxy on specified port', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const lock = getExistingProxy(variantPort1);
|
||||
assert.notStrictEqual(lock, null);
|
||||
assert.strictEqual(lock.port, variantPort1);
|
||||
assert.strictEqual(lock.pid, process.pid);
|
||||
});
|
||||
|
||||
it('returns null if lock file missing', function () {
|
||||
const lock = getExistingProxy(variantPort1);
|
||||
assert.strictEqual(lock, null);
|
||||
});
|
||||
|
||||
it('returns null if port mismatch', function () {
|
||||
// Create lock with different port number in file
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: 9999, // Wrong port
|
||||
pid: process.pid,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const lock = getExistingProxy(variantPort1);
|
||||
assert.strictEqual(lock, null);
|
||||
});
|
||||
|
||||
it('cleans up stale lock if PID not running', function () {
|
||||
// Create lock with dead PID
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: variantPort1,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const lock = getExistingProxy(variantPort1);
|
||||
assert.strictEqual(lock, null);
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSessionLockForPort', function () {
|
||||
it('removes sessions-{port}.json for specified port', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
assert.ok(fs.existsSync(lockPath));
|
||||
|
||||
deleteSessionLockForPort(variantPort1);
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
it('does nothing if file does not exist', function () {
|
||||
// Should not throw
|
||||
deleteSessionLockForPort(variantPort1);
|
||||
});
|
||||
|
||||
it('does not affect other port sessions', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
registerSession(variantPort2, process.pid);
|
||||
|
||||
deleteSessionLockForPort(variantPort1);
|
||||
|
||||
const lock1Path = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
const lock2Path = path.join(cliproxyDir, `sessions-${variantPort2}.json`);
|
||||
|
||||
assert.strictEqual(fs.existsSync(lock1Path), false);
|
||||
assert.ok(fs.existsSync(lock2Path));
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOrphanedSessions with Port', function () {
|
||||
it('deletes lock if PID not running', function () {
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: variantPort1,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
cleanupOrphanedSessions(variantPort1);
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
it('keeps lock if PID still running', function () {
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: variantPort1,
|
||||
pid: process.pid, // Our process - running
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
cleanupOrphanedSessions(variantPort1);
|
||||
assert.ok(fs.existsSync(lockPath));
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopProxy with Port', function () {
|
||||
it('stops proxy on specified port', async function () {
|
||||
// Create lock with dead PID (we can't actually stop a real process in tests)
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: variantPort1,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await stopProxy(variantPort1);
|
||||
assert.strictEqual(result.stopped, false);
|
||||
assert.ok(result.error.includes('not running'));
|
||||
});
|
||||
|
||||
it('cleans up session lock after stop', async function () {
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: variantPort1,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
await stopProxy(variantPort1);
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
it('handles already-stopped proxy gracefully', async function () {
|
||||
const result = await stopProxy(variantPort1);
|
||||
assert.strictEqual(result.stopped, false);
|
||||
assert.strictEqual(result.error, 'No active CLIProxy session found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProxyStatus with Port', function () {
|
||||
it('returns correct status for variant port', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const status = getProxyStatus(variantPort1);
|
||||
assert.strictEqual(status.running, true);
|
||||
assert.strictEqual(status.port, variantPort1);
|
||||
assert.strictEqual(status.pid, process.pid);
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
});
|
||||
|
||||
it('returns not running for empty variant port', function () {
|
||||
const status = getProxyStatus(variantPort1);
|
||||
assert.strictEqual(status.running, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent Variant Sessions', function () {
|
||||
it('manages multiple variant ports independently', function () {
|
||||
// Start sessions on different ports
|
||||
const session1 = registerSession(variantPort1, process.pid);
|
||||
const session2 = registerSession(variantPort2, process.pid);
|
||||
|
||||
// Both should be tracked
|
||||
assert.strictEqual(getProxyStatus(variantPort1).running, true);
|
||||
assert.strictEqual(getProxyStatus(variantPort2).running, true);
|
||||
|
||||
// Unregister one should not affect other
|
||||
unregisterSession(session1, variantPort1);
|
||||
|
||||
assert.strictEqual(getProxyStatus(variantPort1).running, false);
|
||||
assert.strictEqual(getProxyStatus(variantPort2).running, true);
|
||||
|
||||
// Clean up
|
||||
unregisterSession(session2, variantPort2);
|
||||
});
|
||||
|
||||
it('allows same session workflow on different ports', function () {
|
||||
// Simulate concurrent variant usage
|
||||
const port1Session1 = registerSession(variantPort1, process.pid);
|
||||
const port1Session2 = registerSession(variantPort1, process.pid);
|
||||
const port2Session1 = registerSession(variantPort2, process.pid);
|
||||
|
||||
assert.strictEqual(getProxyStatus(variantPort1).sessionCount, 2);
|
||||
assert.strictEqual(getProxyStatus(variantPort2).sessionCount, 1);
|
||||
|
||||
// Unregister from port1
|
||||
const shouldKill1 = unregisterSession(port1Session1, variantPort1);
|
||||
assert.strictEqual(shouldKill1, false); // Still has session2
|
||||
|
||||
const shouldKill2 = unregisterSession(port1Session2, variantPort1);
|
||||
assert.strictEqual(shouldKill2, true); // Last session on port1
|
||||
|
||||
// Port2 should still be running
|
||||
assert.strictEqual(getProxyStatus(variantPort2).running, true);
|
||||
|
||||
// Clean up
|
||||
unregisterSession(port2Session1, variantPort2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,23 +28,39 @@ const {
|
||||
describe('Session Tracker', function () {
|
||||
const testPort = 18317;
|
||||
let sessionLockPath;
|
||||
let cliproxyDir;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test directories
|
||||
const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
|
||||
cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
sessionLockPath = path.join(cliproxyDir, 'sessions.json');
|
||||
// Use port-specific session file for non-default ports
|
||||
sessionLockPath = path.join(cliproxyDir, `sessions-${testPort}.json`);
|
||||
|
||||
// Clean up any existing lock file
|
||||
if (fs.existsSync(sessionLockPath)) {
|
||||
fs.unlinkSync(sessionLockPath);
|
||||
// Clean up any existing lock files
|
||||
try {
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('sessions')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up lock file
|
||||
if (fs.existsSync(sessionLockPath)) {
|
||||
fs.unlinkSync(sessionLockPath);
|
||||
// Clean up lock files
|
||||
try {
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('sessions')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
@@ -164,7 +180,7 @@ describe('Session Tracker', function () {
|
||||
|
||||
describe('unregisterSession', function () {
|
||||
it('should return true when no lock exists', function () {
|
||||
const result = unregisterSession('nonexistent');
|
||||
const result = unregisterSession('nonexistent', testPort);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
@@ -174,7 +190,7 @@ describe('Session Tracker', function () {
|
||||
const session2 = registerSession(testPort, process.pid);
|
||||
|
||||
// Unregister first
|
||||
const shouldKill = unregisterSession(session1);
|
||||
const shouldKill = unregisterSession(session1, testPort);
|
||||
|
||||
assert.strictEqual(shouldKill, false, 'should not kill - other sessions active');
|
||||
|
||||
@@ -188,7 +204,7 @@ describe('Session Tracker', function () {
|
||||
const session1 = registerSession(testPort, process.pid);
|
||||
|
||||
// Unregister it
|
||||
const shouldKill = unregisterSession(session1);
|
||||
const shouldKill = unregisterSession(session1, testPort);
|
||||
|
||||
assert.strictEqual(shouldKill, true, 'should kill - last session');
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false, 'should delete lock file');
|
||||
@@ -199,38 +215,40 @@ describe('Session Tracker', function () {
|
||||
registerSession(testPort, process.pid);
|
||||
|
||||
// Try to unregister wrong session
|
||||
const shouldKill = unregisterSession('wrong-session-id');
|
||||
const shouldKill = unregisterSession('wrong-session-id', testPort);
|
||||
|
||||
// Should return false since a session still exists
|
||||
assert.strictEqual(shouldKill, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionCount', function () {
|
||||
describe('getSessionCount (default port)', function () {
|
||||
it('should return 0 when no lock exists', function () {
|
||||
assert.strictEqual(getSessionCount(), 0);
|
||||
});
|
||||
|
||||
it('should return correct count', function () {
|
||||
it('should return correct count (uses getProxyStatus for port-specific)', function () {
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 1);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 2);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 3);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasActiveSessions', function () {
|
||||
describe('hasActiveSessions (default port)', function () {
|
||||
it('should return false when no lock exists', function () {
|
||||
assert.strictEqual(hasActiveSessions(), false);
|
||||
});
|
||||
|
||||
it('should return true when sessions exist and proxy running', function () {
|
||||
it('should return true when sessions exist on default port', function () {
|
||||
// Note: hasActiveSessions() checks default port only
|
||||
// For port-specific checks, use getProxyStatus(port).running
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(hasActiveSessions(), true);
|
||||
assert.strictEqual(getProxyStatus(testPort).running, true);
|
||||
});
|
||||
|
||||
it('should return false and cleanup when proxy is dead', function () {
|
||||
@@ -243,7 +261,9 @@ describe('Session Tracker', function () {
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
assert.strictEqual(hasActiveSessions(), false);
|
||||
// getProxyStatus will clean up stale lock
|
||||
const status = getProxyStatus(testPort);
|
||||
assert.strictEqual(status.running, false);
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
});
|
||||
@@ -300,7 +320,7 @@ describe('Session Tracker', function () {
|
||||
|
||||
describe('stopProxy', function () {
|
||||
it('should return error when no lock exists', async function () {
|
||||
const result = await stopProxy();
|
||||
const result = await stopProxy(testPort);
|
||||
assert.strictEqual(result.stopped, false);
|
||||
assert.strictEqual(result.error, 'No active CLIProxy session found');
|
||||
});
|
||||
@@ -315,7 +335,7 @@ describe('Session Tracker', function () {
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = await stopProxy();
|
||||
const result = await stopProxy(testPort);
|
||||
assert.strictEqual(result.stopped, false);
|
||||
assert.ok(result.error.includes('not running'));
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
@@ -327,7 +347,7 @@ describe('Session Tracker', function () {
|
||||
|
||||
// Note: We can't actually test killing our own process,
|
||||
// but we can verify the structure is correct before it attempts kill
|
||||
const status = getProxyStatus();
|
||||
const status = getProxyStatus(testPort);
|
||||
assert.strictEqual(status.running, true);
|
||||
assert.strictEqual(status.pid, process.pid);
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
@@ -336,7 +356,7 @@ describe('Session Tracker', function () {
|
||||
|
||||
describe('getProxyStatus', function () {
|
||||
it('should return not running when no lock exists', function () {
|
||||
const result = getProxyStatus();
|
||||
const result = getProxyStatus(testPort);
|
||||
assert.strictEqual(result.running, false);
|
||||
assert.strictEqual(result.port, undefined);
|
||||
assert.strictEqual(result.pid, undefined);
|
||||
@@ -352,7 +372,7 @@ describe('Session Tracker', function () {
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getProxyStatus();
|
||||
const result = getProxyStatus(testPort);
|
||||
assert.strictEqual(result.running, true);
|
||||
assert.strictEqual(result.port, testPort);
|
||||
assert.strictEqual(result.pid, process.pid);
|
||||
@@ -369,22 +389,22 @@ describe('Session Tracker', function () {
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getProxyStatus();
|
||||
const result = getProxyStatus(testPort);
|
||||
assert.strictEqual(result.running, false);
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
|
||||
it('should return correct session count after registrations', function () {
|
||||
registerSession(testPort, process.pid);
|
||||
let status = getProxyStatus();
|
||||
let status = getProxyStatus(testPort);
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
status = getProxyStatus();
|
||||
status = getProxyStatus(testPort);
|
||||
assert.strictEqual(status.sessionCount, 2);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
status = getProxyStatus();
|
||||
status = getProxyStatus(testPort);
|
||||
assert.strictEqual(status.sessionCount, 3);
|
||||
});
|
||||
});
|
||||
@@ -393,30 +413,30 @@ describe('Session Tracker', function () {
|
||||
it('should handle complete multi-terminal workflow', function () {
|
||||
// Terminal 1 starts - first session
|
||||
const session1 = registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 1);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
|
||||
|
||||
// Terminal 2 starts - joins existing
|
||||
const session2 = registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 2);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
|
||||
|
||||
// Terminal 3 starts - joins existing
|
||||
const session3 = registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 3);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 3);
|
||||
|
||||
// Terminal 1 exits - should NOT kill proxy
|
||||
let shouldKill = unregisterSession(session1);
|
||||
let shouldKill = unregisterSession(session1, testPort);
|
||||
assert.strictEqual(shouldKill, false);
|
||||
assert.strictEqual(getSessionCount(), 2);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
|
||||
|
||||
// Terminal 3 exits - should NOT kill proxy
|
||||
shouldKill = unregisterSession(session3);
|
||||
shouldKill = unregisterSession(session3, testPort);
|
||||
assert.strictEqual(shouldKill, false);
|
||||
assert.strictEqual(getSessionCount(), 1);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
|
||||
|
||||
// Terminal 2 exits - SHOULD kill proxy (last session)
|
||||
shouldKill = unregisterSession(session2);
|
||||
shouldKill = unregisterSession(session2, testPort);
|
||||
assert.strictEqual(shouldKill, true);
|
||||
assert.strictEqual(getSessionCount(), 0);
|
||||
assert.strictEqual(getProxyStatus(testPort).running, false);
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Variant Port Allocation Tests
|
||||
*
|
||||
* Tests for port allocation logic in variant-config-adapter.ts.
|
||||
* Verifies unique port assignment (8318-8417) for CLIProxy variants.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(
|
||||
os.tmpdir(),
|
||||
`ccs-test-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
process.env.CCS_HOME = testHome;
|
||||
|
||||
const {
|
||||
getNextAvailablePort,
|
||||
VARIANT_PORT_BASE,
|
||||
VARIANT_PORT_MAX_OFFSET,
|
||||
listVariantsFromConfig,
|
||||
saveVariantLegacy,
|
||||
removeVariantFromLegacyConfig,
|
||||
} = require('../../../dist/cliproxy/services/variant-config-adapter');
|
||||
const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator');
|
||||
|
||||
describe('Variant Port Allocation', function () {
|
||||
let configPath;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test directories
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
configPath = path.join(ccsDir, 'config.json');
|
||||
|
||||
// Start with empty config
|
||||
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up config file
|
||||
if (fs.existsSync(configPath)) {
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
// Clean up test directory
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
delete process.env.CCS_HOME;
|
||||
});
|
||||
|
||||
describe('Constants', function () {
|
||||
it('VARIANT_PORT_BASE equals CLIPROXY_DEFAULT_PORT + 1 (8318)', function () {
|
||||
assert.strictEqual(VARIANT_PORT_BASE, CLIPROXY_DEFAULT_PORT + 1);
|
||||
assert.strictEqual(VARIANT_PORT_BASE, 8318);
|
||||
});
|
||||
|
||||
it('VARIANT_PORT_MAX_OFFSET equals 100 (ports 8318-8417)', function () {
|
||||
assert.strictEqual(VARIANT_PORT_MAX_OFFSET, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextAvailablePort - Basic Allocation', function () {
|
||||
it('returns VARIANT_PORT_BASE (8318) when no variants exist', function () {
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, VARIANT_PORT_BASE);
|
||||
assert.strictEqual(port, 8318);
|
||||
});
|
||||
|
||||
it('returns next available port when some ports used', function () {
|
||||
// Create variant on first port
|
||||
const settingsPath = path.join(testHome, '.ccs', 'test1.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('test1', 'gemini', settingsPath, undefined, 8318);
|
||||
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8319);
|
||||
});
|
||||
|
||||
it('skips used ports and returns first gap', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create variants on ports 8318 and 8320 (leaving 8319 as gap)
|
||||
const settingsPath1 = path.join(ccsDir, 'test1.settings.json');
|
||||
const settingsPath2 = path.join(ccsDir, 'test2.settings.json');
|
||||
fs.writeFileSync(settingsPath1, JSON.stringify({ env: {} }));
|
||||
fs.writeFileSync(settingsPath2, JSON.stringify({ env: {} }));
|
||||
|
||||
saveVariantLegacy('test1', 'gemini', settingsPath1, undefined, 8318);
|
||||
saveVariantLegacy('test2', 'gemini', settingsPath2, undefined, 8320);
|
||||
|
||||
// Should return 8319 (the gap)
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8319);
|
||||
});
|
||||
|
||||
it('allocates sequential ports for multiple variants', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8318 + i);
|
||||
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextAvailablePort - Boundary Conditions', function () {
|
||||
it('returns last port (8417) when 99 ports used', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create 99 variants (ports 8318-8416)
|
||||
for (let i = 0; i < 99; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
|
||||
}
|
||||
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8417); // Last available port
|
||||
});
|
||||
|
||||
it('throws when all 100 ports exhausted', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create 100 variants (ports 8318-8417)
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => getNextAvailablePort(),
|
||||
/Port limit reached/,
|
||||
'Should throw error when all ports exhausted'
|
||||
);
|
||||
});
|
||||
|
||||
it('error message includes variant count and recovery hint', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create 100 variants
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
|
||||
}
|
||||
|
||||
try {
|
||||
getNextAvailablePort();
|
||||
assert.fail('Should have thrown error');
|
||||
} catch (err) {
|
||||
assert.ok(err.message.includes('100/100'), 'Should include variant count');
|
||||
assert.ok(
|
||||
err.message.includes('ccs cliproxy remove'),
|
||||
'Should include recovery hint'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextAvailablePort - Port Reuse', function () {
|
||||
it('reuses port after variant deletion', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create variant on port 8318
|
||||
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('test', 'gemini', settingsPath, undefined, 8318);
|
||||
|
||||
// Next port should be 8319
|
||||
let nextPort = getNextAvailablePort();
|
||||
assert.strictEqual(nextPort, 8319);
|
||||
|
||||
// Remove the variant
|
||||
removeVariantFromLegacyConfig('test');
|
||||
|
||||
// Now 8318 should be available again
|
||||
nextPort = getNextAvailablePort();
|
||||
assert.strictEqual(nextPort, 8318);
|
||||
});
|
||||
|
||||
it('allocates lowest available port (not most recently freed)', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create 3 variants on ports 8318, 8319, 8320
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
|
||||
}
|
||||
|
||||
// Remove middle variant (port 8319)
|
||||
removeVariantFromLegacyConfig('variant1');
|
||||
|
||||
// Next allocation should use 8319 (the gap), not 8321
|
||||
const nextPort = getNextAvailablePort();
|
||||
assert.strictEqual(nextPort, 8319);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextAvailablePort - Legacy Variant Handling', function () {
|
||||
it('ignores variants without port field in usage calculation', function () {
|
||||
// Create variant without port (legacy format)
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy_variant: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/settings.json',
|
||||
// No port field
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
// Should return first port since legacy variant has no port
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8318);
|
||||
});
|
||||
|
||||
it('handles mixed legacy and modern variants', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create legacy variant without port
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy: {
|
||||
provider: 'gemini',
|
||||
settings: path.join(ccsDir, 'legacy.settings.json'),
|
||||
// No port field
|
||||
},
|
||||
modern: {
|
||||
provider: 'gemini',
|
||||
settings: path.join(ccsDir, 'modern.settings.json'),
|
||||
port: 8318,
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
// Should skip 8318 (used by modern) and return 8319
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8319);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listVariantsFromConfig', function () {
|
||||
it('returns empty object when no variants exist', function () {
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.deepStrictEqual(variants, {});
|
||||
});
|
||||
|
||||
it('includes port field for each variant', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
|
||||
saveVariantLegacy('test', 'gemini', settingsPath, undefined, 8318);
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.test.port, 8318);
|
||||
assert.strictEqual(variants.test.provider, 'gemini');
|
||||
});
|
||||
|
||||
it('returns undefined port for legacy variants', function () {
|
||||
// Create legacy variant without port
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/settings.json',
|
||||
// No port field
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.legacy.port, undefined);
|
||||
assert.strictEqual(variants.legacy.provider, 'gemini');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Variant Port Edge Case Tests
|
||||
*
|
||||
* Tests for edge cases and error handling in variant port isolation.
|
||||
* Covers port exhaustion, race conditions, stale session cleanup,
|
||||
* legacy migration, permission errors, and config corruption.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(
|
||||
os.tmpdir(),
|
||||
`ccs-test-edge-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
process.env.CCS_HOME = testHome;
|
||||
|
||||
const {
|
||||
getNextAvailablePort,
|
||||
VARIANT_PORT_BASE,
|
||||
VARIANT_PORT_MAX_OFFSET,
|
||||
listVariantsFromConfig,
|
||||
saveVariantLegacy,
|
||||
removeVariantFromLegacyConfig,
|
||||
} = require('../../../dist/cliproxy/services/variant-config-adapter');
|
||||
const {
|
||||
getExistingProxy,
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
cleanupOrphanedSessions,
|
||||
deleteSessionLockForPort,
|
||||
getProxyStatus,
|
||||
} = require('../../../dist/cliproxy/session-tracker');
|
||||
const {
|
||||
deleteConfigForPort,
|
||||
configExists,
|
||||
generateConfig,
|
||||
} = require('../../../dist/cliproxy/config-generator');
|
||||
|
||||
describe('Variant Port Edge Cases', function () {
|
||||
let configPath;
|
||||
let cliproxyDir;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test directories
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
cliproxyDir = path.join(ccsDir, 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
configPath = path.join(ccsDir, 'config.json');
|
||||
|
||||
// Start with empty config
|
||||
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up config and session files
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
// Clean up test directory
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
delete process.env.CCS_HOME;
|
||||
});
|
||||
|
||||
describe('Port Exhaustion', function () {
|
||||
it('throws after 100 variants created', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create 100 variants
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i);
|
||||
}
|
||||
|
||||
assert.throws(() => getNextAvailablePort(), /Port limit reached/);
|
||||
});
|
||||
|
||||
it('error message shows 100/100 and recovery hint', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i);
|
||||
}
|
||||
|
||||
try {
|
||||
getNextAvailablePort();
|
||||
assert.fail('Should have thrown');
|
||||
} catch (err) {
|
||||
assert.ok(err.message.includes('100/100'));
|
||||
assert.ok(err.message.includes('ccs cliproxy remove'));
|
||||
}
|
||||
});
|
||||
|
||||
it('frees port after variant removal', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create variant on port 8318
|
||||
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('test', 'gemini', settingsPath, undefined, VARIANT_PORT_BASE);
|
||||
|
||||
// Next should be 8319
|
||||
assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE + 1);
|
||||
|
||||
// Remove variant
|
||||
removeVariantFromLegacyConfig('test');
|
||||
|
||||
// 8318 should be free again
|
||||
assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stale Session Cleanup', function () {
|
||||
it('cleans orphaned session lock on variant delete', function () {
|
||||
const port = 8318;
|
||||
|
||||
// Create session file
|
||||
registerSession(port, process.pid);
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
assert.ok(fs.existsSync(sessionPath));
|
||||
|
||||
// Simulate variant delete cleanup
|
||||
deleteSessionLockForPort(port);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
|
||||
it('cleans stale lock when PID not running', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Create lock with dead PID
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
// getExistingProxy should clean it up
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy Variant Migration', function () {
|
||||
it('variant without port gets undefined in listing', function () {
|
||||
// Create legacy variant without port
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/settings.json',
|
||||
// No port field
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.legacy.port, undefined);
|
||||
});
|
||||
|
||||
it('legacy variant does not block port allocation', function () {
|
||||
// Create legacy variant without port
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/settings.json',
|
||||
// No port field - doesn't count toward port usage
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
// First port should still be available
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, VARIANT_PORT_BASE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config Corruption', function () {
|
||||
it('handles malformed sessions-{port}.json gracefully', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Write invalid JSON
|
||||
fs.writeFileSync(sessionPath, '{ invalid json }');
|
||||
|
||||
// getExistingProxy should return null, not throw
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
});
|
||||
|
||||
it('handles missing config-{port}.yaml gracefully', function () {
|
||||
const port = 8318;
|
||||
// configExists should return false, not throw
|
||||
assert.strictEqual(configExists(port), false);
|
||||
});
|
||||
|
||||
it('handles sessions file with missing required fields', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Write JSON missing required fields
|
||||
fs.writeFileSync(sessionPath, JSON.stringify({ port: 8318 }));
|
||||
|
||||
// getExistingProxy should return null (invalid structure)
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
});
|
||||
|
||||
it('handles sessions file with wrong data types', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Write JSON with wrong types
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port: 'not-a-number',
|
||||
pid: 'also-not-a-number',
|
||||
sessions: 'not-an-array',
|
||||
})
|
||||
);
|
||||
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup on Crash', function () {
|
||||
it('getExistingProxy cleans stale lock if PID dead', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Create lock with dead PID (simulating crashed proxy)
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port,
|
||||
pid: 999999999,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
|
||||
it('cleanupOrphanedSessions removes stale lock', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Create lock with dead PID
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port,
|
||||
pid: 999999999,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
cleanupOrphanedSessions(port);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
|
||||
it('variant delete cleans session lock even if proxy crashed', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Create lock with dead PID
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port,
|
||||
pid: 999999999,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
// deleteSessionLockForPort is called during variant removal
|
||||
deleteSessionLockForPort(port);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variant Lifecycle Integration', function () {
|
||||
it('creates variant with unique port and separate files', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const port = getNextAvailablePort();
|
||||
|
||||
// Create variant
|
||||
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('test', 'gemini', settingsPath, undefined, port);
|
||||
|
||||
// Generate config
|
||||
generateConfig('gemini', port);
|
||||
|
||||
// Verify files exist
|
||||
assert.ok(configExists(port));
|
||||
|
||||
// Start session
|
||||
const sessionId = registerSession(port, process.pid);
|
||||
const status = getProxyStatus(port);
|
||||
assert.strictEqual(status.running, true);
|
||||
assert.strictEqual(status.port, port);
|
||||
|
||||
// Clean up session
|
||||
unregisterSession(sessionId, port);
|
||||
});
|
||||
|
||||
it('removes variant and cleans up all port files', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const port = 8318;
|
||||
|
||||
// Create variant
|
||||
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('test', 'gemini', settingsPath, undefined, port);
|
||||
|
||||
// Generate config and session
|
||||
generateConfig('gemini', port);
|
||||
registerSession(port, process.pid);
|
||||
|
||||
// Verify files exist
|
||||
assert.ok(configExists(port));
|
||||
assert.strictEqual(getProxyStatus(port).running, true);
|
||||
|
||||
// Remove variant (simulating full cleanup)
|
||||
removeVariantFromLegacyConfig('test');
|
||||
deleteConfigForPort(port);
|
||||
deleteSessionLockForPort(port);
|
||||
|
||||
// Verify cleanup
|
||||
assert.strictEqual(configExists(port), false);
|
||||
assert.strictEqual(getProxyStatus(port).running, false);
|
||||
});
|
||||
|
||||
it('port reuse after deletion does not have stale data', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create variant A
|
||||
const settingsA = path.join(ccsDir, 'variantA.settings.json');
|
||||
fs.writeFileSync(settingsA, JSON.stringify({ env: { KEY: 'A' } }));
|
||||
const portA = getNextAvailablePort();
|
||||
saveVariantLegacy('variantA', 'gemini', settingsA, undefined, portA);
|
||||
generateConfig('gemini', portA);
|
||||
registerSession(portA, process.pid);
|
||||
|
||||
// Remove variant A with full cleanup
|
||||
removeVariantFromLegacyConfig('variantA');
|
||||
deleteConfigForPort(portA);
|
||||
deleteSessionLockForPort(portA);
|
||||
|
||||
// Create variant B - should get same port
|
||||
const settingsB = path.join(ccsDir, 'variantB.settings.json');
|
||||
fs.writeFileSync(settingsB, JSON.stringify({ env: { KEY: 'B' } }));
|
||||
const portB = getNextAvailablePort();
|
||||
assert.strictEqual(portB, portA); // Port should be reused
|
||||
|
||||
// New session should start fresh
|
||||
saveVariantLegacy('variantB', 'gemini', settingsB, undefined, portB);
|
||||
generateConfig('gemini', portB);
|
||||
const sessionB = registerSession(portB, process.pid);
|
||||
|
||||
const status = getProxyStatus(portB);
|
||||
assert.strictEqual(status.running, true);
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
|
||||
// Clean up
|
||||
unregisterSession(sessionB, portB);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Concurrent Variants', function () {
|
||||
it('creates 3 variants with different ports', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const ports = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
ports.push(port);
|
||||
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
|
||||
// Verify all ports are different
|
||||
const uniquePorts = new Set(ports);
|
||||
assert.strictEqual(uniquePorts.size, 3);
|
||||
|
||||
// Verify sequential assignment
|
||||
assert.strictEqual(ports[0], VARIANT_PORT_BASE);
|
||||
assert.strictEqual(ports[1], VARIANT_PORT_BASE + 1);
|
||||
assert.strictEqual(ports[2], VARIANT_PORT_BASE + 2);
|
||||
});
|
||||
|
||||
it('each has separate config file', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
|
||||
generateConfig('gemini', ports[i]);
|
||||
}
|
||||
|
||||
// Verify separate config files
|
||||
for (const port of ports) {
|
||||
assert.ok(configExists(port), `Config for port ${port} should exist`);
|
||||
}
|
||||
});
|
||||
|
||||
it('each has separate sessions file when running', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
for (const port of ports) {
|
||||
registerSession(port, process.pid);
|
||||
}
|
||||
|
||||
// Verify separate session files
|
||||
for (const port of ports) {
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
assert.ok(fs.existsSync(sessionPath), `Session file for port ${port} should exist`);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
for (const port of ports) {
|
||||
deleteSessionLockForPort(port);
|
||||
}
|
||||
});
|
||||
|
||||
it('removing one does not affect others', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
// Create 3 variants
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
|
||||
generateConfig('gemini', ports[i]);
|
||||
registerSession(ports[i], process.pid);
|
||||
}
|
||||
|
||||
// Remove middle variant
|
||||
removeVariantFromLegacyConfig('variant1');
|
||||
deleteConfigForPort(ports[1]);
|
||||
deleteSessionLockForPort(ports[1]);
|
||||
|
||||
// Verify others still exist
|
||||
assert.ok(configExists(ports[0]));
|
||||
assert.ok(!configExists(ports[1])); // Removed
|
||||
assert.ok(configExists(ports[2]));
|
||||
|
||||
assert.strictEqual(getProxyStatus(ports[0]).running, true);
|
||||
assert.strictEqual(getProxyStatus(ports[1]).running, false); // Removed
|
||||
assert.strictEqual(getProxyStatus(ports[2]).running, true);
|
||||
|
||||
// Clean up remaining
|
||||
deleteSessionLockForPort(ports[0]);
|
||||
deleteSessionLockForPort(ports[2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* Variant Port Isolation Integration Tests
|
||||
*
|
||||
* Tests for PR #184: feat(cliproxy): add variant port isolation
|
||||
* Maps directly to the PR test plan:
|
||||
* - [x] Create multiple variants with `ccs cliproxy create`
|
||||
* - [x] Verify each variant gets unique port in config
|
||||
* - [x] Run multiple variants concurrently
|
||||
* - [x] Verify `ccs cliproxy list` shows port column
|
||||
* - [x] Remove variant and verify cleanup of port-specific files
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(
|
||||
os.tmpdir(),
|
||||
`ccs-test-integration-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
process.env.CCS_HOME = testHome;
|
||||
|
||||
const {
|
||||
getNextAvailablePort,
|
||||
VARIANT_PORT_BASE,
|
||||
VARIANT_PORT_MAX_OFFSET,
|
||||
listVariantsFromConfig,
|
||||
saveVariantLegacy,
|
||||
removeVariantFromLegacyConfig,
|
||||
} = require('../../../dist/cliproxy/services/variant-config-adapter');
|
||||
const {
|
||||
getExistingProxy,
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
getProxyStatus,
|
||||
deleteSessionLockForPort,
|
||||
} = require('../../../dist/cliproxy/session-tracker');
|
||||
const {
|
||||
generateConfig,
|
||||
configExists,
|
||||
deleteConfigForPort,
|
||||
getConfigPathForPort,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} = require('../../../dist/cliproxy/config-generator');
|
||||
|
||||
describe('PR #184: Variant Port Isolation Integration', function () {
|
||||
let configPath;
|
||||
let cliproxyDir;
|
||||
let ccsDir;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test directories
|
||||
ccsDir = path.join(testHome, '.ccs');
|
||||
cliproxyDir = path.join(ccsDir, 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
configPath = path.join(ccsDir, 'config.json');
|
||||
|
||||
// Start with empty config
|
||||
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up all test files
|
||||
try {
|
||||
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
delete process.env.CCS_HOME;
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PR Test Plan: Create multiple variants with ccs cliproxy create
|
||||
// ==========================================================================
|
||||
describe('Test Plan: Create multiple variants', function () {
|
||||
it('creates 3 variants with unique ports via createVariant flow', function () {
|
||||
const variants = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: { VARIANT_ID: i } }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
generateConfig('gemini', port);
|
||||
variants.push({ name: `variant${i}`, port });
|
||||
}
|
||||
|
||||
// Verify all variants created
|
||||
const storedVariants = listVariantsFromConfig();
|
||||
assert.strictEqual(Object.keys(storedVariants).length, 3);
|
||||
|
||||
// Verify unique ports
|
||||
const ports = variants.map((v) => v.port);
|
||||
const uniquePorts = new Set(ports);
|
||||
assert.strictEqual(uniquePorts.size, 3, 'All variants should have unique ports');
|
||||
|
||||
// Verify sequential port assignment
|
||||
assert.strictEqual(variants[0].port, VARIANT_PORT_BASE);
|
||||
assert.strictEqual(variants[1].port, VARIANT_PORT_BASE + 1);
|
||||
assert.strictEqual(variants[2].port, VARIANT_PORT_BASE + 2);
|
||||
});
|
||||
|
||||
it('handles maximum variant creation (100 variants)', function () {
|
||||
// Create 100 variants
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
|
||||
// Verify 100 variants exist
|
||||
const storedVariants = listVariantsFromConfig();
|
||||
assert.strictEqual(Object.keys(storedVariants).length, 100);
|
||||
|
||||
// Verify port exhaustion error
|
||||
assert.throws(
|
||||
() => getNextAvailablePort(),
|
||||
/Port limit reached.*100\/100/,
|
||||
'Should throw port limit error'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PR Test Plan: Verify each variant gets unique port in config
|
||||
// ==========================================================================
|
||||
describe('Test Plan: Verify unique port in config', function () {
|
||||
it('each variant has port stored in config', function () {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
assert.strictEqual(
|
||||
variants[`variant${i}`].port,
|
||||
VARIANT_PORT_BASE + i,
|
||||
`Variant ${i} should have port ${VARIANT_PORT_BASE + i}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('port persists across config reload', function () {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, 'persistent.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('persistent', 'gemini', settingsPath, undefined, port);
|
||||
|
||||
// Simulate reload by reading config directly
|
||||
const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.strictEqual(rawConfig.cliproxy.persistent.port, port);
|
||||
|
||||
// Verify via listVariantsFromConfig
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.persistent.port, port);
|
||||
});
|
||||
|
||||
it('port-specific config.yaml has correct port value', function () {
|
||||
const port = 8320;
|
||||
generateConfig('gemini', port);
|
||||
|
||||
const configContent = fs.readFileSync(getConfigPathForPort(port), 'utf-8');
|
||||
assert.ok(configContent.includes(`port: ${port}`), 'Config should contain correct port');
|
||||
});
|
||||
|
||||
it('different ports have different config files', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
for (const port of ports) {
|
||||
generateConfig('gemini', port);
|
||||
}
|
||||
|
||||
// Verify each has separate file
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8318.yaml')));
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8319.yaml')));
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8320.yaml')));
|
||||
|
||||
// Verify each has correct port in content
|
||||
for (const port of ports) {
|
||||
const content = fs.readFileSync(getConfigPathForPort(port), 'utf-8');
|
||||
assert.ok(content.includes(`port: ${port}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PR Test Plan: Run multiple variants concurrently
|
||||
// ==========================================================================
|
||||
describe('Test Plan: Run multiple variants concurrently', function () {
|
||||
it('registers sessions on 3 different ports simultaneously', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
const sessions = [];
|
||||
|
||||
// Start all 3 variants "concurrently"
|
||||
for (const port of ports) {
|
||||
generateConfig('gemini', port);
|
||||
const sessionId = registerSession(port, process.pid);
|
||||
sessions.push({ port, sessionId });
|
||||
}
|
||||
|
||||
// Verify all 3 are running
|
||||
for (const { port } of sessions) {
|
||||
const status = getProxyStatus(port);
|
||||
assert.strictEqual(status.running, true, `Port ${port} should be running`);
|
||||
assert.strictEqual(status.port, port);
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
}
|
||||
|
||||
// Verify separate session files exist
|
||||
for (const port of ports) {
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
assert.ok(fs.existsSync(sessionPath), `Session file for port ${port} should exist`);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
for (const { sessionId, port } of sessions) {
|
||||
unregisterSession(sessionId, port);
|
||||
}
|
||||
});
|
||||
|
||||
it('concurrent sessions on same variant port accumulate', function () {
|
||||
const port = 8318;
|
||||
generateConfig('gemini', port);
|
||||
|
||||
const session1 = registerSession(port, process.pid);
|
||||
const session2 = registerSession(port, process.pid);
|
||||
const session3 = registerSession(port, process.pid);
|
||||
|
||||
const status = getProxyStatus(port);
|
||||
assert.strictEqual(status.sessionCount, 3);
|
||||
|
||||
// Unregister one by one
|
||||
unregisterSession(session1, port);
|
||||
assert.strictEqual(getProxyStatus(port).sessionCount, 2);
|
||||
|
||||
unregisterSession(session2, port);
|
||||
assert.strictEqual(getProxyStatus(port).sessionCount, 1);
|
||||
|
||||
unregisterSession(session3, port);
|
||||
assert.strictEqual(getProxyStatus(port).running, false);
|
||||
});
|
||||
|
||||
it('stopping one variant does not affect others', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
for (const port of ports) {
|
||||
generateConfig('gemini', port);
|
||||
registerSession(port, process.pid);
|
||||
}
|
||||
|
||||
// Stop middle variant
|
||||
deleteSessionLockForPort(8319);
|
||||
|
||||
// Verify others still running
|
||||
assert.strictEqual(getProxyStatus(8318).running, true);
|
||||
assert.strictEqual(getProxyStatus(8319).running, false);
|
||||
assert.strictEqual(getProxyStatus(8320).running, true);
|
||||
|
||||
// Clean up remaining
|
||||
deleteSessionLockForPort(8318);
|
||||
deleteSessionLockForPort(8320);
|
||||
});
|
||||
|
||||
it('each variant has isolated session tracking', function () {
|
||||
// Create 2 variants
|
||||
const port1 = 8318;
|
||||
const port2 = 8319;
|
||||
|
||||
generateConfig('gemini', port1);
|
||||
generateConfig('gemini', port2);
|
||||
|
||||
// Register different session counts
|
||||
const p1s1 = registerSession(port1, process.pid);
|
||||
const p1s2 = registerSession(port1, process.pid);
|
||||
const p2s1 = registerSession(port2, process.pid);
|
||||
|
||||
assert.strictEqual(getProxyStatus(port1).sessionCount, 2);
|
||||
assert.strictEqual(getProxyStatus(port2).sessionCount, 1);
|
||||
|
||||
// Unregister from port1 - should not affect port2
|
||||
unregisterSession(p1s1, port1);
|
||||
assert.strictEqual(getProxyStatus(port1).sessionCount, 1);
|
||||
assert.strictEqual(getProxyStatus(port2).sessionCount, 1);
|
||||
|
||||
// Clean up
|
||||
unregisterSession(p1s2, port1);
|
||||
unregisterSession(p2s1, port2);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PR Test Plan: Verify ccs cliproxy list shows port column
|
||||
// ==========================================================================
|
||||
describe('Test Plan: Verify list shows port column', function () {
|
||||
it('listVariantsFromConfig returns port for each variant', function () {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
|
||||
// Verify port field exists and is correct for each
|
||||
assert.strictEqual(variants.variant0.port, 8318);
|
||||
assert.strictEqual(variants.variant1.port, 8319);
|
||||
assert.strictEqual(variants.variant2.port, 8320);
|
||||
});
|
||||
|
||||
it('legacy variants without port return undefined', function () {
|
||||
// Create legacy variant without port field
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy_variant: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/settings.json',
|
||||
// No port field
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.legacy_variant.port, undefined);
|
||||
assert.strictEqual(variants.legacy_variant.provider, 'gemini');
|
||||
});
|
||||
|
||||
it('mixed legacy and modern variants show correct ports', function () {
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/legacy.json',
|
||||
// No port
|
||||
},
|
||||
modern: {
|
||||
provider: 'codex',
|
||||
settings: '/path/to/modern.json',
|
||||
port: 8318,
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.legacy.port, undefined);
|
||||
assert.strictEqual(variants.modern.port, 8318);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PR Test Plan: Remove variant and verify cleanup of port-specific files
|
||||
// ==========================================================================
|
||||
describe('Test Plan: Remove variant and verify cleanup', function () {
|
||||
it('removes variant config entry', function () {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, 'to-remove.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('to-remove', 'gemini', settingsPath, undefined, port);
|
||||
|
||||
// Verify exists
|
||||
let variants = listVariantsFromConfig();
|
||||
assert.ok('to-remove' in variants);
|
||||
|
||||
// Remove
|
||||
const removed = removeVariantFromLegacyConfig('to-remove');
|
||||
assert.strictEqual(removed.port, port);
|
||||
|
||||
// Verify gone
|
||||
variants = listVariantsFromConfig();
|
||||
assert.ok(!('to-remove' in variants));
|
||||
});
|
||||
|
||||
it('deleteConfigForPort removes config-{port}.yaml', function () {
|
||||
const port = 8318;
|
||||
generateConfig('gemini', port);
|
||||
assert.ok(configExists(port));
|
||||
|
||||
deleteConfigForPort(port);
|
||||
assert.strictEqual(configExists(port), false);
|
||||
});
|
||||
|
||||
it('deleteSessionLockForPort removes sessions-{port}.json', function () {
|
||||
const port = 8318;
|
||||
registerSession(port, process.pid);
|
||||
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
assert.ok(fs.existsSync(sessionPath));
|
||||
|
||||
deleteSessionLockForPort(port);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
|
||||
it('full variant removal cleans config, session, and config files', function () {
|
||||
const port = 8318;
|
||||
const settingsPath = path.join(ccsDir, 'full-cleanup.settings.json');
|
||||
|
||||
// Create variant with all files
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('full-cleanup', 'gemini', settingsPath, undefined, port);
|
||||
generateConfig('gemini', port);
|
||||
registerSession(port, process.pid);
|
||||
|
||||
// Verify all files exist
|
||||
assert.ok(listVariantsFromConfig()['full-cleanup']);
|
||||
assert.ok(configExists(port));
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, `sessions-${port}.json`)));
|
||||
|
||||
// Full cleanup (simulating removeVariant behavior)
|
||||
removeVariantFromLegacyConfig('full-cleanup');
|
||||
deleteConfigForPort(port);
|
||||
deleteSessionLockForPort(port);
|
||||
|
||||
// Verify all cleaned
|
||||
assert.ok(!listVariantsFromConfig()['full-cleanup']);
|
||||
assert.strictEqual(configExists(port), false);
|
||||
assert.strictEqual(fs.existsSync(path.join(cliproxyDir, `sessions-${port}.json`)), false);
|
||||
});
|
||||
|
||||
it('removing one variant does not affect others', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
// Create 3 variants
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
|
||||
generateConfig('gemini', ports[i]);
|
||||
registerSession(ports[i], process.pid);
|
||||
}
|
||||
|
||||
// Remove middle variant
|
||||
removeVariantFromLegacyConfig('variant1');
|
||||
deleteConfigForPort(8319);
|
||||
deleteSessionLockForPort(8319);
|
||||
|
||||
// Verify others intact
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.ok('variant0' in variants);
|
||||
assert.ok(!('variant1' in variants));
|
||||
assert.ok('variant2' in variants);
|
||||
|
||||
assert.ok(configExists(8318));
|
||||
assert.strictEqual(configExists(8319), false);
|
||||
assert.ok(configExists(8320));
|
||||
|
||||
assert.strictEqual(getProxyStatus(8318).running, true);
|
||||
assert.strictEqual(getProxyStatus(8319).running, false);
|
||||
assert.strictEqual(getProxyStatus(8320).running, true);
|
||||
|
||||
// Clean up remaining
|
||||
deleteSessionLockForPort(8318);
|
||||
deleteSessionLockForPort(8320);
|
||||
});
|
||||
|
||||
it('freed port can be reused after deletion', function () {
|
||||
// Create variant on first port
|
||||
const port1 = getNextAvailablePort();
|
||||
const settingsPath1 = path.join(ccsDir, 'first.settings.json');
|
||||
fs.writeFileSync(settingsPath1, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('first', 'gemini', settingsPath1, undefined, port1);
|
||||
generateConfig('gemini', port1);
|
||||
|
||||
// Next port should be port1 + 1
|
||||
const port2 = getNextAvailablePort();
|
||||
assert.strictEqual(port2, port1 + 1);
|
||||
|
||||
// Remove first variant
|
||||
removeVariantFromLegacyConfig('first');
|
||||
deleteConfigForPort(port1);
|
||||
|
||||
// port1 should now be available again
|
||||
const reusedPort = getNextAvailablePort();
|
||||
assert.strictEqual(reusedPort, port1, 'Freed port should be reusable');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Edge Cases for Port Isolation
|
||||
// ==========================================================================
|
||||
describe('Edge Cases', function () {
|
||||
it('default port (8317) uses sessions.json not sessions-8317.json', function () {
|
||||
registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
|
||||
|
||||
// Default port uses sessions.json
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, 'sessions.json')));
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(cliproxyDir, `sessions-${CLIPROXY_DEFAULT_PORT}.json`)),
|
||||
false
|
||||
);
|
||||
|
||||
deleteSessionLockForPort(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('default port (8317) uses config.yaml not config-8317.yaml', function () {
|
||||
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, 'config.yaml')));
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(cliproxyDir, `config-${CLIPROXY_DEFAULT_PORT}.yaml`)),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('port range is 8318-8417 (100 ports)', function () {
|
||||
assert.strictEqual(VARIANT_PORT_BASE, 8318);
|
||||
assert.strictEqual(VARIANT_PORT_MAX_OFFSET, 100);
|
||||
|
||||
// First variant port
|
||||
assert.strictEqual(getNextAvailablePort(), 8318);
|
||||
|
||||
// Create all 100
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, `v${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`v${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
|
||||
// Last port should be 8417
|
||||
const lastVariant = listVariantsFromConfig()['v99'];
|
||||
assert.strictEqual(lastVariant.port, 8417);
|
||||
});
|
||||
|
||||
it('handles stale session files from crashed processes', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Write stale session with dead PID
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port,
|
||||
pid: 999999999, // Non-existent PID
|
||||
sessions: ['stale-session'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
// getExistingProxy should detect and clean up
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
|
||||
it('gracefully handles missing session file on unregister', function () {
|
||||
// Should not throw
|
||||
const result = unregisterSession('nonexistent-session', 8318);
|
||||
assert.strictEqual(result, true); // No file = should kill
|
||||
});
|
||||
|
||||
it('gracefully handles missing config file on delete', function () {
|
||||
// Should not throw
|
||||
deleteConfigForPort(9999);
|
||||
assert.strictEqual(configExists(9999), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Tokens Command Tests
|
||||
*
|
||||
* Tests for the `ccs tokens` CLI command including:
|
||||
* - Argument parsing
|
||||
* - Token masking
|
||||
* - Error handling
|
||||
* - Exit codes
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
describe('Tokens Command', () => {
|
||||
// =========================================================================
|
||||
// Token Masking Tests (Unit)
|
||||
// =========================================================================
|
||||
describe('maskToken', () => {
|
||||
// Extract the maskToken function pattern for testing
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
it('masks tokens showing first 4 and last 4 characters', () => {
|
||||
const result = maskToken('1234567890abcdef');
|
||||
assert.strictEqual(result, '1234...cdef');
|
||||
});
|
||||
|
||||
it('returns **** for tokens <= 8 characters', () => {
|
||||
assert.strictEqual(maskToken('12345678'), '****');
|
||||
assert.strictEqual(maskToken('1234567'), '****');
|
||||
assert.strictEqual(maskToken('abc'), '****');
|
||||
assert.strictEqual(maskToken(''), '****');
|
||||
});
|
||||
|
||||
it('handles exactly 9 character tokens', () => {
|
||||
const result = maskToken('123456789');
|
||||
assert.strictEqual(result, '1234...6789');
|
||||
});
|
||||
|
||||
it('handles long tokens (typical API keys)', () => {
|
||||
const longToken = 'test_key_1234567890abcdefghijklmnopqrstuvwxyz';
|
||||
const result = maskToken(longToken);
|
||||
assert.strictEqual(result, 'test...wxyz');
|
||||
});
|
||||
|
||||
it('preserves special characters in masked output', () => {
|
||||
const token = '!@#$abcd____wxyz';
|
||||
const result = maskToken(token);
|
||||
assert.strictEqual(result, '!@#$...wxyz');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Argument Parsing Tests (Unit)
|
||||
// =========================================================================
|
||||
describe('Argument Parsing', () => {
|
||||
/**
|
||||
* Simulates the argument parsing logic from tokens-command.ts
|
||||
*/
|
||||
function parseArgs(args) {
|
||||
const showFlag = args.includes('--show');
|
||||
const resetFlag = args.includes('--reset');
|
||||
const regenerateSecretFlag = args.includes('--regenerate-secret');
|
||||
const helpFlag = args.includes('--help') || args.includes('-h');
|
||||
|
||||
const apiKeyIndex = args.indexOf('--api-key');
|
||||
const apiKeyValue = apiKeyIndex !== -1 ? args[apiKeyIndex + 1] : undefined;
|
||||
|
||||
const secretIndex = args.indexOf('--secret');
|
||||
const secretValue = secretIndex !== -1 ? args[secretIndex + 1] : undefined;
|
||||
|
||||
const variantIndex = args.indexOf('--variant');
|
||||
const variantValue = variantIndex !== -1 ? args[variantIndex + 1] : undefined;
|
||||
|
||||
return {
|
||||
showFlag,
|
||||
resetFlag,
|
||||
regenerateSecretFlag,
|
||||
helpFlag,
|
||||
apiKeyValue,
|
||||
secretValue,
|
||||
variantValue,
|
||||
};
|
||||
}
|
||||
|
||||
it('parses --show flag', () => {
|
||||
const result = parseArgs(['--show']);
|
||||
assert.strictEqual(result.showFlag, true);
|
||||
});
|
||||
|
||||
it('parses --reset flag', () => {
|
||||
const result = parseArgs(['--reset']);
|
||||
assert.strictEqual(result.resetFlag, true);
|
||||
});
|
||||
|
||||
it('parses --regenerate-secret flag', () => {
|
||||
const result = parseArgs(['--regenerate-secret']);
|
||||
assert.strictEqual(result.regenerateSecretFlag, true);
|
||||
});
|
||||
|
||||
it('parses --help and -h flags', () => {
|
||||
assert.strictEqual(parseArgs(['--help']).helpFlag, true);
|
||||
assert.strictEqual(parseArgs(['-h']).helpFlag, true);
|
||||
});
|
||||
|
||||
it('parses --api-key with value', () => {
|
||||
const result = parseArgs(['--api-key', 'my-custom-key']);
|
||||
assert.strictEqual(result.apiKeyValue, 'my-custom-key');
|
||||
});
|
||||
|
||||
it('parses --secret with value', () => {
|
||||
const result = parseArgs(['--secret', 'my-secret']);
|
||||
assert.strictEqual(result.secretValue, 'my-secret');
|
||||
});
|
||||
|
||||
it('parses --variant with value', () => {
|
||||
const result = parseArgs(['--variant', 'gemini']);
|
||||
assert.strictEqual(result.variantValue, 'gemini');
|
||||
});
|
||||
|
||||
it('parses combined --variant and --api-key', () => {
|
||||
const result = parseArgs(['--variant', 'gemini', '--api-key', 'variant-key']);
|
||||
assert.strictEqual(result.variantValue, 'gemini');
|
||||
assert.strictEqual(result.apiKeyValue, 'variant-key');
|
||||
});
|
||||
|
||||
it('handles no arguments (default case)', () => {
|
||||
const result = parseArgs([]);
|
||||
assert.strictEqual(result.showFlag, false);
|
||||
assert.strictEqual(result.resetFlag, false);
|
||||
assert.strictEqual(result.apiKeyValue, undefined);
|
||||
});
|
||||
|
||||
it('handles multiple flags', () => {
|
||||
const result = parseArgs(['--show', '--api-key', 'key', '--variant', 'v1']);
|
||||
assert.strictEqual(result.showFlag, true);
|
||||
assert.strictEqual(result.apiKeyValue, 'key');
|
||||
assert.strictEqual(result.variantValue, 'v1');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Validation Tests (Edge Cases)
|
||||
// =========================================================================
|
||||
describe('Input Validation', () => {
|
||||
/**
|
||||
* Simulates the validation logic from tokens-command.ts
|
||||
*/
|
||||
function validateApiKeyValue(apiKeyValue) {
|
||||
if (apiKeyValue !== undefined) {
|
||||
if (!apiKeyValue || apiKeyValue.startsWith('-')) {
|
||||
return { valid: false, error: 'Missing value for --api-key' };
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function validateSecretValue(secretValue) {
|
||||
if (secretValue !== undefined) {
|
||||
if (!secretValue || secretValue.startsWith('-')) {
|
||||
return { valid: false, error: 'Missing value for --secret' };
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
it('rejects --api-key without value', () => {
|
||||
// When --api-key is at end of args, value will be undefined
|
||||
const result = validateApiKeyValue(undefined);
|
||||
// Note: undefined means flag wasn't provided, empty string means missing value
|
||||
assert.strictEqual(result.valid, true); // undefined = not provided = valid
|
||||
});
|
||||
|
||||
it('rejects --api-key with empty string', () => {
|
||||
const result = validateApiKeyValue('');
|
||||
assert.strictEqual(result.valid, false);
|
||||
assert.strictEqual(result.error, 'Missing value for --api-key');
|
||||
});
|
||||
|
||||
it('rejects --api-key followed by another flag', () => {
|
||||
// e.g., --api-key --reset -> apiKeyValue = '--reset'
|
||||
const result = validateApiKeyValue('--reset');
|
||||
assert.strictEqual(result.valid, false);
|
||||
assert.strictEqual(result.error, 'Missing value for --api-key');
|
||||
});
|
||||
|
||||
it('rejects --secret with empty string', () => {
|
||||
const result = validateSecretValue('');
|
||||
assert.strictEqual(result.valid, false);
|
||||
assert.strictEqual(result.error, 'Missing value for --secret');
|
||||
});
|
||||
|
||||
it('rejects --secret followed by another flag', () => {
|
||||
const result = validateSecretValue('--show');
|
||||
assert.strictEqual(result.valid, false);
|
||||
assert.strictEqual(result.error, 'Missing value for --secret');
|
||||
});
|
||||
|
||||
it('accepts valid api key values', () => {
|
||||
assert.strictEqual(validateApiKeyValue('my-key').valid, true);
|
||||
assert.strictEqual(validateApiKeyValue('sk_live_123').valid, true);
|
||||
assert.strictEqual(validateApiKeyValue('a').valid, true);
|
||||
});
|
||||
|
||||
it('accepts valid secret values', () => {
|
||||
assert.strictEqual(validateSecretValue('my-secret').valid, true);
|
||||
assert.strictEqual(validateSecretValue('super-secure-123').valid, true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Exit Code Tests
|
||||
// =========================================================================
|
||||
describe('Exit Codes', () => {
|
||||
it('defines success exit code as 0', () => {
|
||||
// These are the expected exit codes from the command
|
||||
const EXIT_SUCCESS = 0;
|
||||
const EXIT_FAILURE = 1;
|
||||
|
||||
assert.strictEqual(EXIT_SUCCESS, 0);
|
||||
assert.strictEqual(EXIT_FAILURE, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Help Text Tests
|
||||
// =========================================================================
|
||||
describe('Help Text Coverage', () => {
|
||||
// Verify all documented options are present in help output
|
||||
const expectedOptions = [
|
||||
'--show',
|
||||
'--api-key',
|
||||
'--secret',
|
||||
'--regenerate-secret',
|
||||
'--variant',
|
||||
'--reset',
|
||||
'--help',
|
||||
'-h',
|
||||
];
|
||||
|
||||
it('documents all CLI options', () => {
|
||||
// This test ensures we update help when adding new options
|
||||
expectedOptions.forEach((option) => {
|
||||
assert(typeof option === 'string', `Option ${option} should be a string`);
|
||||
assert(option.startsWith('-'), `Option ${option} should start with -`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Settings Routes - Auth Tokens API Tests
|
||||
*
|
||||
* Tests for the auth tokens API endpoints:
|
||||
* - GET /api/settings/auth/tokens (masked)
|
||||
* - GET /api/settings/auth/tokens/raw (unmasked)
|
||||
* - PUT /api/settings/auth/tokens (update)
|
||||
* - POST /api/settings/auth/tokens/regenerate-secret
|
||||
* - POST /api/settings/auth/tokens/reset
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
describe('Settings Routes - Auth Tokens API', () => {
|
||||
// =========================================================================
|
||||
// maskToken Function Tests (same as in routes)
|
||||
// =========================================================================
|
||||
describe('maskToken', () => {
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
it('masks long tokens correctly', () => {
|
||||
assert.strictEqual(maskToken('ccs-internal-managed'), 'ccs-...aged');
|
||||
});
|
||||
|
||||
it('fully masks short tokens', () => {
|
||||
assert.strictEqual(maskToken('ccs'), '****');
|
||||
});
|
||||
|
||||
it('handles exactly 8 character tokens', () => {
|
||||
assert.strictEqual(maskToken('12345678'), '****');
|
||||
});
|
||||
|
||||
it('handles 9 character tokens', () => {
|
||||
assert.strictEqual(maskToken('123456789'), '1234...6789');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Response Format Tests
|
||||
// =========================================================================
|
||||
describe('Response Format', () => {
|
||||
/**
|
||||
* Expected response format for GET /api/settings/auth/tokens
|
||||
*/
|
||||
function createMaskedResponse(apiKey, managementSecret, apiKeyIsCustom, secretIsCustom) {
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: {
|
||||
value: maskToken(apiKey),
|
||||
isCustom: apiKeyIsCustom,
|
||||
},
|
||||
managementSecret: {
|
||||
value: maskToken(managementSecret),
|
||||
isCustom: secretIsCustom,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('includes apiKey with value and isCustom', () => {
|
||||
const response = createMaskedResponse('test-api-key-123', 'test-secret', true, false);
|
||||
|
||||
assert(response.apiKey, 'Should have apiKey');
|
||||
assert.strictEqual(typeof response.apiKey.value, 'string');
|
||||
assert.strictEqual(typeof response.apiKey.isCustom, 'boolean');
|
||||
});
|
||||
|
||||
it('includes managementSecret with value and isCustom', () => {
|
||||
const response = createMaskedResponse('key', 'test-secret-456', false, true);
|
||||
|
||||
assert(response.managementSecret, 'Should have managementSecret');
|
||||
assert.strictEqual(typeof response.managementSecret.value, 'string');
|
||||
assert.strictEqual(typeof response.managementSecret.isCustom, 'boolean');
|
||||
});
|
||||
|
||||
it('masks values in response', () => {
|
||||
const response = createMaskedResponse(
|
||||
'very-long-api-key-12345',
|
||||
'very-long-secret-67890',
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
// Values should be masked (contain ...)
|
||||
assert(response.apiKey.value.includes('...'), 'API key should be masked');
|
||||
assert(response.managementSecret.value.includes('...'), 'Secret should be masked');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// PUT /api/settings/auth/tokens - Update Logic Tests
|
||||
// =========================================================================
|
||||
describe('PUT /api/settings/auth/tokens - Update Logic', () => {
|
||||
/**
|
||||
* Simulates the update logic
|
||||
*/
|
||||
function processUpdate(body, currentState) {
|
||||
const { apiKey, managementSecret } = body;
|
||||
const newState = { ...currentState };
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
// Empty string -> reset to default
|
||||
newState.apiKey = apiKey || null;
|
||||
}
|
||||
|
||||
if (managementSecret !== undefined) {
|
||||
newState.managementSecret = managementSecret || null;
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
it('updates apiKey when provided', () => {
|
||||
const current = { apiKey: 'old-key', managementSecret: 'old-secret' };
|
||||
const result = processUpdate({ apiKey: 'new-key' }, current);
|
||||
|
||||
assert.strictEqual(result.apiKey, 'new-key');
|
||||
assert.strictEqual(result.managementSecret, 'old-secret');
|
||||
});
|
||||
|
||||
it('updates managementSecret when provided', () => {
|
||||
const current = { apiKey: 'old-key', managementSecret: 'old-secret' };
|
||||
const result = processUpdate({ managementSecret: 'new-secret' }, current);
|
||||
|
||||
assert.strictEqual(result.apiKey, 'old-key');
|
||||
assert.strictEqual(result.managementSecret, 'new-secret');
|
||||
});
|
||||
|
||||
it('updates both when provided', () => {
|
||||
const current = { apiKey: 'old-key', managementSecret: 'old-secret' };
|
||||
const result = processUpdate({ apiKey: 'new-key', managementSecret: 'new-secret' }, current);
|
||||
|
||||
assert.strictEqual(result.apiKey, 'new-key');
|
||||
assert.strictEqual(result.managementSecret, 'new-secret');
|
||||
});
|
||||
|
||||
it('resets to default when empty string provided', () => {
|
||||
const current = { apiKey: 'custom-key', managementSecret: 'custom-secret' };
|
||||
const result = processUpdate({ apiKey: '', managementSecret: '' }, current);
|
||||
|
||||
assert.strictEqual(result.apiKey, null);
|
||||
assert.strictEqual(result.managementSecret, null);
|
||||
});
|
||||
|
||||
it('ignores undefined values (no change)', () => {
|
||||
const current = { apiKey: 'keep-key', managementSecret: 'keep-secret' };
|
||||
const result = processUpdate({}, current);
|
||||
|
||||
assert.strictEqual(result.apiKey, 'keep-key');
|
||||
assert.strictEqual(result.managementSecret, 'keep-secret');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// POST /api/settings/auth/tokens/regenerate-secret - Tests
|
||||
// =========================================================================
|
||||
describe('POST /api/settings/auth/tokens/regenerate-secret', () => {
|
||||
/**
|
||||
* Simulates regenerate secret response
|
||||
*/
|
||||
function createRegenerateResponse(newSecret) {
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
managementSecret: {
|
||||
value: maskToken(newSecret),
|
||||
isCustom: true,
|
||||
},
|
||||
message: 'Restart CLIProxy to apply changes',
|
||||
};
|
||||
}
|
||||
|
||||
it('returns success: true', () => {
|
||||
const response = createRegenerateResponse('new-generated-secret-12345');
|
||||
assert.strictEqual(response.success, true);
|
||||
});
|
||||
|
||||
it('returns masked new secret', () => {
|
||||
const response = createRegenerateResponse('abcdefghijklmnopqrstuvwxyz');
|
||||
assert(response.managementSecret.value.includes('...'), 'Should be masked');
|
||||
assert.strictEqual(response.managementSecret.isCustom, true);
|
||||
});
|
||||
|
||||
it('includes restart message', () => {
|
||||
const response = createRegenerateResponse('secret');
|
||||
assert(response.message.includes('Restart'), 'Should include restart instruction');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// POST /api/settings/auth/tokens/reset - Tests
|
||||
// =========================================================================
|
||||
describe('POST /api/settings/auth/tokens/reset', () => {
|
||||
/**
|
||||
* Simulates reset response
|
||||
*/
|
||||
function createResetResponse(defaultApiKey, defaultSecret) {
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
apiKey: {
|
||||
value: maskToken(defaultApiKey),
|
||||
isCustom: false,
|
||||
},
|
||||
managementSecret: {
|
||||
value: maskToken(defaultSecret),
|
||||
isCustom: false,
|
||||
},
|
||||
message: 'Tokens reset to defaults. Restart CLIProxy to apply.',
|
||||
};
|
||||
}
|
||||
|
||||
it('returns success: true', () => {
|
||||
const response = createResetResponse('ccs-internal-managed', 'ccs');
|
||||
assert.strictEqual(response.success, true);
|
||||
});
|
||||
|
||||
it('returns isCustom: false for both tokens', () => {
|
||||
const response = createResetResponse('ccs-internal-managed', 'ccs');
|
||||
assert.strictEqual(response.apiKey.isCustom, false);
|
||||
assert.strictEqual(response.managementSecret.isCustom, false);
|
||||
});
|
||||
|
||||
it('includes reset message', () => {
|
||||
const response = createResetResponse('key', 'secret');
|
||||
assert(response.message.includes('reset'), 'Should include reset confirmation');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Error Response Format Tests
|
||||
// =========================================================================
|
||||
describe('Error Response Format', () => {
|
||||
/**
|
||||
* Simulates error response
|
||||
*/
|
||||
function createErrorResponse(errorMessage) {
|
||||
return {
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
it('includes error message', () => {
|
||||
const response = createErrorResponse('Something went wrong');
|
||||
assert.strictEqual(response.error, 'Something went wrong');
|
||||
});
|
||||
|
||||
it('does not include sensitive data in errors', () => {
|
||||
const response = createErrorResponse('Failed to load config');
|
||||
assert(!response.apiKey, 'Should not include apiKey');
|
||||
assert(!response.managementSecret, 'Should not include secret');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// HTTP Status Code Tests
|
||||
// =========================================================================
|
||||
describe('HTTP Status Codes', () => {
|
||||
const HTTP_OK = 200;
|
||||
const HTTP_INTERNAL_ERROR = 500;
|
||||
|
||||
it('uses 200 for successful responses', () => {
|
||||
assert.strictEqual(HTTP_OK, 200);
|
||||
});
|
||||
|
||||
it('uses 500 for internal errors', () => {
|
||||
assert.strictEqual(HTTP_INTERNAL_ERROR, 500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe } from 'lucide-react';
|
||||
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe, Settings } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { CliproxyServerConfig } from '@/lib/api-client';
|
||||
@@ -15,8 +15,10 @@ import type { CliproxyServerConfig } from '@/lib/api-client';
|
||||
/** CLIProxyAPI default port */
|
||||
const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
|
||||
/** CCS Control Panel secret - must match config-generator.ts CCS_CONTROL_PANEL_SECRET */
|
||||
const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
interface AuthTokensResponse {
|
||||
apiKey: { value: string; isCustom: boolean };
|
||||
managementSecret: { value: string; isCustom: boolean };
|
||||
}
|
||||
|
||||
interface ControlPanelEmbedProps {
|
||||
port?: number;
|
||||
@@ -36,6 +38,17 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
// Fetch auth tokens for local mode (gets effective management secret)
|
||||
const { data: authTokens } = useQuery<AuthTokensResponse>({
|
||||
queryKey: ['auth-tokens-raw'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/settings/auth/tokens/raw');
|
||||
if (!response.ok) throw new Error('Failed to fetch auth tokens');
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
// Log config fetch errors (fallback to local mode on error)
|
||||
useEffect(() => {
|
||||
if (configError) {
|
||||
@@ -67,15 +80,16 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
};
|
||||
}
|
||||
|
||||
// Local mode
|
||||
// Local mode - use effective management secret from auth tokens API
|
||||
const effectiveSecret = authTokens?.managementSecret?.value || 'ccs';
|
||||
return {
|
||||
managementUrl: `http://localhost:${port}/management.html`,
|
||||
checkUrl: `http://localhost:${port}/`,
|
||||
authToken: CCS_CONTROL_PANEL_SECRET,
|
||||
authToken: effectiveSecret,
|
||||
isRemote: false,
|
||||
displayHost: `localhost:${port}`,
|
||||
};
|
||||
}, [cliproxyConfig, port]);
|
||||
}, [cliproxyConfig, authTokens, port]);
|
||||
|
||||
// Check if CLIProxy is running
|
||||
useEffect(() => {
|
||||
@@ -219,6 +233,13 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
: authToken || 'ccs'}
|
||||
</code>
|
||||
</span>
|
||||
<a
|
||||
href="/settings?tab=auth"
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
title="Manage auth tokens"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
onClick={() => setShowLoginHint(false)}
|
||||
|
||||
@@ -31,7 +31,9 @@ export function ProviderEditor({
|
||||
authStatus,
|
||||
catalog,
|
||||
logoProvider,
|
||||
baseProvider,
|
||||
isRemoteMode,
|
||||
port,
|
||||
onAddAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
@@ -46,6 +48,9 @@ export function ProviderEditor({
|
||||
const deletePresetMutation = useDeletePreset();
|
||||
const savedPresets = presetsData?.presets || [];
|
||||
|
||||
// Use baseProvider for model filtering (for variants, this is the parent provider)
|
||||
const modelFilterProvider = baseProvider || provider;
|
||||
|
||||
const providerModels = useMemo(() => {
|
||||
if (!modelsData?.models) return [];
|
||||
const ownerMap: Record<string, string[]> = {
|
||||
@@ -57,11 +62,13 @@ export function ProviderEditor({
|
||||
kiro: ['kiro', 'aws'],
|
||||
ghcp: ['github', 'copilot'],
|
||||
};
|
||||
const owners = ownerMap[provider.toLowerCase()] || [provider.toLowerCase()];
|
||||
const owners = ownerMap[modelFilterProvider.toLowerCase()] || [
|
||||
modelFilterProvider.toLowerCase(),
|
||||
];
|
||||
return modelsData.models.filter((m) =>
|
||||
owners.some((o) => m.owned_by.toLowerCase().includes(o))
|
||||
);
|
||||
}, [modelsData, provider]);
|
||||
}, [modelsData, modelFilterProvider]);
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -128,6 +135,7 @@ export function ProviderEditor({
|
||||
isRawJsonValid={isRawJsonValid}
|
||||
isSaving={saveMutation.isPending}
|
||||
isRemoteMode={isRemoteMode}
|
||||
port={port}
|
||||
onRefetch={refetch}
|
||||
onSave={() => saveMutation.mutate()}
|
||||
/>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ModelConfigSection({
|
||||
<p className="text-xs text-muted-foreground mb-3">Apply pre-configured model mappings</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Recommended presets from catalog */}
|
||||
{catalog?.models.slice(0, 3).map((model) => (
|
||||
{catalog?.models.slice(0, 4).map((model) => (
|
||||
<Button
|
||||
key={model.id}
|
||||
variant="outline"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Save, Loader2, RefreshCw, Globe } from 'lucide-react';
|
||||
import { Save, Loader2, RefreshCw, Globe, Network } from 'lucide-react';
|
||||
import { ProviderLogo } from '../provider-logo';
|
||||
import type { SettingsResponse } from './types';
|
||||
|
||||
@@ -19,6 +19,7 @@ interface ProviderEditorHeaderProps {
|
||||
isRawJsonValid: boolean;
|
||||
isSaving: boolean;
|
||||
isRemoteMode?: boolean;
|
||||
port?: number;
|
||||
onRefetch: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
@@ -33,6 +34,7 @@ export function ProviderEditorHeader({
|
||||
isRawJsonValid,
|
||||
isSaving,
|
||||
isRemoteMode,
|
||||
port,
|
||||
onRefetch,
|
||||
onSave,
|
||||
}: ProviderEditorHeaderProps) {
|
||||
@@ -52,6 +54,11 @@ export function ProviderEditorHeader({
|
||||
Remote
|
||||
</Badge>
|
||||
)}
|
||||
{port && (
|
||||
<Badge variant="outline" className="text-xs gap-1 font-mono">
|
||||
<Network className="w-3 h-3" />:{port}
|
||||
</Badge>
|
||||
)}
|
||||
{!isRemoteMode && data?.path && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{data.path.replace(/^.*[\\/]/, '')}
|
||||
|
||||
@@ -21,8 +21,12 @@ export interface ProviderEditorProps {
|
||||
catalog?: ProviderCatalog;
|
||||
/** Provider type for logo display (defaults to provider) */
|
||||
logoProvider?: string;
|
||||
/** Base provider for model filtering (defaults to provider). For variants, this is the parent provider. */
|
||||
baseProvider?: string;
|
||||
/** True if using remote CLIProxy mode (hides local paths) */
|
||||
isRemoteMode?: boolean;
|
||||
/** Port number for variant (for display in header) */
|
||||
port?: number;
|
||||
onAddAccount: () => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface Variant {
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp';
|
||||
settings: string;
|
||||
account?: string;
|
||||
port?: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface CreateVariant {
|
||||
|
||||
@@ -339,7 +339,9 @@ export function CliproxyPage() {
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
baseProvider={selectedVariantData.provider}
|
||||
isRemoteMode={isRemoteMode}
|
||||
port={selectedVariantData.port}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedVariantData.provider,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Globe, Settings2, Server } from 'lucide-react';
|
||||
import { Globe, Settings2, Server, KeyRound } from 'lucide-react';
|
||||
import type { SettingsTab } from '../types';
|
||||
|
||||
interface TabNavigationProps {
|
||||
@@ -28,6 +28,10 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
||||
<Server className="w-4 h-4" />
|
||||
Proxy
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auth" className="flex-1 gap-2">
|
||||
<KeyRound className="w-4 h-4" />
|
||||
Auth
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,13 @@ export function useSettingsTab() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tabParam = searchParams.get('tab');
|
||||
const activeTab: SettingsTab =
|
||||
tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch';
|
||||
tabParam === 'globalenv'
|
||||
? 'globalenv'
|
||||
: tabParam === 'proxy'
|
||||
? 'proxy'
|
||||
: tabParam === 'auth'
|
||||
? 'auth'
|
||||
: 'websearch';
|
||||
|
||||
const setActiveTab = useCallback(
|
||||
(tab: SettingsTab) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { SettingsTab } from './types';
|
||||
const WebSearchSection = lazy(() => import('./sections/websearch'));
|
||||
const GlobalEnvSection = lazy(() => import('./sections/globalenv-section'));
|
||||
const ProxySection = lazy(() => import('./sections/proxy'));
|
||||
const AuthSection = lazy(() => import('./sections/auth-section'));
|
||||
|
||||
// Inner component that uses context
|
||||
function SettingsPageInner() {
|
||||
@@ -57,6 +58,7 @@ function SettingsPageInner() {
|
||||
{activeTab === 'websearch' && <WebSearchSection />}
|
||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||
{activeTab === 'proxy' && <ProxySection />}
|
||||
{activeTab === 'auth' && <AuthSection />}
|
||||
</Suspense>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Auth Section
|
||||
* Settings section for CLIProxy auth tokens (API key and management secret)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Copy,
|
||||
Check,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { useRawConfig } from '../hooks';
|
||||
|
||||
interface TokenInfo {
|
||||
value: string;
|
||||
isCustom: boolean;
|
||||
}
|
||||
|
||||
interface AuthTokens {
|
||||
apiKey: TokenInfo;
|
||||
managementSecret: TokenInfo;
|
||||
}
|
||||
|
||||
export default function AuthSection() {
|
||||
const { fetchRawConfig } = useRawConfig();
|
||||
|
||||
// State
|
||||
const [tokens, setTokens] = useState<AuthTokens | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Edit state
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [editedApiKey, setEditedApiKey] = useState<string | null>(null);
|
||||
const [editedSecret, setEditedSecret] = useState<string | null>(null);
|
||||
const [copiedApiKey, setCopiedApiKey] = useState(false);
|
||||
const [copiedSecret, setCopiedSecret] = useState(false);
|
||||
|
||||
// Fetch tokens
|
||||
const fetchTokens = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Use /raw to get unmasked values for editing
|
||||
const response = await fetch('/api/settings/auth/tokens/raw');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch auth tokens');
|
||||
}
|
||||
const data = await response.json();
|
||||
setTokens(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load on mount
|
||||
useEffect(() => {
|
||||
fetchTokens();
|
||||
fetchRawConfig();
|
||||
}, [fetchTokens, fetchRawConfig]);
|
||||
|
||||
// Clear success after timeout
|
||||
useEffect(() => {
|
||||
if (success) {
|
||||
const timer = setTimeout(() => setSuccess(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [success]);
|
||||
|
||||
// Clear error after timeout
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => setError(null), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Save all changes
|
||||
const saveChanges = async () => {
|
||||
const hasApiKeyChange = editedApiKey !== null && editedApiKey !== tokens?.apiKey.value;
|
||||
const hasSecretChange =
|
||||
editedSecret !== null && editedSecret !== tokens?.managementSecret.value;
|
||||
|
||||
if (!hasApiKeyChange && !hasSecretChange) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const payload: { apiKey?: string; managementSecret?: string } = {};
|
||||
if (hasApiKeyChange) payload.apiKey = editedApiKey;
|
||||
if (hasSecretChange) payload.managementSecret = editedSecret;
|
||||
|
||||
const response = await fetch('/api/settings/auth/tokens', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to save tokens');
|
||||
}
|
||||
|
||||
setSuccess('Tokens updated. Restart CLIProxy to apply.');
|
||||
setEditedApiKey(null);
|
||||
setEditedSecret(null);
|
||||
await fetchTokens();
|
||||
await fetchRawConfig();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Regenerate management secret
|
||||
const regenerateSecret = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const response = await fetch('/api/settings/auth/tokens/regenerate-secret', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to regenerate secret');
|
||||
}
|
||||
|
||||
setSuccess('New management secret generated. Restart CLIProxy to apply.');
|
||||
await fetchTokens();
|
||||
await fetchRawConfig();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset to defaults
|
||||
const resetToDefaults = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const response = await fetch('/api/settings/auth/tokens/reset', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to reset tokens');
|
||||
}
|
||||
|
||||
setSuccess('Tokens reset to defaults. Restart CLIProxy to apply.');
|
||||
setEditedApiKey(null);
|
||||
setEditedSecret(null);
|
||||
await fetchTokens();
|
||||
await fetchRawConfig();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Copy to clipboard helpers
|
||||
const copyApiKey = async () => {
|
||||
if (!tokens) return;
|
||||
await navigator.clipboard.writeText(tokens.apiKey.value);
|
||||
setCopiedApiKey(true);
|
||||
setTimeout(() => setCopiedApiKey(false), 2000);
|
||||
};
|
||||
|
||||
const copySecret = async () => {
|
||||
if (!tokens) return;
|
||||
await navigator.clipboard.writeText(tokens.managementSecret.value);
|
||||
setCopiedSecret(true);
|
||||
setTimeout(() => setCopiedSecret(false), 2000);
|
||||
};
|
||||
|
||||
if (loading || !tokens) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Display values
|
||||
const displayApiKey = editedApiKey ?? tokens.apiKey.value;
|
||||
const displaySecret = editedSecret ?? tokens.managementSecret.value;
|
||||
|
||||
// Check for unsaved changes
|
||||
const hasChanges =
|
||||
(editedApiKey !== null && editedApiKey !== tokens.apiKey.value) ||
|
||||
(editedSecret !== null && editedSecret !== tokens.managementSecret.value);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toast-style alerts */}
|
||||
<div
|
||||
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||
error || success
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-2 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2 shadow-lg">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm font-medium">{success}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-5 space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure CLIProxy authentication tokens. Changes require CLIProxy restart.
|
||||
</p>
|
||||
|
||||
{/* API Key Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 text-primary" />
|
||||
<h3 className="text-base font-medium">API Key</h3>
|
||||
{tokens.apiKey.isCustom && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used by Claude Code to authenticate with CLIProxy
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={displayApiKey}
|
||||
onChange={(e) => setEditedApiKey(e.target.value)}
|
||||
placeholder="API key"
|
||||
disabled={saving}
|
||||
className="pr-20 font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={copyApiKey}
|
||||
disabled={!tokens.apiKey.value}
|
||||
>
|
||||
{copiedApiKey ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Management Secret Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-primary" />
|
||||
<h3 className="text-base font-medium">Management Secret</h3>
|
||||
{tokens.managementSecret.isCustom && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used by CCS dashboard to access CLIProxy management APIs
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={displaySecret}
|
||||
onChange={(e) => setEditedSecret(e.target.value)}
|
||||
placeholder="Management secret"
|
||||
disabled={saving}
|
||||
className="pr-20 font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
>
|
||||
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={copySecret}
|
||||
disabled={!tokens.managementSecret.value}
|
||||
>
|
||||
{copiedSecret ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={regenerateSecret}
|
||||
disabled={saving}
|
||||
title="Generate new secure secret"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetToDefaults}
|
||||
disabled={saving || (!tokens.apiKey.isCustom && !tokens.managementSecret.isCustom)}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Resets both API key and management secret to their default values.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t bg-background flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
fetchTokens();
|
||||
fetchRawConfig();
|
||||
}}
|
||||
disabled={loading || saving}
|
||||
className="flex-1"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={saveChanges}
|
||||
disabled={!hasChanges || saving}
|
||||
className="flex-1"
|
||||
>
|
||||
<Save className={`w-4 h-4 mr-2 ${saving ? 'animate-pulse' : ''}`} />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export interface GlobalEnvConfig {
|
||||
|
||||
// === Tab Types ===
|
||||
|
||||
export type SettingsTab = 'websearch' | 'globalenv' | 'proxy';
|
||||
export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth';
|
||||
|
||||
// === Re-exports from api-client ===
|
||||
|
||||
|
||||
Reference in New Issue
Block a user