diff --git a/src/cli/init.ts b/src/cli/init.ts index 0414bb7..aa615ca 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { execSync } from "node:child_process"; import { findProjectRoot } from "../scanner/project-root.js"; import { scanProject } from "../scanner/anatomy-scanner.js"; -import { readJSON, writeJSON, readText, writeText } from "../utils/fs-safe.js"; +import { readJSON, writeJSON, readText, writeText, safeCopyFile } from "../utils/fs-safe.js"; import { ensureDir } from "../utils/paths.js"; import { isWindows } from "../utils/platform.js"; import { registerProject } from "./registry.js"; @@ -307,7 +307,7 @@ function writeTemplateFile(templatesDir: string, wolfDir: string, file: string): const srcPath = path.join(templatesDir, file); const destPath = path.join(wolfDir, file); if (fs.existsSync(srcPath)) { - fs.copyFileSync(srcPath, destPath); + safeCopyFile(srcPath, destPath); } else { generateTemplate(destPath, file); } @@ -440,7 +440,7 @@ function copyHookScripts(wolfDir: string): void { for (const file of hookFiles) { const src = path.join(sourceDir, file); if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(hooksDir, file)); + safeCopyFile(src, path.join(hooksDir, file)); copiedAny = true; } } diff --git a/src/cli/update.ts b/src/cli/update.ts index 33cf5cd..bd3a191 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -11,7 +11,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { getRegisteredProjects, registerProject, type RegisteredProject } from "./registry.js"; -import { readJSON, writeJSON, readText, writeText } from "../utils/fs-safe.js"; +import { readJSON, writeJSON, readText, writeText, safeCopyFile } from "../utils/fs-safe.js"; import { ensureDir } from "../utils/paths.js"; const __filename = fileURLToPath(import.meta.url); @@ -170,7 +170,7 @@ async function updateProject( const srcPath = path.join(templatesDir, file); const destPath = path.join(wolfDir, file); if (fs.existsSync(srcPath)) { - fs.copyFileSync(srcPath, destPath); + safeCopyFile(srcPath, destPath); } } console.log(` ✓ Templates updated (${ALWAYS_OVERWRITE.join(", ")})`); @@ -250,7 +250,7 @@ function createBackup(wolfDir: string): string { for (const file of BACKUP_FILES) { const src = path.join(wolfDir, file); if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(backupDir, file)); + safeCopyFile(src, path.join(backupDir, file)); } } @@ -264,7 +264,7 @@ function createBackup(wolfDir: string): string { for (const f of hookFiles) { const src = path.join(hooksDir, f); if (fs.statSync(src).isFile()) { - fs.copyFileSync(src, path.join(hooksBackup, f)); + safeCopyFile(src, path.join(hooksBackup, f)); } } } catch {} @@ -276,13 +276,13 @@ function createBackup(wolfDir: string): string { if (fs.existsSync(claudeSettings)) { const claudeBackup = path.join(backupDir, ".claude"); ensureDir(claudeBackup); - fs.copyFileSync(claudeSettings, path.join(claudeBackup, "settings.json")); + safeCopyFile(claudeSettings, path.join(claudeBackup, "settings.json")); } const claudeRules = path.join(projectRoot, ".claude", "rules", "openwolf.md"); if (fs.existsSync(claudeRules)) { const rulesBackup = path.join(backupDir, ".claude", "rules"); ensureDir(rulesBackup); - fs.copyFileSync(claudeRules, path.join(rulesBackup, "openwolf.md")); + safeCopyFile(claudeRules, path.join(rulesBackup, "openwolf.md")); } return backupDir; @@ -342,7 +342,7 @@ function copyHookScripts(wolfDir: string): void { for (const file of hookFiles) { const src = path.join(sourceDir, file); if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(hooksDir, file)); + safeCopyFile(src, path.join(hooksDir, file)); } } } @@ -443,7 +443,7 @@ export function restoreCommand(backupName?: string): void { // Restore files const files = fs.readdirSync(backupDir).filter(f => fs.statSync(path.join(backupDir, f)).isFile()); for (const file of files) { - fs.copyFileSync(path.join(backupDir, file), path.join(wolfDir, file)); + safeCopyFile(path.join(backupDir, file), path.join(wolfDir, file)); } // Restore hooks if present @@ -453,7 +453,7 @@ export function restoreCommand(backupName?: string): void { const hooksDir = path.join(wolfDir, "hooks"); ensureDir(hooksDir); for (const f of hookFiles) { - fs.copyFileSync(path.join(hooksBackup, f), path.join(hooksDir, f)); + safeCopyFile(path.join(hooksBackup, f), path.join(hooksDir, f)); } } @@ -465,13 +465,13 @@ export function restoreCommand(backupName?: string): void { if (fs.existsSync(settingsBackup)) { const dest = path.join(projectRoot, ".claude", "settings.json"); ensureDir(path.dirname(dest)); - fs.copyFileSync(settingsBackup, dest); + safeCopyFile(settingsBackup, dest); } const rulesBackup = path.join(claudeBackup, "rules", "openwolf.md"); if (fs.existsSync(rulesBackup)) { const dest = path.join(projectRoot, ".claude", "rules", "openwolf.md"); ensureDir(path.dirname(dest)); - fs.copyFileSync(rulesBackup, dest); + safeCopyFile(rulesBackup, dest); } } diff --git a/src/utils/fs-safe.ts b/src/utils/fs-safe.ts index 49d11f5..fb8ff24 100644 --- a/src/utils/fs-safe.ts +++ b/src/utils/fs-safe.ts @@ -60,3 +60,19 @@ export function appendText(filePath: string, content: string): void { } fs.appendFileSync(filePath, content, "utf-8"); } + +// Drop-in replacement for fs.copyFileSync that works around a libuv/9P +// limitation: fs.copyFileSync uses the copy_file_range syscall on Linux, +// which fails with EPERM when writing to EFS-encrypted directories on +// Windows volumes mounted via WSL2 9P. Plain read+write bypasses +// copy_file_range and works in all cases. +export function safeCopyFile(src: string, dest: string): void { + const dir = path.dirname(dest); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(dest, fs.readFileSync(src)); + try { + fs.chmodSync(dest, fs.statSync(src).mode); + } catch {} +}