Files
ccs/scripts/postinstall.js

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/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 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|km
// 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();