Two bugs causing 'EACCES: permission denied, copyfile' when source and target are the same path (e.g. wizard with sourceDir == mosaicHome): 1. No same-path guard — syncDirectory tried to copy every file onto itself; git pack files are read-only (0444) so copyFileSync fails. 2. excludeGit only matched top-level .git — nested .git dirs like sources/agent-skills/.git were copied, hitting the same permission issue. Fixes: - Early return when resolve(source) === resolve(target) - Match .git dirs at any depth via dirName and relPath checks - Skip files inside .git/ paths Added file-ops.test.ts with 4 tests covering all cases.
122 lines
3.4 KiB
TypeScript
122 lines
3.4 KiB
TypeScript
import {
|
|
readFileSync,
|
|
writeFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
copyFileSync,
|
|
renameSync,
|
|
readdirSync,
|
|
unlinkSync,
|
|
statSync,
|
|
} from 'node:fs';
|
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
|
|
const MAX_BACKUPS = 3;
|
|
|
|
/**
|
|
* Atomic write: write to temp file, then rename.
|
|
* Creates parent directories as needed.
|
|
*/
|
|
export function atomicWrite(filePath: string, content: string): void {
|
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
const tmpPath = `${filePath}.tmp-${process.pid.toString()}`;
|
|
writeFileSync(tmpPath, content, 'utf-8');
|
|
renameSync(tmpPath, filePath);
|
|
}
|
|
|
|
/**
|
|
* Create a backup of a file before overwriting.
|
|
* Rotates backups to keep at most MAX_BACKUPS.
|
|
*/
|
|
export function backupFile(filePath: string): string | null {
|
|
if (!existsSync(filePath)) return null;
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '').replace('T', '-').slice(0, 19);
|
|
const backupPath = `${filePath}.bak-${timestamp}`;
|
|
copyFileSync(filePath, backupPath);
|
|
rotateBackups(filePath);
|
|
return backupPath;
|
|
}
|
|
|
|
function rotateBackups(filePath: string): void {
|
|
const dir = dirname(filePath);
|
|
const baseName = filePath.split('/').pop() ?? '';
|
|
const prefix = `${baseName}.bak-`;
|
|
|
|
try {
|
|
const backups = readdirSync(dir)
|
|
.filter((f: string) => f.startsWith(prefix))
|
|
.sort()
|
|
.reverse();
|
|
|
|
for (let i = MAX_BACKUPS; i < backups.length; i++) {
|
|
const backup = backups[i];
|
|
if (backup !== undefined) {
|
|
unlinkSync(join(dir, backup));
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-fatal: backup rotation failure doesn't block writes
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync a source directory to a target, with optional preserve paths.
|
|
* Replaces the rsync/cp logic from install.sh.
|
|
*/
|
|
export function syncDirectory(
|
|
source: string,
|
|
target: string,
|
|
options: { preserve?: string[]; excludeGit?: boolean } = {},
|
|
): void {
|
|
// Guard: source and target are the same directory — nothing to sync
|
|
if (resolve(source) === resolve(target)) return;
|
|
|
|
const preserveSet = new Set(options.preserve ?? []);
|
|
|
|
// Collect files from source
|
|
function copyRecursive(src: string, dest: string, relBase: string): void {
|
|
if (!existsSync(src)) return;
|
|
|
|
const stat = statSync(src);
|
|
if (stat.isDirectory()) {
|
|
const relPath = relative(relBase, src);
|
|
const dirName = relPath.split('/').pop() ?? '';
|
|
|
|
// Skip any .git directory (top-level or nested, e.g. sources/agent-skills/.git)
|
|
if (options.excludeGit && (dirName === '.git' || relPath.includes('/.git'))) return;
|
|
|
|
// Skip preserved paths at top level
|
|
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
|
|
|
mkdirSync(dest, { recursive: true });
|
|
for (const entry of readdirSync(src)) {
|
|
copyRecursive(join(src, entry), join(dest, entry), relBase);
|
|
}
|
|
} else {
|
|
const relPath = relative(relBase, src);
|
|
|
|
// Skip files inside .git directories
|
|
if (options.excludeGit && relPath.includes('/.git/')) return;
|
|
|
|
// Skip preserved files at top level
|
|
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
|
|
|
mkdirSync(dirname(dest), { recursive: true });
|
|
copyFileSync(src, dest);
|
|
}
|
|
}
|
|
|
|
copyRecursive(source, target, source);
|
|
}
|
|
|
|
/**
|
|
* Safely read a file, returning null if it doesn't exist.
|
|
*/
|
|
export function safeReadFile(filePath: string): string | null {
|
|
try {
|
|
return readFileSync(filePath, 'utf-8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|