Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
# `disk-fs`
## Purpose
Disk-backed implementation of `MutableInstallerFs` used by the
`suaveplan-install` bin and any host-side code that wants to write to a
real project directory. All reads and writes resolve relative to a single
configurable `cwd`, so an installer run cannot accidentally escape the
target project's root via absolute paths supplied by a third-party plugin.
## Features
- Constructor accepts an explicit `cwd` (defaults to `process.cwd()`) and
an optional `AbortSignal` — every async method short-circuits with an
`AbortError` once the signal aborts.
- `writeFile` and `rename` automatically create intermediate directories
via `mkdir({ recursive: true })`.
- Absolute paths supplied by callers (e.g. `/foo`) are resolved as
*relative* to the configured `cwd`, never escaping it. This is the same
policy `MemoryFs` uses, keeping the two backends interchangeable for
tests vs. production.
- `readFile` returns a true `Uint8Array` view (not a Node `Buffer`), so
consumers that target browsers and servers behave identically.
## Basic Example
```ts
import { DiskFs } from "@suaveplan/installer";
const fs = new DiskFs({ cwd: process.cwd() });
const bytes = await fs.readFile("package.json");
const pkg = JSON.parse(new TextDecoder().decode(bytes)) as {
name: string;
};
console.log(pkg.name);
```
## Advanced Example
```ts
import { DiskFs } from "@suaveplan/installer";
const ac = new AbortController();
process.once("SIGINT", () => ac.abort());
const fs = new DiskFs({ cwd: process.cwd(), signal: ac.signal });
await fs.mkdir(".suaveplan/skills");
await fs.writeJson(".suaveplan/skills/index.json", {
schemaVersion: 1,
skills: [],
});
await fs.writeFile(
".suaveplan/skills/echo/SKILL.md",
new TextEncoder().encode("# echo\n"),
0o644
);
```
## API Reference
- `new DiskFs(options?: { cwd?, signal? })`
- `pathOf(...segments): string` — resolves relative to `cwd`.
- `exists(path): Promise<boolean>` — never throws; missing path returns
`false`.
- `stat(path): Promise<{ size, isFile, isDir }>`
- `readFile(path): Promise<Uint8Array>`
- `readJson<T>(path): Promise<T>`
- `listDir(path): Promise<string[]>` — sorted ascending.
- `writeFile(path, contents, mode?): Promise<void>`
- `deleteFile(path): Promise<void>` — no-op when absent (`force: true`).
- `mkdir(path): Promise<void>` — recursive.
- `rename(from, to): Promise<void>`
- `writeJson(path, value): Promise<void>`
## Implementation Notes
Internally, paths starting with `/` are stripped of the leading slash and
re-resolved against `cwd` so they cannot escape. This is critical: a third
party plugin that planned `{ kind: "write", path: "/etc/passwd" }` would
otherwise overwrite a system file. With the strip-and-resolve policy, the
write lands at `<cwd>/etc/passwd` instead. Consumers who really want to
write outside `cwd` must use `node:fs/promises` directly — the installer
does not provide an escape hatch on purpose.

View File

