From 6b3f93a80a0232e8c964d73e51aa0afb0768b00f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 2 Dec 2025 22:01:50 -0500 Subject: [PATCH] fix(sync): implement copy fallback for windows when symlinks unavailable When symlink creation fails on Windows (due to Developer Mode being disabled or insufficient permissions), fall back to copying files and directories instead. Changes: - Add copyFallback() method for Windows symlink failures - Add copyDirRecursive() helper for directory copying - Update checkHealth() to recognize copied files as valid on Windows - Add isCopiedItem() helper to validate copied content - Update uninstall() to handle both symlinks and copied files Fixes #45 --- src/utils/claude-symlink-manager.ts | 119 +++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 12 deletions(-) diff --git a/src/utils/claude-symlink-manager.ts b/src/utils/claude-symlink-manager.ts index 4011d007..29c98260 100644 --- a/src/utils/claude-symlink-manager.ts +++ b/src/utils/claude-symlink-manager.ts @@ -164,12 +164,9 @@ export class ClaudeSymlinkManager { if (!silent) console.log(`[OK] Symlinked ${item.target}`); return true; } catch (err) { - // Windows fallback: stub for now, full implementation in v4.2 + // Windows fallback: copy instead of symlink when symlinks unavailable 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`); - } + return this.copyFallback(sourcePath, targetPath, item, silent); } else { const error = err as Error; if (!silent) console.log(`[!] Failed to symlink ${item.target}: ${error.message}`); @@ -178,6 +175,61 @@ export class ClaudeSymlinkManager { } } + /** + * Windows fallback: copy files/directories when symlinks unavailable + * Note: Changes won't auto-sync; user must run 'ccs sync' after updates + */ + private copyFallback( + sourcePath: string, + targetPath: string, + item: CcsItem, + silent = false + ): boolean { + try { + if (item.type === 'directory') { + // Copy directory recursively + this.copyDirRecursive(sourcePath, targetPath); + } else { + // Copy single file + fs.copyFileSync(sourcePath, targetPath); + } + if (!silent) { + console.log(`[OK] Copied ${item.target} (symlink unavailable)`); + console.log(`[i] Run 'ccs sync' after CCS updates to refresh`); + } + return true; + } catch (copyErr) { + const error = copyErr as Error; + if (!silent) { + console.log(`[!] Failed to copy ${item.target}: ${error.message}`); + console.log(`[i] Enable Developer Mode for symlinks, or check permissions`); + } + return false; + } + } + + /** + * Recursively copy directory (for Windows fallback) + */ + private copyDirRecursive(src: string, dest: string): void { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + 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()) { + this.copyDirRecursive(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } + } + /** * Check if target is already the correct symlink pointing to source */ @@ -224,8 +276,8 @@ export class ClaudeSymlinkManager { } /** - * Uninstall CCS items from ~/.claude/ (remove symlinks only) - * Safe: only removes items that are CCS symlinks + * Uninstall CCS items from ~/.claude/ (remove symlinks or copied files) + * Safe: only removes items that are CCS symlinks or valid copies */ uninstall(): void { let removed = 0; @@ -234,10 +286,20 @@ export class ClaudeSymlinkManager { 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)) { + // Check if it's our symlink or a valid copy (Windows fallback) + const isSymlink = this.isOurSymlink(targetPath, sourcePath); + const isCopy = + process.platform === 'win32' && this.isCopiedItem(targetPath, sourcePath, item.type); + + if (fs.existsSync(targetPath) && (isSymlink || isCopy)) { try { - fs.unlinkSync(targetPath); + if (item.type === 'directory' && !isSymlink) { + // Remove copied directory recursively + fs.rmSync(targetPath, { recursive: true, force: true }); + } else { + // Remove symlink or file + fs.unlinkSync(targetPath); + } console.log(`[OK] Removed ${item.target}`); removed++; } catch (err) { @@ -286,14 +348,47 @@ export class ClaudeSymlinkManager { 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; + // On Windows, copied files are valid (symlink fallback) + if (process.platform === 'win32' && this.isCopiedItem(targetPath, sourcePath, item.type)) { + // Copied file is valid on Windows, but note it's not a symlink + issues.push(`${item.target} is a copy (not symlink) - run 'ccs sync' after updates`); + // Still healthy, just a warning + } else { + issues.push(`Not a CCS symlink: ${item.target} (run 'ccs sync' to fix)`); + healthy = false; + } } } return { healthy, issues }; } + /** + * Check if target is a valid copy of source (Windows fallback check) + */ + private isCopiedItem( + targetPath: string, + sourcePath: string, + type: 'file' | 'directory' + ): boolean { + try { + const targetStats = fs.statSync(targetPath); + const sourceStats = fs.statSync(sourcePath); + + if (type === 'directory') { + // For directories, just check both exist and are directories + return targetStats.isDirectory() && sourceStats.isDirectory(); + } else { + // For files, compare size as basic validation + return ( + targetStats.isFile() && sourceStats.isFile() && targetStats.size === sourceStats.size + ); + } + } catch { + return false; + } + } + /** * Sync delegation commands and skills to ~/.claude/ (used by 'ccs sync' command) * Same as install() but with explicit sync message