Files
stack/packages/mosaic/src/platform/file-ops.ts
jason.woltje 32a0ffba13
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
feat(mosaic): manifest-owned upgrade guard so updates never wipe operator config (#791) (#802)
2026-07-16 23:01:26 +00:00

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;
}
}