Project Files
src / workspace.ts
import { existsSync } from "fs";
import fs from "fs/promises";
import path from "path";
import type { FileSnapshot, Patch } from "./types";
export function resolveWithin(root: string, relativePath: string) {
const safeRelative = relativePath.replace(/^[\\/]+/, "");
const rootAbs = path.resolve(root);
const target = path.resolve(rootAbs, safeRelative);
if (target !== rootAbs && !target.startsWith(rootAbs + path.sep)) {
throw new Error(`Path escapes workspace: ${relativePath}`);
}
return target;
}
export async function writeWorkspaceFile(
root: string,
relativePath: string,
content: string
) {
const target = resolveWithin(root, relativePath);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, content, "utf8");
return target;
}
export async function readWorkspaceFile(root: string, relativePath: string) {
const target = resolveWithin(root, relativePath);
if (!existsSync(target)) {
throw new Error(`File does not exist: ${relativePath}`);
}
return await fs.readFile(target, "utf8");
}
export async function fileExists(root: string, relativePath: string) {
const target = resolveWithin(root, relativePath);
return existsSync(target);
}
export async function listWorkspaceFiles(
root: string,
startDir = ".",
maxDepth = 8
): Promise<string[]> {
const start = resolveWithin(root, startDir);
const results: string[] = [];
async function walk(dir: string, depth: number) {
if (depth < 0) return;
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
const rel = path.relative(root, full).replace(/\\/g, "/");
results.push(rel);
if (entry.isDirectory()) {
await walk(full, depth - 1);
}
}
}
if (existsSync(start)) {
await walk(start, maxDepth);
}
return results.sort();
}
export async function snapshotWorkspace(
root: string,
startDir = ".",
maxDepth = 8,
maxBytes = 12000
): Promise<FileSnapshot[]> {
const files = await listWorkspaceFiles(root, startDir, maxDepth);
const snapshots: FileSnapshot[] = [];
for (const file of files) {
const absolute = resolveWithin(root, file);
try {
const stat = await fs.stat(absolute);
if (!stat.isFile()) continue;
const text = await fs.readFile(absolute, "utf8");
snapshots.push({
path: file,
content: text.slice(0, maxBytes),
});
} catch {
continue;
}
}
return snapshots;
}
export function applyPatchToText(original: string, patch: Patch) {
switch (patch.operation) {
case "overwrite":
return patch.content ?? "";
case "replace": {
if (!patch.target) {
throw new Error("Patch target is required for replace");
}
if (patch.content === undefined) {
throw new Error("Patch content is required for replace");
}
if (!original.includes(patch.target)) {
throw new Error("Patch target was not found");
}
return original.replace(patch.target, patch.content);
}
case "insert_before": {
if (!patch.target) {
throw new Error("Patch target is required for insert_before");
}
if (patch.content === undefined) {
throw new Error("Patch content is required for insert_before");
}
const index = original.indexOf(patch.target);
if (index === -1) {
throw new Error("Patch target was not found");
}
return original.slice(0, index) + patch.content + original.slice(index);
}
case "insert_after": {
if (!patch.target) {
throw new Error("Patch target is required for insert_after");
}
if (patch.content === undefined) {
throw new Error("Patch content is required for insert_after");
}
const index = original.indexOf(patch.target);
if (index === -1) {
throw new Error("Patch target was not found");
}
const after = index + patch.target.length;
return original.slice(0, after) + patch.content + original.slice(after);
}
case "delete": {
if (!patch.target) {
throw new Error("Patch target is required for delete");
}
if (!original.includes(patch.target)) {
throw new Error("Patch target was not found");
}
return original.replace(patch.target, "");
}
default:
return original;
}
}
export async function applyPatchToFile(root: string, patch: Patch) {
const absolute = resolveWithin(root, patch.file);
let original = "";
if (existsSync(absolute)) {
original = await fs.readFile(absolute, "utf8");
}
const updated = applyPatchToText(original, patch);
await fs.mkdir(path.dirname(absolute), { recursive: true });
await fs.writeFile(absolute, updated, "utf8");
return {
file: patch.file,
changed: updated !== original,
bytes: updated.length,
};
}
export async function deleteWorkspaceFile(root: string, relativePath: string) {
const target = resolveWithin(root, relativePath);
await fs.unlink(target);
return { ok: true, path: relativePath };
}
export async function moveWorkspaceFile(root: string, oldPath: string, newPath: string) {
const source = resolveWithin(root, oldPath);
const dest = resolveWithin(root, newPath);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.rename(source, dest);
return { ok: true, from: oldPath, to: newPath };
}