From 60d6bbd0271a4bad4df1eaa705c11d34f7abd464 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 11:15:08 +0700 Subject: [PATCH] fix(config): migrate all hardcoded paths to getCcsDir() and improve validation - Replace os.homedir() + '.ccs' with getCcsDir() across 14 instances in 13 files: version-command, model-config, openrouter-catalog, config-checks, disk-cache, aggregator, auth-middleware, shell-completion, shared-manager, recovery-manager, auto-repair, delegation-validator, claude-dir-installer, cliproxy executor - Add isDirectory() validation for --config-dir argument in ccs.ts - Make detectCloudSyncPath() case-insensitive for cloud provider matching - Fix help-command.ts to use dynamic dirDisplay for all path references - Add 5 new tests: setGlobalConfigDir precedence, relative path resolution, reset behavior, getCcsDirSource with --config-dir, case-insensitive detection - Clean up unused os imports after migration --- src/api/services/openrouter-catalog.ts | 4 +- src/ccs.ts | 4 +- src/cliproxy/executor/index.ts | 4 +- src/cliproxy/model-config.ts | 10 ++--- src/commands/help-command.ts | 8 ++-- src/commands/version-command.ts | 11 +++-- src/management/recovery-manager.ts | 11 +---- src/management/repair/auto-repair.ts | 6 +-- src/management/shared-manager.ts | 2 +- src/utils/claude-dir-installer.ts | 7 +--- src/utils/config-manager.ts | 6 +-- src/utils/delegation-validator.ts | 8 ++-- src/utils/shell-completion.ts | 3 +- src/web-server/health/config-checks.ts | 5 +-- src/web-server/middleware/auth-middleware.ts | 4 +- src/web-server/usage/aggregator.ts | 4 +- src/web-server/usage/disk-cache.ts | 5 +-- tests/unit/config-dir-override.test.js | 44 ++++++++++++++++++++ 18 files changed, 87 insertions(+), 59 deletions(-) diff --git a/src/api/services/openrouter-catalog.ts b/src/api/services/openrouter-catalog.ts index b6b2d6ff..3161f7ce 100644 --- a/src/api/services/openrouter-catalog.ts +++ b/src/api/services/openrouter-catalog.ts @@ -5,10 +5,10 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; +import { getCcsDir } from '../../utils/config-manager'; const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/models'; -const CACHE_FILE = path.join(os.homedir(), '.ccs', 'openrouter-models-cache.json'); +const CACHE_FILE = path.join(getCcsDir(), 'openrouter-models-cache.json'); const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours export interface OpenRouterModel { diff --git a/src/ccs.ts b/src/ccs.ts index cf11ea69..b29d3f71 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -286,8 +286,8 @@ async function main(): Promise { process.exit(1); } - if (!fs.existsSync(configDirValue)) { - console.error(fail(`Config directory not found: ${configDirValue}`)); + if (!fs.existsSync(configDirValue) || !fs.statSync(configDirValue).isDirectory()) { + console.error(fail(`Config directory not found or not a directory: ${configDirValue}`)); console.error(info('Create the directory first, then copy your config files into it.')); process.exit(1); } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 595bc07a..986868fb 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -13,8 +13,10 @@ import { spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; +import * as path from 'path'; import { ProgressIndicator } from '../../utils/progress-indicator'; import { ok, fail, info, warn } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; import { escapeShellArg } from '../../utils/shell-executor'; import { ensureCLIProxyBinary } from '../binary-manager'; import { @@ -655,7 +657,7 @@ export async function execClaudeWithCLIProxy( upstreamBaseUrl: postSanitizationBaseUrl, verbose, defaultEffort: 'medium', - traceFilePath: traceEnabled ? `${os.homedir()}/.ccs/codex-reasoning-proxy.log` : '', + traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '', modelMap: { defaultModel: initialEnvVars.ANTHROPIC_MODEL, opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL, diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 2da1cc63..1442a93a 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -7,15 +7,12 @@ 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'; import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator'; import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; - -/** CCS directory */ -const CCS_DIR = path.join(os.homedir(), '.ccs'); +import { getCcsDir } from '../utils/config-manager'; /** * Check if provider has user settings configured @@ -185,8 +182,9 @@ export async function configureProviderModel( }; // Ensure CCS directory exists - if (!fs.existsSync(CCS_DIR)) { - fs.mkdirSync(CCS_DIR, { recursive: true }); + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); } // Write settings file diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 4f2c1d59..17ec05fb 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -111,6 +111,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ); console.log(''); + // Resolve display path for dynamic sections + const [dirSource] = getCcsDirSource(); + const dirDisplay = dirSource === 'default' ? '~/.ccs' : getCcsDir(); + // Usage section console.log(subheader('Usage:')); console.log(` ${color('ccs', 'command')} [profile] [claude-args...]`); @@ -122,7 +126,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // ═══════════════════════════════════════════════════════════════════════════ printMajorSection( 'API Key Profiles', - ['Configure in ~/.ccs/*.settings.json'], + [`Configure in ${dirDisplay}/*.settings.json`], [ ['ccs', 'Use default Claude account'], ['ccs glm', 'GLM 4.6 (API key required)'], @@ -267,8 +271,6 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ]); // Configuration - const [dirSource] = getCcsDirSource(); - const dirDisplay = dirSource === 'default' ? '~/.ccs' : getCcsDir(); printConfigSection('Configuration', [ ['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`], ['Profiles:', `${dirDisplay}/profiles.json`], diff --git a/src/commands/version-command.ts b/src/commands/version-command.ts index 02304b46..c83cd9d1 100644 --- a/src/commands/version-command.ts +++ b/src/commands/version-command.ts @@ -6,9 +6,8 @@ import * as path from 'path'; import * as fs from 'fs'; -import * as os from 'os'; import { initUI, header, subheader, color, warn } from '../utils/ui'; -import { getActiveConfigPath } from '../utils/config-manager'; +import { getActiveConfigPath, getCcsDir } from '../utils/config-manager'; import { getVersion } from '../utils/version'; /** @@ -23,24 +22,24 @@ export async function handleVersionCommand(): Promise { const installLocation = process.argv[1] || '(not found)'; console.log(` ${color('Location:'.padEnd(17), 'info')} ${installLocation}`); - const ccsDir = path.join(os.homedir(), '.ccs'); + const ccsDir = getCcsDir(); console.log(` ${color('CCS Directory:'.padEnd(17), 'info')} ${ccsDir}`); const configPath = getActiveConfigPath(); console.log(` ${color('Config:'.padEnd(17), 'info')} ${configPath}`); - const profilesJson = path.join(os.homedir(), '.ccs', 'profiles.json'); + const profilesJson = path.join(ccsDir, 'profiles.json'); console.log(` ${color('Profiles:'.padEnd(17), 'info')} ${profilesJson}`); // Delegation status - const delegationSessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json'); + const delegationSessionsPath = path.join(ccsDir, 'delegation-sessions.json'); const delegationConfigured = fs.existsSync(delegationSessionsPath); const readyProfiles: string[] = []; // Check for profiles with valid API keys for (const profile of ['glm', 'kimi']) { - const settingsPath = path.join(os.homedir(), '.ccs', `${profile}.settings.json`); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); if (fs.existsSync(settingsPath)) { try { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index 8977870c..79cedbcc 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -6,8 +6,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { info } from '../utils/ui'; +import { getCcsHome, getCcsDir } from '../utils/config-manager'; import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } from '../config/unified-config-types'; import { saveUnifiedConfig, @@ -15,13 +15,6 @@ import { loadUnifiedConfig, } from '../config/unified-config-loader'; -/** - * Get CCS home directory (respects CCS_HOME env for test isolation) - */ -function getCcsHome(): string { - return process.env.CCS_HOME || os.homedir(); -} - /** * Recovery Manager Class */ @@ -35,7 +28,7 @@ class RecoveryManager { constructor() { this.homedir = getCcsHome(); - this.ccsDir = path.join(this.homedir, '.ccs'); + this.ccsDir = getCcsDir(); this.claudeDir = path.join(this.homedir, '.claude'); this.sharedDir = path.join(this.ccsDir, 'shared'); this.completionsDir = path.join(this.ccsDir, 'completions'); diff --git a/src/management/repair/auto-repair.ts b/src/management/repair/auto-repair.ts index b769bbc7..861a30b6 100644 --- a/src/management/repair/auto-repair.ts +++ b/src/management/repair/auto-repair.ts @@ -4,8 +4,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { ok, warn, fail, info, header, color } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; import { CLIPROXY_DEFAULT_PORT, configNeedsRegeneration, @@ -28,8 +28,6 @@ const ora = createSpinner(); * 4. OAuth callback ports blocked by CLIProxy */ export async function runAutoRepair(): Promise { - const homedir = os.homedir(); - console.log(''); console.log(header('AUTO-FIX MODE')); console.log(''); @@ -111,7 +109,7 @@ export async function runAutoRepair(): Promise { // Fix 4: Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.) const symlinkSpinner = ora('Checking shared settings.json symlink').start(); - const sharedSettings = path.join(homedir, '.ccs', 'shared', 'settings.json'); + const sharedSettings = path.join(getCcsDir(), 'shared', 'settings.json'); try { if (fs.existsSync(sharedSettings)) { const stats = fs.lstatSync(sharedSettings); diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index ca6d29be..754e42e7 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -62,7 +62,7 @@ class SharedManager { const resolvedTarget = path.resolve(path.dirname(target), targetLink); // Check if target points back to our shared dir or link path - const sharedDir = path.join(this.homeDir, '.ccs', 'shared'); + const sharedDir = path.join(getCcsDir(), 'shared'); if (resolvedTarget.startsWith(sharedDir) || resolvedTarget === linkPath) { console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`)); return true; diff --git a/src/utils/claude-dir-installer.ts b/src/utils/claude-dir-installer.ts index 789e66aa..38ed9690 100644 --- a/src/utils/claude-dir-installer.ts +++ b/src/utils/claude-dir-installer.ts @@ -197,12 +197,7 @@ export class ClaudeDirInstaller { cleanupDeprecated(silent = false): CleanupResult { const deprecatedFile = path.join(this.ccsClaudeDir, 'agents', 'ccs-delegator.md'); const userSymlinkFile = path.join(this.homeDir, '.claude', 'agents', 'ccs-delegator.md'); - const migrationMarker = path.join( - this.homeDir, - '.ccs', - '.migrations', - 'v435-delegator-cleanup' - ); + const migrationMarker = path.join(getCcsDir(), '.migrations', 'v435-delegator-cleanup'); const cleanedFiles: string[] = []; diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index ff8c6b6b..db6a9e16 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -72,10 +72,10 @@ const CLOUD_SYNC_PATTERNS = [ * @returns Detected service name or null */ export function detectCloudSyncPath(dir: string): string | null { - const normalized = dir.replace(/\\/g, '/'); + const normalized = dir.replace(/\\/g, '/').toLowerCase(); for (const pattern of CLOUD_SYNC_PATTERNS) { - if (normalized.includes(pattern)) { - return pattern; + if (normalized.includes(pattern.toLowerCase())) { + return pattern; // Return original casing for display } } return null; diff --git a/src/utils/delegation-validator.ts b/src/utils/delegation-validator.ts index 76be898b..93d669dd 100644 --- a/src/utils/delegation-validator.ts +++ b/src/utils/delegation-validator.ts @@ -2,9 +2,9 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { Settings } from '../types'; import { ValidationResult } from '../types/utils'; +import { getCcsDir } from './config-manager'; /** * Extended validation result for delegation profiles @@ -28,8 +28,7 @@ export class DelegationValidator { * @returns Validation result { valid: boolean, error?: string, settingsPath?: string } */ static validate(profileName: string): DelegationValidationResult { - const homeDir = os.homedir(); - const settingsPath = path.join(homeDir, '.ccs', `${profileName}.settings.json`); + const settingsPath = path.join(getCcsDir(), `${profileName}.settings.json`); // Check if profile directory exists if (!fs.existsSync(settingsPath)) { @@ -144,8 +143,7 @@ export class DelegationValidator { * @returns List of profile names ready for delegation */ static getReadyProfiles(): string[] { - const homeDir = os.homedir(); - const ccsDir = path.join(homeDir, '.ccs'); + const ccsDir = getCcsDir(); const configPath = path.join(ccsDir, 'config.yaml'); if (!fs.existsSync(ccsDir)) { diff --git a/src/utils/shell-completion.ts b/src/utils/shell-completion.ts index 68419571..ae2479eb 100644 --- a/src/utils/shell-completion.ts +++ b/src/utils/shell-completion.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { getCcsDir } from './config-manager'; // import { execSync } from 'child_process'; // Currently unused type ShellType = 'bash' | 'zsh' | 'fish' | 'powershell' | null; @@ -24,7 +25,7 @@ export class ShellCompletionInstaller { constructor() { this.homeDir = os.homedir(); - this.ccsDir = path.join(this.homeDir, '.ccs'); + this.ccsDir = getCcsDir(); this.completionDir = path.join(this.ccsDir, 'completions'); this.scriptsDir = path.join(__dirname, '../../scripts/completion'); } diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts index 5f7ca34a..17fb04ac 100644 --- a/src/web-server/health/config-checks.ts +++ b/src/web-server/health/config-checks.ts @@ -6,9 +6,8 @@ */ import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; -import { getConfigPath } from '../../utils/config-manager'; +import { getConfigPath, getCcsDir } from '../../utils/config-manager'; import { isUnifiedMode, hasUnifiedConfig } from '../../config/unified-config-loader'; import type { HealthCheck } from './types'; @@ -18,7 +17,7 @@ import type { HealthCheck } from './types'; export function checkConfigFile(): HealthCheck { // In unified mode, check config.yaml if (isUnifiedMode() || hasUnifiedConfig()) { - const ccsDir = path.join(os.homedir(), '.ccs'); + const ccsDir = getCcsDir(); const yamlPath = path.join(ccsDir, 'config.yaml'); if (!fs.existsSync(yamlPath)) { diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index 248e303c..af4dd424 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -10,7 +10,7 @@ import { getDashboardAuthConfig } from '../../config/unified-config-loader'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -import os from 'os'; +import { getCcsDir } from '../../utils/config-manager'; // Extend Express Request with session declare module 'express-session' { @@ -24,7 +24,7 @@ declare module 'express-session' { const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/check', '/api/auth/setup', '/api/health']; /** Path to persistent session secret file */ -const SESSION_SECRET_PATH = path.join(os.homedir(), '.ccs', '.session-secret'); +const SESSION_SECRET_PATH = path.join(getCcsDir(), '.session-secret'); /** * Generate or retrieve persistent session secret. diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index ce59ba07..8be8ee93 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -7,7 +7,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { loadDailyUsageData, loadMonthlyUsageData, @@ -25,13 +24,14 @@ import { getCacheAge, } from './disk-cache'; import { ok, info, fail } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; // ============================================================================ // Multi-Instance Support - Aggregate usage from CCS profiles // ============================================================================ /** Path to CCS instances directory */ -const CCS_INSTANCES_DIR = path.join(os.homedir(), '.ccs', 'instances'); +const CCS_INSTANCES_DIR = path.join(getCcsDir(), 'instances'); /** * Get list of CCS instance paths that have usage data diff --git a/src/web-server/usage/disk-cache.ts b/src/web-server/usage/disk-cache.ts index fd834da2..9053e5c1 100644 --- a/src/web-server/usage/disk-cache.ts +++ b/src/web-server/usage/disk-cache.ts @@ -10,13 +10,12 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; import { ok, info, warn } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; // Cache configuration -const CCS_DIR = path.join(os.homedir(), '.ccs'); -const CACHE_DIR = path.join(CCS_DIR, 'cache'); +const CACHE_DIR = path.join(getCcsDir(), 'cache'); const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max age for stale data) diff --git a/tests/unit/config-dir-override.test.js b/tests/unit/config-dir-override.test.js index 5e075f81..93adf68b 100644 --- a/tests/unit/config-dir-override.test.js +++ b/tests/unit/config-dir-override.test.js @@ -18,6 +18,9 @@ describe('Config Directory Override', function () { originalCcsHome = process.env.CCS_HOME; delete process.env.CCS_DIR; delete process.env.CCS_HOME; + // Reset module-level state + const { setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir(undefined); }); afterEach(function () { @@ -25,6 +28,9 @@ describe('Config Directory Override', function () { else delete process.env.CCS_DIR; if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; else delete process.env.CCS_HOME; + // Reset module-level state + const { setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir(undefined); }); describe('getCcsDir() precedence', function () { @@ -52,6 +58,29 @@ describe('Config Directory Override', function () { const { getCcsDir } = require('../../dist/utils/config-manager'); assert.strictEqual(getCcsDir(), path.resolve('/tmp/ccs-dir')); }); + + it('should give setGlobalConfigDir() highest precedence', function () { + process.env.CCS_DIR = '/tmp/env-dir'; + process.env.CCS_HOME = '/tmp/env-home'; + const { getCcsDir, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('/tmp/flag-dir'); + assert.strictEqual(getCcsDir(), path.resolve('/tmp/flag-dir')); + }); + + it('should resolve relative paths in setGlobalConfigDir()', function () { + const { getCcsDir, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('relative/path'); + assert.strictEqual(getCcsDir(), path.resolve('relative/path')); + }); + + it('should clear override when setGlobalConfigDir(undefined) is called', function () { + const { getCcsDir, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('/tmp/override'); + assert.strictEqual(getCcsDir(), path.resolve('/tmp/override')); + setGlobalConfigDir(undefined); + const expected = path.join(os.homedir(), '.ccs'); + assert.strictEqual(getCcsDir(), expected); + }); }); describe('getCcsDirSource()', function () { @@ -74,6 +103,14 @@ describe('Config Directory Override', function () { const [source] = getCcsDirSource(); assert.strictEqual(source, 'CCS_HOME'); }); + + it('should return "--config-dir" when setGlobalConfigDir() is set', function () { + const { getCcsDirSource, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('/tmp/flag-test'); + const [source, dir] = getCcsDirSource(); + assert.strictEqual(source, '--config-dir'); + assert.strictEqual(dir, path.resolve('/tmp/flag-test')); + }); }); describe('detectCloudSyncPath()', function () { @@ -100,6 +137,13 @@ describe('Config Directory Override', function () { assert.strictEqual(detectCloudSyncPath('/Users/kai/Google Drive/ccs'), 'Google Drive'); }); + it('should detect cloud paths case-insensitively', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('/Users/kai/dropbox/ccs'), 'Dropbox'); + assert.strictEqual(detectCloudSyncPath('C:\\Users\\kai\\onedrive\\ccs'), 'OneDrive'); + assert.strictEqual(detectCloudSyncPath('/Users/kai/google drive/ccs'), 'Google Drive'); + }); + it('should return null for regular paths', function () { const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); assert.strictEqual(detectCloudSyncPath('/home/kai/.ccs'), null);