mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 18:21:09 +00:00
- setup-command.ts: Add Ctrl+C handling (UserCancelledError), port validation (1-65535), protocol stripping from host, try-catch with user-friendly error messages - postinstall.js: Add ~/.ccs file check (not directory), wrap js-yaml require in try-catch with JSON fallback, validate config.json before migration, warn when both config files exist - recovery-manager.ts: Verify config is loadable (not just exists), add nested error handling for fallback write - ccs.ts: Make first-time install hint independent of recovery status (shows even when user manually created empty config.yaml) All edge cases identified by code review addressed.
359 lines
13 KiB
JavaScript
Executable File
359 lines
13 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
/**
|
|
* CCS Postinstall Script
|
|
* Automatically creates config files in ~/.ccs/ after npm install
|
|
*
|
|
* Runs when: npm install -g @kaitranntt/ccs
|
|
* Idempotent: Safe to run multiple times (won't overwrite existing configs)
|
|
* Cross-platform: Works on Unix, macOS, Windows
|
|
*
|
|
* Test isolation: Set CCS_HOME env var to redirect all operations to a test directory
|
|
*/
|
|
|
|
/**
|
|
* Get the CCS home directory (respects CCS_HOME env var for test isolation)
|
|
* @returns {string} Home directory path
|
|
*/
|
|
function getCcsHome() {
|
|
return process.env.CCS_HOME || os.homedir();
|
|
}
|
|
|
|
/**
|
|
* Check if path is a broken symlink and remove it if so
|
|
* Fixes: ENOENT error when mkdir tries to create over a dangling symlink
|
|
* @param {string} targetPath - Path to check
|
|
* @returns {boolean} true if broken symlink was removed
|
|
*/
|
|
function removeIfBrokenSymlink(targetPath) {
|
|
try {
|
|
// lstatSync doesn't follow symlinks - it checks the link itself
|
|
const stats = fs.lstatSync(targetPath);
|
|
if (stats.isSymbolicLink()) {
|
|
// Check if symlink target exists
|
|
try {
|
|
fs.statSync(targetPath); // This follows symlinks
|
|
return false; // Symlink is valid
|
|
} catch {
|
|
// Target doesn't exist - broken symlink
|
|
fs.unlinkSync(targetPath);
|
|
console.log(`[!] Removed broken symlink: ${targetPath}`);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
} catch {
|
|
// Path doesn't exist at all
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate created configuration files
|
|
* @returns {object} { success: boolean, errors: string[], warnings: string[] }
|
|
*/
|
|
function validateConfiguration() {
|
|
const homedir = getCcsHome();
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
// Check ~/.ccs/ directory
|
|
const ccsDir = path.join(homedir, '.ccs');
|
|
if (!fs.existsSync(ccsDir)) {
|
|
errors.push('~/.ccs/ directory not found');
|
|
}
|
|
|
|
// 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);
|
|
|
|
if (!hasConfig) {
|
|
errors.push('config.yaml (or config.json) not found');
|
|
}
|
|
|
|
// Check ~/.claude/settings.json (warning only, not critical)
|
|
const claudeSettings = path.join(homedir, '.claude', 'settings.json');
|
|
if (!fs.existsSync(claudeSettings)) {
|
|
warnings.push('~/.claude/settings.json not found - run "claude /login"');
|
|
}
|
|
|
|
return { success: errors.length === 0, errors, warnings };
|
|
}
|
|
|
|
function createConfigFiles() {
|
|
try {
|
|
// Get user home directory (cross-platform, respects CCS_HOME for test isolation)
|
|
const homedir = getCcsHome();
|
|
const ccsDir = path.join(homedir, '.ccs');
|
|
|
|
// Create ~/.ccs/ directory if missing
|
|
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/');
|
|
}
|
|
|
|
// Create ~/.ccs/shared/ directory structure (Phase 1)
|
|
const sharedDir = path.join(ccsDir, 'shared');
|
|
// Handle broken symlinks (common when upgrading from older versions)
|
|
removeIfBrokenSymlink(sharedDir);
|
|
if (!fs.existsSync(sharedDir)) {
|
|
fs.mkdirSync(sharedDir, { recursive: true, mode: 0o755 });
|
|
console.log('[OK] Created directory: ~/.ccs/shared/');
|
|
}
|
|
|
|
// Create shared subdirectories
|
|
const sharedSubdirs = ['commands', 'skills', 'agents', 'plugins'];
|
|
for (const subdir of sharedSubdirs) {
|
|
const subdirPath = path.join(sharedDir, subdir);
|
|
// Handle broken symlinks before creating directory
|
|
removeIfBrokenSymlink(subdirPath);
|
|
if (!fs.existsSync(subdirPath)) {
|
|
fs.mkdirSync(subdirPath, { recursive: true, mode: 0o755 });
|
|
console.log(`[OK] Created directory: ~/.ccs/shared/${subdir}/`);
|
|
}
|
|
}
|
|
|
|
// Migrate from v3.1.1 to v3.2.0 (symlink architecture)
|
|
console.log('');
|
|
try {
|
|
const SharedManager = require('../dist/management/shared-manager').default;
|
|
const sharedManager = new SharedManager();
|
|
sharedManager.migrateFromV311();
|
|
sharedManager.ensureSharedDirectories();
|
|
|
|
// Run v4.4 migration: Migrate instances to shared settings.json
|
|
sharedManager.migrateToSharedSettings();
|
|
} catch (err) {
|
|
console.warn('[!] Migration warning:', err.message);
|
|
console.warn(' Migration will retry on next run');
|
|
}
|
|
console.log('');
|
|
|
|
// NOTE: .claude/ directory installation moved to "ccs sync" command
|
|
// 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.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 configYamlPath = path.join(ccsDir, 'config.yaml');
|
|
const legacyConfigPath = path.join(ccsDir, 'config.json');
|
|
|
|
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`);
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// Users can create these via:
|
|
// - UI: Profile Create Dialog → Provider Presets
|
|
// - CLI: ccs api create --preset glm|glmt|kimi
|
|
// This gives users control over which providers they want to use
|
|
// Existing profiles are preserved for backward compatibility
|
|
|
|
// Copy shell completion files to ~/.ccs/completions/
|
|
const completionsDir = path.join(ccsDir, 'completions');
|
|
const scriptsCompletionDir = path.join(__dirname, '../scripts/completion');
|
|
|
|
if (!fs.existsSync(completionsDir)) {
|
|
fs.mkdirSync(completionsDir, { recursive: true, mode: 0o755 });
|
|
}
|
|
|
|
const completionFiles = ['ccs.bash', 'ccs.zsh', 'ccs.fish', 'ccs.ps1'];
|
|
completionFiles.forEach(file => {
|
|
const src = path.join(scriptsCompletionDir, file);
|
|
const dest = path.join(completionsDir, file);
|
|
|
|
if (fs.existsSync(src)) {
|
|
fs.copyFileSync(src, dest);
|
|
}
|
|
});
|
|
|
|
console.log('[OK] Installed shell completions: ~/.ccs/completions/');
|
|
console.log('');
|
|
console.log(' [i] Enable auto-completion:');
|
|
console.log(' Run: ccs --shell-completion');
|
|
console.log('');
|
|
|
|
// Create ~/.claude/settings.json if missing (NEW)
|
|
const claudeDir = path.join(homedir, '.claude');
|
|
const claudeSettingsPath = path.join(claudeDir, 'settings.json');
|
|
|
|
if (!fs.existsSync(claudeDir)) {
|
|
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o755 });
|
|
console.log('[OK] Created directory: ~/.claude/');
|
|
}
|
|
|
|
if (!fs.existsSync(claudeSettingsPath)) {
|
|
// Create empty settings (matches Claude CLI behavior)
|
|
const tmpPath = `${claudeSettingsPath}.tmp`;
|
|
fs.writeFileSync(tmpPath, '{}\n', 'utf8');
|
|
fs.renameSync(tmpPath, claudeSettingsPath);
|
|
|
|
console.log('[OK] Created default settings: ~/.claude/settings.json');
|
|
console.log('');
|
|
console.log(' [i] Configure Claude CLI:');
|
|
console.log(' Run: claude /login');
|
|
console.log('');
|
|
} else {
|
|
console.log('[OK] Claude settings exist: ~/.claude/settings.json (preserved)');
|
|
}
|
|
|
|
// Validate configuration
|
|
console.log('');
|
|
console.log('[i] Validating configuration...');
|
|
const validation = validateConfiguration();
|
|
|
|
if (!validation.success) {
|
|
console.error('');
|
|
console.error('[X] Configuration validation failed:');
|
|
validation.errors.forEach(err => console.error(` - ${err}`));
|
|
console.error('');
|
|
throw new Error('Configuration incomplete');
|
|
}
|
|
|
|
// Show warnings (non-critical)
|
|
if (validation.warnings.length > 0) {
|
|
console.warn('');
|
|
console.warn('[!] Warnings:');
|
|
validation.warnings.forEach(warn => console.warn(` - ${warn}`));
|
|
}
|
|
|
|
console.log('');
|
|
console.log('[OK] CCS configuration ready!');
|
|
console.log(' Run: ccs --version');
|
|
|
|
} catch (err) {
|
|
// Show error details
|
|
console.error('');
|
|
console.error('[X] CCS configuration failed');
|
|
console.error(` Error: ${err.message}`);
|
|
console.error('');
|
|
console.error('Recovery steps:');
|
|
console.error(' 1. Create directory manually:');
|
|
console.error(' mkdir -p ~/.ccs ~/.claude');
|
|
console.error('');
|
|
console.error(' 2. Create empty settings:');
|
|
console.error(' echo "{}" > ~/.claude/settings.json');
|
|
console.error('');
|
|
console.error(' 3. Retry installation:');
|
|
console.error(' npm install -g @kaitranntt/ccs --force');
|
|
console.error('');
|
|
console.error(' 4. If issue persists, report at:');
|
|
console.error(' https://github.com/kaitranntt/ccs/issues');
|
|
console.error('');
|
|
|
|
// Exit with error code (npm will show warning)
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Run postinstall
|
|
createConfigFiles();
|