diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 60251501..0da1a18b 100755 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -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) @@ -150,62 +139,77 @@ 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)) { + console.log('[OK] Legacy config.json found - will migrate to config.yaml on first run'); } else { - console.log('[OK] Config exists: ~/.ccs/config.json (preserved)'); + // 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 + const yaml = require('js-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)'); + } + + // Handle legacy config.json migrations (for users upgrading) + if (fs.existsSync(legacyConfigPath) && !fs.existsSync(configYamlPath)) { + // Migration will happen via autoMigrate() in ccs.ts on first run + console.log('[i] Legacy config.json will be migrated to config.yaml on first run'); } // NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index ec990812..ff867eaf 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -93,10 +93,31 @@ async function selectOption( } /** - * Check if this is a first-time install (no config exists) + * 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 { - return !hasUnifiedConfig(); + // No config at all → definitely first time + if (!hasUnifiedConfig()) { + return true; + } + + // Config exists - check if it's meaningfully configured + const config = loadOrCreateUnifiedConfig(); + + // 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; } /** diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index 9c43dd1e..b93621d3 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -8,6 +8,8 @@ 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 } from '../config/unified-config-loader'; /** * Get CCS home directory (respects CCS_HOME env for test isolation) @@ -49,37 +51,39 @@ 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 + if (hasUnifiedConfig()) { + return false; } - // 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 (_e) { + // Fallback: create minimal config.json for backward compat + 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; + } } /** @@ -281,8 +285,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(); diff --git a/tests/npm/postinstall.test.js b/tests/npm/postinstall.test.js index 07572362..136df681 100644 --- a/tests/npm/postinstall.test.js +++ b/tests/npm/postinstall.test.js @@ -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'); });