mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-05 18:16:28 +00:00
refactor(config): migrate to config.yaml as primary format
- Update postinstall.js to create config.yaml instead of config.json - Update recovery-manager to create config.yaml as primary config - Fix isFirstTimeInstall() to check for meaningful config content (profiles, accounts, variants, oauth_accounts, remote proxy) - Update validation to accept config.yaml OR config.json - Preserve backward compatibility: legacy config.json is migrated to config.yaml on first run via autoMigrate() - Update postinstall tests to verify config.yaml creation Fixes #142 - remote CLIProxyAPI configuration
This commit is contained in:
+71
-67
@@ -68,24 +68,13 @@ function validateConfiguration() {
|
|||||||
errors.push('~/.ccs/ directory not found');
|
errors.push('~/.ccs/ directory not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check required files (GLM/GLMT/Kimi are now optional - created via presets)
|
// Check for config file - prefer config.yaml, fallback to config.json
|
||||||
const requiredFiles = [
|
const configYaml = path.join(ccsDir, 'config.yaml');
|
||||||
{ path: path.join(ccsDir, 'config.json'), name: 'config.json' }
|
const configJson = path.join(ccsDir, 'config.json');
|
||||||
];
|
const hasConfig = fs.existsSync(configYaml) || fs.existsSync(configJson);
|
||||||
|
|
||||||
for (const file of requiredFiles) {
|
if (!hasConfig) {
|
||||||
if (!fs.existsSync(file.path)) {
|
errors.push('config.yaml (or config.json) not found');
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check ~/.claude/settings.json (warning only, not critical)
|
// 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/
|
// Users can run "ccs sync" to install CCS commands/skills to ~/.claude/
|
||||||
// This gives users control over when to modify their Claude configuration
|
// 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
|
// 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)
|
// 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
|
// NOTE: GLM/GLMT/Kimi profiles are now created via UI/CLI presets, not auto-created
|
||||||
const configPath = path.join(ccsDir, 'config.json');
|
const configYamlPath = path.join(ccsDir, 'config.yaml');
|
||||||
if (!fs.existsSync(configPath)) {
|
const legacyConfigPath = path.join(ccsDir, 'config.json');
|
||||||
// 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: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Atomic write: temp file → rename
|
if (!fs.existsSync(configYamlPath)) {
|
||||||
const tmpPath = `${configPath}.tmp`;
|
// Check for legacy config.json - autoMigrate() in ccs.ts will handle migration
|
||||||
fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
if (fs.existsSync(legacyConfigPath)) {
|
||||||
fs.renameSync(tmpPath, configPath);
|
console.log('[OK] Legacy config.json found - will migrate to config.yaml on first run');
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
} else {
|
} 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
|
// NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install
|
||||||
|
|||||||
@@ -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 {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { info } from '../utils/ui';
|
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)
|
* 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 {
|
ensureConfigYaml(): boolean {
|
||||||
const configPath = path.join(this.ccsDir, 'config.json');
|
// Skip if config.yaml already exists
|
||||||
|
if (hasUnifiedConfig()) {
|
||||||
// Check if exists and valid
|
return false;
|
||||||
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)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create default config (matches postinstall.js)
|
// Check for legacy config.json - if exists, let autoMigrate handle it
|
||||||
// NOTE: Empty profiles - users create profiles via `ccs api create` or UI
|
const legacyConfigPath = path.join(this.ccsDir, 'config.json');
|
||||||
const defaultConfig = {
|
if (fs.existsSync(legacyConfigPath)) {
|
||||||
profiles: {},
|
// Legacy config exists - autoMigrate() in ccs.ts will handle migration
|
||||||
};
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const tmpPath = `${configPath}.tmp`;
|
// Create fresh config.yaml with defaults
|
||||||
fs.writeFileSync(tmpPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
|
const config = createEmptyUnifiedConfig();
|
||||||
fs.renameSync(tmpPath, configPath);
|
config.version = UNIFIED_CONFIG_VERSION;
|
||||||
|
|
||||||
this.recovered.push('Created ~/.ccs/config.json');
|
try {
|
||||||
return true;
|
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.ensureSharedDirectories();
|
||||||
this.ensureClaudeSettings();
|
this.ensureClaudeSettings();
|
||||||
|
|
||||||
// Config files (core only - no GLM/GLMT/Kimi auto-creation)
|
// Config files - use YAML as primary format
|
||||||
this.ensureConfigJson();
|
this.ensureConfigYaml();
|
||||||
|
|
||||||
// Shell completions
|
// Shell completions
|
||||||
this.ensureShellCompletions();
|
this.ensureShellCompletions();
|
||||||
|
|||||||
@@ -19,19 +19,25 @@ describe('npm postinstall', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates config.json', () => {
|
it('creates config.yaml (primary format)', () => {
|
||||||
execSync(`node "${postinstallScript}"`, {
|
execSync(`node "${postinstallScript}"`, {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env: { ...process.env, CCS_HOME: testEnv.testHome }
|
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);
|
// Read YAML config and verify structure
|
||||||
assert(config.profiles, 'config.json should have profiles');
|
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');
|
assert(typeof config.profiles === 'object', 'profiles should be an object');
|
||||||
// Profiles are now empty by default - users create via presets
|
// Profiles are now empty by default - users create via presets
|
||||||
assert.deepStrictEqual(config.profiles, {}, 'profiles should be empty by default');
|
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)', () => {
|
it('does NOT auto-create glm.settings.json (v6.0 - use presets instead)', () => {
|
||||||
@@ -49,24 +55,30 @@ describe('npm postinstall', () => {
|
|||||||
|
|
||||||
it('is idempotent', () => {
|
it('is idempotent', () => {
|
||||||
const env = { ...process.env, CCS_HOME: testEnv.testHome };
|
const env = { ...process.env, CCS_HOME: testEnv.testHome };
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
|
||||||
// Run postinstall first time
|
// Run postinstall first time
|
||||||
execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env });
|
execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env });
|
||||||
|
|
||||||
// Create custom config
|
// Create custom config.yaml to test preservation
|
||||||
const customConfig = {
|
const customConfig = {
|
||||||
|
version: '2.0',
|
||||||
profiles: {
|
profiles: {
|
||||||
custom: '~/.custom.json',
|
custom: '~/.custom.json',
|
||||||
glm: '~/.ccs/glm.settings.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
|
// Run postinstall again
|
||||||
execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env });
|
execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env });
|
||||||
|
|
||||||
// Verify custom config preserved
|
// 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(config.profiles.custom, 'Custom profile should be preserved');
|
||||||
assert.strictEqual(config.profiles.custom, '~/.custom.json');
|
assert.strictEqual(config.profiles.custom, '~/.custom.json');
|
||||||
});
|
});
|
||||||
@@ -97,7 +109,7 @@ describe('npm postinstall', () => {
|
|||||||
|
|
||||||
// Verify existing file still exists and new files are created
|
// Verify existing file still exists and new files are created
|
||||||
assert(testEnv.fileExists('existing.txt'), 'Existing files should be preserved');
|
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
|
// GLM/GLMT/Kimi are no longer auto-created
|
||||||
assert(!testEnv.fileExists('glm.settings.json'), 'glm.settings.json should NOT be auto-created');
|
assert(!testEnv.fileExists('glm.settings.json'), 'glm.settings.json should NOT be auto-created');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user