mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 16:19:12 +00:00
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
This commit is contained in:
committed by
kaitranntt
parent
3b4c29ddcb
commit
ef7e595b6f
@@ -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.
|
||||
|
||||
@@ -437,6 +437,13 @@ async function main(): Promise<void> {
|
||||
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');
|
||||
|
||||
@@ -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 <profile>', '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'],
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<string, unknown> {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
try {
|
||||
const content = fs.readFileSync(settingsPath, 'utf8');
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
} 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<string, unknown>): 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<ResolvedEnv> {
|
||||
switch (profileResult.type) {
|
||||
case 'settings': {
|
||||
// API profile - load from settings file
|
||||
let env: Record<string, string> = {};
|
||||
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 <profile>'
|
||||
);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown profile type: ${profileResult.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle --list-backups flag */
|
||||
async function handleListBackups(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await initUI();
|
||||
console.log(header('CCS Persist Command'));
|
||||
console.log('');
|
||||
console.log(subheader('Usage'));
|
||||
console.log(` ${color('ccs persist', 'command')} <profile> [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 <ts>', '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<void> {
|
||||
// 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 <profile>', '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<string, string>) || {};
|
||||
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('');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user