@@ -0,0 +1,134 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { DiskFs } from "./disk-fs.js";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "installer-disk-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
describe("DiskFs", () => {
it("default cwd is process.cwd", { timeout: 5000 }, () => {
const fs = new DiskFs();
expect(fs.pathOf("a")).toContain("a");
});
it("exists returns true for present file", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "f"), "x");
expect(await fs.exists("f")).toBe(true);
});
it("exists returns false on missing", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
expect(await fs.exists("missing")).toBe(false);
});
it("readFile returns Uint8Array", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "f"), "hi");
const bytes = await fs.readFile("f");
expect(decoder.decode(bytes)).toBe("hi");
});
it("readJson decodes JSON", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "x.json"), '{"a":1}');
expect(await fs.readJson<{ a: number }>("x.json")).toEqual({ a: 1 });
});
it("stat reports file stats", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "f"), "abc");
const s = await fs.stat("f");
expect(s.isFile).toBe(true);
expect(s.size).toBe(3);
});
it("listDir lists entries sorted", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await mkdir(join(root, "d"));
await writeFile(join(root, "d", "b"), "x");
await writeFile(join(root, "d", "a"), "y");
expect(await fs.listDir("d")).toEqual(["a", "b"]);
});
it("writeFile creates intermediate dirs", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await fs.writeFile("nested/deep/file", encoder.encode("ok"));
expect(await fs.exists("nested/deep/file")).toBe(true);
});
it("writeFile honours mode", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await fs.writeFile("f", encoder.encode("ok"), 0o600);
expect(await fs.exists("f")).toBe(true);
});
it("deleteFile removes file", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "f"), "x");
await fs.deleteFile("f");
expect(await fs.exists("f")).toBe(false);
});
it("deleteFile on missing is no-op (force)", {
timeout: 5000,
}, async () => {
const fs = new DiskFs({ cwd: root });
await expect(fs.deleteFile("nope")).resolves.toBeUndefined();
});
it("mkdir creates dirs recursively", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await fs.mkdir("a/b/c");
expect(await fs.exists("a/b/c")).toBe(true);
});
it("rename moves file", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await writeFile(join(root, "f"), "x");
await fs.rename("f", "g");
expect(await fs.exists("g")).toBe(true);
});
it("writeJson writes pretty", { timeout: 5000 }, async () => {
const fs = new DiskFs({ cwd: root });
await fs.writeJson("x.json", { a: 1 });
const text = decoder.decode(await fs.readFile("x.json"));
expect(text).toBe('{\n "a": 1\n}\n');
});
it("aborted signal throws", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
const fs = new DiskFs({ cwd: root, signal: ac.signal });
await expect(fs.writeFile("x", encoder.encode("y"))).rejects.toThrow(
/aborted/i
);
});
it("absolute paths are resolved relative to cwd", {
timeout: 5000,
}, async () => {
const fs = new DiskFs({ cwd: root });
await fs.writeFile("/inner/f", encoder.encode("ok"));
expect(await fs.exists("inner/f")).toBe(true);
});
});

View File

@@ -0,0 +1,126 @@
import {
mkdir,
readdir,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import type { MutableInstallerFs } from "./installer-fs.js";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new DOMException("Operation aborted", "AbortError");
}
}
export interface DiskFsOptions {
readonly cwd?: string;
readonly signal?: AbortSignal;
}
/**
* Disk-backed implementation of `MutableInstallerFs`.
*
* All paths are resolved against `options.cwd` (or `process.cwd()`).
* Every async operation checks `options.signal` so the host's abort flow
* can interrupt mid-apply.
*/
export class DiskFs implements MutableInstallerFs {
private readonly cwd: string;
private readonly signal: AbortSignal | undefined;
public constructor(options: DiskFsOptions = {}) {
this.cwd = options.cwd ?? process.cwd();
this.signal = options.signal;
}
public pathOf(...segments: string[]): string {
return resolve(this.cwd, ...segments);
}
public async exists(path: string): Promise<boolean> {
ensureNotAborted(this.signal);
try {
await stat(this.resolve(path));
return true;
} catch {
return false;
}
}
public async stat(
path: string
): Promise<{ size: number; isFile: boolean; isDir: boolean }> {
ensureNotAborted(this.signal);
const s = await stat(this.resolve(path));
return { size: s.size, isFile: s.isFile(), isDir: s.isDirectory() };
}
public async readFile(path: string): Promise<Uint8Array> {
ensureNotAborted(this.signal);
const buf = await readFile(this.resolve(path));
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
}
public async readJson<T = unknown>(path: string): Promise<T> {
const bytes = await this.readFile(path);
return JSON.parse(decoder.decode(bytes)) as T;
}
public async listDir(path: string): Promise<string[]> {
ensureNotAborted(this.signal);
const entries = await readdir(this.resolve(path));
return entries.sort();
}
public async writeFile(
path: string,
contents: Uint8Array,
mode?: number
): Promise<void> {
ensureNotAborted(this.signal);
const target = this.resolve(path);
await mkdir(dirname(target), { recursive: true });
await writeFile(
target,
contents,
mode === undefined ? undefined : { mode }
);
}
public async deleteFile(path: string): Promise<void> {
ensureNotAborted(this.signal);
await rm(this.resolve(path), { force: true });
}
public async mkdir(path: string): Promise<void> {
ensureNotAborted(this.signal);
await mkdir(this.resolve(path), { recursive: true });
}
public async rename(from: string, to: string): Promise<void> {
ensureNotAborted(this.signal);
await mkdir(dirname(this.resolve(to)), { recursive: true });
await rename(this.resolve(from), this.resolve(to));
}
public async writeJson(path: string, value: unknown): Promise<void> {
await this.writeFile(
path,
encoder.encode(`${JSON.stringify(value, null, 2)}\n`)
);
}
private resolve(path: string): string {
return resolve(
this.cwd,
path.startsWith("/") ? path.slice(1) : join(path)
);
}
}

