diff --git a/package.json b/package.json index d0b747cf..ffaa5168 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "scripts": { "build": "tsc && node scripts/add-shebang.js", "build:watch": "tsc --watch", - "prebuild": "rm -rf dist", + "prebuild": "rm -rf dist tsconfig.tsbuildinfo", "test": "npm run build && npm run test:all", "test:all": "npm run test:unit && npm run test:npm", "test:unit": "npx mocha tests/shared/unit/**/*.test.js --timeout 5000", diff --git a/src/utils/claude-dir-installer.ts b/src/utils/claude-dir-installer.ts new file mode 100644 index 00000000..78e9d3d9 --- /dev/null +++ b/src/utils/claude-dir-installer.ts @@ -0,0 +1,301 @@ +/** + * ClaudeDirInstaller - Manages copying .claude/ directory from package to ~/.ccs/.claude/ + * v4.1.1: Fix for npm install not copying .claude/ directory + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { colored } from './helpers'; + +// Ora fallback type for when ora is not available +interface OraSpinner { + text: string; + succeed: (msg?: string) => void; + fail: (msg?: string) => void; + warn: (msg?: string) => void; + info: (msg?: string) => void; +} + +interface OraInstance { + start: () => OraSpinner; +} + +// Make ora optional (might not be available during npm install postinstall) +let ora: ((text: string) => OraInstance) | null = null; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const oraModule = require('ora'); + ora = oraModule.default || oraModule; +} catch { + // ora not available, create fallback spinner that uses console.log + ora = function(text: string): OraInstance { + return { + start: () => ({ + succeed: (msg?: string) => console.log(msg || `[OK] ${text}`), + fail: (msg?: string) => console.log(msg || `[X] ${text}`), + warn: (msg?: string) => console.log(msg || `[!] ${text}`), + info: (msg?: string) => console.log(msg || `[i] ${text}`), + text: '' + }) + }; + }; +} + +interface ItemCount { + files: number; + dirs: number; +} + +interface CleanupResult { + success: boolean; + cleanedFiles: string[]; + error?: string; +} + +/** + * ClaudeDirInstaller - Manages copying .claude/ directory from package to ~/.ccs/.claude/ + */ +export class ClaudeDirInstaller { + private homeDir: string; + private ccsClaudeDir: string; + + constructor() { + this.homeDir = os.homedir(); + this.ccsClaudeDir = path.join(this.homeDir, '.ccs', '.claude'); + } + + /** + * Copy .claude/ directory from package to ~/.ccs/.claude/ + * @param packageDir - Package installation directory (default: auto-detect) + * @param silent - Suppress spinner output + */ + install(packageDir?: string, silent = false): boolean { + const spinner = (silent || !ora) ? null : ora('Copying .claude/ items to ~/.ccs/.claude/').start(); + + try { + // Auto-detect package directory if not provided + if (!packageDir) { + // Try to find package root by going up from this file + packageDir = path.join(__dirname, '..', '..'); + } + + const packageClaudeDir = path.join(packageDir, '.claude'); + + if (!fs.existsSync(packageClaudeDir)) { + const msg = 'Package .claude/ directory not found'; + if (spinner) { + spinner.warn(`[!] ${msg}`); + console.log(` Searched in: ${packageClaudeDir}`); + console.log(' This may be a development installation'); + } else { + console.log(`[!] ${msg}`); + console.log(` Searched in: ${packageClaudeDir}`); + console.log(' This may be a development installation'); + } + return false; + } + + // Remove old version before copying new one + if (fs.existsSync(this.ccsClaudeDir)) { + if (spinner) spinner.text = 'Removing old .claude/ items...'; + fs.rmSync(this.ccsClaudeDir, { recursive: true, force: true }); + } + + // Use fs.cpSync for recursive copy (Node.js 16.7.0+) + if (spinner) spinner.text = 'Copying .claude/ items...'; + + if (fs.cpSync) { + fs.cpSync(packageClaudeDir, this.ccsClaudeDir, { recursive: true }); + } else { + // Fallback for Node.js < 16.7.0 + this.copyDirRecursive(packageClaudeDir, this.ccsClaudeDir); + } + + // Count files and directories + const itemCount = this.countItems(this.ccsClaudeDir); + const msg = `Copied .claude/ items (${itemCount.files} files, ${itemCount.dirs} directories)`; + + if (spinner) { + spinner.succeed(colored('[OK]', 'green') + ` ${msg}`); + } else { + console.log(`[OK] ${msg}`); + } + return true; + } catch (err) { + const error = err as Error; + const msg = `Failed to copy .claude/ directory: ${error.message}`; + if (spinner) { + spinner.fail(colored('[!]', 'yellow') + ` ${msg}`); + console.warn(' CCS items may not be available'); + } else { + console.warn(`[!] ${msg}`); + console.warn(' CCS items may not be available'); + } + return false; + } + } + + /** + * Recursively copy directory (fallback for Node.js < 16.7.0) + */ + private copyDirRecursive(src: string, dest: string): void { + // Create destination directory + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + // Read source directory + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + // Recursively copy subdirectory + this.copyDirRecursive(srcPath, destPath); + } else { + // Copy file + fs.copyFileSync(srcPath, destPath); + } + } + } + + /** + * Count files and directories in a path + */ + private countItems(dirPath: string): ItemCount { + let files = 0; + let dirs = 0; + + const countRecursive = (p: string): void => { + const entries = fs.readdirSync(p, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + dirs++; + countRecursive(path.join(p, entry.name)); + } else { + files++; + } + } + }; + + try { + countRecursive(dirPath); + } catch { + // Ignore errors + } + + return { files, dirs }; + } + + /** + * Clean up deprecated files from previous installations + * Removes ccs-delegator.md that was deprecated in v4.3.2 + */ + cleanupDeprecated(silent = false): CleanupResult { + const deprecatedFile = path.join(this.ccsClaudeDir, 'agents', 'ccs-delegator.md'); + const userSymlinkFile = path.join(this.homeDir, '.claude', 'agents', 'ccs-delegator.md'); + const migrationMarker = path.join(this.homeDir, '.ccs', '.migrations', 'v435-delegator-cleanup'); + + const cleanedFiles: string[] = []; + + try { + // Check if cleanup already done + if (fs.existsSync(migrationMarker)) { + return { success: true, cleanedFiles: [] }; // Already cleaned + } + + // Clean up user symlink in ~/.claude/agents/ccs-delegator.md FIRST + try { + const userStats = fs.lstatSync(userSymlinkFile); + if (userStats.isSymbolicLink()) { + fs.unlinkSync(userSymlinkFile); + cleanedFiles.push('user symlink'); + } else { + // It's not a symlink (user created their own file), backup it + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0]; + const backupPath = `${userSymlinkFile}.backup-${timestamp}`; + fs.renameSync(userSymlinkFile, backupPath); + if (!silent) console.log(`[i] Backed up user file to ${path.basename(backupPath)}`); + cleanedFiles.push('user file (backed up)'); + } + } catch (err) { + const error = err as NodeJS.ErrnoException; + // File doesn't exist or other error - that's okay + if (error.code !== 'ENOENT' && !silent) { + console.log(`[!] Failed to remove user symlink: ${error.message}`); + } + } + + // Clean up package copy in ~/.ccs/.claude/agents/ccs-delegator.md + if (fs.existsSync(deprecatedFile)) { + try { + // Check if file was modified by user (compare with expected content) + const shouldBackup = this.shouldBackupDeprecatedFile(deprecatedFile); + + if (shouldBackup) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0]; + const backupPath = `${deprecatedFile}.backup-${timestamp}`; + fs.renameSync(deprecatedFile, backupPath); + if (!silent) console.log(`[i] Backed up modified deprecated file to ${path.basename(backupPath)}`); + } else { + fs.rmSync(deprecatedFile, { force: true }); + } + cleanedFiles.push('package copy'); + } catch (err) { + const error = err as Error; + if (!silent) console.log(`[!] Failed to remove package copy: ${error.message}`); + } + } + + // Create migration marker + if (cleanedFiles.length > 0) { + const migrationsDir = path.dirname(migrationMarker); + if (!fs.existsSync(migrationsDir)) { + fs.mkdirSync(migrationsDir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(migrationMarker, new Date().toISOString()); + + if (!silent) { + console.log(`[OK] Cleaned up deprecated agent files: ${cleanedFiles.join(', ')}`); + } + } + + return { success: true, cleanedFiles }; + } catch (err) { + const error = err as Error; + if (!silent) console.log(`[!] Cleanup failed: ${error.message}`); + return { success: false, error: error.message, cleanedFiles }; + } + } + + /** + * Check if deprecated file should be backed up (user modified) + */ + private shouldBackupDeprecatedFile(filePath: string): boolean { + try { + // Simple heuristic: if file size differs significantly from expected, assume user modified + // Expected size for ccs-delegator.md was around 2-3KB + const stats = fs.statSync(filePath); + const expectedMinSize = 1000; // 1KB minimum + const expectedMaxSize = 10000; // 10KB maximum + + // If size is outside expected range, likely user modified + return stats.size < expectedMinSize || stats.size > expectedMaxSize; + } catch { + // If we can't determine, err on side of caution and backup + return true; + } + } + + /** + * Check if ~/.ccs/.claude/ exists and is valid + */ + isInstalled(): boolean { + return fs.existsSync(this.ccsClaudeDir); + } +} + +export default ClaudeDirInstaller; diff --git a/src/utils/claude-symlink-manager.ts b/src/utils/claude-symlink-manager.ts new file mode 100644 index 00000000..72f6406c --- /dev/null +++ b/src/utils/claude-symlink-manager.ts @@ -0,0 +1,310 @@ +/** + * ClaudeSymlinkManager - Manages selective symlinks from ~/.ccs/.claude/ to ~/.claude/ + * v4.1.0: Selective symlinking for CCS items + * + * Purpose: Ship CCS items (.claude/) with package and symlink them to user's ~/.claude/ + * Architecture: + * - ~/.ccs/.claude/* (source, ships with CCS) + * - ~/.claude/* (target, gets selective symlinks) + * - ~/.ccs/shared/ (UNTOUCHED, existing profile mechanism) + * + * Symlink Chain: + * profile -> ~/.ccs/shared/ -> ~/.claude/ (which has symlinks to ~/.ccs/.claude/) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { colored } from './helpers'; + +// Ora fallback type for when ora is not available +interface OraSpinner { + text: string; + succeed: (msg?: string) => void; + fail: (msg?: string) => void; + warn: (msg?: string) => void; + info: (msg?: string) => void; +} + +interface OraInstance { + start: () => OraSpinner; +} + +// Make ora optional (might not be available during npm install postinstall) +let ora: ((text: string) => OraInstance) | null = null; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const oraModule = require('ora'); + ora = oraModule.default || oraModule; +} catch { + // ora not available, create fallback spinner that uses console.log + ora = function(text: string): OraInstance { + return { + start: () => ({ + succeed: (msg?: string) => console.log(msg || `[OK] ${text}`), + fail: (msg?: string) => console.log(msg || `[X] ${text}`), + warn: (msg?: string) => console.log(msg || `[!] ${text}`), + info: (msg?: string) => console.log(msg || `[i] ${text}`), + text: '' + }) + }; + }; +} + +interface CcsItem { + source: string; + target: string; + type: 'file' | 'directory'; +} + +interface HealthCheckResult { + healthy: boolean; + issues: string[]; +} + +/** + * ClaudeSymlinkManager - Manages selective symlinks from ~/.ccs/.claude/ to ~/.claude/ + */ +export class ClaudeSymlinkManager { + private homeDir: string; + private ccsClaudeDir: string; + private userClaudeDir: string; + private ccsItems: CcsItem[]; + + constructor() { + this.homeDir = os.homedir(); + this.ccsClaudeDir = path.join(this.homeDir, '.ccs', '.claude'); + this.userClaudeDir = path.join(this.homeDir, '.claude'); + + // CCS items to symlink (selective, item-level) + this.ccsItems = [ + { source: 'commands/ccs.md', target: 'commands/ccs.md', type: 'file' }, + { source: 'commands/ccs', target: 'commands/ccs', type: 'directory' }, + { source: 'skills/ccs-delegation', target: 'skills/ccs-delegation', type: 'directory' } + ]; + } + + /** + * Install CCS items to user's ~/.claude/ via selective symlinks + * Safe: backs up existing files before creating symlinks + */ + install(silent = false): void { + const spinner = (silent || !ora) ? null : ora('Installing CCS items to ~/.claude/').start(); + + // Ensure ~/.ccs/.claude/ exists (should be shipped with package) + if (!fs.existsSync(this.ccsClaudeDir)) { + const msg = 'CCS .claude/ directory not found, skipping symlink installation'; + if (spinner) { + spinner.warn(`[!] ${msg}`); + } else { + console.log(`[!] ${msg}`); + } + return; + } + + // Create ~/.claude/ if missing + if (!fs.existsSync(this.userClaudeDir)) { + if (!silent) { + if (spinner) spinner.text = 'Creating ~/.claude/ directory'; + } + fs.mkdirSync(this.userClaudeDir, { recursive: true, mode: 0o700 }); + } + + // Install each CCS item + let installed = 0; + for (const item of this.ccsItems) { + if (!silent && spinner) { + spinner.text = `Installing ${item.target}...`; + } + const result = this.installItem(item, silent); + if (result) installed++; + } + + const msg = `${installed}/${this.ccsItems.length} items installed to ~/.claude/`; + if (spinner) { + spinner.succeed(colored('[OK]', 'green') + ` ${msg}`); + } else { + console.log(`[OK] ${msg}`); + } + } + + /** + * Install a single CCS item with conflict handling + */ + private installItem(item: CcsItem, silent = false): boolean { + const sourcePath = path.join(this.ccsClaudeDir, item.source); + const targetPath = path.join(this.userClaudeDir, item.target); + const targetDir = path.dirname(targetPath); + + // Ensure source exists + if (!fs.existsSync(sourcePath)) { + if (!silent) console.log(`[!] Source not found: ${item.source}, skipping`); + return false; + } + + // Create target parent directory if needed + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 }); + } + + // Check if target already exists + if (fs.existsSync(targetPath)) { + // Check if it's already the correct symlink + if (this.isOurSymlink(targetPath, sourcePath)) { + return true; // Already correct, counts as success + } + + // Backup existing file/directory + this.backupItem(targetPath, silent); + } + + // Create symlink + try { + const symlinkType = item.type === 'directory' ? 'dir' : 'file'; + fs.symlinkSync(sourcePath, targetPath, symlinkType); + if (!silent) console.log(`[OK] Symlinked ${item.target}`); + return true; + } catch (err) { + // Windows fallback: stub for now, full implementation in v4.2 + if (process.platform === 'win32') { + if (!silent) { + console.log(`[!] Symlink failed for ${item.target} (Windows fallback deferred to v4.2)`); + console.log(`[i] Enable Developer Mode or wait for next update`); + } + } else { + const error = err as Error; + if (!silent) console.log(`[!] Failed to symlink ${item.target}: ${error.message}`); + } + return false; + } + } + + /** + * Check if target is already the correct symlink pointing to source + */ + private isOurSymlink(targetPath: string, expectedSource: string): boolean { + try { + const stats = fs.lstatSync(targetPath); + + if (!stats.isSymbolicLink()) { + return false; + } + + const actualTarget = fs.readlinkSync(targetPath); + const resolvedTarget = path.resolve(path.dirname(targetPath), actualTarget); + + return resolvedTarget === expectedSource; + } catch { + return false; + } + } + + /** + * Backup existing item before replacing with symlink + */ + private backupItem(itemPath: string, silent = false): void { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0]; + const backupPath = `${itemPath}.backup-${timestamp}`; + + try { + // If backup already exists, use counter + let finalBackupPath = backupPath; + let counter = 1; + while (fs.existsSync(finalBackupPath)) { + finalBackupPath = `${backupPath}-${counter}`; + counter++; + } + + fs.renameSync(itemPath, finalBackupPath); + if (!silent) console.log(`[i] Backed up existing item to ${path.basename(finalBackupPath)}`); + } catch (err) { + const error = err as Error; + if (!silent) console.log(`[!] Failed to backup ${itemPath}: ${error.message}`); + throw err; // Don't proceed if backup fails + } + } + + /** + * Uninstall CCS items from ~/.claude/ (remove symlinks only) + * Safe: only removes items that are CCS symlinks + */ + uninstall(): void { + let removed = 0; + + for (const item of this.ccsItems) { + const targetPath = path.join(this.userClaudeDir, item.target); + const sourcePath = path.join(this.ccsClaudeDir, item.source); + + // Only remove if it's our symlink + if (fs.existsSync(targetPath) && this.isOurSymlink(targetPath, sourcePath)) { + try { + fs.unlinkSync(targetPath); + console.log(`[OK] Removed ${item.target}`); + removed++; + } catch (err) { + const error = err as Error; + console.log(`[!] Failed to remove ${item.target}: ${error.message}`); + } + } + } + + if (removed > 0) { + console.log(`[OK] Removed ${removed} delegation commands and skills from ~/.claude/`); + } else { + console.log('[i] No delegation commands or skills to remove'); + } + } + + /** + * Check symlink health and report issues + * Used by 'ccs doctor' command + */ + checkHealth(): HealthCheckResult { + const issues: string[] = []; + let healthy = true; + + // Check if ~/.ccs/.claude/ exists + if (!fs.existsSync(this.ccsClaudeDir)) { + issues.push('CCS .claude/ directory missing (reinstall CCS)'); + healthy = false; + return { healthy, issues }; + } + + // Check each item + for (const item of this.ccsItems) { + const sourcePath = path.join(this.ccsClaudeDir, item.source); + const targetPath = path.join(this.userClaudeDir, item.target); + + // Check source exists + if (!fs.existsSync(sourcePath)) { + issues.push(`Source missing: ${item.source}`); + healthy = false; + continue; + } + + // Check target + if (!fs.existsSync(targetPath)) { + issues.push(`Not installed: ${item.target} (run 'ccs sync' to install)`); + healthy = false; + } else if (!this.isOurSymlink(targetPath, sourcePath)) { + issues.push(`Not a CCS symlink: ${item.target} (run 'ccs sync' to fix)`); + healthy = false; + } + } + + return { healthy, issues }; + } + + /** + * Sync delegation commands and skills to ~/.claude/ (used by 'ccs sync' command) + * Same as install() but with explicit sync message + */ + sync(): void { + console.log(''); + console.log(colored('Syncing CCS Components...', 'cyan')); + console.log(''); + this.install(false); + } +} + +export default ClaudeSymlinkManager; diff --git a/src/utils/progress-indicator.ts b/src/utils/progress-indicator.ts new file mode 100644 index 00000000..36f1b195 --- /dev/null +++ b/src/utils/progress-indicator.ts @@ -0,0 +1,119 @@ +/** + * Simple Progress Indicator (no external dependencies) + * + * Features: + * - ASCII-only spinner frames (cross-platform compatible) + * - TTY detection (no spinners in pipes/logs) + * - Elapsed time display + * - CI environment detection + */ + +interface ProgressOptions { + frames?: string[]; + interval?: number; +} + +export class ProgressIndicator { + private message: string; + private frames: string[]; + private frameIndex: number; + private interval: NodeJS.Timeout | null; + private startTime: number; + private isTTY: boolean; + + /** + * Create a progress indicator + * @param message - Message to display + * @param options - Options + */ + constructor(message: string, options: ProgressOptions = {}) { + this.message = message; + // ASCII-only frames for cross-platform compatibility + this.frames = options.frames || ['|', '/', '-', '\\']; + this.frameIndex = 0; + this.interval = null; + this.startTime = Date.now(); + + // TTY detection: only animate if stderr is TTY and not in CI + this.isTTY = process.stderr.isTTY === true && !process.env.CI && !process.env.NO_COLOR; + } + + /** + * Start the spinner + */ + start(): void { + if (!this.isTTY) { + // Non-TTY: just print message once + process.stderr.write(`[i] ${this.message}...\n`); + return; + } + + // TTY: animate spinner + this.interval = setInterval(() => { + const frame = this.frames[this.frameIndex]; + const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1); + process.stderr.write(`\r[${frame}] ${this.message}... (${elapsed}s)`); + this.frameIndex = (this.frameIndex + 1) % this.frames.length; + }, 80); // 12.5fps for smooth animation + } + + /** + * Stop spinner with success message + * @param message - Optional success message (defaults to original message) + */ + succeed(message?: string): void { + this.stop(); + const finalMessage = message || this.message; + const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1); + + if (this.isTTY) { + // Clear spinner line and show success + process.stderr.write(`\r[OK] ${finalMessage} (${elapsed}s)\n`); + } else { + // Non-TTY: just show completion + process.stderr.write(`[OK] ${finalMessage}\n`); + } + } + + /** + * Stop spinner with failure message + * @param message - Optional failure message (defaults to original message) + */ + fail(message?: string): void { + this.stop(); + const finalMessage = message || this.message; + + if (this.isTTY) { + // Clear spinner line and show failure + process.stderr.write(`\r[X] ${finalMessage}\n`); + } else { + // Non-TTY: just show failure + process.stderr.write(`[X] ${finalMessage}\n`); + } + } + + /** + * Update spinner message (while running) + * @param newMessage - New message to display + */ + update(newMessage: string): void { + this.message = newMessage; + } + + /** + * Stop the spinner without showing success/failure + */ + stop(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + + if (this.isTTY) { + // Clear the spinner line + process.stderr.write('\r\x1b[K'); + } + } + } +} + +export default ProgressIndicator; diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts new file mode 100644 index 00000000..c31c1829 --- /dev/null +++ b/src/utils/update-checker.ts @@ -0,0 +1,245 @@ +/** + * Update Checker - Check for new CCS versions from npm registry or GitHub + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as https from 'https'; +import { colored } from './helpers'; + +const UPDATE_CHECK_FILE = path.join(os.homedir(), '.ccs', 'update-check.json'); +const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours +const GITHUB_API_URL = 'https://api.github.com/repos/kaitranntt/ccs/releases/latest'; +const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@kaitranntt/ccs/latest'; +const REQUEST_TIMEOUT = 5000; // 5 seconds + +interface UpdateCache { + last_check: number; + latest_version: string | null; + dismissed_version: string | null; +} + +interface UpdateResult { + status: 'update_available' | 'no_update' | 'check_failed'; + reason?: string; + latest?: string; + current?: string; + message?: string; +} + +/** + * Compare semantic versions + * @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal + */ +export function compareVersions(v1: string, v2: string): number { + const parts1 = v1.replace(/^v/, '').split('.').map(Number); + const parts2 = v2.replace(/^v/, '').split('.').map(Number); + + for (let i = 0; i < 3; i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 > p2) return 1; + if (p1 < p2) return -1; + } + return 0; +} + +/** + * Fetch latest version from GitHub releases + */ +function fetchLatestVersionFromGitHub(): Promise { + return new Promise((resolve) => { + const req = https.get(GITHUB_API_URL, { + headers: { 'User-Agent': 'CCS-Update-Checker' }, + timeout: REQUEST_TIMEOUT + }, (res) => { + let data = ''; + + res.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + + res.on('end', () => { + try { + if (res.statusCode !== 200) { + resolve(null); + return; + } + + const release = JSON.parse(data) as { tag_name?: string }; + const version = release.tag_name?.replace(/^v/, '') || null; + resolve(version); + } catch { + resolve(null); + } + }); + }); + + req.on('error', () => resolve(null)); + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + }); +} + +/** + * Fetch latest version from npm registry + */ +function fetchLatestVersionFromNpm(): Promise { + return new Promise((resolve) => { + const req = https.get(NPM_REGISTRY_URL, { + headers: { 'User-Agent': 'CCS-Update-Checker' }, + timeout: REQUEST_TIMEOUT + }, (res) => { + let data = ''; + + res.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + + res.on('end', () => { + try { + if (res.statusCode !== 200) { + resolve(null); + return; + } + + const packageData = JSON.parse(data) as { version?: string }; + const version = packageData.version || null; + resolve(version); + } catch { + resolve(null); + } + }); + }); + + req.on('error', () => resolve(null)); + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + }); +} + +/** + * Read update check cache + */ +export function readCache(): UpdateCache { + try { + if (!fs.existsSync(UPDATE_CHECK_FILE)) { + return { last_check: 0, latest_version: null, dismissed_version: null }; + } + + const data = fs.readFileSync(UPDATE_CHECK_FILE, 'utf8'); + return JSON.parse(data) as UpdateCache; + } catch { + return { last_check: 0, latest_version: null, dismissed_version: null }; + } +} + +/** + * Write update check cache + */ +export function writeCache(cache: UpdateCache): void { + try { + const ccsDir = path.join(os.homedir(), '.ccs'); + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 }); + } + + fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify(cache, null, 2), 'utf8'); + } catch { + // Silently fail - not critical + } +} + +/** + * Check for updates (async, non-blocking) + * @param currentVersion - Current CCS version + * @param force - Force check even if within interval + * @param installMethod - Installation method ('npm' or 'direct') + */ +export async function checkForUpdates( + currentVersion: string, + force = false, + installMethod: 'npm' | 'direct' = 'direct' +): Promise { + const cache = readCache(); + const now = Date.now(); + + // Check if we should check for updates + if (!force && (now - cache.last_check < CHECK_INTERVAL)) { + // Use cached result if available + if (cache.latest_version && compareVersions(cache.latest_version, currentVersion) > 0) { + // Don't show if user dismissed this version + if (cache.dismissed_version === cache.latest_version) { + return { status: 'no_update', reason: 'dismissed' }; + } + return { status: 'update_available', latest: cache.latest_version, current: currentVersion }; + } + return { status: 'no_update', reason: 'cached' }; + } + + // Fetch latest version from appropriate source + let latestVersion: string | null; + let fetchError: string | null = null; + + if (installMethod === 'npm') { + latestVersion = await fetchLatestVersionFromNpm(); + if (!latestVersion) fetchError = 'npm_registry_error'; + } else { + latestVersion = await fetchLatestVersionFromGitHub(); + if (!latestVersion) fetchError = 'github_api_error'; + } + + // Update cache + cache.last_check = now; + if (latestVersion) { + cache.latest_version = latestVersion; + } + writeCache(cache); + + // Handle fetch errors + if (fetchError) { + return { + status: 'check_failed', + reason: fetchError, + message: `Failed to check for updates: ${fetchError.replace(/_/g, ' ')}` + }; + } + + // Check if update available + if (latestVersion && compareVersions(latestVersion, currentVersion) > 0) { + // Don't show if user dismissed this version + if (cache.dismissed_version === latestVersion) { + return { status: 'no_update', reason: 'dismissed' }; + } + return { status: 'update_available', latest: latestVersion, current: currentVersion }; + } + + return { status: 'no_update', reason: 'latest' }; +} + +/** + * Show update notification + */ +export function showUpdateNotification(updateInfo: { current: string; latest: string }): void { + console.log(''); + console.log(colored('═══════════════════════════════════════════════════════', 'cyan')); + console.log(colored(` Update available: ${updateInfo.current} → ${updateInfo.latest}`, 'yellow')); + console.log(colored('═══════════════════════════════════════════════════════', 'cyan')); + console.log(''); + console.log(` Run ${colored('ccs update', 'yellow')} to update`); + console.log(''); +} + +/** + * Dismiss update notification for a specific version + */ +export function dismissUpdate(version: string): void { + const cache = readCache(); + cache.dismissed_version = version; + writeCache(cache); +}