feat(typescript): convert 4 missing utilities to TypeScript

- claude-dir-installer.ts: copy .claude/ from package (~300 LOC)
- claude-symlink-manager.ts: selective symlinks to ~/.claude/ (~310 LOC)
- progress-indicator.ts: CLI spinners/progress display (~120 LOC)
- update-checker.ts: version update notifications (~250 LOC)

All strict TypeScript, no any types, cross-platform compatible.
39/39 tests pass.
This commit is contained in:
kaitranntt
2025-11-26 19:37:59 -05:00
parent 77d026869e
commit ea6695f4a9
5 changed files with 976 additions and 1 deletions
+1 -1
View File
@@ -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",
+301
View File
@@ -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;
+310
View File
@@ -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;
+119
View File
@@ -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;
+245
View File
@@ -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<string | null> {
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<string | null> {
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<UpdateResult> {
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);
}