View File

@@ -0,0 +1,6 @@
export { DiskFs, type DiskFsOptions } from "./disk-fs.js";
export type {
InstallerFs,
MutableInstallerFs,
} from "./installer-fs.js";
export { MemoryFs, type MemoryFsOptions } from "./memory-fs.js";

View File

@@ -0,0 +1,76 @@
# `installer-fs`
## Purpose
Re-exports the read-only `InstallerFs` interface from the types module and
declares its mutating extension `MutableInstallerFs`. Plugins receive a
read-only `InstallerFs` through the context — they can describe writes via
`FileOp[]` but cannot perform them directly. The host uses
`MutableInstallerFs` internally to materialise ops with rollback. Keeping
the read/write split at the interface level is what makes `--dry-run`
trivially correct: a dry-run never resolves the mutating subset.
## Features
- Single source of truth for filesystem capability boundaries inside the
installer.
- `MutableInstallerFs` extends `InstallerFs` with `writeFile`, `deleteFile`,
`mkdir`, `rename`, and `writeJson`.
- Both `MemoryFs` and `DiskFs` implement `MutableInstallerFs`, but only the
read-only view is ever handed to plugin code.
## Basic Example
```ts
import { MemoryFs, type InstallerFs } from "@suaveplan/installer";
async function describe(fs: InstallerFs): Promise<number> {
const entries = await fs.listDir("/");
return entries.length;
}
const fs = new MemoryFs({ seed: { "/a.txt": "hi" } });
await describe(fs);
```
## Advanced Example
```ts
import {
type MutableInstallerFs,
MemoryFs,
} from "@suaveplan/installer";
async function backupThenWrite(
fs: MutableInstallerFs,
path: string,
next: Uint8Array
): Promise<void> {
if (await fs.exists(path)) {
const prior = await fs.readFile(path);
await fs.writeFile(`${path}.bak`, prior);
}
await fs.writeFile(path, next);
}
await backupThenWrite(new MemoryFs(), "/cfg.json", new Uint8Array([123, 125]));
```
## API Reference
- `interface InstallerFs` (re-export from `types/installer.types.ts`):
`exists`, `readFile`, `readJson`, `stat`, `listDir`, `pathOf`.
- `interface MutableInstallerFs extends InstallerFs`:
`writeFile(path, contents, mode?)`, `deleteFile(path)`, `mkdir(path)`,
`rename(from, to)`, `writeJson(path, value)`.
## Implementation Notes
`MutableInstallerFs` is exposed publicly through `./fs` so authors who want
to write a custom backing store (e.g. an S3-backed installer fs) have a
single contract to satisfy. Implementations must honour any `AbortSignal`
they were constructed with — both in-tree implementations call a shared
`ensureNotAborted(signal)` helper at the start of every async operation.
The interface deliberately has no `chmod` or `chown` operations: per-file
mode is carried on the `WriteOp` itself, and ownership is left to the
host filesystem.

View File

@@ -0,0 +1,15 @@
import type { InstallerFs } from "../types/installer.types.js";
/**
* Mutating extension to {@link InstallerFs} used internally by the host's
* apply stage. Plugins only see the read-only `InstallerFs`.
*/
export interface MutableInstallerFs extends InstallerFs {
writeFile(path: string, contents: Uint8Array, mode?: number): Promise<void>;
deleteFile(path: string): Promise<void>;
mkdir(path: string): Promise<void>;
rename(from: string, to: string): Promise<void>;
writeJson(path: string, value: unknown): Promise<void>;
}
export type { InstallerFs } from "../types/installer.types.js";

View File

