mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-12 18:24:22 +00:00
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:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user