138 lines
4.2 KiB
TypeScript
138 lines
4.2 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.
|
|
*
|
|
* `isOperatorOwned` is the #791 ownership guard: when supplied, any source path
|
|
* it flags as operator-owned is never copied (the framework must never write an
|
|
* operator path). Callers derive it from the shared framework manifest so the TS
|
|
* and bash sync paths obey one source of truth. This copy is non-destructive —
|
|
* it never deletes a target file — so honoring the guard is sufficient to leave
|
|
* operator config untouched.
|
|
*/
|
|
export function syncDirectory(
|
|
source: string,
|
|
target: string,
|
|
options: {
|
|
preserve?: string[];
|
|
excludeGit?: boolean;
|
|
isOperatorOwned?: (relPath: string) => 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 ?? []);
|
|
const isOperatorOwned = options.isOperatorOwned ?? (() => false);
|
|
|
|
// 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 (relPath !== '' && 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;
|
|
|
|
// #791: never write an operator-owned path (the framework owns only its
|
|
// own files; unknown paths resolve to operator and are skipped too).
|
|
if (isOperatorOwned(relPath)) 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;
|
|
}
|
|
}
|