@@ -0,0 +1,82 @@
# `memory-fs`
## Purpose
In-memory implementation of `MutableInstallerFs`. Used by the testing
harness (`runPlugin` constructs one if the caller doesn't supply one) and
by anything that needs deterministic, side-effect-free filesystem
behaviour: integration tests, dry-run inspection of large plugins,
fixture seeding for replays. POSIX-style paths only; Windows-style
backslashes are normalised to forward slashes on entry.
## Features
- Seedable from a `Record<string, string | Uint8Array>` map at
construction time — strings are UTF-8 encoded automatically.
- Tracks directories as first-class entries, so `listDir` returns
deterministic, sorted results without sniffing files for shared
prefixes.
- `writeFile`/`mkdir` create intermediate directories implicitly.
- `deleteFile` refuses to remove a non-empty directory (`ENOTEMPTY`); empty
dirs delete cleanly.
- `snapshot()` returns a `{ path: Uint8Array }` map of every file — handy
for end-of-test assertions.
- Honours an optional `AbortSignal` — every async call short-circuits
with an `AbortError` once the signal aborts.
## Basic Example
```ts
import { MemoryFs } from "@suaveplan/installer";
const fs = new MemoryFs({ seed: { "/README.md": "# hi\n" } });
const bytes = await fs.readFile("/README.md");
console.log(new TextDecoder().decode(bytes));
```
## Advanced Example
```ts
import { MemoryFs } from "@suaveplan/installer";
const ac = new AbortController();
const fs = new MemoryFs({ signal: ac.signal });
await fs.mkdir("/.suaveplan/skills");
await fs.writeJson("/.suaveplan/skills/index.json", { skills: [] });
await fs.writeFile(
"/.suaveplan/skills/echo/SKILL.md",
new TextEncoder().encode("# echo\n")
);
const list = await fs.listDir("/.suaveplan/skills");
console.log(list); // ["echo", "index.json"]
ac.abort();
await fs.writeFile("/late.txt", new Uint8Array()); // throws AbortError
```
## API Reference
- `new MemoryFs(options?: { seed?, signal? })`
- `pathOf(...segments): string`
- `exists(path): Promise<boolean>`
- `stat(path): Promise<{ size, isFile, isDir }>`
- `readFile(path): Promise<Uint8Array>`
- `readJson<T>(path): Promise<T>`
- `listDir(path): Promise<string[]>`
- `writeFile(path, contents, mode?): Promise<void>`
- `deleteFile(path): Promise<void>`
- `mkdir(path): Promise<void>`
- `rename(from, to): Promise<void>`
- `writeJson(path, value): Promise<void>`
- `snapshot(): Record<string, Uint8Array>`
## Implementation Notes
Path normalisation collapses repeated slashes, strips trailing slashes
(except at the root), and canonicalises backslashes. Seeding paths whose
parents collide with an already-seeded *file* throws `ENOTDIR` — this is
intentional and mirrors POSIX behaviour. The internal `entries` map is
ordered by insertion; `listDir` re-sorts on every call so results are
stable regardless of write order.

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryFs } from "./memory-fs.js";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
describe("MemoryFs", () => {
it("seeds with strings and Uint8Array", { timeout: 5000 }, async () => {
const fs = new MemoryFs({
seed: { "/a.txt": "hi", "/b.bin": encoder.encode("x") },
});
expect(decoder.decode(await fs.readFile("/a.txt"))).toBe("hi");
expect(decoder.decode(await fs.readFile("/b.bin"))).toBe("x");
});
it("pathOf joins segments", { timeout: 5000 }, () => {
const fs = new MemoryFs();
expect(fs.pathOf()).toBe("/");
expect(fs.pathOf("a", "b")).toBe("a/b");
expect(fs.pathOf("a\\b", "c")).toBe("a/b/c");
});
it("readJson decodes JSON", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/x.json": '{"a":1}' } });
expect(await fs.readJson<{ a: number }>("/x.json")).toEqual({ a: 1 });
});
it("stat for files and dirs", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/d/f.txt": "x" } });
const file = await fs.stat("/d/f.txt");
expect(file).toEqual({ size: 1, isFile: true, isDir: false });
const dir = await fs.stat("/d");
expect(dir).toEqual({ size: 0, isFile: false, isDir: true });
});
it("stat throws on missing", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await expect(fs.stat("/nope")).rejects.toThrow(/ENOENT/);
});
it("readFile throws on missing", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await expect(fs.readFile("/nope")).rejects.toThrow(/ENOENT/);
});
it("listDir lists immediate children sorted", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({
seed: { "/d/b.txt": "b", "/d/a.txt": "a", "/d/sub/x.txt": "x" },
});
expect(await fs.listDir("/d")).toEqual(["a.txt", "b.txt", "sub"]);
expect(await fs.listDir("/")).toContain("d");
});
it("listDir on file throws", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/f.txt": "x" } });
await expect(fs.listDir("/f.txt")).rejects.toThrow(/ENOTDIR/);
});
it("writeFile + readFile + mode", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.writeFile("/a.txt", encoder.encode("hi"), 0o600);
expect(decoder.decode(await fs.readFile("/a.txt"))).toBe("hi");
});
it("deleteFile removes file", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/x.txt": "x" } });
await fs.deleteFile("/x.txt");
expect(await fs.exists("/x.txt")).toBe(false);
});
it("deleteFile on missing is no-op", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await expect(fs.deleteFile("/nope")).resolves.toBeUndefined();
});
it("deleteFile rejects non-empty directory", {
timeout: 5000,
}, async () => {
const fs = new MemoryFs({ seed: { "/d/x.txt": "x" } });
await expect(fs.deleteFile("/d")).rejects.toThrow(/ENOTEMPTY/);
});
it("deleteFile removes empty directory", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.mkdir("/d");
await expect(fs.deleteFile("/d")).resolves.toBeUndefined();
});
it("rename moves file", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/a.txt": "x" } });
await fs.rename("/a.txt", "/b.txt");
expect(await fs.exists("/a.txt")).toBe(false);
expect(await fs.exists("/b.txt")).toBe(true);
});
it("rename throws on missing source", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await expect(fs.rename("/no", "/yes")).rejects.toThrow(/ENOENT/);
});
it("writeJson writes pretty JSON", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.writeJson("/x.json", { a: 1 });
expect(decoder.decode(await fs.readFile("/x.json"))).toBe(
'{\n "a": 1\n}\n'
);
});
it("snapshot returns file map", { timeout: 5000 }, async () => {
const fs = new MemoryFs({ seed: { "/a.txt": "a", "/b.txt": "b" } });
const snap = fs.snapshot();
expect(Object.keys(snap).sort()).toEqual(["/a.txt", "/b.txt"]);
});
it("seeding a path that conflicts with existing file throws", {
timeout: 5000,
}, () => {
expect(
() =>
new MemoryFs({
seed: { "/a.txt": "x", "/a.txt/inner": "y" },
})
).toThrow(/ENOTDIR/);
});
it("aborted signal makes operations throw", { timeout: 5000 }, async () => {
const ac = new AbortController();
ac.abort();
const fs = new MemoryFs({ signal: ac.signal });
await expect(fs.writeFile("/x", encoder.encode("y"))).rejects.toThrow(
/aborted/i
);
});
it("mkdir creates intermediate dirs", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.mkdir("/a/b/c");
expect(await fs.exists("/a")).toBe(true);
expect(await fs.exists("/a/b/c")).toBe(true);
});
it("mkdir at root keeps root", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.mkdir("/");
expect(await fs.exists("/")).toBe(true);
});
it("normalize strips trailing slash", { timeout: 5000 }, async () => {
const fs = new MemoryFs();
await fs.mkdir("/a/b/");
expect(await fs.exists("/a/b")).toBe(true);
});
});

