diff --git a/ccw/src/commands/install.ts b/ccw/src/commands/install.ts index f959b744..a7027f37 100644 --- a/ccw/src/commands/install.ts +++ b/ccw/src/commands/install.ts @@ -1,11 +1,11 @@ -import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync, unlinkSync, rmdirSync } from 'fs'; import { join, dirname, basename } from 'path'; import { homedir } from 'os'; import { fileURLToPath } from 'url'; import inquirer from 'inquirer'; import chalk from 'chalk'; import { showHeader, createSpinner, info, warning, error, summaryBox, divider } from '../utils/ui.js'; -import { createManifest, addFileEntry, addDirectoryEntry, saveManifest, findManifest, getAllManifests } from '../core/manifest.js'; +import { createManifest, addFileEntry, addDirectoryEntry, saveManifest, findManifest, getAllManifests, getFileReferenceCounts } from '../core/manifest.js'; import { validatePath } from '../utils/path-resolver.js'; import type { Spinner } from 'ora'; @@ -122,6 +122,8 @@ export async function installCommand(options: InstallOptions): Promise { // Check for existing installation at target path const existingManifest = findManifest(installPath, mode); + let cleanupStats = { removed: 0, skipped: 0 }; + if (existingManifest) { warning('Existing installation found at this location'); const { backup } = await inquirer.prompt([{ @@ -134,6 +136,30 @@ export async function installCommand(options: InstallOptions): Promise { if (backup) { await createBackup(installPath, existingManifest); } + + // Clean up old files that won't be replaced + console.log(''); + const cleanupSpinner = createSpinner('Analyzing files to clean up...').start(); + + try { + // Get list of files that will be installed + const newFiles = getNewInstallationFiles(sourceDir, installPath, mode); + // Also add version.json which will be created + const versionPath = join(installPath, '.claude', 'version.json'); + newFiles.add(versionPath.toLowerCase().replace(/\\/g, '/')); + + cleanupSpinner.text = 'Cleaning up obsolete files...'; + cleanupStats = await cleanupOldFiles(existingManifest, newFiles, cleanupSpinner); + + if (cleanupStats.removed > 0 || cleanupStats.skipped > 0) { + cleanupSpinner.succeed(`Cleanup: ${cleanupStats.removed} files removed, ${cleanupStats.skipped} shared files preserved`); + } else { + cleanupSpinner.succeed('No obsolete files to clean up'); + } + } catch (err) { + const errMsg = err as Error; + cleanupSpinner.warn(`Cleanup warning: ${errMsg.message}`); + } } // Create manifest @@ -211,11 +237,20 @@ export async function installCommand(options: InstallOptions): Promise { chalk.white(`Version: ${chalk.cyan(version)}`), '', chalk.gray(`Files installed: ${totalFiles}`), - chalk.gray(`Directories created: ${totalDirs}`), - '', - chalk.gray(`Manifest: ${basename(manifestPath)}`) + chalk.gray(`Directories created: ${totalDirs}`) ]; + // Add cleanup stats if any files were processed + if (cleanupStats.removed > 0 || cleanupStats.skipped > 0) { + summaryLines.push(chalk.gray(`Obsolete files removed: ${cleanupStats.removed}`)); + if (cleanupStats.skipped > 0) { + summaryLines.push(chalk.gray(`Shared files preserved: ${cleanupStats.skipped}`)); + } + } + + summaryLines.push(''); + summaryLines.push(chalk.gray(`Manifest: ${basename(manifestPath)}`)); + // Add codex prompts info if installed if (availableDirs.includes('.codex')) { summaryLines.push(''); @@ -284,6 +319,139 @@ async function selectPath(): Promise { return path; } +/** + * Get list of files that will be installed from source directories + * @param sourceDir - Source directory + * @param installPath - Installation path + * @param mode - Installation mode + * @returns Set of normalized file paths that will be installed + */ +function getNewInstallationFiles(sourceDir: string, installPath: string, mode: string): Set { + const newFiles = new Set(); + const globalPath = homedir(); + + // For Path mode, also include global subdirectories + if (mode === 'Path') { + for (const subdir of GLOBAL_SUBDIRS) { + const srcPath = join(sourceDir, '.claude', subdir); + if (existsSync(srcPath)) { + const destPath = join(globalPath, '.claude', subdir); + collectFilesRecursive(srcPath, destPath, newFiles); + } + } + } + + // Collect files from all source directories + const availableDirs = SOURCE_DIRS.filter(dir => existsSync(join(sourceDir, dir))); + for (const dir of availableDirs) { + const srcPath = join(sourceDir, dir); + const destPath = join(installPath, dir); + const excludeDirs = (mode === 'Path' && dir === '.claude') ? GLOBAL_SUBDIRS : []; + collectFilesRecursive(srcPath, destPath, newFiles, excludeDirs); + } + + return newFiles; +} + +/** + * Recursively collect file paths from source to destination mapping + * @param srcDir - Source directory + * @param destDir - Destination directory + * @param files - Set to add file paths to + * @param excludeDirs - Directories to exclude + */ +function collectFilesRecursive(srcDir: string, destDir: string, files: Set, excludeDirs: string[] = []): void { + if (!existsSync(srcDir)) return; + + const entries = readdirSync(srcDir); + for (const entry of entries) { + if (excludeDirs.includes(entry)) continue; + + const srcPath = join(srcDir, entry); + const destPath = join(destDir, entry); + const stat = statSync(srcPath); + + if (stat.isDirectory()) { + collectFilesRecursive(srcPath, destPath, files); + } else { + files.add(destPath.toLowerCase().replace(/\\/g, '/')); + } + } +} + +/** + * Clean up old installation files that won't be replaced by new installation + * @param existingManifest - Existing manifest with old file list + * @param newFiles - Set of file paths that will be installed + * @param spinner - Spinner for progress display + * @returns Count of removed files and skipped files + */ +async function cleanupOldFiles( + existingManifest: any, + newFiles: Set, + spinner: any +): Promise<{ removed: number; skipped: number }> { + let removed = 0; + let skipped = 0; + + const oldFiles = existingManifest.files || []; + const manifestId = existingManifest.manifest_id; + + // Get file reference counts from other installations + const fileRefs = getFileReferenceCounts(manifestId); + + // Process files in reverse order (deepest first) + const sortedFiles = [...oldFiles].sort((a: any, b: any) => b.path.length - a.path.length); + + for (const fileEntry of sortedFiles) { + const filePath = fileEntry.path; + const normalizedPath = filePath.toLowerCase().replace(/\\/g, '/'); + + // Skip if file will be replaced by new installation + if (newFiles.has(normalizedPath)) { + continue; + } + + // Skip if file is referenced by other installations + const refs = fileRefs.get(normalizedPath) || []; + if (refs.length > 0) { + skipped++; + continue; + } + + // Try to remove the file + try { + if (existsSync(filePath)) { + spinner.text = `Cleaning: ${basename(filePath)}`; + unlinkSync(filePath); + removed++; + } + } catch { + // Ignore errors during cleanup + } + } + + // Clean up empty directories from old installation + const oldDirs = existingManifest.directories || []; + const sortedDirs = [...oldDirs].sort((a: any, b: any) => b.path.length - a.path.length); + + for (const dirEntry of sortedDirs) { + const dirPath = dirEntry.path; + try { + if (existsSync(dirPath)) { + const contents = readdirSync(dirPath); + if (contents.length === 0) { + rmdirSync(dirPath); + } + } + } catch { + // Ignore errors during cleanup + } + } + + return { removed, skipped }; +} + /** * Create backup of existing installation * @param {string} installPath - Installation path diff --git a/ccw/src/core/manifest.ts b/ccw/src/core/manifest.ts index cc865234..e03066e4 100644 --- a/ccw/src/core/manifest.ts +++ b/ccw/src/core/manifest.ts @@ -229,3 +229,43 @@ export function deleteManifest(manifestFile: string): void { export function getManifestDir(): string { return MANIFEST_DIR; } + +/** + * Get file reference counts across all manifests + * Returns a map of file path -> array of manifest IDs that reference it + * @param excludeManifestId - Optional manifest ID to exclude from counting + * @returns Map of file paths to referencing manifest IDs + */ +export function getFileReferenceCounts(excludeManifestId?: string): Map { + const fileRefs = new Map(); + const manifests = getAllManifests(); + + for (const manifest of manifests) { + // Skip the excluded manifest (usually the one being replaced) + if (excludeManifestId && manifest.manifest_id === excludeManifestId) { + continue; + } + + for (const fileEntry of manifest.files || []) { + const normalizedPath = fileEntry.path.toLowerCase().replace(/\\/g, '/'); + const refs = fileRefs.get(normalizedPath) || []; + refs.push(manifest.manifest_id); + fileRefs.set(normalizedPath, refs); + } + } + + return fileRefs; +} + +/** + * Check if a file is referenced by other installations + * @param filePath - File path to check + * @param excludeManifestId - Manifest ID to exclude from checking + * @returns True if file is referenced by other installations + */ +export function isFileReferencedByOthers(filePath: string, excludeManifestId: string): boolean { + const fileRefs = getFileReferenceCounts(excludeManifestId); + const normalizedPath = filePath.toLowerCase().replace(/\\/g, '/'); + const refs = fileRefs.get(normalizedPath) || []; + return refs.length > 0; +}