@stablemodels/durable-bash 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/final-review.md +20 -0
- package/.github/workflows/ci.yml +20 -0
- package/.github/workflows/publish.yml +28 -0
- package/CLAUDE.md +62 -0
- package/README.md +97 -0
- package/biome.json +25 -0
- package/bun.lock +442 -0
- package/bunfig.toml +2 -0
- package/dist/durable-fs.d.ts +88 -0
- package/dist/durable-fs.d.ts.map +1 -0
- package/dist/durable-fs.js +175 -0
- package/dist/durable-fs.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +16 -0
- package/dist/errors.js.map +1 -0
- package/dist/fs-object.d.ts +68 -0
- package/dist/fs-object.d.ts.map +1 -0
- package/dist/fs-object.js +455 -0
- package/dist/fs-object.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +35 -0
- package/src/durable-fs.ts +249 -0
- package/src/errors.ts +21 -0
- package/src/fs-object.ts +603 -0
- package/src/index.ts +12 -0
- package/src/types.ts +16 -0
- package/tests/durable-fs.test.ts +356 -0
- package/tests/fs-object.test.ts +435 -0
- package/tests/helpers.ts +105 -0
- package/tests/integration.test.ts +189 -0
- package/tests/just-bash.test.ts +85 -0
- package/tests/setup.ts +14 -0
- package/tsconfig.json +22 -0
- package/wrangler.toml +13 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { DurableFs } from "../src/durable-fs.js";
|
|
3
|
+
import { normalizePath } from "../src/fs-object.js";
|
|
4
|
+
|
|
5
|
+
// ─── Mock stub ────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function createMockStub() {
|
|
8
|
+
const calls: { method: string; args: unknown[] }[] = [];
|
|
9
|
+
|
|
10
|
+
const stub = new Proxy(
|
|
11
|
+
{},
|
|
12
|
+
{
|
|
13
|
+
get(_target, prop: string) {
|
|
14
|
+
return (...args: unknown[]) => {
|
|
15
|
+
calls.push({ method: prop, args });
|
|
16
|
+
// Return canned responses based on method
|
|
17
|
+
switch (prop) {
|
|
18
|
+
case "readFile":
|
|
19
|
+
return Promise.resolve({
|
|
20
|
+
content: "mock content",
|
|
21
|
+
encoding: "utf-8",
|
|
22
|
+
});
|
|
23
|
+
case "readFileBuffer":
|
|
24
|
+
return Promise.resolve({
|
|
25
|
+
content: new Uint8Array([1, 2, 3]),
|
|
26
|
+
});
|
|
27
|
+
case "exists":
|
|
28
|
+
return Promise.resolve(true);
|
|
29
|
+
case "stat":
|
|
30
|
+
case "lstat":
|
|
31
|
+
return Promise.resolve({
|
|
32
|
+
isDir: false,
|
|
33
|
+
isSymlink: false,
|
|
34
|
+
size: 42,
|
|
35
|
+
mode: 0o644,
|
|
36
|
+
mtimeMs: 1700000000000,
|
|
37
|
+
});
|
|
38
|
+
case "readdir":
|
|
39
|
+
return Promise.resolve(["a.txt", "b.txt"]);
|
|
40
|
+
case "readdirWithFileTypes":
|
|
41
|
+
return Promise.resolve([
|
|
42
|
+
{
|
|
43
|
+
name: "file.txt",
|
|
44
|
+
isFile: true,
|
|
45
|
+
isDirectory: false,
|
|
46
|
+
isSymlink: false,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "dir",
|
|
50
|
+
isFile: false,
|
|
51
|
+
isDirectory: true,
|
|
52
|
+
isSymlink: false,
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
case "readlink":
|
|
56
|
+
return Promise.resolve("/target");
|
|
57
|
+
case "realpath":
|
|
58
|
+
return Promise.resolve("/resolved");
|
|
59
|
+
case "getAllPaths":
|
|
60
|
+
return Promise.resolve(["/", "/a.txt", "/dir", "/dir/b.txt"]);
|
|
61
|
+
default:
|
|
62
|
+
return Promise.resolve();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return { stub: stub as never, calls };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Tests ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe("DurableFs", () => {
|
|
75
|
+
let fs: DurableFs;
|
|
76
|
+
let calls: { method: string; args: unknown[] }[];
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
const mock = createMockStub();
|
|
80
|
+
fs = new DurableFs(mock.stub, "/home");
|
|
81
|
+
calls = mock.calls;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("path resolution", () => {
|
|
85
|
+
test("resolvePath handles relative path", () => {
|
|
86
|
+
expect(fs.resolvePath("/home", "foo.txt")).toBe("/home/foo.txt");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("resolvePath handles absolute path", () => {
|
|
90
|
+
expect(fs.resolvePath("/home", "/etc/x")).toBe("/etc/x");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("resolvePath normalizes ..", () => {
|
|
94
|
+
expect(fs.resolvePath("/a/b", "../c")).toBe("/a/c");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("resolvePath normalizes .", () => {
|
|
98
|
+
expect(fs.resolvePath("/a/b", "./c")).toBe("/a/b/c");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("readFile", () => {
|
|
103
|
+
test("delegates to stub with resolved path", async () => {
|
|
104
|
+
const content = await fs.readFile("test.txt");
|
|
105
|
+
expect(content).toBe("mock content");
|
|
106
|
+
expect(calls[0].method).toBe("readFile");
|
|
107
|
+
expect(calls[0].args[0]).toBe("/home/test.txt");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("handles absolute path", async () => {
|
|
111
|
+
await fs.readFile("/etc/config");
|
|
112
|
+
expect(calls[0].args[0]).toBe("/etc/config");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("readFileBuffer", () => {
|
|
117
|
+
test("delegates to stub", async () => {
|
|
118
|
+
const result = await fs.readFileBuffer("data.bin");
|
|
119
|
+
expect(result).toBeInstanceOf(Uint8Array);
|
|
120
|
+
expect(calls[0].method).toBe("readFileBuffer");
|
|
121
|
+
expect(calls[0].args[0]).toBe("/home/data.bin");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("writeFile", () => {
|
|
126
|
+
test("delegates to stub", async () => {
|
|
127
|
+
await fs.writeFile("output.txt", "hello");
|
|
128
|
+
expect(calls[0].method).toBe("writeFile");
|
|
129
|
+
expect(calls[0].args[0]).toBe("/home/output.txt");
|
|
130
|
+
expect(calls[0].args[1]).toBe("hello");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("appendFile", () => {
|
|
135
|
+
test("delegates to stub", async () => {
|
|
136
|
+
await fs.appendFile("log.txt", "line\n");
|
|
137
|
+
expect(calls[0].method).toBe("appendFile");
|
|
138
|
+
expect(calls[0].args[0]).toBe("/home/log.txt");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("exists", () => {
|
|
143
|
+
test("delegates to stub", async () => {
|
|
144
|
+
const result = await fs.exists("test.txt");
|
|
145
|
+
expect(result).toBe(true);
|
|
146
|
+
expect(calls[0].args[0]).toBe("/home/test.txt");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("stat", () => {
|
|
151
|
+
test("returns FsStat with correct shape", async () => {
|
|
152
|
+
const s = await fs.stat("file.txt");
|
|
153
|
+
expect(s.isFile).toBe(true);
|
|
154
|
+
expect(s.isDirectory).toBe(false);
|
|
155
|
+
expect(s.isSymbolicLink).toBe(false);
|
|
156
|
+
expect(s.size).toBe(42);
|
|
157
|
+
expect(s.mode).toBe(0o644);
|
|
158
|
+
expect(s.mtime).toBeInstanceOf(Date);
|
|
159
|
+
expect(s.mtime.getTime()).toBe(1700000000000);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("lstat", () => {
|
|
164
|
+
test("delegates to stub lstat", async () => {
|
|
165
|
+
await fs.lstat("link.txt");
|
|
166
|
+
expect(calls[0].method).toBe("lstat");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("mkdir", () => {
|
|
171
|
+
test("delegates with options", async () => {
|
|
172
|
+
await fs.mkdir("newdir", { recursive: true });
|
|
173
|
+
expect(calls[0].method).toBe("mkdir");
|
|
174
|
+
expect(calls[0].args[0]).toBe("/home/newdir");
|
|
175
|
+
expect(calls[0].args[1]).toEqual({ recursive: true });
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("readdir", () => {
|
|
180
|
+
test("delegates and returns names", async () => {
|
|
181
|
+
const result = await fs.readdir(".");
|
|
182
|
+
expect(result).toEqual(["a.txt", "b.txt"]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("readdirWithFileTypes", () => {
|
|
187
|
+
test("wraps DirentEntry correctly", async () => {
|
|
188
|
+
const entries = await fs.readdirWithFileTypes(".");
|
|
189
|
+
expect(entries).toHaveLength(2);
|
|
190
|
+
|
|
191
|
+
const file = entries[0];
|
|
192
|
+
expect(file.name).toBe("file.txt");
|
|
193
|
+
expect(file.isFile).toBe(true);
|
|
194
|
+
expect(file.isDirectory).toBe(false);
|
|
195
|
+
expect(file.isSymbolicLink).toBe(false);
|
|
196
|
+
|
|
197
|
+
const dir = entries[1];
|
|
198
|
+
expect(dir.name).toBe("dir");
|
|
199
|
+
expect(dir.isFile).toBe(false);
|
|
200
|
+
expect(dir.isDirectory).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("rm", () => {
|
|
205
|
+
test("delegates with options", async () => {
|
|
206
|
+
await fs.rm("file.txt", { recursive: true, force: true });
|
|
207
|
+
expect(calls[0].method).toBe("rm");
|
|
208
|
+
expect(calls[0].args[1]).toEqual({ recursive: true, force: true });
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("cp", () => {
|
|
213
|
+
test("resolves both paths", async () => {
|
|
214
|
+
await fs.cp("src.txt", "dest.txt");
|
|
215
|
+
expect(calls[0].method).toBe("cp");
|
|
216
|
+
expect(calls[0].args[0]).toBe("/home/src.txt");
|
|
217
|
+
expect(calls[0].args[1]).toBe("/home/dest.txt");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("mv", () => {
|
|
222
|
+
test("resolves both paths", async () => {
|
|
223
|
+
await fs.mv("old.txt", "new.txt");
|
|
224
|
+
expect(calls[0].method).toBe("mv");
|
|
225
|
+
expect(calls[0].args[0]).toBe("/home/old.txt");
|
|
226
|
+
expect(calls[0].args[1]).toBe("/home/new.txt");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("chmod", () => {
|
|
231
|
+
test("delegates to stub", async () => {
|
|
232
|
+
await fs.chmod("file.txt", 0o755);
|
|
233
|
+
expect(calls[0].method).toBe("chmod");
|
|
234
|
+
expect(calls[0].args[1]).toBe(0o755);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("symlink", () => {
|
|
239
|
+
test("resolves link path", async () => {
|
|
240
|
+
await fs.symlink("/target", "link.txt");
|
|
241
|
+
expect(calls[0].method).toBe("symlink");
|
|
242
|
+
expect(calls[0].args[0]).toBe("/target");
|
|
243
|
+
expect(calls[0].args[1]).toBe("/home/link.txt");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("link", () => {
|
|
248
|
+
test("resolves both paths", async () => {
|
|
249
|
+
await fs.link("existing.txt", "new.txt");
|
|
250
|
+
expect(calls[0].method).toBe("link");
|
|
251
|
+
expect(calls[0].args[0]).toBe("/home/existing.txt");
|
|
252
|
+
expect(calls[0].args[1]).toBe("/home/new.txt");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("readlink", () => {
|
|
257
|
+
test("delegates to stub", async () => {
|
|
258
|
+
const target = await fs.readlink("link.txt");
|
|
259
|
+
expect(target).toBe("/target");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("realpath", () => {
|
|
264
|
+
test("delegates to stub", async () => {
|
|
265
|
+
const resolved = await fs.realpath("link.txt");
|
|
266
|
+
expect(resolved).toBe("/resolved");
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("utimes", () => {
|
|
271
|
+
test("converts Date to ms", async () => {
|
|
272
|
+
const atime = new Date(1700000000000);
|
|
273
|
+
const mtime = new Date(1700000001000);
|
|
274
|
+
await fs.utimes("file.txt", atime, mtime);
|
|
275
|
+
expect(calls[0].method).toBe("utimes");
|
|
276
|
+
expect(calls[0].args[1]).toBe(1700000000000);
|
|
277
|
+
expect(calls[0].args[2]).toBe(1700000001000);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("sync and getAllPaths", () => {
|
|
282
|
+
test("sync populates cache", async () => {
|
|
283
|
+
await fs.sync();
|
|
284
|
+
const paths = fs.getAllPaths();
|
|
285
|
+
expect(paths).toEqual(["/", "/a.txt", "/dir", "/dir/b.txt"]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("getAllPaths returns empty before sync", () => {
|
|
289
|
+
expect(fs.getAllPaths()).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("writeFile adds to cache", async () => {
|
|
293
|
+
await fs.sync();
|
|
294
|
+
await fs.writeFile("new.txt", "data");
|
|
295
|
+
expect(fs.getAllPaths()).toContain("/home/new.txt");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("rm removes from cache", async () => {
|
|
299
|
+
await fs.sync();
|
|
300
|
+
await fs.rm("/a.txt");
|
|
301
|
+
expect(fs.getAllPaths()).not.toContain("/a.txt");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("rm removes descendants from cache", async () => {
|
|
305
|
+
await fs.sync();
|
|
306
|
+
await fs.rm("/dir", { recursive: true });
|
|
307
|
+
expect(fs.getAllPaths()).not.toContain("/dir");
|
|
308
|
+
expect(fs.getAllPaths()).not.toContain("/dir/b.txt");
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("create factory", () => {
|
|
313
|
+
test("returns a synced DurableFs", async () => {
|
|
314
|
+
const mock = createMockStub();
|
|
315
|
+
const namespace = {
|
|
316
|
+
idFromName: (name: string) => ({ name }),
|
|
317
|
+
get: () => mock.stub,
|
|
318
|
+
} as never;
|
|
319
|
+
|
|
320
|
+
const created = await DurableFs.create(namespace, "test-agent");
|
|
321
|
+
// Cache should already be populated (sync called internally)
|
|
322
|
+
expect(created.getAllPaths()).toEqual([
|
|
323
|
+
"/",
|
|
324
|
+
"/a.txt",
|
|
325
|
+
"/dir",
|
|
326
|
+
"/dir/b.txt",
|
|
327
|
+
]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("passes cwd to instance", async () => {
|
|
331
|
+
const mock = createMockStub();
|
|
332
|
+
const namespace = {
|
|
333
|
+
idFromName: (name: string) => ({ name }),
|
|
334
|
+
get: () => mock.stub,
|
|
335
|
+
} as never;
|
|
336
|
+
|
|
337
|
+
const created = await DurableFs.create(namespace, "agent", "/workspace");
|
|
338
|
+
await created.readFile("foo.txt");
|
|
339
|
+
// Should resolve relative to /workspace
|
|
340
|
+
expect(mock.calls.at(-1)!.args[0]).toBe("/workspace/foo.txt");
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ─── normalizePath standalone tests ───────────────────────────────────
|
|
346
|
+
|
|
347
|
+
describe("normalizePath", () => {
|
|
348
|
+
test("root", () => expect(normalizePath("/")).toBe("/"));
|
|
349
|
+
test("empty", () => expect(normalizePath("")).toBe("/"));
|
|
350
|
+
test("simple", () => expect(normalizePath("/a/b/c")).toBe("/a/b/c"));
|
|
351
|
+
test("trailing slash", () => expect(normalizePath("/a/b/")).toBe("/a/b"));
|
|
352
|
+
test("double slash", () => expect(normalizePath("/a//b")).toBe("/a/b"));
|
|
353
|
+
test("dot", () => expect(normalizePath("/a/./b")).toBe("/a/b"));
|
|
354
|
+
test("dotdot", () => expect(normalizePath("/a/b/../c")).toBe("/a/c"));
|
|
355
|
+
test("dotdot at root", () => expect(normalizePath("/a/../../b")).toBe("/b"));
|
|
356
|
+
});
|