View File

@@ -0,0 +1,217 @@
import type { MutableInstallerFs } from "./installer-fs.js";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
interface MemoryDirEntry {
readonly kind: "dir";
}
interface MemoryFileEntry {
readonly kind: "file";
contents: Uint8Array;
mode?: number;
}
type MemoryEntry = MemoryDirEntry | MemoryFileEntry;
function normalize(p: string): string {
const trimmed = p.replace(/\\/g, "/").replace(/\/+/g, "/");
if (trimmed.length > 1 && trimmed.endsWith("/")) {
return trimmed.slice(0, -1);
}
return trimmed;
}
function dirname(p: string): string {
const norm = normalize(p);
const idx = norm.lastIndexOf("/");
if (idx <= 0) return "/";
return norm.slice(0, idx);
}
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new DOMException("Operation aborted", "AbortError");
}
}
export interface MemoryFsOptions {
readonly seed?: Record<string, string | Uint8Array>;
readonly signal?: AbortSignal;
}
/**
* In-memory `InstallerFs` used by the testing harness and by `apply`'s
* staged-tmp transaction. Strictly POSIX-style paths; directories are
* tracked as first-class entries so `listDir` returns deterministic
* results.
*/
export class MemoryFs implements MutableInstallerFs {
private readonly entries = new Map<string, MemoryEntry>();
private readonly signal: AbortSignal | undefined;
public constructor(options: MemoryFsOptions = {}) {
this.entries.set("/", { kind: "dir" });
this.signal = options.signal;
if (options.seed) {
for (const [path, value] of Object.entries(options.seed)) {
const bytes =
typeof value === "string" ? encoder.encode(value) : value;
this.seedFile(path, bytes);
}
}
}
public pathOf(...segments: string[]): string {
if (segments.length === 0) return "/";
const joined = segments
.map((s) => s.replace(/\\/g, "/"))
.join("/")
.replace(/\/+/g, "/");
return normalize(joined);
}
public async exists(path: string): Promise<boolean> {
ensureNotAborted(this.signal);
return this.entries.has(normalize(path));
}
public async stat(
path: string
): Promise<{ size: number; isFile: boolean; isDir: boolean }> {
ensureNotAborted(this.signal);
const entry = this.entries.get(normalize(path));
if (!entry) {
throw new Error(`ENOENT: ${path}`);
}
if (entry.kind === "file") {
return {
size: entry.contents.byteLength,
isFile: true,
isDir: false,
};
}
return { size: 0, isFile: false, isDir: true };
}
public async readFile(path: string): Promise<Uint8Array> {
ensureNotAborted(this.signal);
const entry = this.entries.get(normalize(path));
if (!entry || entry.kind !== "file") {
throw new Error(`ENOENT: ${path}`);
}
return new Uint8Array(entry.contents);
}
public async readJson<T = unknown>(path: string): Promise<T> {
const bytes = await this.readFile(path);
return JSON.parse(decoder.decode(bytes)) as T;
}
public async listDir(path: string): Promise<string[]> {
ensureNotAborted(this.signal);
const norm = normalize(path);
const entry = this.entries.get(norm);
if (!entry || entry.kind !== "dir") {
throw new Error(`ENOTDIR: ${path}`);
}
const prefix = norm === "/" ? "/" : `${norm}/`;
const out = new Set<string>();
for (const key of this.entries.keys()) {
if (key === norm) continue;
if (!key.startsWith(prefix)) continue;
const rest = key.slice(prefix.length);
const slash = rest.indexOf("/");
out.add(slash === -1 ? rest : rest.slice(0, slash));
}
return Array.from(out).sort();
}
public async writeFile(
path: string,
contents: Uint8Array,
mode?: number
): Promise<void> {
ensureNotAborted(this.signal);
const norm = normalize(path);
this.ensureDirChain(dirname(norm));
this.entries.set(norm, {
kind: "file",
contents: new Uint8Array(contents),
...(mode === undefined ? {} : { mode }),
});
}
public async deleteFile(path: string): Promise<void> {
ensureNotAborted(this.signal);
const norm = normalize(path);
const entry = this.entries.get(norm);
if (!entry) return;
if (entry.kind === "dir") {
const children = await this.listDir(norm);
if (children.length > 0) {
throw new Error(`ENOTEMPTY: ${path}`);
}
}
this.entries.delete(norm);
}
public async mkdir(path: string): Promise<void> {
ensureNotAborted(this.signal);
this.ensureDirChain(normalize(path));
}
public async rename(from: string, to: string): Promise<void> {
ensureNotAborted(this.signal);
const f = normalize(from);
const t = normalize(to);
const src = this.entries.get(f);
if (!src) {
throw new Error(`ENOENT: ${from}`);
}
this.ensureDirChain(dirname(t));
this.entries.set(t, src);
this.entries.delete(f);
}
public async writeJson(path: string, value: unknown): Promise<void> {
await this.writeFile(
path,
encoder.encode(`${JSON.stringify(value, null, 2)}\n`)
);
}
public snapshot(): Record<string, Uint8Array> {
const out: Record<string, Uint8Array> = {};
for (const [path, entry] of this.entries) {
if (entry.kind === "file") {
out[path] = new Uint8Array(entry.contents);
}
}
return out;
}
private seedFile(path: string, contents: Uint8Array): void {
const norm = normalize(path);
this.ensureDirChain(dirname(norm));
this.entries.set(norm, { kind: "file", contents });
}
private ensureDirChain(path: string): void {
const norm = normalize(path);
if (norm === "/" || norm === "") {
this.entries.set("/", { kind: "dir" });
return;
}
const parts = norm.split("/").filter(Boolean);
let cursor = "";
for (const part of parts) {
cursor = `${cursor}/${part}`;
const existing = this.entries.get(cursor);
if (!existing) {
this.entries.set(cursor, { kind: "dir" });
} else if (existing.kind === "file") {
throw new Error(`ENOTDIR: ${cursor}`);
}
}
}
}