From ef7e595b6fa4c96ac88e2e98f992fd05f7525e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leynier=20Guti=C3=A9rrez=20Gonz=C3=A1lez?= Date: Sat, 10 Jan 2026 21:47:26 -0600 Subject: [PATCH] feat(persist): add --list-backups and --restore options for backup management Add backup management functionality to the persist command: - List available backups with timestamps and dates - Restore from most recent or specific backup - Improved error handling for corrupted settings.json - Updated CLAUDE.md to document the persist command and the exception to the non-invasive constraint --- CLAUDE.md | 3 +- src/ccs.ts | 7 + src/commands/help-command.ts | 3 + src/commands/persist-command.ts | 485 +++++++++++++++++++ tests/unit/commands/persist-command.test.js | 509 ++++++++++++++++++++ 5 files changed, 1006 insertions(+), 1 deletion(-) create mode 100644 src/commands/persist-command.ts create mode 100644 tests/unit/commands/persist-command.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 5404a1a4..37bc8ede 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ bun run validate # Step 3: Final check (must pass) 1. **NO EMOJIS** - ASCII only: [OK], [!], [X], [i] 2. **TTY-aware colors** - Respect NO_COLOR env var -3. **Non-invasive** - NEVER modify `~/.claude/settings.json` +3. **Non-invasive** - NEVER modify `~/.claude/settings.json` without explicit user request and confirmation (exception: `ccs persist` command) 4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically 5. **CLI documentation** - ALL CLI changes MUST update respective `--help` handler (see table below) 6. **Idempotent** - All install operations safe to run multiple times @@ -116,6 +116,7 @@ bun run validate # Step 3: Final check (must pass) | `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` | | `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` | | `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` | +| `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` | | `ccs setup --help` | `src/commands/setup-command.ts` → `showHelp()` | **Note:** `lib/ccs` and `lib/ccs.ps1` are bootstrap wrappers only—they delegate to Node.js and contain no help text. diff --git a/src/ccs.ts b/src/ccs.ts index ca9c10d4..6aecf3b5 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -437,6 +437,13 @@ async function main(): Promise { process.exit(exitCode); } + // Special case: persist command (write profile env to ~/.claude/settings.json) + if (firstArg === 'persist') { + const { handlePersistCommand } = await import('./commands/persist-command'); + await handlePersistCommand(args.slice(1)); + return; + } + // Special case: setup command (first-time wizard) if (firstArg === 'setup' || firstArg === '--setup') { const { handleSetupCommand } = await import('./commands/setup-command'); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 29fc1c38..effb55a4 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -233,6 +233,9 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs config auth setup', 'Configure dashboard login'], ['ccs config auth show', 'Show dashboard auth status'], ['ccs config --port 3000', 'Use specific port'], + ['ccs persist ', 'Write profile env to ~/.claude/settings.json'], + ['ccs persist --list-backups', 'List available settings.json backups'], + ['ccs persist --restore', 'Restore settings.json from latest backup'], ['ccs sync', 'Sync delegation commands and skills'], ['ccs update', 'Update CCS to latest version'], ['ccs update --force', 'Force reinstall current version'], diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts new file mode 100644 index 00000000..2977102d --- /dev/null +++ b/src/commands/persist-command.ts @@ -0,0 +1,485 @@ +/** + * Persist Command Handler + * + * Writes a profile's environment variables to ~/.claude/settings.json + * for native Claude Code usage (IDEs, extensions, etc.). + * + * Supports all profile types: API, CLIProxy, Copilot. + * Account-based profiles are not supported (use CLAUDE_CONFIG_DIR). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui'; +import { InteractivePrompt } from '../utils/prompt'; +import ProfileDetector, { + ProfileDetectionResult, + loadSettingsFromFile, + CLIPROXY_PROFILES, +} from '../auth/profile-detector'; +import { getEffectiveEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; +import { generateCopilotEnv } from '../copilot/copilot-executor'; +import { expandPath } from '../utils/helpers'; + +interface PersistCommandArgs { + profile?: string; + yes?: boolean; + listBackups?: boolean; + restore?: string | boolean; +} + +interface ResolvedEnv { + env: Record; + profileType: string; + warning?: string; +} + +/** Parse command line arguments */ +function parseArgs(args: string[]): PersistCommandArgs { + const result: PersistCommandArgs = {}; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--yes' || arg === '-y') { + result.yes = true; + } else if (arg === '--help' || arg === '-h') { + // Will be handled in main function + } else if (arg === '--list-backups') { + result.listBackups = true; + } else if (arg === '--restore') { + // Check if next arg is a timestamp (not a flag) + const nextArg = args[i + 1]; + if (nextArg && !nextArg.startsWith('-')) { + result.restore = nextArg; + i++; // Skip next arg + } else { + result.restore = true; // Use latest + } + } else if (!arg.startsWith('-') && !result.profile) { + result.profile = arg; + } + } + return result; +} + +/** Get Claude settings.json path */ +function getClaudeSettingsPath(): string { + return path.join(os.homedir(), '.claude', 'settings.json'); +} + +/** Read existing Claude settings.json */ +function readClaudeSettings(): Record { + const settingsPath = getClaudeSettingsPath(); + try { + const content = fs.readFileSync(settingsPath, 'utf8'); + return JSON.parse(content) as Record; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + return {}; + } + throw new Error(`Failed to parse settings.json: ${(error as Error).message}`); + } +} + +/** + * Write settings back to settings.json + * Note: mode 0o600 only applies when creating a new file. + * Existing file permissions are preserved (acceptable behavior). + */ +function writeClaudeSettings(settings: Record): void { + const settingsPath = getClaudeSettingsPath(); + const dir = path.dirname(settingsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }); +} + +/** Create backup of settings.json */ +function createBackup(): string { + const settingsPath = getClaudeSettingsPath(); + if (!fs.existsSync(settingsPath)) { + throw new Error('No settings.json to backup'); + } + const now = new Date(); + const timestamp = + now.getFullYear().toString() + + (now.getMonth() + 1).toString().padStart(2, '0') + + now.getDate().toString().padStart(2, '0') + + '_' + + now.getHours().toString().padStart(2, '0') + + now.getMinutes().toString().padStart(2, '0') + + now.getSeconds().toString().padStart(2, '0'); + const backupPath = `${settingsPath}.backup.${timestamp}`; + fs.copyFileSync(settingsPath, backupPath); + return backupPath; +} + +interface BackupFile { + path: string; + timestamp: string; + date: Date; +} + +/** Get all backup files sorted by date (newest first) */ +function getBackupFiles(): BackupFile[] { + const settingsPath = getClaudeSettingsPath(); + const dir = path.dirname(settingsPath); + if (!fs.existsSync(dir)) { + return []; + } + const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/; + const files = fs + .readdirSync(dir) + .filter((f) => backupPattern.test(f)) + .map((f) => { + const match = f.match(backupPattern); + if (!match) return null; + const timestamp = match[1]; + // Parse YYYYMMDD_HHMMSS + const year = parseInt(timestamp.slice(0, 4)); + const month = parseInt(timestamp.slice(4, 6)) - 1; + const day = parseInt(timestamp.slice(6, 8)); + const hour = parseInt(timestamp.slice(9, 11)); + const min = parseInt(timestamp.slice(11, 13)); + const sec = parseInt(timestamp.slice(13, 15)); + return { + path: path.join(dir, f), + timestamp, + date: new Date(year, month, day, hour, min, sec), + }; + }) + .filter((f): f is BackupFile => f !== null) + .sort((a, b) => b.date.getTime() - a.date.getTime()); // newest first + return files; +} + +/** Mask API key for display (show first 4 and last 4 chars) */ +function maskApiKey(key: string): string { + if (key.length <= 12) { + return '****'; + } + return `${key.slice(0, 4)}...${key.slice(-4)}`; +} + +/** Resolve env vars for a profile */ +async function resolveProfileEnvVars( + profileName: string, + profileResult: ProfileDetectionResult +): Promise { + switch (profileResult.type) { + case 'settings': { + // API profile - load from settings file + let env: Record = {}; + if (profileResult.env) { + env = profileResult.env; + } else if (profileResult.settingsPath) { + env = loadSettingsFromFile(expandPath(profileResult.settingsPath)); + } + if (Object.keys(env).length === 0) { + throw new Error(`Profile '${profileName}' has no env vars configured`); + } + return { env, profileType: 'API' }; + } + case 'cliproxy': { + // CLIProxy profile - generate env vars + const provider = + profileResult.provider || (profileName as (typeof CLIPROXY_PROFILES)[number]); + const port = profileResult.port || CLIPROXY_DEFAULT_PORT; + const env = getEffectiveEnvVars(provider, port, profileResult.settingsPath) as Record< + string, + string + >; + return { + env, + profileType: 'CLIProxy', + warning: 'CLIProxy must be running for this profile to work', + }; + } + case 'copilot': { + // Copilot profile - generate env vars + if (!profileResult.copilotConfig) { + throw new Error('Copilot configuration not found'); + } + const env = generateCopilotEnv(profileResult.copilotConfig); + return { + env, + profileType: 'Copilot', + warning: 'copilot-api daemon must be running for this profile to work', + }; + } + case 'account': { + throw new Error( + `Account profiles use CLAUDE_CONFIG_DIR isolation, not env vars.\n` + + `Use 'ccs ${profileName}' to run with this profile instead.` + ); + } + case 'default': { + throw new Error( + 'Default profile has no env vars to persist.\n' + + 'Specify a profile name: ccs persist ' + ); + } + default: { + throw new Error(`Unknown profile type: ${profileResult.type}`); + } + } +} + +/** Handle --list-backups flag */ +async function handleListBackups(): Promise { + await initUI(); + const backups = getBackupFiles(); + if (backups.length === 0) { + console.log(info('No backups found')); + return; + } + console.log(header('Available Backups')); + console.log(''); + backups.forEach((b, i) => { + const dateStr = b.date.toLocaleString(); + const marker = i === 0 ? color(' (latest)', 'success') : ''; + console.log(` ${color(b.timestamp, 'command')} ${dim(dateStr)}${marker}`); + }); + console.log(''); + console.log(dim('To restore: ccs persist --restore [timestamp]')); +} + +/** Handle --restore [timestamp] flag */ +async function handleRestore(timestamp: string | boolean, yes: boolean): Promise { + await initUI(); + const backups = getBackupFiles(); + if (backups.length === 0) { + console.log(fail('No backups found')); + process.exit(1); + } + // Find backup to restore + let backup: BackupFile; + if (timestamp === true) { + // Use latest + backup = backups[0]; + } else { + const found = backups.find((b) => b.timestamp === timestamp); + if (!found) { + console.log(fail(`Backup not found: ${timestamp}`)); + console.log(''); + console.log('Available backups:'); + backups.slice(0, 5).forEach((b) => console.log(` ${b.timestamp}`)); + process.exit(1); + } + backup = found; + } + console.log(header('Restore Backup')); + console.log(''); + console.log(`Backup: ${color(backup.timestamp, 'command')}`); + console.log(`Date: ${backup.date.toLocaleString()}`); + console.log(''); + console.log(warn('This will replace ~/.claude/settings.json')); + console.log(''); + if (!yes) { + const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false }); + if (!proceed) { + console.log(info('Cancelled')); + process.exit(0); + } + } + // Copy backup over settings.json + fs.copyFileSync(backup.path, getClaudeSettingsPath()); + console.log(ok(`Restored from backup: ${backup.timestamp}`)); +} + +/** Show help for persist command */ +async function showHelp(): Promise { + await initUI(); + console.log(header('CCS Persist Command')); + console.log(''); + console.log(subheader('Usage')); + console.log(` ${color('ccs persist', 'command')} [options]`); + console.log(` ${color('ccs persist', 'command')} --list-backups`); + console.log(` ${color('ccs persist', 'command')} --restore [timestamp]`); + console.log(''); + console.log(subheader('Description')); + console.log(" Writes a profile's environment variables directly to"); + console.log(' ~/.claude/settings.json for native Claude Code usage.'); + console.log(''); + console.log(' This allows Claude Code to use the profile without CCS,'); + console.log(' enabling compatibility with IDEs and extensions.'); + console.log(''); + console.log(subheader('Options')); + console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts (auto-backup)`); + console.log(` ${color('--help, -h', 'command')} Show this help message`); + console.log(''); + console.log(subheader('Backup Management')); + console.log(` ${color('--list-backups', 'command')} List available backup files`); + console.log(` ${color('--restore', 'command')} Restore from the most recent backup`); + console.log( + ` ${color('--restore ', 'command')} Restore from specific backup (e.g., 20260110_205324)` + ); + console.log(''); + console.log(subheader('Supported Profile Types')); + console.log(` ${color('API profiles', 'command')} glm, glmt, kimi, custom API profiles`); + console.log(` ${color('CLIProxy', 'command')} gemini, codex, agy, qwen, kiro, ghcp`); + console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`); + console.log(` ${dim('Account-based')} Not supported (uses CLAUDE_CONFIG_DIR)`); + console.log(''); + console.log(subheader('Examples')); + console.log(` ${dim('# Persist GLM profile')}`); + console.log(` ${color('ccs persist glm', 'command')}`); + console.log(''); + console.log(` ${dim('# Persist with auto-confirmation')}`); + console.log(` ${color('ccs persist gemini --yes', 'command')}`); + console.log(''); + console.log(` ${dim('# List all backups')}`); + console.log(` ${color('ccs persist --list-backups', 'command')}`); + console.log(''); + console.log(` ${dim('# Restore latest backup')}`); + console.log(` ${color('ccs persist --restore', 'command')}`); + console.log(''); + console.log(` ${dim('# Restore specific backup')}`); + console.log(` ${color('ccs persist --restore 20260110_205324', 'command')}`); + console.log(''); + console.log(subheader('Notes')); + console.log(' [i] CLIProxy profiles require the proxy to be running.'); + console.log(' [i] Copilot profiles require copilot-api daemon.'); + console.log(' [i] Backups are saved as ~/.claude/settings.json.backup.YYYYMMDD_HHMMSS'); + console.log(''); +} + +/** Main persist command handler */ +export async function handlePersistCommand(args: string[]): Promise { + // Check for help first + if (args.includes('--help') || args.includes('-h') || args.length === 0) { + await showHelp(); + return; + } + const parsedArgs = parseArgs(args); + // Handle --list-backups + if (parsedArgs.listBackups) { + await handleListBackups(); + return; + } + // Handle --restore + if (parsedArgs.restore) { + await handleRestore(parsedArgs.restore, parsedArgs.yes ?? false); + return; + } + await initUI(); + if (!parsedArgs.profile) { + console.log(fail('Profile name is required')); + console.log(''); + console.log('Usage:'); + console.log(` ${color('ccs persist ', 'command')}`); + console.log(''); + console.log('Run for help:'); + console.log(` ${color('ccs persist --help', 'command')}`); + process.exit(1); + } + // Detect profile + const detector = new ProfileDetector(); + let profileResult: ProfileDetectionResult; + try { + profileResult = detector.detectProfileType(parsedArgs.profile); + } catch (error) { + const err = error as Error & { availableProfiles?: string }; + console.log(fail(`Profile not found: ${parsedArgs.profile}`)); + console.log(''); + if (err.availableProfiles) { + console.log(err.availableProfiles); + } + process.exit(1); + } + // Resolve env vars + let resolved: ResolvedEnv; + try { + resolved = await resolveProfileEnvVars(parsedArgs.profile, profileResult); + } catch (error) { + console.log(fail((error as Error).message)); + process.exit(1); + } + // Display what will be written + console.log(header(`Persist Profile: ${parsedArgs.profile}`)); + console.log(''); + console.log(`Profile type: ${color(resolved.profileType, 'command')}`); + console.log(''); + console.log('The following env vars will be written to ~/.claude/settings.json:'); + console.log(''); + // Display env vars (mask sensitive values) + const envKeys = Object.keys(resolved.env); + if (envKeys.length === 0) { + console.log(fail('Profile has no environment variables to persist')); + process.exit(1); + } + const maxKeyLen = Math.max(...envKeys.map((k) => k.length)); + for (const [key, value] of Object.entries(resolved.env)) { + const paddedKey = key.padEnd(maxKeyLen + 2); + const displayValue = + key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET') + ? maskApiKey(value) + : value; + console.log(` ${color(paddedKey, 'command')} = ${displayValue}`); + } + console.log(''); + // Show warning if applicable + if (resolved.warning) { + console.log(warn(resolved.warning)); + console.log(''); + } + // Warning about modification + console.log(warn('This will modify ~/.claude/settings.json')); + console.log(dim(' Existing hooks and other settings will be preserved.')); + console.log(''); + // Check if settings.json exists for backup + const settingsPath = getClaudeSettingsPath(); + const settingsExist = fs.existsSync(settingsPath); + // Backup prompt (unless --yes) + if (settingsExist) { + let createBackupFlag: boolean = parsedArgs.yes === true; // Auto-backup with --yes + if (!parsedArgs.yes) { + createBackupFlag = await InteractivePrompt.confirm('Create backup before modifying?', { + default: true, + }); + } + if (createBackupFlag) { + try { + const backupPath = createBackup(); + console.log(ok(`Backup created: ${backupPath.replace(os.homedir(), '~')}`)); + console.log(''); + } catch (error) { + console.log(fail(`Failed to create backup: ${(error as Error).message}`)); + process.exit(1); + } + } + } + // Proceed confirmation (unless --yes) + if (!parsedArgs.yes) { + const proceed = await InteractivePrompt.confirm('Proceed with persist?', { default: true }); + if (!proceed) { + console.log(info('Cancelled')); + process.exit(0); + } + } + // Read existing settings and merge + const existingSettings = readClaudeSettings(); + const existingEnv = (existingSettings.env as Record) || {}; + const mergedSettings = { + ...existingSettings, + env: { + ...existingEnv, + ...resolved.env, + }, + }; + // Write merged settings + try { + writeClaudeSettings(mergedSettings); + } catch (error) { + console.log(fail(`Failed to write settings: ${(error as Error).message}`)); + process.exit(1); + } + console.log(''); + console.log(ok(`Profile '${parsedArgs.profile}' written to ~/.claude/settings.json`)); + console.log(''); + console.log(info('Claude Code will now use this profile by default.')); + console.log(dim(' To revert, restore the backup or edit settings.json manually.')); + console.log(''); +} diff --git a/tests/unit/commands/persist-command.test.js b/tests/unit/commands/persist-command.test.js new file mode 100644 index 00000000..7282ac37 --- /dev/null +++ b/tests/unit/commands/persist-command.test.js @@ -0,0 +1,509 @@ +/** + * Persist Command Tests + * + * Tests for the `ccs persist` CLI command including: + * - Argument parsing + * - API key masking + * - Settings merge logic + * - Backup management + * - Error handling + */ + +const assert = require('assert'); + +describe('Persist Command', () => { + // ========================================================================= + // Argument Parsing Tests + // ========================================================================= + describe('parseArgs', () => { + /** + * Simulates the argument parsing logic from persist-command.ts + */ + function parseArgs(args) { + const result = { + profile: undefined, + yes: false, + listBackups: false, + restore: undefined, + }; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--yes' || arg === '-y') { + result.yes = true; + } else if (arg === '--help' || arg === '-h') { + // Will be handled in main function + } else if (arg === '--list-backups') { + result.listBackups = true; + } else if (arg === '--restore') { + // Check if next arg is a timestamp (not a flag) + const nextArg = args[i + 1]; + if (nextArg && !nextArg.startsWith('-')) { + result.restore = nextArg; + i++; // Skip next arg + } else { + result.restore = true; // Use latest + } + } else if (!arg.startsWith('-') && !result.profile) { + result.profile = arg; + } + } + return result; + } + + it('parses profile name as first positional argument', () => { + const result = parseArgs(['glm']); + assert.strictEqual(result.profile, 'glm'); + }); + + it('parses --yes flag', () => { + const result = parseArgs(['glm', '--yes']); + assert.strictEqual(result.yes, true); + assert.strictEqual(result.profile, 'glm'); + }); + + it('parses -y short flag', () => { + const result = parseArgs(['gemini', '-y']); + assert.strictEqual(result.yes, true); + assert.strictEqual(result.profile, 'gemini'); + }); + + it('parses flags before profile name', () => { + const result = parseArgs(['--yes', 'kimi']); + assert.strictEqual(result.yes, true); + assert.strictEqual(result.profile, 'kimi'); + }); + + it('handles no arguments', () => { + const result = parseArgs([]); + assert.strictEqual(result.profile, undefined); + assert.strictEqual(result.yes, false); + }); + + it('ignores unknown flags', () => { + const result = parseArgs(['glm', '--unknown', '--yes']); + assert.strictEqual(result.profile, 'glm'); + assert.strictEqual(result.yes, true); + }); + + it('takes only first positional as profile', () => { + const result = parseArgs(['first', 'second', 'third']); + assert.strictEqual(result.profile, 'first'); + }); + + it('parses --list-backups flag', () => { + const result = parseArgs(['--list-backups']); + assert.strictEqual(result.listBackups, true); + assert.strictEqual(result.profile, undefined); + }); + + it('parses --restore flag without timestamp (use latest)', () => { + const result = parseArgs(['--restore']); + assert.strictEqual(result.restore, true); + }); + + it('parses --restore flag with timestamp', () => { + const result = parseArgs(['--restore', '20260110_205324']); + assert.strictEqual(result.restore, '20260110_205324'); + }); + + it('parses --restore with --yes flag', () => { + const result = parseArgs(['--restore', '--yes']); + assert.strictEqual(result.restore, true); + assert.strictEqual(result.yes, true); + }); + + it('parses --restore with timestamp and --yes flag', () => { + const result = parseArgs(['--restore', '20260110_205324', '--yes']); + assert.strictEqual(result.restore, '20260110_205324'); + assert.strictEqual(result.yes, true); + }); + }); + + // ========================================================================= + // API Key Masking Tests + // ========================================================================= + describe('maskApiKey', () => { + /** + * Simulates the maskApiKey function from persist-command.ts + */ + function maskApiKey(key) { + if (key.length <= 12) { + return '****'; + } + return `${key.slice(0, 4)}...${key.slice(-4)}`; + } + + it('masks keys showing first 4 and last 4 characters', () => { + const result = maskApiKey('sk-1234567890abcdef'); + assert.strictEqual(result, 'sk-1...cdef'); + }); + + it('returns **** for keys <= 12 characters', () => { + assert.strictEqual(maskApiKey('123456789012'), '****'); + assert.strictEqual(maskApiKey('12345678901'), '****'); + assert.strictEqual(maskApiKey('short'), '****'); + assert.strictEqual(maskApiKey(''), '****'); + }); + + it('handles exactly 13 character keys', () => { + const result = maskApiKey('1234567890abc'); + assert.strictEqual(result, '1234...0abc'); + }); + + it('handles long API keys', () => { + const longKey = 'api_key_1234567890abcdefghijklmnopqrstuvwxyz'; + const result = maskApiKey(longKey); + assert.strictEqual(result, 'api_...wxyz'); + }); + + it('preserves special characters', () => { + const key = '!@#$%^&*()_+-=[]{}'; + const result = maskApiKey(key); + assert.strictEqual(result, '!@#$...[]{}'); + }); + }); + + // ========================================================================= + // Settings Merge Tests + // ========================================================================= + describe('Settings Merge Logic', () => { + /** + * Simulates the merge logic from persist-command.ts + */ + function mergeSettings(existing, newEnv) { + const existingEnv = existing.env || {}; + return { + ...existing, + env: { + ...existingEnv, + ...newEnv, + }, + }; + } + + it('merges env vars into empty settings', () => { + const existing = {}; + const newEnv = { ANTHROPIC_BASE_URL: 'http://example.com' }; + const result = mergeSettings(existing, newEnv); + assert.deepStrictEqual(result.env, newEnv); + }); + + it('preserves existing hooks', () => { + const existing = { + hooks: { PreToolUse: [{ matcher: 'WebSearch' }] }, + }; + const newEnv = { ANTHROPIC_MODEL: 'test' }; + const result = mergeSettings(existing, newEnv); + assert.deepStrictEqual(result.hooks, existing.hooks); + }); + + it('preserves existing presets', () => { + const existing = { + presets: [{ name: 'test', default: 'model' }], + }; + const newEnv = { ANTHROPIC_MODEL: 'test' }; + const result = mergeSettings(existing, newEnv); + assert.deepStrictEqual(result.presets, existing.presets); + }); + + it('preserves other settings like model and alwaysThinkingEnabled', () => { + const existing = { + model: 'opus', + alwaysThinkingEnabled: true, + }; + const newEnv = { ANTHROPIC_MODEL: 'test' }; + const result = mergeSettings(existing, newEnv); + assert.strictEqual(result.model, 'opus'); + assert.strictEqual(result.alwaysThinkingEnabled, true); + }); + + it('merges new env vars with existing env vars', () => { + const existing = { + env: { EXISTING_VAR: 'value' }, + }; + const newEnv = { NEW_VAR: 'new_value' }; + const result = mergeSettings(existing, newEnv); + assert.strictEqual(result.env.EXISTING_VAR, 'value'); + assert.strictEqual(result.env.NEW_VAR, 'new_value'); + }); + + it('overwrites existing env vars with new values', () => { + const existing = { + env: { ANTHROPIC_MODEL: 'old_model' }, + }; + const newEnv = { ANTHROPIC_MODEL: 'new_model' }; + const result = mergeSettings(existing, newEnv); + assert.strictEqual(result.env.ANTHROPIC_MODEL, 'new_model'); + }); + + it('handles complex settings with multiple fields', () => { + const existing = { + hooks: { PreToolUse: [] }, + presets: [], + model: 'sonnet', + env: { OLD_VAR: 'old' }, + customField: 'custom', + }; + const newEnv = { + ANTHROPIC_BASE_URL: 'http://test.com', + ANTHROPIC_MODEL: 'test', + }; + const result = mergeSettings(existing, newEnv); + + assert.deepStrictEqual(result.hooks, existing.hooks); + assert.deepStrictEqual(result.presets, existing.presets); + assert.strictEqual(result.model, 'sonnet'); + assert.strictEqual(result.customField, 'custom'); + assert.strictEqual(result.env.OLD_VAR, 'old'); + assert.strictEqual(result.env.ANTHROPIC_BASE_URL, 'http://test.com'); + assert.strictEqual(result.env.ANTHROPIC_MODEL, 'test'); + }); + }); + + // ========================================================================= + // Backup Timestamp Tests + // ========================================================================= + describe('Backup Timestamp Format', () => { + /** + * Simulates the timestamp generation from persist-command.ts + */ + function generateTimestamp(date) { + return ( + date.getFullYear().toString() + + (date.getMonth() + 1).toString().padStart(2, '0') + + date.getDate().toString().padStart(2, '0') + + '_' + + date.getHours().toString().padStart(2, '0') + + date.getMinutes().toString().padStart(2, '0') + + date.getSeconds().toString().padStart(2, '0') + ); + } + + it('generates correct format YYYYMMDD_HHMMSS', () => { + const date = new Date(2025, 0, 15, 10, 30, 45); // Jan 15, 2025 10:30:45 + const result = generateTimestamp(date); + assert.strictEqual(result, '20250115_103045'); + }); + + it('pads single digit months', () => { + const date = new Date(2025, 0, 1, 0, 0, 0); // Jan 1 + const result = generateTimestamp(date); + assert.match(result, /^202501/); + }); + + it('pads single digit days', () => { + const date = new Date(2025, 11, 5, 0, 0, 0); // Dec 5 + const result = generateTimestamp(date); + assert.match(result, /^20251205/); + }); + + it('pads single digit hours, minutes, seconds', () => { + const date = new Date(2025, 5, 15, 1, 2, 3); + const result = generateTimestamp(date); + assert.strictEqual(result, '20250615_010203'); + }); + }); + + // ========================================================================= + // Profile Type Detection Tests + // ========================================================================= + describe('Profile Type Messages', () => { + const profileTypes = { + settings: 'API', + cliproxy: 'CLIProxy', + copilot: 'Copilot', + account: 'Account (not supported)', + default: 'Default (not supported)', + }; + + it('maps settings type to API', () => { + assert.strictEqual(profileTypes.settings, 'API'); + }); + + it('maps cliproxy type to CLIProxy', () => { + assert.strictEqual(profileTypes.cliproxy, 'CLIProxy'); + }); + + it('maps copilot type to Copilot', () => { + assert.strictEqual(profileTypes.copilot, 'Copilot'); + }); + }); + + // ========================================================================= + // Sensitive Key Detection Tests + // ========================================================================= + describe('Sensitive Key Detection', () => { + /** + * Simulates the logic to detect sensitive keys for masking + */ + function isSensitiveKey(key) { + return key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET'); + } + + it('detects TOKEN in key name', () => { + assert.strictEqual(isSensitiveKey('ANTHROPIC_AUTH_TOKEN'), true); + assert.strictEqual(isSensitiveKey('ACCESS_TOKEN'), true); + }); + + it('detects KEY in key name', () => { + assert.strictEqual(isSensitiveKey('API_KEY'), true); + assert.strictEqual(isSensitiveKey('ANTHROPIC_API_KEY'), true); + }); + + it('detects SECRET in key name', () => { + assert.strictEqual(isSensitiveKey('CLIENT_SECRET'), true); + assert.strictEqual(isSensitiveKey('SECRET_KEY'), true); + }); + + it('does not flag non-sensitive keys', () => { + assert.strictEqual(isSensitiveKey('ANTHROPIC_BASE_URL'), false); + assert.strictEqual(isSensitiveKey('ANTHROPIC_MODEL'), false); + assert.strictEqual(isSensitiveKey('DISABLE_TELEMETRY'), false); + }); + }); + + // ========================================================================= + // Help Text Coverage Tests + // ========================================================================= + describe('Help Text Coverage', () => { + const expectedOptions = ['--yes', '-y', '--help', '-h']; + const expectedProfileTypes = ['API profiles', 'CLIProxy', 'Copilot', 'Account-based']; + + it('documents all CLI options', () => { + expectedOptions.forEach((option) => { + assert(typeof option === 'string', `Option ${option} should be a string`); + assert(option.startsWith('-'), `Option ${option} should start with -`); + }); + }); + + it('documents all profile types', () => { + expectedProfileTypes.forEach((type) => { + assert(typeof type === 'string', `Profile type ${type} should be documented`); + }); + }); + }); + + // ========================================================================= + // Error Message Tests + // ========================================================================= + describe('Error Messages', () => { + it('account profile error message mentions CLAUDE_CONFIG_DIR', () => { + const errorMessage = + "Account profiles use CLAUDE_CONFIG_DIR isolation, not env vars.\n" + + "Use 'ccs profileName' to run with this profile instead."; + assert(errorMessage.includes('CLAUDE_CONFIG_DIR')); + assert(errorMessage.includes('ccs profileName')); + }); + + it('no env vars error message includes profile name placeholder', () => { + const profileName = 'test'; + const errorMessage = `Profile '${profileName}' has no env vars configured`; + assert(errorMessage.includes(profileName)); + }); + }); + + // ========================================================================= + // Backup File Parsing Tests + // ========================================================================= + describe('Backup File Parsing', () => { + const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/; + + /** + * Simulates parsing a backup filename into a BackupFile object + */ + function parseBackupFile(filename, dir) { + const match = filename.match(backupPattern); + if (!match) return null; + const timestamp = match[1]; + // Parse YYYYMMDD_HHMMSS + const year = parseInt(timestamp.slice(0, 4)); + const month = parseInt(timestamp.slice(4, 6)) - 1; + const day = parseInt(timestamp.slice(6, 8)); + const hour = parseInt(timestamp.slice(9, 11)); + const min = parseInt(timestamp.slice(11, 13)); + const sec = parseInt(timestamp.slice(13, 15)); + return { + path: dir + '/' + filename, + timestamp, + date: new Date(year, month, day, hour, min, sec), + }; + } + + it('matches valid backup filename pattern', () => { + assert(backupPattern.test('settings.json.backup.20260110_205324')); + assert(backupPattern.test('settings.json.backup.20250101_000000')); + }); + + it('rejects invalid backup filenames', () => { + assert(!backupPattern.test('settings.json')); + assert(!backupPattern.test('settings.json.backup')); + assert(!backupPattern.test('settings.json.backup.invalid')); + assert(!backupPattern.test('settings.json.backup.20260110')); + assert(!backupPattern.test('other.json.backup.20260110_205324')); + }); + + it('parses backup file correctly', () => { + const result = parseBackupFile('settings.json.backup.20260110_205324', '/home/user/.claude'); + assert.strictEqual(result.timestamp, '20260110_205324'); + assert.strictEqual(result.path, '/home/user/.claude/settings.json.backup.20260110_205324'); + assert.strictEqual(result.date.getFullYear(), 2026); + assert.strictEqual(result.date.getMonth(), 0); // January + assert.strictEqual(result.date.getDate(), 10); + assert.strictEqual(result.date.getHours(), 20); + assert.strictEqual(result.date.getMinutes(), 53); + assert.strictEqual(result.date.getSeconds(), 24); + }); + + it('returns null for non-matching files', () => { + const result = parseBackupFile('settings.json', '/home/user/.claude'); + assert.strictEqual(result, null); + }); + + it('sorts backup files by date descending (newest first)', () => { + const files = [ + { timestamp: '20260109_100000', date: new Date(2026, 0, 9, 10, 0, 0) }, + { timestamp: '20260110_205324', date: new Date(2026, 0, 10, 20, 53, 24) }, + { timestamp: '20260110_100000', date: new Date(2026, 0, 10, 10, 0, 0) }, + ]; + const sorted = files.sort((a, b) => b.date.getTime() - a.date.getTime()); + assert.strictEqual(sorted[0].timestamp, '20260110_205324'); + assert.strictEqual(sorted[1].timestamp, '20260110_100000'); + assert.strictEqual(sorted[2].timestamp, '20260109_100000'); + }); + }); + + // ========================================================================= + // Backup Restore Logic Tests + // ========================================================================= + describe('Backup Restore Logic', () => { + it('selects first backup when restore=true (latest)', () => { + const backups = [ + { timestamp: '20260110_205324' }, + { timestamp: '20260110_100000' }, + ]; + const restore = true; + const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore); + assert.strictEqual(selected.timestamp, '20260110_205324'); + }); + + it('selects specific backup when restore is a timestamp', () => { + const backups = [ + { timestamp: '20260110_205324' }, + { timestamp: '20260110_100000' }, + ]; + const restore = '20260110_100000'; + const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore); + assert.strictEqual(selected.timestamp, '20260110_100000'); + }); + + it('returns undefined when timestamp not found', () => { + const backups = [ + { timestamp: '20260110_205324' }, + { timestamp: '20260110_100000' }, + ]; + const restore = '20260101_000000'; + const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore); + assert.strictEqual(selected, undefined); + }); + }); +});