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 lockfile from 'proper-lockfile'; import { handlePersistCommand } from '../../../src/commands/persist-command'; import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; import { saveUnifiedConfig } from '../../../src/config/unified-config-loader'; import { runWithScopedCcsHome } from '../../../src/utils/config-manager'; interface RestoreFixture { claudeDir: string; settingsPath: string; backupPath: string; timestamp: string; originalSettings: Record; backupSettings: Record; } let tempRoot: string; let originalClaudeConfigDir: string | undefined; let originalProcessExit: typeof process.exit; let originalFsOpen: typeof fs.promises.open; let originalFsRename: typeof fs.promises.rename; async function withScopedHome(fn: () => Promise): Promise { return await runWithScopedCcsHome(tempRoot, fn); } async function pathExists(filePath: string): Promise { try { await fs.promises.access(filePath, fs.constants.F_OK); return true; } catch { return false; } } async function createRestoreFixture( options: { timestamp?: string; originalSettings?: Record; backupSettings?: Record; } = {} ): Promise { const timestamp = options.timestamp ?? '20260110_205324'; const claudeDir = path.join(tempRoot, '.claude'); const settingsPath = path.join(claudeDir, 'settings.json'); const backupPath = `${settingsPath}.backup.${timestamp}`; const originalSettings = options.originalSettings ?? { env: { ORIGINAL_TOKEN: 'original-value' }, permissions: { defaultMode: 'plan' }, }; const backupSettings = options.backupSettings ?? { env: { NEW_TOKEN: 'new-value' }, permissions: { defaultMode: 'acceptEdits' }, }; await fs.promises.mkdir(claudeDir, { recursive: true }); await fs.promises.writeFile(settingsPath, JSON.stringify(originalSettings, null, 2) + '\n', 'utf8'); await fs.promises.writeFile(backupPath, JSON.stringify(backupSettings, null, 2) + '\n', 'utf8'); return { claudeDir, settingsPath, backupPath, timestamp, originalSettings, backupSettings }; } function stubProcessExit(): void { process.exit = ((code?: number) => { throw new Error(`process.exit(${code ?? 0})`); }) as typeof process.exit; } beforeEach(async () => { tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-')); originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; originalProcessExit = process.exit; originalFsOpen = fs.promises.open; originalFsRename = fs.promises.rename; }); afterEach(async () => { process.exit = originalProcessExit; fs.promises.open = originalFsOpen; fs.promises.rename = originalFsRename; if (originalClaudeConfigDir === undefined) { delete process.env.CLAUDE_CONFIG_DIR; } else { process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; } if (tempRoot) { await fs.promises.rm(tempRoot, { recursive: true, force: true }); } }); describe('persist command real handler paths', () => { it('throws parseError for missing --permission-mode before profile detection', async () => { await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode']))).rejects.toThrow( 'Missing value for --permission-mode' ); }); it('throws parseError for empty inline --permission-mode before profile detection', async () => { await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow( 'Missing value for --permission-mode' ); }); it('throws parseError for invalid --permission-mode before profile detection', async () => { await expect( withScopedHome(() => handlePersistCommand(['glm', '--permission-mode', 'invalid-mode'])) ).rejects.toThrow(/Invalid --permission-mode/); }); it('throws parseError for unknown flags on real handler path', async () => { await expect(withScopedHome(() => handlePersistCommand(['glm', '--unknown-flag']))).rejects.toThrow( /Unknown option\(s\)/ ); }); it('throws parseError for list/restore conflict on real handler path', async () => { await expect(withScopedHome(() => handlePersistCommand(['--list-backups', '--restore']))).rejects.toThrow( '--list-backups cannot be used with --restore' ); }); it('throws parseError for permission flags with --restore on real handler path', async () => { await expect(withScopedHome(() => handlePersistCommand(['--restore', '--auto-approve']))).rejects.toThrow( /Permission flags are not valid with backup operations/ ); }); it('shows help when --help is present even with other invalid args', async () => { await expect(withScopedHome(() => handlePersistCommand(['--help', '--permission-mode']))).resolves.toBeUndefined(); }); it('does not create CLAUDE_CONFIG_DIR on parseError path', async () => { const isolatedClaudeDir = path.join(tempRoot, '.claude-parse-early'); process.env.CLAUDE_CONFIG_DIR = isolatedClaudeDir; await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow( 'Missing value for --permission-mode' ); expect(await pathExists(isolatedClaudeDir)).toBe(false); }); }); describe('persist command restore failure handling', () => { it('exits when lock cannot be acquired (concurrency protection)', async () => { const fixture = await createRestoreFixture(); process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; const release = await lockfile.lock(fixture.claudeDir, { stale: 60000, retries: { retries: 0 }, realpath: false, }); stubProcessExit(); try { await expect( withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) ).rejects.toThrow('process.exit(1)'); } finally { await release(); } }); it('exits when backup read fails with ENOENT after selection', async () => { const fixture = await createRestoreFixture(); process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; fs.promises.open = (async (...args: Parameters) => { const target = String(args[0]); if (target === fixture.backupPath) { const error = new Error('forced missing backup') as NodeJS.ErrnoException; error.code = 'ENOENT'; throw error; } return originalFsOpen(...args); }) as typeof fs.promises.open; stubProcessExit(); await expect( withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) ).rejects.toThrow('process.exit(1)'); }); it('exits when backup read fails with ELOOP (symlink rejection)', async () => { const fixture = await createRestoreFixture(); process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; fs.promises.open = (async (...args: Parameters) => { const target = String(args[0]); if (target === fixture.backupPath) { const error = new Error('forced symlink rejection') as NodeJS.ErrnoException; error.code = 'ELOOP'; throw error; } return originalFsOpen(...args); }) as typeof fs.promises.open; stubProcessExit(); await expect( withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) ).rejects.toThrow('process.exit(1)'); }); it('exits when backup path resolves to a non-regular file', async () => { const fixture = await createRestoreFixture(); process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; fs.promises.open = (async (...args: Parameters) => { const target = String(args[0]); if (target === fixture.backupPath) { const fakeHandle = { stat: async () => ({ isFile: () => false }), readFile: async () => '', close: async () => undefined, } as unknown as fs.promises.FileHandle; return fakeHandle; } return originalFsOpen(...args); }) as typeof fs.promises.open; stubProcessExit(); await expect( withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) ).rejects.toThrow('process.exit(1)'); }); it('rolls back settings when restore write fails mid-flight', async () => { const fixture = await createRestoreFixture(); process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; let renameCalls = 0; fs.promises.rename = (async (...args: Parameters) => { renameCalls += 1; if (renameCalls === 1) { throw new Error('forced rename failure'); } return originalFsRename(...args); }) as typeof fs.promises.rename; stubProcessExit(); await expect( withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) ).rejects.toThrow('process.exit(1)'); const finalContent = await fs.promises.readFile(fixture.settingsPath, 'utf8'); const finalSettings = JSON.parse(finalContent); expect(finalSettings).toEqual(fixture.originalSettings); }); it('includes dual failure context when restore write and rollback both fail', async () => { const fixture = await createRestoreFixture(); process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; fs.promises.rename = (async () => { throw new Error('forced rename failure'); }) as typeof fs.promises.rename; const originalConsoleLog = console.log; const capturedLogs: string[] = []; console.log = (...args: unknown[]) => { capturedLogs.push(args.map((arg) => String(arg)).join(' ')); }; stubProcessExit(); try { await expect( withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) ).rejects.toThrow('process.exit(1)'); expect(capturedLogs.some((line) => line.includes('Rollback also failed'))).toBe(true); } finally { console.log = originalConsoleLog; } }); }); describe('persist command Claude extension parity', () => { async function writeUnifiedConfig(): Promise { const config = createEmptyUnifiedConfig(); config.accounts.work = { created: '2026-03-15T00:00:00.000Z', last_used: null, context_mode: 'isolated', }; config.default = 'work'; config.profiles.glm = { type: 'api', settings: path.join(tempRoot, '.ccs', 'glm.settings.json'), }; await fs.promises.mkdir(path.join(tempRoot, '.ccs'), { recursive: true }); await fs.promises.writeFile( path.join(tempRoot, '.ccs', 'glm.settings.json'), JSON.stringify( { env: { ANTHROPIC_BASE_URL: 'https://api.example.test', ANTHROPIC_API_KEY: 'sk-ant-test-123456', ANTHROPIC_MODEL: 'claude-sonnet-4-5', }, }, null, 2 ) + '\n', 'utf8' ); await withScopedHome(async () => { saveUnifiedConfig(config); }); } it('persists account profiles via CLAUDE_CONFIG_DIR and clears stale managed env keys', async () => { await writeUnifiedConfig(); const settingsPath = path.join(tempRoot, '.claude', 'settings.json'); await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true }); await fs.promises.writeFile( settingsPath, JSON.stringify( { env: { ANTHROPIC_API_KEY: 'stale-key', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', KEEP_ME: 'still-here', }, }, null, 2 ) + '\n', 'utf8' ); await withScopedHome(() => handlePersistCommand(['work', '--yes'])); const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { env: Record; }; expect(persisted.env.KEEP_ME).toBe('still-here'); expect(persisted.env.ANTHROPIC_API_KEY).toBeUndefined(); expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined(); expect(persisted.env.CLAUDE_CONFIG_DIR).toBe(path.join(tempRoot, '.ccs', 'instances', 'work')); expect(fs.existsSync(persisted.env.CLAUDE_CONFIG_DIR)).toBe(true); }); it('persists default profile using mapped account continuity and preserves unrelated env', async () => { await writeUnifiedConfig(); const settingsPath = path.join(tempRoot, '.claude', 'settings.json'); await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true }); await fs.promises.writeFile( settingsPath, JSON.stringify( { env: { ANTHROPIC_AUTH_TOKEN: 'stale-token', ANTHROPIC_MODEL: 'stale-model', KEEP_ME: 'still-here', }, }, null, 2 ) + '\n', 'utf8' ); await withScopedHome(() => handlePersistCommand(['default', '--yes'])); const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { env: Record; }; expect(persisted.env.KEEP_ME).toBe('still-here'); expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); expect(persisted.env.ANTHROPIC_MODEL).toBeUndefined(); expect(persisted.env.CLAUDE_CONFIG_DIR).toBe(path.join(tempRoot, '.ccs', 'instances', 'work')); expect(fs.existsSync(persisted.env.CLAUDE_CONFIG_DIR)).toBe(true); }); });