mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 16:23:54 +00:00
Merge pull request #1150 from kaitranntt/kai/refactor/1135-structural-maintainability
refactor: Phase 2 structural maintainability (#1135)
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Config Loader Facade Unit Tests
|
||||
*
|
||||
* Tests memoization cache behavior, cache invalidation on write ops,
|
||||
* and verifies all re-exports are present from underlying modules.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
/**
|
||||
* Helper: create a temp home dir with a minimal valid config.yaml so
|
||||
* loadOrCreateUnifiedConfig succeeds without touching the real ~/.ccs.
|
||||
*/
|
||||
function createTestHome(): string {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-facade-test-'));
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const configPath = path.join(ccsDir, 'config.yaml');
|
||||
fs.writeFileSync(configPath, `version: 1\n`, 'utf8');
|
||||
return tempHome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: get the facade module, bypassing the import cache each time.
|
||||
* We use dynamic import with a cache-busting query param so beforeEach
|
||||
* re-imports get a fresh module with a clean cache state.
|
||||
*/
|
||||
async function importFacade(): Promise<typeof import('../config-loader-facade')> {
|
||||
return import(`../config-loader-facade?cachebust=${Date.now()}`);
|
||||
}
|
||||
|
||||
describe('config-loader-facade', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = createTestHome();
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
// Clean up temp dir
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('re-exports from unified-config-loader', () => {
|
||||
it('should export all core loader functions', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
expect(typeof facade.loadUnifiedConfig).toBe('function');
|
||||
expect(typeof facade.loadOrCreateUnifiedConfig).toBe('function');
|
||||
});
|
||||
|
||||
it('should export all path/format utilities', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
expect(typeof facade.getConfigYamlPath).toBe('function');
|
||||
expect(typeof facade.getConfigJsonPath).toBe('function');
|
||||
expect(typeof facade.hasUnifiedConfig).toBe('function');
|
||||
expect(typeof facade.hasLegacyConfig).toBe('function');
|
||||
expect(typeof facade.getConfigFormat).toBe('function');
|
||||
expect(typeof facade.isUnifiedMode).toBe('function');
|
||||
});
|
||||
|
||||
it('should export all profile getters', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
expect(typeof facade.getDefaultProfile).toBe('function');
|
||||
expect(typeof facade.setDefaultProfile).toBe('function');
|
||||
});
|
||||
|
||||
it('should export all section getters', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
expect(typeof facade.getWebSearchConfig).toBe('function');
|
||||
expect(typeof facade.getGlobalEnvConfig).toBe('function');
|
||||
expect(typeof facade.getContinuityInheritanceMap).toBe('function');
|
||||
expect(typeof facade.getCliproxySafetyConfig).toBe('function');
|
||||
expect(typeof facade.getThinkingConfig).toBe('function');
|
||||
expect(typeof facade.getOfficialChannelsConfig).toBe('function');
|
||||
expect(typeof facade.isDashboardAuthEnabled).toBe('function');
|
||||
expect(typeof facade.getDashboardAuthConfig).toBe('function');
|
||||
expect(typeof facade.getBrowserConfig).toBe('function');
|
||||
expect(typeof facade.getImageAnalysisConfig).toBe('function');
|
||||
expect(typeof facade.getLoggingConfig).toBe('function');
|
||||
expect(typeof facade.getCursorConfig).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-exports from config-manager', () => {
|
||||
it('should export loadSettings, loadConfigSafe, readConfig, getCcsDir', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
expect(typeof facade.loadSettings).toBe('function');
|
||||
expect(typeof facade.loadConfigSafe).toBe('function');
|
||||
expect(typeof facade.readConfig).toBe('function');
|
||||
expect(typeof facade.getCcsDir).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache coherence', () => {
|
||||
it('should NOT export raw write functions that bypass cache', async () => {
|
||||
const facade = (await importFacade()) as Record<string, unknown>;
|
||||
|
||||
expect(facade.saveUnifiedConfig).toBeUndefined();
|
||||
expect(facade.mutateUnifiedConfig).toBeUndefined();
|
||||
expect(facade.updateUnifiedConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should export cache-coherent write wrappers instead', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
expect(typeof facade.saveConfig).toBe('function');
|
||||
expect(typeof facade.mutateConfig).toBe('function');
|
||||
expect(typeof facade.updateConfig).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoization', () => {
|
||||
it('getCachedConfig returns equivalent config on repeated calls', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
const first = facade.getCachedConfig();
|
||||
const second = facade.getCachedConfig();
|
||||
|
||||
// Deep copies — different references but same content (no re-read from disk)
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.version).toBe(second.version);
|
||||
});
|
||||
|
||||
it('invalidateConfigCache forces re-read on next getCachedConfig', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
const first = facade.getCachedConfig();
|
||||
facade.invalidateConfigCache();
|
||||
const second = facade.getCachedConfig();
|
||||
|
||||
// Different reference after invalidation
|
||||
expect(first).not.toBe(second);
|
||||
// But same content
|
||||
expect(first.version).toBe(second.version);
|
||||
});
|
||||
|
||||
it('saveConfig persists to disk and updates cache', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
const config = facade.getCachedConfig();
|
||||
config.default = 'test-profile';
|
||||
facade.saveConfig(config);
|
||||
|
||||
// Verify disk content reflects the save
|
||||
const configPath = path.join(tempHome, '.ccs', 'config.yaml');
|
||||
const diskContent = fs.readFileSync(configPath, 'utf8');
|
||||
const diskConfig = yaml.load(diskContent) as Record<string, unknown>;
|
||||
expect(diskConfig.default).toBe('test-profile');
|
||||
|
||||
const cached = facade.getCachedConfig();
|
||||
// Cache holds a copy of the saved config
|
||||
expect(cached.default).toBe('test-profile');
|
||||
});
|
||||
|
||||
it('mutateConfig invalidates the cache', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
const before = facade.getCachedConfig();
|
||||
facade.mutateConfig((cfg) => {
|
||||
cfg.default = 'mutated-profile';
|
||||
});
|
||||
const after = facade.getCachedConfig();
|
||||
|
||||
// Different reference (mutator may change it arbitrarily)
|
||||
expect(before).not.toBe(after);
|
||||
expect(after.default).toBe('mutated-profile');
|
||||
});
|
||||
|
||||
it('updateConfig invalidates the cache', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
const before = facade.getCachedConfig();
|
||||
facade.updateConfig({ default: 'updated-profile' });
|
||||
const after = facade.getCachedConfig();
|
||||
|
||||
expect(before).not.toBe(after);
|
||||
expect(after.default).toBe('updated-profile');
|
||||
});
|
||||
|
||||
it('getCachedConfig returns valid config with expected fields', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
const config = facade.getCachedConfig();
|
||||
expect(config).toBeDefined();
|
||||
expect(typeof config.version).toBe('number');
|
||||
expect(config.accounts).toBeDefined();
|
||||
expect(config.profiles).toBeDefined();
|
||||
expect(config.cliproxy).toBeDefined();
|
||||
});
|
||||
|
||||
it('auto-invalidates when config file is modified externally', async () => {
|
||||
const facade = await importFacade();
|
||||
|
||||
// Prime the cache
|
||||
const first = facade.getCachedConfig();
|
||||
expect(first.version).toBeDefined();
|
||||
|
||||
// Modify config file directly on disk (simulating external code
|
||||
// bypassing the facade)
|
||||
const configPath = path.join(tempHome, '.ccs', 'config.yaml');
|
||||
// Touch the file to update mtime (wait briefly for mtime resolution)
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
fs.writeFileSync(configPath, content + '# touched\n', 'utf8');
|
||||
|
||||
// getCachedConfig should detect the mtime change and re-read
|
||||
const second = facade.getCachedConfig();
|
||||
expect(first).not.toBe(second);
|
||||
// Content should still be valid (re-read from disk)
|
||||
expect(second.version).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Config Loader Facade
|
||||
*
|
||||
* Single import path for all config loading operations.
|
||||
* Re-exports read-only functions from unified-config-loader and config-manager,
|
||||
* and provides cache-coherent write wrappers that keep the memoization cache in sync.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - Raw write functions (saveUnifiedConfig, mutateUnifiedConfig,
|
||||
* updateUnifiedConfig) are NOT re-exported here. Use the cache-coherent
|
||||
* wrappers (saveConfig, mutateConfig, updateConfig) instead.
|
||||
* - `loadOrCreateUnifiedConfig` and `loadUnifiedConfig` are re-exported as
|
||||
* uncached reads. If you need cached reads, use `getCachedConfig()`.
|
||||
* If you use an uncached read followed by a write outside this facade,
|
||||
* call `invalidateConfigCache()` to keep the cache coherent.
|
||||
*
|
||||
* Usage:
|
||||
* import { getCachedConfig, saveConfig, mutateConfig } from '../config/config-loader-facade';
|
||||
* import { getCcsDir, loadSettings } from '../config/config-loader-facade';
|
||||
*/
|
||||
|
||||
// Re-export read-only functions from unified-config-loader
|
||||
export {
|
||||
loadUnifiedConfig,
|
||||
loadOrCreateUnifiedConfig,
|
||||
getConfigYamlPath,
|
||||
getConfigJsonPath,
|
||||
hasUnifiedConfig,
|
||||
hasLegacyConfig,
|
||||
getConfigFormat,
|
||||
isUnifiedMode,
|
||||
getDefaultProfile,
|
||||
setDefaultProfile,
|
||||
getWebSearchConfig,
|
||||
getGlobalEnvConfig,
|
||||
getContinuityInheritanceMap,
|
||||
getCliproxySafetyConfig,
|
||||
getThinkingConfig,
|
||||
getOfficialChannelsConfig,
|
||||
isDashboardAuthEnabled,
|
||||
getDashboardAuthConfig,
|
||||
getBrowserConfig,
|
||||
getImageAnalysisConfig,
|
||||
getLoggingConfig,
|
||||
getCursorConfig,
|
||||
} from './unified-config-loader';
|
||||
|
||||
// Re-export types from unified-config-loader
|
||||
export type { GeminiWebSearchInfo } from './unified-config-loader';
|
||||
|
||||
// Re-export selected functions from config-manager
|
||||
export { loadSettings, loadConfigSafe, readConfig, getCcsDir } from '../utils/config-manager';
|
||||
|
||||
// Internal imports for memoization wrappers
|
||||
import { statSync } from 'fs';
|
||||
import type { UnifiedConfig } from './unified-config-types';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig as _loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig as _saveUnifiedConfig,
|
||||
mutateUnifiedConfig as _mutateUnifiedConfig,
|
||||
updateUnifiedConfig as _updateUnifiedConfig,
|
||||
getConfigYamlPath as _getConfigYamlPath,
|
||||
} from './unified-config-loader';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memoization cache with mtime-based staleness detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _configCache: UnifiedConfig | null = null;
|
||||
let _cacheMtimeMs: number = 0;
|
||||
|
||||
function getConfigFileMtime(): number {
|
||||
try {
|
||||
return statSync(_getConfigYamlPath()).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unified config with in-memory caching.
|
||||
* Checks file mtime on each call — if the config file was modified
|
||||
* externally (e.g. by code importing unified-config-loader directly),
|
||||
* the cache is automatically invalidated and re-read from disk.
|
||||
*/
|
||||
export function getCachedConfig(): UnifiedConfig {
|
||||
const currentMtime = getConfigFileMtime();
|
||||
if (!_configCache || currentMtime > _cacheMtimeMs) {
|
||||
_configCache = _loadOrCreateUnifiedConfig();
|
||||
_cacheMtimeMs = getConfigFileMtime();
|
||||
}
|
||||
return structuredClone(_configCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the memoization cache.
|
||||
* The next call to getCachedConfig() will re-read from disk.
|
||||
*/
|
||||
export function invalidateConfigCache(): void {
|
||||
_configCache = null;
|
||||
_cacheMtimeMs = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save config to disk and update the cache.
|
||||
* Stores a deep copy to break the reference alias.
|
||||
*/
|
||||
export function saveConfig(config: UnifiedConfig): void {
|
||||
_saveUnifiedConfig(config);
|
||||
_configCache = structuredClone(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically mutate config (read-modify-write with lock) and invalidate cache.
|
||||
* Invalidated AFTER _mutateUnifiedConfig returns — if that throws, cache stays valid.
|
||||
*/
|
||||
export function mutateConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig {
|
||||
const result = _mutateUnifiedConfig(mutator);
|
||||
_configCache = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial-update config and invalidate cache.
|
||||
* Shorthand for mutateConfig with Object.assign.
|
||||
*/
|
||||
export function updateConfig(updates: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
const result = _updateUnifiedConfig(updates);
|
||||
_configCache = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cache state (for diagnostics/testing).
|
||||
* Returns true if a cached config exists, false otherwise.
|
||||
*/
|
||||
export function hasCachedConfig(): boolean {
|
||||
return _configCache !== null;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Tests: config schemas re-export backward compatibility.
|
||||
*
|
||||
* Verifies that every type, interface, constant, and function originally
|
||||
* exported from unified-config-types.ts is still accessible via both
|
||||
* the barrel file and the schemas/index barrel.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// Import from the backward-compatible barrel (this is what all existing code uses)
|
||||
import * as barrel from '../../unified-config-types';
|
||||
|
||||
// Import from the new schemas barrel (this is what the barrel delegates to)
|
||||
import * as schemas from '../index';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-level checks (compile-time, not runtime)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Verify key interfaces are accessible as types
|
||||
import type {
|
||||
UnifiedConfig,
|
||||
AccountConfig,
|
||||
ProfileConfig,
|
||||
OAuthAccounts,
|
||||
CLIProxyAuthConfig,
|
||||
TokenRefreshSettings,
|
||||
DashboardAuthConfig,
|
||||
CLIProxyVariantConfig,
|
||||
CompositeTierConfig,
|
||||
CompositeVariantConfig,
|
||||
CLIProxyLoggingConfig,
|
||||
CLIProxySafetyConfig,
|
||||
CLIProxyRoutingConfig,
|
||||
CLIProxyConfig,
|
||||
AutoQuotaConfig,
|
||||
RuntimeMonitorConfig,
|
||||
ManualQuotaConfig,
|
||||
QuotaManagementMode,
|
||||
QuotaManagementConfig,
|
||||
ThinkingMode,
|
||||
ThinkingTierDefaults,
|
||||
ThinkingConfig,
|
||||
OfficialChannelId,
|
||||
OfficialChannelsConfig,
|
||||
DuckDuckGoWebSearchConfig,
|
||||
BraveWebSearchConfig,
|
||||
ExaWebSearchConfig,
|
||||
TavilyWebSearchConfig,
|
||||
SearxngWebSearchConfig,
|
||||
GeminiWebSearchConfig,
|
||||
GrokWebSearchConfig,
|
||||
OpenCodeWebSearchConfig,
|
||||
WebSearchProvidersConfig,
|
||||
WebSearchConfig,
|
||||
BrowserToolPolicy,
|
||||
BrowserEvalMode,
|
||||
BrowserClaudeConfig,
|
||||
BrowserCodexConfig,
|
||||
BrowserConfig,
|
||||
LoggingLevel,
|
||||
LoggingConfig,
|
||||
PreferencesConfig,
|
||||
CopilotAccountType,
|
||||
CopilotConfig,
|
||||
CursorConfig,
|
||||
ProxyRemoteConfig,
|
||||
ProxyFallbackConfig,
|
||||
ProxyLocalConfig,
|
||||
OpenAICompatProxyRoutingConfig,
|
||||
OpenAICompatProxyConfig,
|
||||
CliproxyServerConfig,
|
||||
GlobalEnvConfig,
|
||||
ContinuityConfig,
|
||||
ImageAnalysisConfig,
|
||||
} from '../../unified-config-types';
|
||||
|
||||
describe('config schemas backward compatibility', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// Constants
|
||||
// -------------------------------------------------------------------------
|
||||
it('re-exports UNIFIED_CONFIG_VERSION', () => {
|
||||
expect(barrel.UNIFIED_CONFIG_VERSION).toBe(13);
|
||||
expect(schemas.UNIFIED_CONFIG_VERSION).toBe(13);
|
||||
});
|
||||
|
||||
it('re-exports CLIPROXY_SUPPORTED_PROVIDERS', () => {
|
||||
expect(Array.isArray(barrel.CLIPROXY_SUPPORTED_PROVIDERS)).toBe(true);
|
||||
expect(barrel.CLIPROXY_SUPPORTED_PROVIDERS.length).toBeGreaterThan(0);
|
||||
expect(barrel.CLIPROXY_SUPPORTED_PROVIDERS).toEqual(schemas.CLIPROXY_SUPPORTED_PROVIDERS);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Default constants
|
||||
// -------------------------------------------------------------------------
|
||||
const defaultConstants = [
|
||||
'DEFAULT_CLIPROXY_SAFETY_CONFIG',
|
||||
'DEFAULT_LOGGING_CONFIG',
|
||||
'DEFAULT_OFFICIAL_CHANNELS_CONFIG',
|
||||
'DEFAULT_BROWSER_CONFIG',
|
||||
'DEFAULT_DASHBOARD_AUTH_CONFIG',
|
||||
'DEFAULT_AUTO_QUOTA_CONFIG',
|
||||
'DEFAULT_MANUAL_QUOTA_CONFIG',
|
||||
'DEFAULT_RUNTIME_MONITOR_CONFIG',
|
||||
'DEFAULT_QUOTA_MANAGEMENT_CONFIG',
|
||||
'DEFAULT_THINKING_TIER_DEFAULTS',
|
||||
'DEFAULT_THINKING_CONFIG',
|
||||
'DEFAULT_COPILOT_CONFIG',
|
||||
'DEFAULT_CURSOR_CONFIG',
|
||||
'DEFAULT_CLIPROXY_SERVER_CONFIG',
|
||||
'DEFAULT_OPENAI_COMPAT_PROXY_CONFIG',
|
||||
'DEFAULT_GLOBAL_ENV',
|
||||
'DEFAULT_IMAGE_ANALYSIS_CONFIG',
|
||||
] as const;
|
||||
|
||||
for (const name of defaultConstants) {
|
||||
it(`re-exports ${name}`, () => {
|
||||
expect(barrel[name]).toBeDefined();
|
||||
expect(barrel[name]).toEqual(schemas[name]);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Functions
|
||||
// -------------------------------------------------------------------------
|
||||
it('re-exports createEmptyUnifiedConfig', () => {
|
||||
expect(typeof barrel.createEmptyUnifiedConfig).toBe('function');
|
||||
expect(typeof schemas.createEmptyUnifiedConfig).toBe('function');
|
||||
|
||||
const config = barrel.createEmptyUnifiedConfig();
|
||||
expect(config.version).toBe(13);
|
||||
expect(config.accounts).toEqual({});
|
||||
expect(config.profiles).toEqual({});
|
||||
expect(config.cliproxy).toBeDefined();
|
||||
expect(config.cliproxy.oauth_accounts).toEqual({});
|
||||
expect(config.cliproxy.variants).toEqual({});
|
||||
expect(config.logging).toBeDefined();
|
||||
expect(config.preferences).toBeDefined();
|
||||
expect(config.browser).toBeDefined();
|
||||
expect(config.image_analysis).toBeDefined();
|
||||
expect(config.quota_management).toBeDefined();
|
||||
expect(config.thinking).toBeDefined();
|
||||
expect(config.channels).toBeDefined();
|
||||
expect(config.dashboard_auth).toBeDefined();
|
||||
expect(config.copilot).toBeDefined();
|
||||
expect(config.cursor).toBeDefined();
|
||||
expect(config.cliproxy_server).toBeDefined();
|
||||
expect(config.websearch).toBeDefined();
|
||||
});
|
||||
|
||||
it('re-exports isUnifiedConfig', () => {
|
||||
expect(typeof barrel.isUnifiedConfig).toBe('function');
|
||||
expect(typeof schemas.isUnifiedConfig).toBe('function');
|
||||
|
||||
expect(barrel.isUnifiedConfig({ version: 13 })).toBe(true);
|
||||
expect(barrel.isUnifiedConfig(null)).toBe(false);
|
||||
expect(barrel.isUnifiedConfig({})).toBe(false);
|
||||
expect(barrel.isUnifiedConfig({ version: 0 })).toBe(false);
|
||||
expect(barrel.isUnifiedConfig({ version: 1 })).toBe(true);
|
||||
expect(barrel.isUnifiedConfig('not an object')).toBe(false);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Barrel has all expected runtime exports (type-only exports are verified
|
||||
// at compile time via the import type block above — they are erased at
|
||||
// runtime and cannot be checked with the `in` operator).
|
||||
// -------------------------------------------------------------------------
|
||||
const expectedRuntimeExports = [
|
||||
'UNIFIED_CONFIG_VERSION',
|
||||
'CLIPROXY_SUPPORTED_PROVIDERS',
|
||||
'createEmptyUnifiedConfig',
|
||||
'isUnifiedConfig',
|
||||
'DEFAULT_CLIPROXY_SAFETY_CONFIG',
|
||||
'DEFAULT_LOGGING_CONFIG',
|
||||
'DEFAULT_OFFICIAL_CHANNELS_CONFIG',
|
||||
'DEFAULT_BROWSER_CONFIG',
|
||||
'DEFAULT_DASHBOARD_AUTH_CONFIG',
|
||||
'DEFAULT_AUTO_QUOTA_CONFIG',
|
||||
'DEFAULT_MANUAL_QUOTA_CONFIG',
|
||||
'DEFAULT_RUNTIME_MONITOR_CONFIG',
|
||||
'DEFAULT_QUOTA_MANAGEMENT_CONFIG',
|
||||
'DEFAULT_THINKING_TIER_DEFAULTS',
|
||||
'DEFAULT_THINKING_CONFIG',
|
||||
'DEFAULT_COPILOT_CONFIG',
|
||||
'DEFAULT_CURSOR_CONFIG',
|
||||
'DEFAULT_CLIPROXY_SERVER_CONFIG',
|
||||
'DEFAULT_OPENAI_COMPAT_PROXY_CONFIG',
|
||||
'DEFAULT_GLOBAL_ENV',
|
||||
'DEFAULT_IMAGE_ANALYSIS_CONFIG',
|
||||
] as const;
|
||||
|
||||
for (const name of expectedRuntimeExports) {
|
||||
it(`barrel exports "${name}"`, () => {
|
||||
expect(name in barrel).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Account, profile, and authentication config types.
|
||||
*
|
||||
* Covers:
|
||||
* - AccountConfig: isolated Claude instances via CLAUDE_CONFIG_DIR
|
||||
* - ProfileConfig: API-based profiles (env var injection)
|
||||
* - OAuthAccounts: CLIProxy nickname-to-email mapping
|
||||
* - CLIProxyAuthConfig: API key and management secret customization
|
||||
* - TokenRefreshSettings: background token refresh worker config
|
||||
* - DashboardAuthConfig: dashboard login protection
|
||||
*/
|
||||
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
|
||||
/**
|
||||
* Account configuration (formerly in profiles.json).
|
||||
* Represents an isolated Claude instance via CLAUDE_CONFIG_DIR.
|
||||
*/
|
||||
export interface AccountConfig {
|
||||
/** ISO timestamp when account was created */
|
||||
created: string;
|
||||
/** ISO timestamp of last usage, null if never used */
|
||||
last_used: string | null;
|
||||
/** Context mode for project workspace data */
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
/** Context-sharing group when context_mode='shared' */
|
||||
context_group?: string;
|
||||
/** Shared continuity depth when context_mode='shared' */
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* API-based profile configuration.
|
||||
* Injects environment variables for alternative providers (GLM, Kimi, etc.).
|
||||
*
|
||||
* Settings are stored in separate *.settings.json files (matching Claude's pattern)
|
||||
* to allow users to edit them directly without touching config.yaml.
|
||||
*/
|
||||
export interface ProfileConfig {
|
||||
/** Profile type - currently only 'api' */
|
||||
type: 'api';
|
||||
/** Path to settings file (e.g., "~/.ccs/glm.settings.json") */
|
||||
settings: string;
|
||||
/** Target CLI to use for this profile (default: 'claude') */
|
||||
target?: TargetType;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy OAuth account nickname mapping.
|
||||
* Maps user-friendly nicknames to email addresses.
|
||||
*/
|
||||
export type OAuthAccounts = Record<string, string>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token refresh configuration.
|
||||
* Manages background token refresh worker settings.
|
||||
*/
|
||||
export interface TokenRefreshSettings {
|
||||
/** Enable background token refresh (default: false) */
|
||||
enabled?: boolean;
|
||||
/** Refresh check interval in minutes (default: 30) */
|
||||
interval_minutes?: number;
|
||||
/** Preemptive refresh time in minutes (default: 45) */
|
||||
preemptive_minutes?: number;
|
||||
/** Maximum retry attempts per token (default: 3) */
|
||||
max_retries?: number;
|
||||
/** Enable verbose logging (default: false) */
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard authentication configuration.
|
||||
* Optional login protection for CCS dashboard.
|
||||
* Disabled by default for backward compatibility.
|
||||
*/
|
||||
export interface DashboardAuthConfig {
|
||||
/** Enable dashboard authentication (default: false) */
|
||||
enabled: boolean;
|
||||
/** Username for dashboard login */
|
||||
username: string;
|
||||
/** Bcrypt-hashed password (use: npx bcrypt-cli hash 'password') */
|
||||
password_hash: string;
|
||||
/** Session timeout in hours (default: 24) */
|
||||
session_timeout_hours?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default dashboard auth configuration.
|
||||
* Disabled by default - must be explicitly enabled.
|
||||
*/
|
||||
export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
|
||||
enabled: false,
|
||||
username: '',
|
||||
password_hash: '',
|
||||
session_timeout_hours: 24,
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Browser automation configuration types and defaults.
|
||||
*
|
||||
* Controls Claude browser attach and Codex browser tooling.
|
||||
* Version 13+ feature.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Browser tool exposure policy.
|
||||
*/
|
||||
export type BrowserToolPolicy = 'auto' | 'manual';
|
||||
|
||||
/**
|
||||
* Browser eval access mode.
|
||||
*/
|
||||
export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite';
|
||||
|
||||
/**
|
||||
* Claude browser attach configuration.
|
||||
*/
|
||||
export interface BrowserClaudeConfig {
|
||||
/** Enable Claude browser attach (default: false) */
|
||||
enabled: boolean;
|
||||
/** Control whether Claude browser attach is exposed automatically or only via --browser */
|
||||
policy: BrowserToolPolicy;
|
||||
/** Chrome user-data directory used for attach mode */
|
||||
user_data_dir: string;
|
||||
/** DevTools port used for attach mode (default: 9222) */
|
||||
devtools_port: number;
|
||||
/** Eval access mode exposed through browser settings/status surfaces */
|
||||
eval_mode?: BrowserEvalMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex browser tooling configuration.
|
||||
*/
|
||||
export interface BrowserCodexConfig {
|
||||
/** Enable Codex browser tooling injection (default: false) */
|
||||
enabled: boolean;
|
||||
/** Control whether Codex browser tooling is exposed automatically or only via --browser */
|
||||
policy: BrowserToolPolicy;
|
||||
/** Eval access mode exposed through browser settings/status surfaces */
|
||||
eval_mode?: BrowserEvalMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser automation configuration.
|
||||
* Controls Claude browser attach and Codex browser tooling.
|
||||
*/
|
||||
export interface BrowserConfig {
|
||||
claude: BrowserClaudeConfig;
|
||||
codex: BrowserCodexConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default browser configuration.
|
||||
*/
|
||||
export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
|
||||
claude: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
eval_mode: 'readonly',
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
eval_mode: 'readonly',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Official Channels configuration types and defaults.
|
||||
*
|
||||
* Controls runtime-only injection of Anthropic's official channel plugins
|
||||
* (Telegram, Discord, iMessage).
|
||||
* Version 12+ feature.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported Anthropic official channel IDs.
|
||||
*/
|
||||
export type OfficialChannelId = 'telegram' | 'discord' | 'imessage';
|
||||
|
||||
/**
|
||||
* Official Channels configuration.
|
||||
* Controls runtime-only injection of Anthropic's official channel plugins.
|
||||
*/
|
||||
export interface OfficialChannelsConfig {
|
||||
/** Selected official channels to auto-enable for compatible sessions */
|
||||
selected: OfficialChannelId[];
|
||||
/** Also add --dangerously-skip-permissions when auto-enable is active */
|
||||
unattended: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Official Channels configuration.
|
||||
* Disabled by default because the feature requires explicit user setup.
|
||||
*/
|
||||
export const DEFAULT_OFFICIAL_CHANNELS_CONFIG: OfficialChannelsConfig = {
|
||||
selected: [],
|
||||
unattended: false,
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* CLIProxy configuration types and defaults.
|
||||
*
|
||||
* Covers provider/variant/routing/safety/logging configuration
|
||||
* for the CLIProxy integration layer.
|
||||
*/
|
||||
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import type { CLIProxyProvider, CliproxyRoutingStrategy } from '../../cliproxy/types';
|
||||
import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities';
|
||||
import type { OAuthAccounts, CLIProxyAuthConfig, TokenRefreshSettings } from './auth';
|
||||
|
||||
/**
|
||||
* Supported CLIProxy providers.
|
||||
* Derived from CLIPROXY_PROVIDER_IDS — single source of truth in provider-capabilities.ts.
|
||||
*/
|
||||
export { CLIPROXY_PROVIDER_IDS as CLIPROXY_SUPPORTED_PROVIDERS };
|
||||
|
||||
/**
|
||||
* CLIProxy variant configuration.
|
||||
* User-defined variants of built-in OAuth providers.
|
||||
*
|
||||
* Settings are stored in separate *.settings.json files (matching Claude's pattern)
|
||||
* to allow users to edit them directly without touching config.yaml.
|
||||
*/
|
||||
export interface CLIProxyVariantConfig {
|
||||
/** Base provider to use */
|
||||
provider: CLIProxyProvider;
|
||||
/** Account nickname (references oauth_accounts) */
|
||||
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;
|
||||
/** Target CLI to use for this variant (default: 'claude') */
|
||||
target?: TargetType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tier provider+model mapping for composite variants.
|
||||
*/
|
||||
export interface CompositeTierConfig {
|
||||
/** Provider for this tier */
|
||||
provider: CLIProxyProvider;
|
||||
/** Model ID to use for this tier */
|
||||
model: string;
|
||||
/** Account nickname (optional, references oauth_accounts) */
|
||||
account?: string;
|
||||
/** Fallback provider+model if primary fails */
|
||||
fallback?: {
|
||||
provider: CLIProxyProvider;
|
||||
model: string;
|
||||
account?: string;
|
||||
};
|
||||
/** Per-tier thinking budget override (e.g. 'xhigh', 'medium', 'off') */
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite variant configuration.
|
||||
* Mixes different providers per Claude tier (opus, sonnet, haiku) in a single profile.
|
||||
* Uses CLIProxyAPI root endpoints (/v1/messages) for model-based routing
|
||||
* instead of provider-specific endpoints (/api/provider/{provider}).
|
||||
*/
|
||||
export interface CompositeVariantConfig {
|
||||
/** Discriminator for composite type */
|
||||
type: 'composite';
|
||||
/** Which tier ANTHROPIC_MODEL equals (default must be one of the three) */
|
||||
default_tier: 'opus' | 'sonnet' | 'haiku';
|
||||
/** Per-tier provider+model mapping */
|
||||
tiers: {
|
||||
opus: CompositeTierConfig;
|
||||
sonnet: CompositeTierConfig;
|
||||
haiku: CompositeTierConfig;
|
||||
};
|
||||
/** Path to settings file */
|
||||
settings?: string;
|
||||
/** Shared port for the composite profile */
|
||||
port?: number;
|
||||
/** Per-variant auth override (optional) */
|
||||
auth?: CLIProxyAuthConfig;
|
||||
/** Target CLI to use for this composite variant (default: 'claude') */
|
||||
target?: TargetType;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy logging configuration.
|
||||
* Controls whether CLIProxyAPI writes logs to disk.
|
||||
* Logs can grow to several GB if left enabled.
|
||||
*/
|
||||
export interface CLIProxyLoggingConfig {
|
||||
/** Enable logging to file (default: false to prevent disk bloat) */
|
||||
enabled?: boolean;
|
||||
/** Enable request logging for debugging (default: false) */
|
||||
request_log?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy safety configuration.
|
||||
* Controls high-risk flow safeguards for supported providers.
|
||||
*/
|
||||
export interface CLIProxySafetyConfig {
|
||||
/** Allow skipping AGY responsibility checks and Gemini dashboard typed acknowledgement */
|
||||
antigravity_ack_bypass?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default CLIProxy safety configuration.
|
||||
*/
|
||||
export const DEFAULT_CLIPROXY_SAFETY_CONFIG: CLIProxySafetyConfig = {
|
||||
antigravity_ack_bypass: false,
|
||||
};
|
||||
|
||||
export interface CLIProxyRoutingConfig {
|
||||
/** Credential selection strategy when multiple accounts match */
|
||||
strategy?: CliproxyRoutingStrategy;
|
||||
/** Keep one conversation pinned to the same account when possible */
|
||||
session_affinity?: boolean;
|
||||
/** Go-style duration for session-affinity binding retention */
|
||||
session_affinity_ttl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy configuration section.
|
||||
*/
|
||||
export interface CLIProxyConfig {
|
||||
/** Backend selection: 'original' or 'plus' (default: 'original') */
|
||||
backend?: 'original' | 'plus';
|
||||
/** Nickname to email mapping for OAuth accounts */
|
||||
oauth_accounts: OAuthAccounts;
|
||||
/** Built-in providers (read-only, for reference) */
|
||||
providers: readonly string[];
|
||||
/** User-defined provider variants (single-provider or composite) */
|
||||
variants: Record<string, CLIProxyVariantConfig | CompositeVariantConfig>;
|
||||
/** Logging configuration (disabled by default) */
|
||||
logging?: CLIProxyLoggingConfig;
|
||||
/** Safety controls for high-risk provider flows */
|
||||
safety?: CLIProxySafetyConfig;
|
||||
/** Kiro: disable incognito browser mode (use normal browser to save credentials) */
|
||||
kiro_no_incognito?: boolean;
|
||||
/** Global auth configuration for CLIProxyAPI */
|
||||
auth?: CLIProxyAuthConfig;
|
||||
/** Background token refresh worker settings */
|
||||
token_refresh?: TokenRefreshSettings;
|
||||
/** Auto-sync API profiles to local CLIProxy config on settings change (default: true) */
|
||||
auto_sync?: boolean;
|
||||
/** Routing strategy for multi-account CLIProxy selection */
|
||||
routing?: CLIProxyRoutingConfig;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copilot and Cursor IDE integration configuration types and defaults.
|
||||
*
|
||||
* Covers:
|
||||
* - CopilotConfig: GitHub Copilot proxy integration (strictly opt-in)
|
||||
* - CursorConfig: Cursor IDE proxy daemon
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copilot API account type.
|
||||
*/
|
||||
export type CopilotAccountType = 'individual' | 'business' | 'enterprise';
|
||||
|
||||
/**
|
||||
* Copilot API configuration.
|
||||
* Enables GitHub Copilot subscription usage via copilot-api proxy.
|
||||
* Strictly opt-in - disabled by default.
|
||||
*
|
||||
* !! DISCLAIMER - USE AT YOUR OWN RISK !!
|
||||
* This uses an UNOFFICIAL reverse-engineered API.
|
||||
* Excessive usage may trigger GitHub account restrictions.
|
||||
* CCS provides NO WARRANTY and accepts NO RESPONSIBILITY for any consequences.
|
||||
*/
|
||||
export interface CopilotConfig {
|
||||
/** Enable Copilot integration (default: false) - must be explicitly enabled */
|
||||
enabled: boolean;
|
||||
/** Auto-start copilot-api daemon when using profile (default: false) */
|
||||
auto_start: boolean;
|
||||
/** Port for copilot-api proxy (default: 4141) */
|
||||
port: number;
|
||||
/** GitHub Copilot account type (default: individual) */
|
||||
account_type: CopilotAccountType;
|
||||
/** Rate limit in seconds between requests (null = no limit) */
|
||||
rate_limit: number | null;
|
||||
/** Wait instead of error when rate limit is hit (default: true) */
|
||||
wait_on_limit: boolean;
|
||||
/** Default model ID (e.g., claude-sonnet-4.5) */
|
||||
model: string;
|
||||
/** Model mapping for Claude tiers - maps opus/sonnet/haiku to specific models */
|
||||
opus_model?: string;
|
||||
sonnet_model?: string;
|
||||
haiku_model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor IDE integration configuration.
|
||||
* Enables Cursor IDE usage via cursor proxy daemon.
|
||||
*/
|
||||
export interface CursorConfig {
|
||||
/** Enable Cursor integration (default: false) */
|
||||
enabled: boolean;
|
||||
/** Port for cursor proxy daemon (default: 20129) */
|
||||
port: number;
|
||||
/** Auto-start daemon when CCS starts (default: false) */
|
||||
auto_start: boolean;
|
||||
/** Enable ghost mode to disable telemetry (default: true) */
|
||||
ghost_mode: boolean;
|
||||
/** Default model ID used by Cursor integration */
|
||||
model: string;
|
||||
/** Optional tier mapping for Claude-compatible model routing */
|
||||
opus_model?: string;
|
||||
/** Optional tier mapping for Claude-compatible model routing */
|
||||
sonnet_model?: string;
|
||||
/** Optional tier mapping for Claude-compatible model routing */
|
||||
haiku_model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Copilot configuration.
|
||||
* Strictly opt-in - disabled by default.
|
||||
* Uses gpt-4.1 as default model (free tier compatible).
|
||||
*/
|
||||
export const DEFAULT_COPILOT_CONFIG: CopilotConfig = {
|
||||
enabled: false,
|
||||
auto_start: false,
|
||||
port: 4141,
|
||||
account_type: 'individual',
|
||||
rate_limit: null,
|
||||
wait_on_limit: true,
|
||||
model: 'gpt-4.1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Default Cursor configuration.
|
||||
* Disabled by default, ghost mode enabled for privacy.
|
||||
*/
|
||||
export const DEFAULT_CURSOR_CONFIG: CursorConfig = {
|
||||
enabled: false,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
model: 'gpt-5.3-codex',
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Config schema barrel re-exports.
|
||||
*
|
||||
* All types, interfaces, constants, and functions originally in
|
||||
* unified-config-types.ts are re-exported here for backward compatibility.
|
||||
* Each module is responsible for a focused domain of the config schema.
|
||||
*/
|
||||
|
||||
// Version constant
|
||||
export { UNIFIED_CONFIG_VERSION } from './version';
|
||||
|
||||
// Account, profile, OAuth, auth types
|
||||
export type {
|
||||
AccountConfig,
|
||||
ProfileConfig,
|
||||
OAuthAccounts,
|
||||
CLIProxyAuthConfig,
|
||||
TokenRefreshSettings,
|
||||
DashboardAuthConfig,
|
||||
} from './auth';
|
||||
export { DEFAULT_DASHBOARD_AUTH_CONFIG } from './auth';
|
||||
|
||||
// CLIProxy provider, variant, routing, safety, logging types
|
||||
export { CLIPROXY_SUPPORTED_PROVIDERS, DEFAULT_CLIPROXY_SAFETY_CONFIG } from './cliproxy';
|
||||
export type {
|
||||
CLIProxyVariantConfig,
|
||||
CompositeTierConfig,
|
||||
CompositeVariantConfig,
|
||||
CLIProxyLoggingConfig,
|
||||
CLIProxySafetyConfig,
|
||||
CLIProxyRoutingConfig,
|
||||
CLIProxyConfig,
|
||||
} from './cliproxy';
|
||||
|
||||
// Quota management types and defaults
|
||||
export {
|
||||
DEFAULT_AUTO_QUOTA_CONFIG,
|
||||
DEFAULT_MANUAL_QUOTA_CONFIG,
|
||||
DEFAULT_RUNTIME_MONITOR_CONFIG,
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
|
||||
} from './quota';
|
||||
export type {
|
||||
AutoQuotaConfig,
|
||||
RuntimeMonitorConfig,
|
||||
ManualQuotaConfig,
|
||||
QuotaManagementMode,
|
||||
QuotaManagementConfig,
|
||||
} from './quota';
|
||||
|
||||
// Thinking/reasoning budget types and defaults
|
||||
export { DEFAULT_THINKING_TIER_DEFAULTS, DEFAULT_THINKING_CONFIG } from './thinking';
|
||||
export type { ThinkingMode, ThinkingTierDefaults, ThinkingConfig } from './thinking';
|
||||
|
||||
// Official channels types and defaults
|
||||
export { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from './channels';
|
||||
export type { OfficialChannelId, OfficialChannelsConfig } from './channels';
|
||||
|
||||
// WebSearch backend types
|
||||
export type {
|
||||
DuckDuckGoWebSearchConfig,
|
||||
BraveWebSearchConfig,
|
||||
ExaWebSearchConfig,
|
||||
TavilyWebSearchConfig,
|
||||
SearxngWebSearchConfig,
|
||||
GeminiWebSearchConfig,
|
||||
GrokWebSearchConfig,
|
||||
OpenCodeWebSearchConfig,
|
||||
WebSearchProvidersConfig,
|
||||
WebSearchConfig,
|
||||
} from './websearch';
|
||||
|
||||
// Browser automation types and defaults
|
||||
export { DEFAULT_BROWSER_CONFIG } from './browser';
|
||||
export type {
|
||||
BrowserToolPolicy,
|
||||
BrowserEvalMode,
|
||||
BrowserClaudeConfig,
|
||||
BrowserCodexConfig,
|
||||
BrowserConfig,
|
||||
} from './browser';
|
||||
|
||||
// Logging and preferences types and defaults
|
||||
export { DEFAULT_LOGGING_CONFIG } from './logging';
|
||||
export type { LoggingLevel, LoggingConfig, PreferencesConfig } from './logging';
|
||||
|
||||
// Provider integration types and defaults
|
||||
export {
|
||||
DEFAULT_GLOBAL_ENV,
|
||||
DEFAULT_COPILOT_CONFIG,
|
||||
DEFAULT_CURSOR_CONFIG,
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
DEFAULT_OPENAI_COMPAT_PROXY_CONFIG,
|
||||
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||
} from './providers';
|
||||
export type {
|
||||
CopilotAccountType,
|
||||
CopilotConfig,
|
||||
CursorConfig,
|
||||
ProxyRemoteConfig,
|
||||
ProxyFallbackConfig,
|
||||
ProxyLocalConfig,
|
||||
OpenAICompatProxyRoutingConfig,
|
||||
OpenAICompatProxyConfig,
|
||||
CliproxyServerConfig,
|
||||
GlobalEnvConfig,
|
||||
ContinuityConfig,
|
||||
ImageAnalysisConfig,
|
||||
} from './providers';
|
||||
|
||||
// Main unified config interface, factory, and type guard
|
||||
export { createEmptyUnifiedConfig, isUnifiedConfig } from './unified-config';
|
||||
export type { UnifiedConfig } from './unified-config';
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Logging and preferences configuration types and defaults.
|
||||
*
|
||||
* Covers:
|
||||
* - LoggingConfig: CCS-owned structured runtime logging
|
||||
* - LoggingLevel: log severity levels
|
||||
* - PreferencesConfig: user preferences (theme, telemetry, auto-update)
|
||||
*/
|
||||
|
||||
export type LoggingLevel = 'error' | 'warn' | 'info' | 'debug';
|
||||
|
||||
/**
|
||||
* CCS-owned structured logging configuration.
|
||||
* Separate from cliproxy.logging, which controls CLIProxy runtime files.
|
||||
*/
|
||||
export interface LoggingConfig {
|
||||
/** Enable CCS-owned structured runtime logging */
|
||||
enabled: boolean;
|
||||
/** Minimum level written to disk */
|
||||
level: LoggingLevel;
|
||||
/** Rotate current log when it reaches this size in MB */
|
||||
rotate_mb: number;
|
||||
/** Keep archived segments for this many days */
|
||||
retain_days: number;
|
||||
/** Redact sensitive values before persistence */
|
||||
redact: boolean;
|
||||
/** In-memory recent event buffer size for dashboard reads */
|
||||
live_buffer_size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default logging configuration.
|
||||
*/
|
||||
export const DEFAULT_LOGGING_CONFIG: LoggingConfig = {
|
||||
enabled: true,
|
||||
level: 'info',
|
||||
rotate_mb: 10,
|
||||
retain_days: 7,
|
||||
redact: true,
|
||||
live_buffer_size: 250,
|
||||
};
|
||||
|
||||
/**
|
||||
* User preferences.
|
||||
*/
|
||||
export interface PreferencesConfig {
|
||||
/** UI theme preference */
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
/** Enable anonymous telemetry */
|
||||
telemetry?: boolean;
|
||||
/** Enable automatic update checks */
|
||||
auto_update?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Provider integration configuration types and defaults.
|
||||
*
|
||||
* Re-exports from focused sub-modules for backward compatibility.
|
||||
* Actual definitions live in:
|
||||
* - copilot-cursor.ts: CopilotConfig, CursorConfig + defaults
|
||||
* - proxy-server.ts: CliproxyServerConfig, OpenAICompatProxyConfig,
|
||||
* GlobalEnvConfig, ContinuityConfig, ImageAnalysisConfig + defaults
|
||||
*/
|
||||
|
||||
export type { CopilotAccountType, CopilotConfig, CursorConfig } from './copilot-cursor';
|
||||
export { DEFAULT_COPILOT_CONFIG, DEFAULT_CURSOR_CONFIG } from './copilot-cursor';
|
||||
|
||||
export type {
|
||||
ProxyRemoteConfig,
|
||||
ProxyFallbackConfig,
|
||||
ProxyLocalConfig,
|
||||
OpenAICompatProxyRoutingConfig,
|
||||
OpenAICompatProxyConfig,
|
||||
CliproxyServerConfig,
|
||||
GlobalEnvConfig,
|
||||
ContinuityConfig,
|
||||
ImageAnalysisConfig,
|
||||
} from './proxy-server';
|
||||
export {
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
DEFAULT_OPENAI_COMPAT_PROXY_CONFIG,
|
||||
DEFAULT_GLOBAL_ENV,
|
||||
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||
} from './proxy-server';
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Proxy server, global env, continuity, and image analysis types and defaults.
|
||||
*
|
||||
* Covers:
|
||||
* - CliproxyServerConfig: remote/local CLIProxy server mode
|
||||
* - OpenAICompatProxyConfig: OpenAI-compatible local proxy
|
||||
* - GlobalEnvConfig: global environment variable injection
|
||||
* - ContinuityConfig: cross-profile continuity inheritance
|
||||
* - ImageAnalysisConfig: vision analysis via CLIProxy
|
||||
*/
|
||||
|
||||
/**
|
||||
* Remote proxy configuration.
|
||||
* Connect to a remote CLIProxyAPI instance instead of spawning local binary.
|
||||
*/
|
||||
export interface ProxyRemoteConfig {
|
||||
/** Enable remote proxy mode (default: false = local mode) */
|
||||
enabled: boolean;
|
||||
/** Remote proxy hostname or IP (empty = not configured) */
|
||||
host: string;
|
||||
/**
|
||||
* Remote proxy port.
|
||||
* Optional - defaults based on protocol:
|
||||
* - HTTPS: 443
|
||||
* - HTTP: 8317
|
||||
* When empty/undefined, uses protocol default.
|
||||
*/
|
||||
port?: number;
|
||||
/** Protocol for remote connection */
|
||||
protocol: 'http' | 'https';
|
||||
/** Auth token for remote proxy API endpoints (optional, sent as header) */
|
||||
auth_token: string;
|
||||
/**
|
||||
* Management key for remote proxy management API endpoints.
|
||||
* CLIProxyAPI uses separate authentication for management endpoints
|
||||
* (/v0/management/*) via 'secret-key' config.
|
||||
* If not set, falls back to auth_token for backwards compatibility.
|
||||
*/
|
||||
management_key?: string;
|
||||
/** Connection timeout in milliseconds (default: 2000) */
|
||||
timeout?: number;
|
||||
/** Enable auto-sync profiles to remote on settings change (default: false) */
|
||||
auto_sync?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback configuration when remote proxy is unreachable.
|
||||
*/
|
||||
export interface ProxyFallbackConfig {
|
||||
/** Enable fallback to local proxy (default: true) */
|
||||
enabled: boolean;
|
||||
/** Auto-start local proxy without prompting (default: false = prompt user) */
|
||||
auto_start: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local proxy configuration.
|
||||
*/
|
||||
export interface ProxyLocalConfig {
|
||||
/** Local proxy port (default: 8317) */
|
||||
port: number;
|
||||
/** Auto-start local binary (default: true) */
|
||||
auto_start: boolean;
|
||||
}
|
||||
|
||||
export interface OpenAICompatProxyRoutingConfig {
|
||||
default?: string;
|
||||
background?: string;
|
||||
think?: string;
|
||||
longContext?: string;
|
||||
webSearch?: string;
|
||||
longContextThreshold?: number;
|
||||
}
|
||||
|
||||
export interface OpenAICompatProxyConfig {
|
||||
/** Default local port for OpenAI-compatible proxy instances */
|
||||
port?: number;
|
||||
/** Optional profile-scoped local port overrides */
|
||||
profile_ports?: Record<string, number>;
|
||||
routing?: OpenAICompatProxyRoutingConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy server configuration section.
|
||||
* Controls whether CCS uses local or remote CLIProxyAPI instance.
|
||||
*/
|
||||
export interface CliproxyServerConfig {
|
||||
/** Remote proxy settings */
|
||||
remote: ProxyRemoteConfig;
|
||||
/** Fallback behavior when remote is unreachable */
|
||||
fallback: ProxyFallbackConfig;
|
||||
/** Local proxy settings */
|
||||
local: ProxyLocalConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global environment variables configuration.
|
||||
* These env vars are injected into ALL non-Claude subscription profiles.
|
||||
* Useful for disabling telemetry, bug commands, error reporting, etc.
|
||||
*/
|
||||
export interface GlobalEnvConfig {
|
||||
/** Enable global env injection (default: true) */
|
||||
enabled: boolean;
|
||||
/** Environment variables to inject */
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-profile continuity inheritance configuration.
|
||||
* Maps execution profile names to source account profiles for CLAUDE_CONFIG_DIR reuse.
|
||||
*/
|
||||
export interface ContinuityConfig {
|
||||
/** Profile name -> source account profile name */
|
||||
inherit_from_account?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default global env vars for third-party profiles.
|
||||
* These disable Claude Code telemetry/reporting since we're using proxy.
|
||||
*/
|
||||
export const DEFAULT_GLOBAL_ENV: Record<string, string> = {
|
||||
DISABLE_BUG_COMMAND: '1',
|
||||
DISABLE_ERROR_REPORTING: '1',
|
||||
DISABLE_TELEMETRY: '1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Default CLIProxy server configuration.
|
||||
* Local mode by default - remote must be explicitly enabled.
|
||||
* Port is optional for remote - defaults based on protocol.
|
||||
*/
|
||||
export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = {
|
||||
remote: {
|
||||
enabled: false,
|
||||
host: '',
|
||||
protocol: 'http',
|
||||
auth_token: '',
|
||||
},
|
||||
fallback: {
|
||||
enabled: true,
|
||||
auto_start: false,
|
||||
},
|
||||
local: {
|
||||
port: 8317,
|
||||
auto_start: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_OPENAI_COMPAT_PROXY_CONFIG: OpenAICompatProxyConfig = {
|
||||
profile_ports: {},
|
||||
routing: {
|
||||
longContextThreshold: 60_000,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Image analysis configuration.
|
||||
* Routes image/PDF files through CLIProxy for vision analysis.
|
||||
*/
|
||||
export interface ImageAnalysisConfig {
|
||||
/** Enable image analysis via CLIProxy (default: true) */
|
||||
enabled: boolean;
|
||||
/** Timeout in seconds (default: 60) */
|
||||
timeout: number;
|
||||
/** Provider-to-model mapping for vision analysis */
|
||||
provider_models: Record<string, string>;
|
||||
/** Fallback backend used when a profile does not resolve to a provider-specific backend */
|
||||
fallback_backend?: string;
|
||||
/** Explicit profile-name-to-backend overrides for settings/custom aliases */
|
||||
profile_backends?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default image analysis configuration.
|
||||
* Enabled by default for CLIProxy providers with vision support.
|
||||
*/
|
||||
export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = {
|
||||
enabled: true,
|
||||
timeout: 60,
|
||||
provider_models: {
|
||||
agy: 'gemini-3-1-flash-preview',
|
||||
gemini: 'gemini-3-flash-preview',
|
||||
codex: 'gpt-5.1-codex-mini',
|
||||
kiro: 'kiro-claude-haiku-4-5',
|
||||
ghcp: 'claude-haiku-4.5',
|
||||
claude: 'claude-haiku-4.5-20251001',
|
||||
qwen: 'vision-model',
|
||||
iflow: 'qwen3-vl-plus',
|
||||
kimi: 'vision-model',
|
||||
},
|
||||
fallback_backend: 'gemini',
|
||||
profile_backends: {},
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Quota management configuration types and defaults.
|
||||
*
|
||||
* Controls hybrid auto+manual account selection for multi-account setups.
|
||||
* Version 7+ feature.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// QUOTA MANAGEMENT CONFIGURATION (v7+)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Auto quota management configuration.
|
||||
* Controls automatic failover behavior.
|
||||
*/
|
||||
export interface AutoQuotaConfig {
|
||||
/** Enable pre-flight quota check before requests (default: true) */
|
||||
preflight_check: boolean;
|
||||
/** Quota percentage below which account is "exhausted" (default: 5) */
|
||||
exhaustion_threshold: number;
|
||||
/** Tier priority for failover, highest to lowest (default: ['paid']) */
|
||||
tier_priority: string[];
|
||||
/** Minutes to skip exhausted account before retry (default: 5) */
|
||||
cooldown_minutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime quota monitor configuration.
|
||||
* Controls adaptive polling during active sessions.
|
||||
*/
|
||||
export interface RuntimeMonitorConfig {
|
||||
/** Enable runtime monitoring during sessions (default: true) */
|
||||
enabled: boolean;
|
||||
/** Poll interval in seconds when quota > warn_threshold (default: 300) */
|
||||
normal_interval_seconds: number;
|
||||
/** Poll interval in seconds when quota <= warn_threshold (default: 60) */
|
||||
critical_interval_seconds: number;
|
||||
/** Quota percentage that triggers fast polling + warning (default: 20) */
|
||||
warn_threshold: number;
|
||||
/** Quota percentage that triggers cooldown + switch (default: 5) */
|
||||
exhaustion_threshold: number;
|
||||
/** Minutes to cooldown exhausted account (default: 5) */
|
||||
cooldown_minutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual quota management configuration.
|
||||
* User-controlled overrides for account selection.
|
||||
*/
|
||||
export interface ManualQuotaConfig {
|
||||
/** User-paused accounts (stored in accounts.json) */
|
||||
paused_accounts: string[];
|
||||
/** Force use of specific account (overrides auto-selection) */
|
||||
forced_default: string | null;
|
||||
/** Lock to specific tier only */
|
||||
tier_lock: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quota management mode.
|
||||
* - auto: Fully automatic failover based on quota
|
||||
* - manual: User controls everything, no auto-switching
|
||||
* - hybrid: Auto-failover with user overrides (default)
|
||||
*/
|
||||
export type QuotaManagementMode = 'auto' | 'manual' | 'hybrid';
|
||||
|
||||
/**
|
||||
* Quota management configuration section.
|
||||
* Controls hybrid auto+manual account selection for multi-account setups.
|
||||
*/
|
||||
export interface QuotaManagementConfig {
|
||||
/** Management mode (default: hybrid) */
|
||||
mode: QuotaManagementMode;
|
||||
/** Auto mode settings */
|
||||
auto: AutoQuotaConfig;
|
||||
/** Manual mode settings */
|
||||
manual: ManualQuotaConfig;
|
||||
/** Runtime monitor settings */
|
||||
runtime_monitor: RuntimeMonitorConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default auto quota configuration.
|
||||
*/
|
||||
export const DEFAULT_AUTO_QUOTA_CONFIG: AutoQuotaConfig = {
|
||||
preflight_check: true,
|
||||
exhaustion_threshold: 5,
|
||||
tier_priority: ['ultra', 'pro', 'free'],
|
||||
cooldown_minutes: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default manual quota configuration.
|
||||
*/
|
||||
export const DEFAULT_MANUAL_QUOTA_CONFIG: ManualQuotaConfig = {
|
||||
paused_accounts: [],
|
||||
forced_default: null,
|
||||
tier_lock: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default runtime monitor configuration.
|
||||
*/
|
||||
export const DEFAULT_RUNTIME_MONITOR_CONFIG: RuntimeMonitorConfig = {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default quota management configuration.
|
||||
*/
|
||||
export const DEFAULT_QUOTA_MANAGEMENT_CONFIG: QuotaManagementConfig = {
|
||||
mode: 'hybrid',
|
||||
auto: { ...DEFAULT_AUTO_QUOTA_CONFIG },
|
||||
manual: { ...DEFAULT_MANUAL_QUOTA_CONFIG },
|
||||
runtime_monitor: { ...DEFAULT_RUNTIME_MONITOR_CONFIG },
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Thinking/reasoning budget configuration types and defaults.
|
||||
*
|
||||
* Controls thinking budget injection for CLIProxy providers.
|
||||
* Version 8+ feature.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// THINKING CONFIGURATION (v8+)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Thinking mode for auto/manual/off control.
|
||||
* - auto: Apply tier-based defaults (opus→high, sonnet→medium, haiku→low)
|
||||
* - off: Disable thinking entirely
|
||||
* - manual: Use explicit override value
|
||||
*/
|
||||
export type ThinkingMode = 'auto' | 'off' | 'manual';
|
||||
|
||||
/**
|
||||
* Tier-to-thinking level defaults.
|
||||
* Maps Claude tier names to thinking level names.
|
||||
*/
|
||||
export interface ThinkingTierDefaults {
|
||||
/** Thinking level for opus tier (default: 'high') */
|
||||
opus: string;
|
||||
/** Thinking level for sonnet tier (default: 'medium') */
|
||||
sonnet: string;
|
||||
/** Thinking level for haiku tier (default: 'low') */
|
||||
haiku: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thinking configuration section.
|
||||
* Controls thinking/reasoning budget injection for CLIProxy providers.
|
||||
*/
|
||||
export interface ThinkingConfig {
|
||||
/** Thinking mode (default: 'auto') */
|
||||
mode: ThinkingMode;
|
||||
/** Manual override value (level name or budget number) */
|
||||
override?: string | number;
|
||||
/** Tier-to-level mapping */
|
||||
tier_defaults: ThinkingTierDefaults;
|
||||
/** Per-provider overrides (e.g., { gemini: { opus: 'high' } }) */
|
||||
provider_overrides?: Record<string, Partial<ThinkingTierDefaults>>;
|
||||
/** Show warning when values are clamped (default: true) */
|
||||
show_warnings?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default thinking tier defaults.
|
||||
*/
|
||||
export const DEFAULT_THINKING_TIER_DEFAULTS: ThinkingTierDefaults = {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
/**
|
||||
* Default thinking configuration.
|
||||
*/
|
||||
export const DEFAULT_THINKING_CONFIG: ThinkingConfig = {
|
||||
mode: 'auto',
|
||||
tier_defaults: { ...DEFAULT_THINKING_TIER_DEFAULTS },
|
||||
show_warnings: true,
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Main unified configuration interface, factory, and type guard.
|
||||
*
|
||||
* The UnifiedConfig type is the root of the entire config.yaml schema.
|
||||
* This file imports all section types from their respective schema modules.
|
||||
*/
|
||||
|
||||
import type { AccountConfig, ProfileConfig, DashboardAuthConfig } from './auth';
|
||||
import { DEFAULT_DASHBOARD_AUTH_CONFIG } from './auth';
|
||||
import type { CLIProxyConfig } from './cliproxy';
|
||||
import { CLIPROXY_SUPPORTED_PROVIDERS, DEFAULT_CLIPROXY_SAFETY_CONFIG } from './cliproxy';
|
||||
import type { LoggingConfig, PreferencesConfig } from './logging';
|
||||
import { DEFAULT_LOGGING_CONFIG } from './logging';
|
||||
import type { WebSearchConfig } from './websearch';
|
||||
import type {
|
||||
GlobalEnvConfig,
|
||||
ContinuityConfig,
|
||||
CopilotConfig,
|
||||
CursorConfig,
|
||||
CliproxyServerConfig,
|
||||
OpenAICompatProxyConfig,
|
||||
ImageAnalysisConfig,
|
||||
} from './providers';
|
||||
import {
|
||||
DEFAULT_COPILOT_CONFIG,
|
||||
DEFAULT_CURSOR_CONFIG,
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
DEFAULT_OPENAI_COMPAT_PROXY_CONFIG,
|
||||
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||
DEFAULT_GLOBAL_ENV,
|
||||
} from './providers';
|
||||
import { UNIFIED_CONFIG_VERSION } from './version';
|
||||
import type { QuotaManagementConfig } from './quota';
|
||||
import { DEFAULT_QUOTA_MANAGEMENT_CONFIG } from './quota';
|
||||
import type { ThinkingConfig } from './thinking';
|
||||
import { DEFAULT_THINKING_CONFIG } from './thinking';
|
||||
import type { OfficialChannelsConfig } from './channels';
|
||||
import { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from './channels';
|
||||
import type { BrowserConfig } from './browser';
|
||||
import { DEFAULT_BROWSER_CONFIG } from './browser';
|
||||
|
||||
/**
|
||||
* Main unified configuration structure.
|
||||
* Stored in ~/.ccs/config.yaml
|
||||
*/
|
||||
export interface UnifiedConfig {
|
||||
/** Config version */
|
||||
version: number;
|
||||
/** Flag indicating setup wizard has been completed */
|
||||
setup_completed?: boolean;
|
||||
/** Default profile name to use when none specified */
|
||||
default?: string;
|
||||
/** Account-based profiles (isolated Claude instances) */
|
||||
accounts: Record<string, AccountConfig>;
|
||||
/** API-based profiles (env var injection) */
|
||||
profiles: Record<string, ProfileConfig>;
|
||||
/** CLIProxy configuration */
|
||||
cliproxy: CLIProxyConfig;
|
||||
/** OpenAI-compatible local proxy configuration */
|
||||
proxy?: OpenAICompatProxyConfig;
|
||||
/** CCS-owned structured logging configuration */
|
||||
logging?: LoggingConfig;
|
||||
/** User preferences */
|
||||
preferences: PreferencesConfig;
|
||||
/** WebSearch configuration */
|
||||
websearch?: WebSearchConfig;
|
||||
/** Global environment variables for all non-Claude subscription profiles */
|
||||
global_env?: GlobalEnvConfig;
|
||||
/** Cross-profile continuity inheritance mapping */
|
||||
continuity?: ContinuityConfig;
|
||||
/** Copilot API configuration (GitHub Copilot proxy) */
|
||||
copilot?: CopilotConfig;
|
||||
/** Cursor IDE configuration (Cursor proxy daemon) */
|
||||
cursor?: CursorConfig;
|
||||
/** CLIProxy server configuration for remote/local mode */
|
||||
cliproxy_server?: CliproxyServerConfig;
|
||||
/** Quota management configuration (v7+) */
|
||||
quota_management?: QuotaManagementConfig;
|
||||
/** Thinking/reasoning budget configuration (v8+) */
|
||||
thinking?: ThinkingConfig;
|
||||
/** Official Channels runtime auto-enable preferences (v11+) */
|
||||
channels?: OfficialChannelsConfig;
|
||||
/** Dashboard authentication configuration (optional) */
|
||||
dashboard_auth?: DashboardAuthConfig;
|
||||
/** Browser automation configuration */
|
||||
browser?: BrowserConfig;
|
||||
/** Image analysis configuration (vision via CLIProxy) */
|
||||
image_analysis?: ImageAnalysisConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty unified config with defaults.
|
||||
*/
|
||||
export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
return {
|
||||
version: UNIFIED_CONFIG_VERSION,
|
||||
default: undefined,
|
||||
accounts: {},
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
backend: 'original',
|
||||
oauth_accounts: {},
|
||||
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
|
||||
variants: {},
|
||||
logging: {
|
||||
enabled: false,
|
||||
request_log: false,
|
||||
},
|
||||
safety: { ...DEFAULT_CLIPROXY_SAFETY_CONFIG },
|
||||
auto_sync: true,
|
||||
routing: {
|
||||
strategy: 'round-robin',
|
||||
session_affinity: false,
|
||||
session_affinity_ttl: '1h',
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
port: DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port,
|
||||
profile_ports: { ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports },
|
||||
routing: {
|
||||
...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.routing,
|
||||
},
|
||||
},
|
||||
logging: { ...DEFAULT_LOGGING_CONFIG },
|
||||
preferences: {
|
||||
theme: 'system',
|
||||
telemetry: false,
|
||||
auto_update: true,
|
||||
},
|
||||
websearch: {
|
||||
enabled: true,
|
||||
providers: {
|
||||
exa: {
|
||||
enabled: false,
|
||||
max_results: 5,
|
||||
},
|
||||
tavily: {
|
||||
enabled: false,
|
||||
max_results: 5,
|
||||
},
|
||||
brave: {
|
||||
enabled: false,
|
||||
max_results: 5,
|
||||
},
|
||||
searxng: {
|
||||
enabled: false,
|
||||
url: '',
|
||||
max_results: 5,
|
||||
},
|
||||
duckduckgo: {
|
||||
enabled: true,
|
||||
max_results: 5,
|
||||
},
|
||||
gemini: {
|
||||
enabled: false,
|
||||
model: 'gemini-2.5-flash',
|
||||
timeout: 55,
|
||||
},
|
||||
opencode: {
|
||||
enabled: false,
|
||||
model: 'opencode/grok-code',
|
||||
timeout: 90,
|
||||
},
|
||||
grok: {
|
||||
enabled: false,
|
||||
timeout: 55,
|
||||
},
|
||||
},
|
||||
},
|
||||
global_env: {
|
||||
enabled: true,
|
||||
env: { ...DEFAULT_GLOBAL_ENV },
|
||||
},
|
||||
copilot: { ...DEFAULT_COPILOT_CONFIG },
|
||||
cursor: { ...DEFAULT_CURSOR_CONFIG },
|
||||
cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG },
|
||||
quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG },
|
||||
thinking: { ...DEFAULT_THINKING_CONFIG },
|
||||
channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG },
|
||||
dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG },
|
||||
browser: {
|
||||
claude: { ...DEFAULT_BROWSER_CONFIG.claude },
|
||||
codex: { ...DEFAULT_BROWSER_CONFIG.codex },
|
||||
},
|
||||
image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for UnifiedConfig.
|
||||
* Relaxed validation: accepts configs with version >= 1 and any subset of sections.
|
||||
* Missing sections will be filled with defaults during merge.
|
||||
*/
|
||||
export function isUnifiedConfig(obj: unknown): obj is UnifiedConfig {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
const config = obj as Record<string, unknown>;
|
||||
// Only require version to be a number >= 1 (allow future versions)
|
||||
// Sections are optional - will be merged with defaults in loadOrCreateUnifiedConfig
|
||||
return typeof config.version === 'number' && config.version >= 1;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Unified config version constant.
|
||||
*
|
||||
* Central source of truth for the current config schema version.
|
||||
* Incremented whenever new sections are added to config.yaml.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Unified config version.
|
||||
* Version 2 = YAML unified format
|
||||
* 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)
|
||||
* Version 7 = Quota management for hybrid auto+manual account control
|
||||
* Version 8 = Thinking/reasoning budget configuration
|
||||
* Version 9 = Real WebSearch backends (DuckDuckGo/Brave) with legacy CLI fallback
|
||||
* Version 10 = Exa + Tavily WebSearch backends
|
||||
* Version 11 = Discord Channels runtime auto-enable preferences
|
||||
* Version 12 = Official Channels multi-provider support (Telegram, Discord, iMessage)
|
||||
* Version 13 = Browser automation defaults to safe manual/off exposure
|
||||
*/
|
||||
export const UNIFIED_CONFIG_VERSION = 13;
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* WebSearch backend configuration types.
|
||||
*
|
||||
* Covers all supported search backends:
|
||||
* - API-backed: Exa, Tavily, Brave
|
||||
* - Self-hosted: SearXNG
|
||||
* - Zero-setup: DuckDuckGo
|
||||
* - Legacy CLI fallbacks: Gemini, Grok, OpenCode
|
||||
*/
|
||||
|
||||
/**
|
||||
* DuckDuckGo WebSearch configuration.
|
||||
*/
|
||||
export interface DuckDuckGoWebSearchConfig {
|
||||
/** Enable DuckDuckGo HTML search fallback (default: true) */
|
||||
enabled?: boolean;
|
||||
/** Number of results to fetch (default: 5) */
|
||||
max_results?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brave WebSearch configuration.
|
||||
*/
|
||||
export interface BraveWebSearchConfig {
|
||||
/** Enable Brave Search when BRAVE_API_KEY is available (default: false) */
|
||||
enabled?: boolean;
|
||||
/** Number of results to fetch (default: 5) */
|
||||
max_results?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exa WebSearch configuration.
|
||||
*/
|
||||
export interface ExaWebSearchConfig {
|
||||
/** Enable Exa Search when EXA_API_KEY is available (default: false) */
|
||||
enabled?: boolean;
|
||||
/** Number of results to fetch (default: 5) */
|
||||
max_results?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tavily WebSearch configuration.
|
||||
*/
|
||||
export interface TavilyWebSearchConfig {
|
||||
/** Enable Tavily Search when TAVILY_API_KEY is available (default: false) */
|
||||
enabled?: boolean;
|
||||
/** Number of results to fetch (default: 5) */
|
||||
max_results?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SearXNG WebSearch configuration.
|
||||
*/
|
||||
export interface SearxngWebSearchConfig {
|
||||
/** Enable SearXNG JSON search backend (default: false) */
|
||||
enabled?: boolean;
|
||||
/** Base SearXNG URL, e.g. https://search.example.com (default: '') */
|
||||
url?: string;
|
||||
/** Number of results to fetch (default: 5) */
|
||||
max_results?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini CLI WebSearch configuration.
|
||||
*/
|
||||
export interface GeminiWebSearchConfig {
|
||||
/** Enable Gemini CLI legacy fallback (default: false) */
|
||||
enabled?: boolean;
|
||||
/** Model to use (default: gemini-2.5-flash) */
|
||||
model?: string;
|
||||
/** Timeout in seconds (default: 55) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grok CLI WebSearch configuration.
|
||||
*/
|
||||
export interface GrokWebSearchConfig {
|
||||
/** Enable Grok CLI legacy fallback (default: false - requires GROK_API_KEY) */
|
||||
enabled?: boolean;
|
||||
/** Timeout in seconds (default: 55) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode CLI WebSearch configuration.
|
||||
*/
|
||||
export interface OpenCodeWebSearchConfig {
|
||||
/** Enable OpenCode CLI legacy fallback (default: false) */
|
||||
enabled?: boolean;
|
||||
/** Model to use (default: opencode/grok-code) */
|
||||
model?: string;
|
||||
/** Timeout in seconds (default: 60) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSearch providers configuration.
|
||||
* Uses deterministic search backends first, with optional legacy CLI fallback.
|
||||
*/
|
||||
export interface WebSearchProvidersConfig {
|
||||
/** Exa Search API - API-backed search with strong relevance and content extraction */
|
||||
exa?: ExaWebSearchConfig;
|
||||
/** Tavily Search API - API-backed search optimized for agent/tool usage */
|
||||
tavily?: TavilyWebSearchConfig;
|
||||
/** Brave Search API - higher quality results when BRAVE_API_KEY is set */
|
||||
brave?: BraveWebSearchConfig;
|
||||
/** SearXNG JSON search - self-hosted or public instance backend */
|
||||
searxng?: SearxngWebSearchConfig;
|
||||
/** DuckDuckGo HTML search - zero setup default backend */
|
||||
duckduckgo?: DuckDuckGoWebSearchConfig;
|
||||
/** Gemini CLI - optional legacy LLM fallback */
|
||||
gemini?: GeminiWebSearchConfig;
|
||||
/** Grok CLI - optional legacy LLM fallback */
|
||||
grok?: GrokWebSearchConfig;
|
||||
/** OpenCode - optional legacy LLM fallback */
|
||||
opencode?: OpenCodeWebSearchConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSearch configuration.
|
||||
* Uses deterministic local backends for third-party profiles.
|
||||
* Legacy AI CLI fallbacks remain available for compatibility only.
|
||||
*/
|
||||
export interface WebSearchConfig {
|
||||
/** Master switch - enable/disable WebSearch (default: true) */
|
||||
enabled?: boolean;
|
||||
/** Individual provider configurations */
|
||||
providers?: WebSearchProvidersConfig;
|
||||
// Legacy fields (deprecated, kept for backwards compatibility)
|
||||
/** @deprecated Use providers.gemini instead */
|
||||
gemini?: {
|
||||
enabled?: boolean;
|
||||
timeout?: number;
|
||||
};
|
||||
/** @deprecated Unused */
|
||||
mode?: 'sequential' | 'parallel';
|
||||
/** @deprecated Unused */
|
||||
provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
|
||||
/** @deprecated Unused */
|
||||
fallback?: boolean;
|
||||
/** @deprecated Unused */
|
||||
webSearchPrimeUrl?: string;
|
||||
/** @deprecated Unused */
|
||||
selectedProviders?: string[];
|
||||
/** @deprecated Unused */
|
||||
customMcp?: unknown[];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { CCSError, RetryableError, isCCSError, isRecoverableError } from '../error-types';
|
||||
import { ExitCode } from '../exit-codes';
|
||||
|
||||
describe('RetryableError', () => {
|
||||
it('extends CCSError', () => {
|
||||
const err = new RetryableError('test');
|
||||
expect(err).toBeInstanceOf(CCSError);
|
||||
expect(err).toBeInstanceOf(RetryableError);
|
||||
});
|
||||
|
||||
it('sets name to RetryableError', () => {
|
||||
const err = new RetryableError('test');
|
||||
expect(err.name).toBe('RetryableError');
|
||||
});
|
||||
|
||||
it('sets recoverable to true', () => {
|
||||
const err = new RetryableError('test');
|
||||
expect(err.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults exit code to GENERAL_ERROR', () => {
|
||||
const err = new RetryableError('test');
|
||||
expect(err.code).toBe(ExitCode.GENERAL_ERROR);
|
||||
});
|
||||
|
||||
it('passes message through', () => {
|
||||
const err = new RetryableError('something went wrong');
|
||||
expect(err.message).toBe('something went wrong');
|
||||
});
|
||||
|
||||
it('accepts an optional originalError', () => {
|
||||
const original = new Error('original');
|
||||
const err = new RetryableError('wrapped', original);
|
||||
expect(err.originalError).toBe(original);
|
||||
});
|
||||
|
||||
it('accepts an optional retryAfter (ms)', () => {
|
||||
const err = new RetryableError('rate limited', undefined, 5000);
|
||||
expect(err.retryAfter).toBe(5000);
|
||||
});
|
||||
|
||||
it('defaults retryAfter to undefined', () => {
|
||||
const err = new RetryableError('test');
|
||||
expect(err.retryAfter).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is identified by isCCSError', () => {
|
||||
const err = new RetryableError('test');
|
||||
expect(isCCSError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('is identified as recoverable by isRecoverableError', () => {
|
||||
const err = new RetryableError('test');
|
||||
expect(isRecoverableError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('has a proper stack trace', () => {
|
||||
const err = new RetryableError('test');
|
||||
expect(err.stack).toBeDefined();
|
||||
expect(err.stack).toContain('RetryableError');
|
||||
});
|
||||
});
|
||||
@@ -169,6 +169,21 @@ export class ValidationError extends CCSError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retryable/transient error
|
||||
* Signals that the operation may succeed on retry (e.g. rate limits, timeouts)
|
||||
*/
|
||||
export class RetryableError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly originalError?: Error,
|
||||
public readonly retryAfter?: number // ms until next attempt
|
||||
) {
|
||||
super(message, ExitCode.GENERAL_ERROR, true);
|
||||
this.name = 'RetryableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a CCSError
|
||||
*/
|
||||
|
||||
@@ -37,6 +37,8 @@ export {
|
||||
ProxyError,
|
||||
MigrationError,
|
||||
UserAbortError,
|
||||
ValidationError,
|
||||
RetryableError,
|
||||
isCCSError,
|
||||
isRecoverableError,
|
||||
} from './error-types';
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, mock, spyOn } from 'bun:test';
|
||||
import { withRetry, type RetryOptions } from '../retry-strategy';
|
||||
import { CCSError, RetryableError } from '../../errors/error-types';
|
||||
|
||||
describe('withRetry', () => {
|
||||
it('returns the result on first success', async () => {
|
||||
const fn = mock(() => Promise.resolve(42));
|
||||
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10 });
|
||||
expect(result).toBe(42);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries on RetryableError and succeeds', async () => {
|
||||
let attempt = 0;
|
||||
const fn = mock(() => {
|
||||
attempt++;
|
||||
if (attempt < 3) {
|
||||
return Promise.reject(new RetryableError('transient failure'));
|
||||
}
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
|
||||
const result = await withRetry(fn, { maxRetries: 5, baseDelayMs: 1 });
|
||||
expect(result).toBe('ok');
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('throws after max retries exhausted', async () => {
|
||||
const fn = mock(() => Promise.reject(new RetryableError('always fails')));
|
||||
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 })).rejects.toThrow('always fails');
|
||||
// 1 initial + 2 retries = 3 total calls
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does not retry non-retryable errors', async () => {
|
||||
const fn = mock(() => Promise.reject(new Error('fatal')));
|
||||
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1 })).rejects.toThrow('fatal');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry CCSErrors with recoverable=false', async () => {
|
||||
const fn = mock(() => Promise.reject(new CCSError('non-retryable', 1, false)));
|
||||
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1 })).rejects.toThrow('non-retryable');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses custom retryableCheck when provided', async () => {
|
||||
let attempt = 0;
|
||||
const fn = mock(() => {
|
||||
attempt++;
|
||||
if (attempt < 2) {
|
||||
return Promise.reject(new Error('custom-retry'));
|
||||
}
|
||||
return Promise.resolve('recovered');
|
||||
});
|
||||
|
||||
const customCheck = (error: unknown) =>
|
||||
error instanceof Error && error.message === 'custom-retry';
|
||||
|
||||
const result = await withRetry(fn, {
|
||||
maxRetries: 5,
|
||||
baseDelayMs: 1,
|
||||
retryableCheck: customCheck,
|
||||
});
|
||||
expect(result).toBe('recovered');
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('calls onRetry callback on each retry attempt', async () => {
|
||||
const onRetry = mock(() => {});
|
||||
const fn = mock(() => Promise.reject(new RetryableError('fail')));
|
||||
|
||||
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1, onRetry })).rejects.toThrow('fail');
|
||||
|
||||
// 1 initial + 3 retries = 3 onRetry calls (not called for initial)
|
||||
expect(onRetry).toHaveBeenCalledTimes(3);
|
||||
// First retry call
|
||||
expect(onRetry.mock.calls[0][1]).toBe(1);
|
||||
});
|
||||
|
||||
it('respects maxDelayMs cap', async () => {
|
||||
const sleepSpy = spyOn(globalThis, 'setTimeout');
|
||||
let attempt = 0;
|
||||
const fn = mock(() => {
|
||||
attempt++;
|
||||
if (attempt <= 2) {
|
||||
return Promise.reject(new RetryableError('fail'));
|
||||
}
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
|
||||
await withRetry(fn, {
|
||||
maxRetries: 5,
|
||||
baseDelayMs: 1000,
|
||||
maxDelayMs: 200,
|
||||
});
|
||||
|
||||
// Verify setTimeout was called with delay <= maxDelayMs (200ms)
|
||||
// Jitter is capped, so delay must never exceed 200ms
|
||||
for (const call of sleepSpy.mock.calls) {
|
||||
const delay = call[1] as number;
|
||||
expect(delay).toBeLessThanOrEqual(200);
|
||||
}
|
||||
sleepSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('uses default backoffMultiplier when not specified', async () => {
|
||||
let attempt = 0;
|
||||
const fn = mock(() => {
|
||||
attempt++;
|
||||
if (attempt < 2) {
|
||||
return Promise.reject(new RetryableError('fail'));
|
||||
}
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
|
||||
// Should not throw - defaults to multiplier of 2
|
||||
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1 })).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('applies exponential backoff with custom multiplier', async () => {
|
||||
const sleepSpy = spyOn(globalThis, 'setTimeout');
|
||||
let attempt = 0;
|
||||
const fn = mock(() => {
|
||||
attempt++;
|
||||
if (attempt <= 3) {
|
||||
return Promise.reject(new RetryableError('fail'));
|
||||
}
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
|
||||
await withRetry(fn, {
|
||||
maxRetries: 5,
|
||||
baseDelayMs: 10,
|
||||
backoffMultiplier: 3,
|
||||
});
|
||||
|
||||
// Delays should grow: ~10, ~30, ~90 (with jitter)
|
||||
const delays = sleepSpy.mock.calls.map((call) => call[1] as number);
|
||||
// First delay should be close to base * multiplier^0 = 10
|
||||
expect(delays[0]).toBeGreaterThan(5);
|
||||
expect(delays[0]).toBeLessThan(25); // 10 + jitter
|
||||
// Second delay should be close to base * multiplier^1 = 30
|
||||
expect(delays[1]).toBeGreaterThan(20);
|
||||
expect(delays[1]).toBeLessThan(50); // 30 + jitter
|
||||
|
||||
sleepSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('passes through errors that are not Error instances', async () => {
|
||||
const fn = mock(() => Promise.reject('string error'));
|
||||
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1 })).rejects.toBe('string error');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('works with maxRetries of 0 (no retries)', async () => {
|
||||
const fn = mock(() => Promise.reject(new RetryableError('fail')));
|
||||
await expect(withRetry(fn, { maxRetries: 0, baseDelayMs: 1 })).rejects.toThrow('fail');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws on negative maxRetries', async () => {
|
||||
const fn = mock(() => Promise.resolve('ok'));
|
||||
await expect(withRetry(fn, { maxRetries: -1, baseDelayMs: 1 })).rejects.toThrow(
|
||||
'maxRetries must be >= 0'
|
||||
);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects retryAfter from RetryableError', async () => {
|
||||
const sleepSpy = spyOn(globalThis, 'setTimeout');
|
||||
let attempt = 0;
|
||||
const fn = mock(() => {
|
||||
attempt++;
|
||||
if (attempt < 2) {
|
||||
return Promise.reject(new RetryableError('rate limited', undefined, 500));
|
||||
}
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
|
||||
await withRetry(fn, { maxRetries: 3, baseDelayMs: 10, maxDelayMs: 1000 });
|
||||
// retryAfter=500 should override the computed backoff (~10ms) since 500 > 10
|
||||
const delay = sleepSpy.mock.calls[0][1] as number;
|
||||
expect(delay).toBeGreaterThanOrEqual(500);
|
||||
sleepSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('swallows onRetry callback errors and continues retrying', async () => {
|
||||
let attempt = 0;
|
||||
const fn = mock(() => {
|
||||
attempt++;
|
||||
if (attempt < 3) {
|
||||
return Promise.reject(new RetryableError('fail'));
|
||||
}
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
const onRetry = mock(() => {
|
||||
throw new Error('callback blew up');
|
||||
});
|
||||
|
||||
const result = await withRetry(fn, { maxRetries: 5, baseDelayMs: 1, onRetry });
|
||||
expect(result).toBe('ok');
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
expect(onRetry).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws on negative baseDelayMs', async () => {
|
||||
const fn = mock(() => Promise.resolve('ok'));
|
||||
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: -1 })).rejects.toThrow(
|
||||
'baseDelayMs must be >= 0'
|
||||
);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retryAfter can exceed maxDelayMs (server directive wins)', async () => {
|
||||
const sleepSpy = spyOn(globalThis, 'setTimeout');
|
||||
let attempt = 0;
|
||||
const fn = mock(() => {
|
||||
attempt++;
|
||||
if (attempt < 2) {
|
||||
return Promise.reject(new RetryableError('rate limited', undefined, 500));
|
||||
}
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
|
||||
await withRetry(fn, { maxRetries: 3, baseDelayMs: 10, maxDelayMs: 100 });
|
||||
// retryAfter=500 > maxDelayMs=100 — server directive takes precedence
|
||||
const delay = sleepSpy.mock.calls[0][1] as number;
|
||||
expect(delay).toBeGreaterThanOrEqual(500);
|
||||
sleepSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('baseDelayMs=0 produces immediate retries', async () => {
|
||||
let attempt = 0;
|
||||
const fn = mock(() => {
|
||||
attempt++;
|
||||
if (attempt < 3) {
|
||||
return Promise.reject(new RetryableError('fail'));
|
||||
}
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
|
||||
const result = await withRetry(fn, { maxRetries: 5, baseDelayMs: 0 });
|
||||
expect(result).toBe('ok');
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does not call onRetry when maxRetries=0', async () => {
|
||||
const onRetry = mock(() => {});
|
||||
const fn = mock(() => Promise.reject(new RetryableError('fail')));
|
||||
|
||||
await expect(withRetry(fn, { maxRetries: 0, baseDelayMs: 1, onRetry })).rejects.toThrow('fail');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Retry Strategy Utility
|
||||
*
|
||||
* Reusable exponential-backoff retry wrapper extracted from
|
||||
* scattered retry logic in glmt-proxy and binary/downloader.
|
||||
*
|
||||
* Usage:
|
||||
* const data = await withRetry(() => fetch(url), { maxRetries: 3, baseDelayMs: 100 });
|
||||
*/
|
||||
|
||||
import { RetryableError, isRecoverableError } from '../errors/error-types';
|
||||
|
||||
/** Configuration options for retry behavior */
|
||||
export interface RetryOptions {
|
||||
/** Maximum number of retry attempts (default: 3) */
|
||||
maxRetries: number;
|
||||
/** Base delay in ms for the first retry (default: 1000) */
|
||||
baseDelayMs: number;
|
||||
/** Upper bound for the computed backoff delay. Note: server-provided `retryAfter` (from RetryableError) takes precedence and may exceed this cap. */
|
||||
maxDelayMs?: number;
|
||||
/** Multiplier applied per attempt (default: 2). Values <1 produce degrowth. */
|
||||
backoffMultiplier?: number;
|
||||
/** Override the default retryability check */
|
||||
retryableCheck?: (error: unknown) => boolean;
|
||||
/** Callback fired before each retry. Errors thrown by this callback are swallowed to prevent aborting the retry loop. */
|
||||
onRetry?: (error: Error, attempt: number) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_DELAY_MS = 30_000;
|
||||
const DEFAULT_MULTIPLIER = 2;
|
||||
const JITTER_RATIO = 0.2; // 20% of delay as random jitter
|
||||
|
||||
/**
|
||||
* Check whether an unknown thrown value is retryable.
|
||||
* Uses CCSError.recoverable flag and RetryableError instance check.
|
||||
*/
|
||||
function defaultRetryableCheck(error: unknown): boolean {
|
||||
if (error instanceof RetryableError) {
|
||||
return true;
|
||||
}
|
||||
if (isRecoverableError(error)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute backoff delay: base * multiplier^attempt + jitter, capped at maxDelay.
|
||||
* Jitter is applied before the final cap to ensure the result never exceeds maxDelayMs.
|
||||
*/
|
||||
function computeDelay(
|
||||
attempt: number,
|
||||
baseDelayMs: number,
|
||||
maxDelayMs: number,
|
||||
multiplier: number,
|
||||
retryAfter?: number
|
||||
): number {
|
||||
const exponentialDelay = Math.min(baseDelayMs * Math.pow(multiplier, attempt), maxDelayMs);
|
||||
const jitter = exponentialDelay * JITTER_RATIO * Math.random();
|
||||
const delay = Math.min(exponentialDelay + jitter, maxDelayMs);
|
||||
if (retryAfter !== undefined && retryAfter > 0) {
|
||||
return Math.max(delay, retryAfter);
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for the specified number of milliseconds.
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute `fn` with automatic retries on retryable failures.
|
||||
*
|
||||
* Retryability defaults to checking for `RetryableError` instances
|
||||
* and CCSError with `recoverable === true`. Override with `retryableCheck`.
|
||||
*
|
||||
* @param fn - The async function to execute
|
||||
* @param options - Retry configuration
|
||||
* @returns The resolved value from `fn`
|
||||
* @throws The last error encountered after exhausting retries
|
||||
*/
|
||||
export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions): Promise<T> {
|
||||
const {
|
||||
maxRetries,
|
||||
baseDelayMs,
|
||||
maxDelayMs = DEFAULT_MAX_DELAY_MS,
|
||||
backoffMultiplier = DEFAULT_MULTIPLIER,
|
||||
retryableCheck = defaultRetryableCheck,
|
||||
onRetry,
|
||||
} = options;
|
||||
|
||||
if (maxRetries < 0) {
|
||||
throw new Error('withRetry: maxRetries must be >= 0');
|
||||
}
|
||||
if (baseDelayMs < 0) {
|
||||
throw new Error('withRetry: baseDelayMs must be >= 0');
|
||||
}
|
||||
|
||||
const isRetryable = retryableCheck;
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// No more retries left
|
||||
if (attempt >= maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check retryability
|
||||
if (!isRetryable(error)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
try {
|
||||
onRetry?.(err, attempt + 1);
|
||||
} catch {
|
||||
// Swallow callback errors — retry decision is already made
|
||||
}
|
||||
|
||||
const retryAfter = error instanceof RetryableError ? error.retryAfter : undefined;
|
||||
const delay = computeDelay(attempt, baseDelayMs, maxDelayMs, backoffMultiplier, retryAfter);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
Reference in New Issue
Block a user