Merge pull request #187 from kaitranntt/fix/config-init-defaults

feat(setup): add first-time setup wizard and migrate to config.yaml
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-23 23:02:57 -05:00
committed by GitHub
6 changed files with 628 additions and 104 deletions
+110 -68
View File
@@ -68,24 +68,13 @@ function validateConfiguration() {
errors.push('~/.ccs/ directory not found');
}
// Check required files (GLM/GLMT/Kimi are now optional - created via presets)
const requiredFiles = [
{ path: path.join(ccsDir, 'config.json'), name: 'config.json' }
];
// Check for config file - prefer config.yaml, fallback to config.json
const configYaml = path.join(ccsDir, 'config.yaml');
const configJson = path.join(ccsDir, 'config.json');
const hasConfig = fs.existsSync(configYaml) || fs.existsSync(configJson);
for (const file of requiredFiles) {
if (!fs.existsSync(file.path)) {
errors.push(`${file.name} not found`);
continue;
}
// Validate JSON syntax
try {
const content = fs.readFileSync(file.path, 'utf8');
JSON.parse(content);
} catch (e) {
errors.push(`${file.name} has invalid JSON: ${e.message}`);
}
if (!hasConfig) {
errors.push('config.yaml (or config.json) not found');
}
// Check ~/.claude/settings.json (warning only, not critical)
@@ -104,7 +93,15 @@ function createConfigFiles() {
const ccsDir = path.join(homedir, '.ccs');
// Create ~/.ccs/ directory if missing
if (!fs.existsSync(ccsDir)) {
if (fs.existsSync(ccsDir)) {
// Check if it's a file instead of directory (edge case)
const stats = fs.statSync(ccsDir);
if (!stats.isDirectory()) {
console.error('[X] ~/.ccs exists but is not a directory');
console.error(' Remove or rename it: mv ~/.ccs ~/.ccs.bak');
process.exit(1);
}
} else {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o755 });
console.log('[OK] Created directory: ~/.ccs/');
}
@@ -150,62 +147,107 @@ function createConfigFiles() {
// Users can run "ccs sync" to install CCS commands/skills to ~/.claude/
// This gives users control over when to modify their Claude configuration
// Create config.json if missing
// Create config.yaml if missing (primary format)
// NOTE: gemini/codex profiles NOT included - they are added on-demand when user
// runs `ccs gemini` or `ccs codex` for first time (requires OAuth auth first)
// NOTE: GLM/GLMT/Kimi profiles are now created via UI/CLI presets, not auto-created
const configPath = path.join(ccsDir, 'config.json');
if (!fs.existsSync(configPath)) {
// NOTE: No 'default' entry - when no profile specified, CCS passes through
// to Claude's native auth without --settings flag. This prevents env var
// pollution from affecting the default profile.
// Profiles are empty by default - users create via `ccs api create --preset` or UI
const config = {
profiles: {}
};
const configYamlPath = path.join(ccsDir, 'config.yaml');
const legacyConfigPath = path.join(ccsDir, 'config.json');
// Atomic write: temp file → rename
const tmpPath = `${configPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, configPath);
console.log('[OK] Created config: ~/.ccs/config.json');
} else {
// Update existing config (migration for older versions)
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
// Ensure profiles object exists
if (!config.profiles) {
config.profiles = {};
}
let configUpdated = false;
// Migration: Add glmt if missing (v3.x)
if (!config.profiles.glmt) {
config.profiles.glmt = '~/.ccs/glmt.settings.json';
configUpdated = true;
}
// Migration: Remove 'default' entry pointing to ~/.claude/settings.json (v5.4.0)
// This entry caused the default profile to pass --settings flag, which could
// pick up stale env vars (ANTHROPIC_BASE_URL) from previous profile sessions.
// Fix: Let CCS pass through to Claude's native auth without --settings flag.
if (config.profiles.default === '~/.claude/settings.json') {
delete config.profiles.default;
configUpdated = true;
console.log('[OK] Removed legacy default profile (now uses native Claude auth)');
}
// NOTE: gemini/codex profiles added on-demand, not during migration
if (configUpdated) {
const tmpPath = `${configPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, configPath);
if (!config.profiles.glmt) {
console.log('[OK] Updated config with glmt profile');
if (!fs.existsSync(configYamlPath)) {
// Check for legacy config.json - autoMigrate() in ccs.ts will handle migration
if (fs.existsSync(legacyConfigPath)) {
// Validate legacy config.json before assuming migration will work
try {
const content = fs.readFileSync(legacyConfigPath, 'utf8');
JSON.parse(content);
console.log('[OK] Legacy config.json found - will migrate to config.yaml on first run');
} catch {
console.warn('[!] Legacy config.json is corrupted/invalid');
console.warn(' Backup: mv ~/.ccs/config.json ~/.ccs/config.json.bak');
console.warn(' Creating fresh config.yaml instead');
// Fall through to create new config.yaml
fs.renameSync(legacyConfigPath, `${legacyConfigPath}.bak`);
}
} else {
console.log('[OK] Config exists: ~/.ccs/config.json (preserved)');
}
// Create config.yaml if it doesn't exist (and legacy wasn't valid)
if (!fs.existsSync(configYamlPath) && !fs.existsSync(legacyConfigPath)) {
// Try to use unified config loader if dist is available
try {
const { saveUnifiedConfig } = require('../dist/config/unified-config-loader');
const { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } = require('../dist/config/unified-config-types');
const config = createEmptyUnifiedConfig();
config.version = UNIFIED_CONFIG_VERSION;
saveUnifiedConfig(config);
console.log('[OK] Created config: ~/.ccs/config.yaml');
} catch (loaderErr) {
// Dist not built yet (fresh clone) - create minimal config.yaml manually
// Wrap js-yaml require in try-catch in case it's not available
let yaml;
try {
yaml = require('js-yaml');
} catch {
// js-yaml not available - fallback to JSON
console.warn('[!] js-yaml not available, creating legacy config.json');
const fallbackConfig = { profiles: {} };
const tmpPath = `${legacyConfigPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, legacyConfigPath);
console.log('[OK] Created config: ~/.ccs/config.json (fallback)');
yaml = null;
}
if (yaml) {
const config = {
version: '2.0',
profiles: {},
accounts: {},
cliproxy: {
variants: {},
oauth_accounts: {}
},
cliproxy_server: {
local: {
port: 8317,
auto_start: true
}
}
};
try {
const yamlContent = yaml.dump(config, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: false
});
const tmpPath = `${configYamlPath}.tmp`;
fs.writeFileSync(tmpPath, yamlContent, 'utf8');
fs.renameSync(tmpPath, configYamlPath);
console.log('[OK] Created config: ~/.ccs/config.yaml');
} catch (yamlErr) {
// Final fallback: create legacy config.json
console.warn('[!] YAML write failed, creating legacy config.json');
const fallbackConfig = { profiles: {} };
const tmpPath = `${legacyConfigPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, legacyConfigPath);
console.log('[OK] Created config: ~/.ccs/config.json (fallback)');
}
}
}
}
} else {
console.log('[OK] Config exists: ~/.ccs/config.yaml (preserved)');
}
// Warn if both config files exist (user may want to clean up)
if (fs.existsSync(legacyConfigPath) && fs.existsSync(configYamlPath)) {
console.log('[!] Both config.yaml and config.json exist');
console.log(' config.json will be ignored - consider removing it');
}
// NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install
+18
View File
@@ -404,6 +404,13 @@ async function main(): Promise<void> {
return;
}
// Special case: setup command (first-time wizard)
if (firstArg === 'setup' || firstArg === '--setup') {
const { handleSetupCommand } = await import('./commands/setup-command');
await handleSetupCommand(args.slice(1));
return;
}
// Special case: copilot command (GitHub Copilot integration)
// Only route to command handler for known subcommands, otherwise treat as profile
const COPILOT_SUBCOMMANDS = [
@@ -443,6 +450,17 @@ async function main(): Promise<void> {
recovery.showRecoveryHints();
}
// First-time install: offer setup wizard for interactive users
// Check independently of recovery status (user may have empty config.yaml)
// Skip if headless, CI, or non-TTY environment
const { isFirstTimeInstall } = await import('./commands/setup-command');
if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) {
console.log('');
console.log(info('First-time install detected. Run `ccs setup` for guided configuration.'));
console.log(' Or use `ccs config` for the web dashboard.');
console.log('');
}
// Detect profile
const { profile, remainingArgs } = detectProfile(args);
+1
View File
@@ -213,6 +213,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
// Diagnostics
printSubSection('Diagnostics', [
['ccs setup', 'First-time setup wizard'],
['ccs doctor', 'Run health check and diagnostics'],
['ccs cleanup', 'Remove old CLIProxy logs'],
['ccs config', 'Open web configuration dashboard'],
+431
View File
@@ -0,0 +1,431 @@
/**
* Setup Command Handler
*
* Interactive first-time setup wizard for CCS.
* Guides users through initial configuration including:
* - Local vs Remote CLIProxy mode selection
* - Remote proxy configuration (host, port, auth token)
* - Default profile selection
*
* Usage: ccs setup
*
* Related: Issue #142 - remote CLIProxyAPI configuration
*/
import * as readline from 'readline';
import { initUI, header, ok, info, warn } from '../utils/ui';
import {
loadOrCreateUnifiedConfig,
loadUnifiedConfig,
saveUnifiedConfig,
hasUnifiedConfig,
} from '../config/unified-config-loader';
import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types';
/** Custom error for user cancellation (Ctrl+C) */
class UserCancelledError extends Error {
constructor() {
super('Setup cancelled by user');
this.name = 'UserCancelledError';
}
}
/**
* Create readline interface for interactive prompts
*/
function createReadline(): readline.Interface {
return readline.createInterface({
input: process.stdin,
output: process.stdout,
});
}
/**
* Prompt user for input with optional default value
* Handles Ctrl+C gracefully by rejecting with UserCancelledError
*/
async function prompt(
rl: readline.Interface,
question: string,
defaultValue?: string
): Promise<string> {
return new Promise((resolve, reject) => {
const displayQuestion = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
const onClose = () => {
reject(new UserCancelledError());
};
rl.once('close', onClose);
rl.question(displayQuestion, (answer) => {
rl.removeListener('close', onClose);
resolve(answer.trim() || defaultValue || '');
});
});
}
/**
* Prompt user for yes/no confirmation
*/
async function confirm(
rl: readline.Interface,
question: string,
defaultYes: boolean = true
): Promise<boolean> {
const hint = defaultYes ? '[Y/n]' : '[y/N]';
const answer = await prompt(rl, `${question} ${hint}`);
if (answer === '') return defaultYes;
return answer.toLowerCase().startsWith('y');
}
/**
* Prompt user to select from numbered options
*/
async function selectOption(
rl: readline.Interface,
question: string,
options: { label: string; value: string; description?: string }[]
): Promise<string> {
console.log('');
console.log(question);
console.log('');
options.forEach((opt, idx) => {
const desc = opt.description ? ` - ${opt.description}` : '';
console.log(` ${idx + 1}) ${opt.label}${desc}`);
});
console.log('');
const answer = await prompt(rl, 'Enter choice (number)', '1');
const idx = parseInt(answer, 10) - 1;
if (idx >= 0 && idx < options.length) {
return options[idx].value;
}
// Invalid selection, default to first
console.log(warn(`Invalid selection, using default: ${options[0].label}`));
return options[0].value;
}
/**
* Check if this is a first-time install (config exists but is empty/unconfigured)
* Returns true if user should be prompted to run setup wizard
*/
export function isFirstTimeInstall(): boolean {
// No config at all → definitely first time
if (!hasUnifiedConfig()) {
return true;
}
// Try loading config directly to detect corruption
const loaded = loadUnifiedConfig();
if (loaded === null) {
// Config exists but is corrupted/invalid - don't treat as first-time
// User should fix or delete the file, or use --force
console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted'));
console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose'));
return false;
}
// Config exists and is valid - check if it's meaningfully configured
const config = loaded;
// Check for any meaningful configuration
const hasProfiles = Object.keys(config.profiles || {}).length > 0;
const hasAccounts = Object.keys(config.accounts || {}).length > 0;
const hasVariants = Object.keys(config.cliproxy?.variants || {}).length > 0;
const hasOAuthAccounts = Object.keys(config.cliproxy?.oauth_accounts || {}).length > 0;
const hasRemoteProxy =
config.cliproxy_server?.remote?.enabled && config.cliproxy_server?.remote?.host;
// If any of these exist, user has configured something
const isConfigured =
hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy;
return !isConfigured;
}
/**
* Configure remote CLIProxy settings interactively
*/
async function configureRemoteProxy(rl: readline.Interface): Promise<{
host: string;
port?: number;
protocol: 'http' | 'https';
authToken: string;
}> {
console.log('');
console.log(info('Configure Remote CLIProxyAPI Connection'));
console.log('');
console.log(' Enter the details for your remote CLIProxyAPI server.');
console.log(' Example: your-server.example.com');
console.log('');
// Host - with protocol stripping
let host = await prompt(rl, 'Remote host (hostname or IP)');
if (!host) {
throw new Error('Host is required for remote proxy mode');
}
// Strip protocol if user included it (common mistake)
host = host.replace(/^https?:\/\//, '');
// Strip trailing slashes
host = host.replace(/\/+$/, '');
// Protocol
const protocol = (await selectOption(rl, 'Protocol:', [
{ label: 'HTTPS', value: 'https', description: 'Secure connection (recommended)' },
{ label: 'HTTP', value: 'http', description: 'Unencrypted connection' },
])) as 'http' | 'https';
// Port (optional) - with validation
const defaultPort = protocol === 'https' ? '443' : '80';
const portStr = await prompt(rl, `Port (leave empty for default ${defaultPort})`);
let port: number | undefined;
if (portStr) {
const parsed = parseInt(portStr, 10);
if (isNaN(parsed) || parsed < 1 || parsed > 65535 || !Number.isInteger(parsed)) {
console.log(warn(`Invalid port "${portStr}", using default: ${defaultPort}`));
port = undefined; // Use default
} else {
port = parsed;
}
}
// Auth token
console.log('');
console.log(info('Authentication'));
console.log(' The auth token is configured in your CLIProxyAPI config.yaml');
console.log(' under api-keys section. Example: "ccs-internal-managed"');
console.log('');
const authToken = await prompt(rl, 'Auth token', 'ccs-internal-managed');
return { host, port, protocol, authToken };
}
/**
* Main setup wizard
*/
async function runSetupWizard(force: boolean = false): Promise<void> {
const rl = createReadline();
try {
console.log('');
console.log(header('CCS First-Time Setup'));
console.log('');
// Check if already configured
if (!force && !isFirstTimeInstall()) {
console.log(info('CCS is already configured.'));
console.log(' Use --force to reconfigure, or run `ccs config` for the dashboard.');
console.log('');
rl.close();
return;
}
console.log('Welcome to CCS (Claude Code Switch)!');
console.log('This wizard will help you configure CCS for first-time use.');
console.log('');
// Step 1: Local vs Remote mode
const proxyMode = await selectOption(
rl,
'How do you want to use CLIProxy providers (gemini, codex, agy)?',
[
{
label: 'Local (Recommended)',
value: 'local',
description: 'CCS auto-starts CLIProxyAPI binary on your machine',
},
{
label: 'Remote Server',
value: 'remote',
description: 'Connect to a remote CLIProxyAPI instance (Issue #142)',
},
{
label: 'Skip CLIProxy',
value: 'skip',
description: 'Only use API profiles (GLM, Kimi) or Claude accounts',
},
]
);
// Load or create config
const config = loadOrCreateUnifiedConfig();
if (proxyMode === 'remote') {
// Configure remote proxy
const remoteConfig = await configureRemoteProxy(rl);
config.cliproxy_server = {
remote: {
enabled: true,
host: remoteConfig.host,
port: remoteConfig.port,
protocol: remoteConfig.protocol,
auth_token: remoteConfig.authToken,
},
fallback: {
enabled: true,
auto_start: false,
},
local: {
port: 8317,
auto_start: false, // Disable local auto-start when using remote
},
};
console.log('');
console.log(ok('Remote proxy configured successfully!'));
console.log('');
console.log(
` URL: ${remoteConfig.protocol}://${remoteConfig.host}${remoteConfig.port ? `:${remoteConfig.port}` : ''}`
);
console.log(` Auth: ${remoteConfig.authToken ? '[configured]' : '[none]'}`);
} else if (proxyMode === 'local') {
// Ensure local mode is configured
config.cliproxy_server = {
...DEFAULT_CLIPROXY_SERVER_CONFIG,
remote: {
enabled: false,
host: '',
protocol: 'http',
auth_token: '',
},
local: {
port: 8317,
auto_start: true,
},
};
console.log('');
console.log(ok('Local proxy mode configured!'));
console.log(' CLIProxyAPI will auto-start when you use gemini/codex/agy profiles.');
} else {
// Skip CLIProxy - just use local config
console.log('');
console.log(ok('CLIProxy skipped.'));
console.log(' You can still use API profiles (GLM, Kimi) or Claude accounts.');
}
// Step 2: Ask about API profiles
console.log('');
const wantsApiProfile = await confirm(
rl,
'Do you want to set up an API profile (GLM, Kimi, custom)?',
false
);
if (wantsApiProfile) {
console.log('');
console.log(info('Creating API profiles...'));
console.log(' Use the following commands to create profiles:');
console.log('');
console.log(' ccs api create glm --preset glm');
console.log(' ccs api create kimi --preset kimi');
console.log(' ccs api create custom --prompt');
console.log('');
console.log(' After creating, edit the settings file to add your API key.');
}
// Save config
saveUnifiedConfig(config);
// Final summary
console.log('');
console.log(header('Setup Complete!'));
console.log('');
console.log('Quick start commands:');
console.log('');
if (proxyMode !== 'skip') {
console.log(' ccs gemini # Use Gemini via CLIProxy (OAuth)');
console.log(' ccs codex # Use Codex via CLIProxy (OAuth)');
console.log(' ccs agy # Use Antigravity via CLIProxy (OAuth)');
}
console.log(' ccs # Use default Claude CLI');
console.log(' ccs config # Open web dashboard');
console.log(' ccs doctor # Check configuration health');
console.log('');
if (proxyMode === 'remote') {
console.log(info('Remote proxy tip:'));
console.log(' If connection fails, CCS will offer to start local proxy as fallback.');
console.log(' Edit ~/.ccs/config.yaml to adjust remote settings.');
console.log('');
}
console.log(info('Configuration saved to: ~/.ccs/config.yaml'));
console.log('');
} catch (err) {
// Handle user cancellation gracefully
if (err instanceof UserCancelledError) {
console.log('');
console.log(info('Setup cancelled.'));
console.log(' Run `ccs setup` when ready to configure.');
console.log('');
return;
}
// Handle other errors with user-friendly message
const message = err instanceof Error ? err.message : String(err);
console.log('');
console.log(warn(`Setup failed: ${message}`));
console.log(info(' Run `ccs setup` to try again.'));
console.log('');
} finally {
rl.close();
}
}
/**
* Parse command line arguments
*/
function parseArgs(args: string[]): { force: boolean; help: boolean } {
return {
force: args.includes('--force') || args.includes('-f'),
help: args.includes('--help') || args.includes('-h'),
};
}
/**
* Show help message
*/
function showHelp(): void {
console.log('');
console.log('Usage: ccs setup [options]');
console.log('');
console.log('Interactive first-time setup wizard for CCS.');
console.log('');
console.log('Options:');
console.log(' --force, -f Force setup even if already configured');
console.log(' --help, -h Show this help message');
console.log('');
console.log('This wizard helps you configure:');
console.log(' - Local vs Remote CLIProxy mode');
console.log(' - Remote proxy connection (host, port, auth token)');
console.log(' - API profile creation');
console.log('');
console.log('Examples:');
console.log(' ccs setup Run setup wizard');
console.log(' ccs setup --force Force reconfiguration');
console.log('');
}
/**
* Handle setup command
*/
export async function handleSetupCommand(args: string[]): Promise<void> {
await initUI();
const options = parseArgs(args);
if (options.help) {
showHelp();
return;
}
await runSetupWizard(options.force);
}
+47 -27
View File
@@ -8,6 +8,12 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { info } from '../utils/ui';
import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } from '../config/unified-config-types';
import {
saveUnifiedConfig,
hasUnifiedConfig,
loadUnifiedConfig,
} from '../config/unified-config-loader';
/**
* Get CCS home directory (respects CCS_HOME env for test isolation)
@@ -49,37 +55,51 @@ class RecoveryManager {
}
/**
* Ensure ~/.ccs/config.json exists with defaults
* Ensure ~/.ccs/config.yaml exists with defaults
* This is the primary config format (YAML unified config)
*/
ensureConfigJson(): boolean {
const configPath = path.join(this.ccsDir, 'config.json');
// Check if exists and valid
if (fs.existsSync(configPath)) {
try {
const content = fs.readFileSync(configPath, 'utf8');
JSON.parse(content); // Validate JSON
return false; // No recovery needed
} catch (_e) {
// Corrupted - backup and recreate
const backupPath = `${configPath}.backup.${Date.now()}`;
fs.renameSync(configPath, backupPath);
this.recovered.push(`Backed up corrupted config.json to ${path.basename(backupPath)}`);
ensureConfigYaml(): boolean {
// Skip if config.yaml already exists AND is valid
if (hasUnifiedConfig()) {
// Verify it's loadable (not corrupted)
const loaded = loadUnifiedConfig();
if (loaded !== null) {
return false; // Config exists and is valid
}
// Config exists but is corrupted - will be recreated below
this.recovered.push('Detected corrupted ~/.ccs/config.yaml');
}
// Create default config (matches postinstall.js)
// NOTE: Empty profiles - users create profiles via `ccs api create` or UI
const defaultConfig = {
profiles: {},
};
// Check for legacy config.json - if exists, let autoMigrate handle it
const legacyConfigPath = path.join(this.ccsDir, 'config.json');
if (fs.existsSync(legacyConfigPath)) {
// Legacy config exists - autoMigrate() in ccs.ts will handle migration
return false;
}
const tmpPath = `${configPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, configPath);
// Create fresh config.yaml with defaults
const config = createEmptyUnifiedConfig();
config.version = UNIFIED_CONFIG_VERSION;
this.recovered.push('Created ~/.ccs/config.json');
return true;
try {
saveUnifiedConfig(config);
this.recovered.push('Created ~/.ccs/config.yaml');
return true;
} catch (_saveErr) {
// Fallback: create minimal config.json for backward compat
try {
const fallbackConfig = { profiles: {} };
const tmpPath = `${legacyConfigPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, legacyConfigPath);
this.recovered.push('Created ~/.ccs/config.json (fallback)');
return true;
} catch (_fallbackErr) {
// Both writes failed - log but don't crash
this.recovered.push('Failed to create config file (permission issue?)');
return false;
}
}
}
/**
@@ -281,8 +301,8 @@ class RecoveryManager {
this.ensureSharedDirectories();
this.ensureClaudeSettings();
// Config files (core only - no GLM/GLMT/Kimi auto-creation)
this.ensureConfigJson();
// Config files - use YAML as primary format
this.ensureConfigYaml();
// Shell completions
this.ensureShellCompletions();
+21 -9
View File
@@ -19,19 +19,25 @@ describe('npm postinstall', () => {
}
});
it('creates config.json', () => {
it('creates config.yaml (primary format)', () => {
execSync(`node "${postinstallScript}"`, {
stdio: 'ignore',
env: { ...process.env, CCS_HOME: testEnv.testHome }
});
assert(testEnv.fileExists('config.json'), 'config.json should be created');
// config.yaml is now the primary format (v6.x+)
assert(testEnv.fileExists('config.yaml'), 'config.yaml should be created');
const config = testEnv.readFile('config.json', true);
assert(config.profiles, 'config.json should have profiles');
// Read YAML config and verify structure
const yaml = require('js-yaml');
const configContent = testEnv.readFile('config.yaml', false);
const config = yaml.load(configContent);
assert(config.profiles !== undefined, 'config.yaml should have profiles');
assert(typeof config.profiles === 'object', 'profiles should be an object');
// Profiles are now empty by default - users create via presets
assert.deepStrictEqual(config.profiles, {}, 'profiles should be empty by default');
assert(config.version, 'config.yaml should have version');
});
it('does NOT auto-create glm.settings.json (v6.0 - use presets instead)', () => {
@@ -49,24 +55,30 @@ describe('npm postinstall', () => {
it('is idempotent', () => {
const env = { ...process.env, CCS_HOME: testEnv.testHome };
const yaml = require('js-yaml');
// Run postinstall first time
execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env });
// Create custom config
// Create custom config.yaml to test preservation
const customConfig = {
version: '2.0',
profiles: {
custom: '~/.custom.json',
glm: '~/.ccs/glm.settings.json'
}
},
accounts: {},
cliproxy: { variants: {}, oauth_accounts: {} }
};
testEnv.createFile('config.json', customConfig);
const yamlContent = yaml.dump(customConfig, { indent: 2 });
testEnv.createFile('config.yaml', yamlContent);
// Run postinstall again
execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env });
// Verify custom config preserved
const config = testEnv.readFile('config.json', true);
const configContent = testEnv.readFile('config.yaml', false);
const config = yaml.load(configContent);
assert(config.profiles.custom, 'Custom profile should be preserved');
assert.strictEqual(config.profiles.custom, '~/.custom.json');
});
@@ -97,7 +109,7 @@ describe('npm postinstall', () => {
// Verify existing file still exists and new files are created
assert(testEnv.fileExists('existing.txt'), 'Existing files should be preserved');
assert(testEnv.fileExists('config.json'), 'config.json should be created');
assert(testEnv.fileExists('config.yaml'), 'config.yaml should be created');
// GLM/GLMT/Kimi are no longer auto-created
assert(!testEnv.fileExists('glm.settings.json'), 'glm.settings.json should NOT be auto-created');
});