Files
Claude-Code-Workflow/ccw/src/core/manifest.ts
catlog22 48ac43d628 fix: Add cleanup of obsolete files during ccw install reinstallation
- Add getFileReferenceCounts() to track file references across manifests
- Add cleanupOldFiles() to remove files not in new installation
- Protect shared files referenced by other installations (Global/Path)
- Display cleanup statistics in installation summary

Previously, reinstalling would only overwrite existing files but leave
obsolete files that were removed from source. Now properly cleans up
while protecting files shared between Global and Path installations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 23:47:49 +08:00

272 lines
7.6 KiB
TypeScript

import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
// Manifest directory location
const MANIFEST_DIR = join(homedir(), '.claude-manifests');
export interface ManifestFileEntry {
path: string;
type: 'File';
timestamp: string;
}
export interface ManifestDirectoryEntry {
path: string;
type: 'Directory';
timestamp: string;
}
export interface Manifest {
manifest_id: string;
version: string;
installation_mode: string;
installation_path: string;
installation_date: string;
installer_version: string;
files: ManifestFileEntry[];
directories: ManifestDirectoryEntry[];
}
export interface ManifestWithMetadata extends Manifest {
manifest_file: string;
application_version: string;
files_count: number;
directories_count: number;
}
/**
* Ensure manifest directory exists
*/
function ensureManifestDir(): void {
if (!existsSync(MANIFEST_DIR)) {
mkdirSync(MANIFEST_DIR, { recursive: true });
}
}
/**
* Create a new installation manifest
* @param mode - Installation mode (Global/Path)
* @param installPath - Installation path
* @returns New manifest object
*/
export function createManifest(mode: string, installPath: string): Manifest {
ensureManifestDir();
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
const modePrefix = mode === 'Global' ? 'manifest-global' : 'manifest-path';
const manifestId = `${modePrefix}-${timestamp}`;
return {
manifest_id: manifestId,
version: '1.0',
installation_mode: mode,
installation_path: installPath,
installation_date: new Date().toISOString(),
installer_version: '1.0.0',
files: [],
directories: []
};
}
/**
* Add file entry to manifest
* @param manifest - Manifest object
* @param filePath - File path
*/
export function addFileEntry(manifest: Manifest, filePath: string): void {
manifest.files.push({
path: filePath,
type: 'File',
timestamp: new Date().toISOString()
});
}
/**
* Add directory entry to manifest
* @param manifest - Manifest object
* @param dirPath - Directory path
*/
export function addDirectoryEntry(manifest: Manifest, dirPath: string): void {
manifest.directories.push({
path: dirPath,
type: 'Directory',
timestamp: new Date().toISOString()
});
}
/**
* Save manifest to disk
* @param manifest - Manifest object
* @returns Path to saved manifest
*/
export function saveManifest(manifest: Manifest): string {
ensureManifestDir();
// Remove old manifests for same path and mode
removeOldManifests(manifest.installation_path, manifest.installation_mode);
const manifestPath = join(MANIFEST_DIR, `${manifest.manifest_id}.json`);
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
return manifestPath;
}
/**
* Remove old manifests for the same installation path and mode
* @param installPath - Installation path
* @param mode - Installation mode
*/
function removeOldManifests(installPath: string, mode: string): void {
if (!existsSync(MANIFEST_DIR)) return;
const normalizedPath = installPath.toLowerCase().replace(/[\\/]+$/, '');
try {
const files = readdirSync(MANIFEST_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const filePath = join(MANIFEST_DIR, file);
const content = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<Manifest>;
const manifestPath = (content.installation_path || '').toLowerCase().replace(/[\\/]+$/, '');
const manifestMode = content.installation_mode || 'Global';
if (manifestPath === normalizedPath && manifestMode === mode) {
unlinkSync(filePath);
}
} catch {
// Skip invalid manifest files
}
}
} catch {
// Ignore errors
}
}
/**
* Get all installation manifests
* @returns Array of manifest objects
*/
export function getAllManifests(): ManifestWithMetadata[] {
if (!existsSync(MANIFEST_DIR)) return [];
const manifests: ManifestWithMetadata[] = [];
try {
const files = readdirSync(MANIFEST_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const filePath = join(MANIFEST_DIR, file);
const content = JSON.parse(readFileSync(filePath, 'utf8')) as Manifest;
// Try to read version.json for application version
let appVersion = 'unknown';
try {
const versionPath = join(content.installation_path, '.claude', 'version.json');
if (existsSync(versionPath)) {
const versionInfo = JSON.parse(readFileSync(versionPath, 'utf8')) as { version?: string };
appVersion = versionInfo.version || 'unknown';
}
} catch {
// Ignore
}
manifests.push({
...content,
manifest_file: filePath,
application_version: appVersion,
files_count: content.files?.length || 0,
directories_count: content.directories?.length || 0
});
} catch {
// Skip invalid manifest files
}
}
// Sort by installation date (newest first)
manifests.sort((a, b) => new Date(b.installation_date).getTime() - new Date(a.installation_date).getTime());
} catch {
// Ignore errors
}
return manifests;
}
/**
* Find manifest for a specific path and mode
* @param installPath - Installation path
* @param mode - Installation mode
* @returns Manifest or null
*/
export function findManifest(installPath: string, mode: string): ManifestWithMetadata | null {
const manifests = getAllManifests();
const normalizedPath = installPath.toLowerCase().replace(/[\\/]+$/, '');
return manifests.find(m => {
const manifestPath = (m.installation_path || '').toLowerCase().replace(/[\\/]+$/, '');
return manifestPath === normalizedPath && m.installation_mode === mode;
}) || null;
}
/**
* Delete a manifest file
* @param manifestFile - Path to manifest file
*/
export function deleteManifest(manifestFile: string): void {
if (existsSync(manifestFile)) {
unlinkSync(manifestFile);
}
}
/**
* Get manifest directory path
* @returns Manifest directory path
*/
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<string, string[]> {
const fileRefs = new Map<string, string[]>();
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;
}