Initial Commit
This commit is contained in:
83
packages/devtools/tooling/installer/src/fs/disk-fs.md
Normal file
83
packages/devtools/tooling/installer/src/fs/disk-fs.md
Normal 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.
|
||||
134
packages/devtools/tooling/installer/src/fs/disk-fs.test.ts
Normal file
134
packages/devtools/tooling/installer/src/fs/disk-fs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
126
packages/devtools/tooling/installer/src/fs/disk-fs.ts
Normal file
126
packages/devtools/tooling/installer/src/fs/disk-fs.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
6
packages/devtools/tooling/installer/src/fs/index.ts
Normal file
6
packages/devtools/tooling/installer/src/fs/index.ts
Normal 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";
|
||||
76
packages/devtools/tooling/installer/src/fs/installer-fs.md
Normal file
76
packages/devtools/tooling/installer/src/fs/installer-fs.md
Normal 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.
|
||||
15
packages/devtools/tooling/installer/src/fs/installer-fs.ts
Normal file
15
packages/devtools/tooling/installer/src/fs/installer-fs.ts
Normal 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";
|
||||
82
packages/devtools/tooling/installer/src/fs/memory-fs.md
Normal file
82
packages/devtools/tooling/installer/src/fs/memory-fs.md
Normal 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.
|
||||
155
packages/devtools/tooling/installer/src/fs/memory-fs.test.ts
Normal file
155
packages/devtools/tooling/installer/src/fs/memory-fs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
217
packages/devtools/tooling/installer/src/fs/memory-fs.ts
Normal file
217
packages/devtools/tooling/installer/src/fs/memory-fs.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user