@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,189 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { DurableFs } from "../src/durable-fs.js";
|
|
3
|
+
import type { FsObject } from "../src/fs-object.js";
|
|
4
|
+
import { createDirectStub, createTestFsObject } from "./helpers.js";
|
|
5
|
+
|
|
6
|
+
let fs: DurableFs;
|
|
7
|
+
let obj: FsObject;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
const result = await createTestFsObject();
|
|
11
|
+
obj = result.obj;
|
|
12
|
+
const stub = createDirectStub(obj);
|
|
13
|
+
fs = new DurableFs(stub, "/home");
|
|
14
|
+
await fs.sync();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("integration: DurableFs + FsObject", () => {
|
|
18
|
+
test("create, read, list cycle", async () => {
|
|
19
|
+
await fs.writeFile("hello.txt", "world");
|
|
20
|
+
const content = await fs.readFile("hello.txt");
|
|
21
|
+
expect(content).toBe("world");
|
|
22
|
+
|
|
23
|
+
const entries = await fs.readdir("/home");
|
|
24
|
+
expect(entries).toContain("hello.txt");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("nested directory tree", async () => {
|
|
28
|
+
await fs.mkdir("a/b/c", { recursive: true });
|
|
29
|
+
await fs.writeFile("a/b/c/deep.txt", "deep");
|
|
30
|
+
|
|
31
|
+
const aEntries = await fs.readdir("/home/a");
|
|
32
|
+
expect(aEntries).toEqual(["b"]);
|
|
33
|
+
|
|
34
|
+
const bEntries = await fs.readdir("/home/a/b");
|
|
35
|
+
expect(bEntries).toEqual(["c"]);
|
|
36
|
+
|
|
37
|
+
const cEntries = await fs.readdir("/home/a/b/c");
|
|
38
|
+
expect(cEntries).toEqual(["deep.txt"]);
|
|
39
|
+
|
|
40
|
+
const content = await fs.readFile("a/b/c/deep.txt");
|
|
41
|
+
expect(content).toBe("deep");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("overwrite preserves directory structure", async () => {
|
|
45
|
+
await fs.writeFile("dir/file.txt", "first");
|
|
46
|
+
await fs.writeFile("dir/file.txt", "second");
|
|
47
|
+
|
|
48
|
+
const content = await fs.readFile("dir/file.txt");
|
|
49
|
+
expect(content).toBe("second");
|
|
50
|
+
|
|
51
|
+
// Parent dir still exists
|
|
52
|
+
expect(await fs.exists("/home/dir")).toBe(true);
|
|
53
|
+
const stat = await fs.stat("/home/dir");
|
|
54
|
+
expect(stat.isDirectory).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rm -rf on tree", async () => {
|
|
58
|
+
await fs.writeFile("tree/a.txt", "a");
|
|
59
|
+
await fs.writeFile("tree/sub/b.txt", "b");
|
|
60
|
+
await fs.writeFile("tree/sub/deep/c.txt", "c");
|
|
61
|
+
|
|
62
|
+
await fs.rm("tree", { recursive: true });
|
|
63
|
+
|
|
64
|
+
expect(await fs.exists("/home/tree")).toBe(false);
|
|
65
|
+
expect(await fs.exists("/home/tree/a.txt")).toBe(false);
|
|
66
|
+
expect(await fs.exists("/home/tree/sub/b.txt")).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("symlink chain", async () => {
|
|
70
|
+
await fs.writeFile("actual.txt", "real content");
|
|
71
|
+
await fs.symlink("/home/actual.txt", "link1.txt");
|
|
72
|
+
await fs.symlink("/home/link1.txt", "link2.txt");
|
|
73
|
+
|
|
74
|
+
// Reading through chain of symlinks
|
|
75
|
+
const content = await fs.readFile("link2.txt");
|
|
76
|
+
expect(content).toBe("real content");
|
|
77
|
+
|
|
78
|
+
// lstat sees symlink
|
|
79
|
+
const lstat = await fs.lstat("link2.txt");
|
|
80
|
+
expect(lstat.isSymbolicLink).toBe(true);
|
|
81
|
+
|
|
82
|
+
// stat follows symlinks
|
|
83
|
+
const stat = await fs.stat("link2.txt");
|
|
84
|
+
expect(stat.isFile).toBe(true);
|
|
85
|
+
expect(stat.isSymbolicLink).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("concurrent writes to different files", async () => {
|
|
89
|
+
await Promise.all([
|
|
90
|
+
fs.writeFile("file1.txt", "content1"),
|
|
91
|
+
fs.writeFile("file2.txt", "content2"),
|
|
92
|
+
fs.writeFile("file3.txt", "content3"),
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
expect(await fs.readFile("file1.txt")).toBe("content1");
|
|
96
|
+
expect(await fs.readFile("file2.txt")).toBe("content2");
|
|
97
|
+
expect(await fs.readFile("file3.txt")).toBe("content3");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("stat returns correct FsStat shape", async () => {
|
|
101
|
+
await fs.writeFile("file.txt", "hello");
|
|
102
|
+
const stat = await fs.stat("file.txt");
|
|
103
|
+
|
|
104
|
+
expect(stat.isFile).toBe(true);
|
|
105
|
+
expect(stat.isDirectory).toBe(false);
|
|
106
|
+
expect(stat.isSymbolicLink).toBe(false);
|
|
107
|
+
expect(stat.size).toBe(5);
|
|
108
|
+
expect(stat.mode).toBe(0o644);
|
|
109
|
+
expect(stat.mtime).toBeInstanceOf(Date);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("readdirWithFileTypes returns DirentEntry shape", async () => {
|
|
113
|
+
await fs.writeFile("dir/file.txt", "data");
|
|
114
|
+
await fs.mkdir("dir/subdir");
|
|
115
|
+
|
|
116
|
+
const entries = await fs.readdirWithFileTypes("/home/dir");
|
|
117
|
+
expect(entries).toHaveLength(2);
|
|
118
|
+
|
|
119
|
+
const file = entries.find((e) => e.name === "file.txt")!;
|
|
120
|
+
expect(file.isFile).toBe(true);
|
|
121
|
+
expect(file.isDirectory).toBe(false);
|
|
122
|
+
expect(file.isSymbolicLink).toBe(false);
|
|
123
|
+
|
|
124
|
+
const dir = entries.find((e) => e.name === "subdir")!;
|
|
125
|
+
expect(dir.isFile).toBe(false);
|
|
126
|
+
expect(dir.isDirectory).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("getAllPaths reflects writes", async () => {
|
|
130
|
+
await fs.writeFile("new.txt", "data");
|
|
131
|
+
const paths = fs.getAllPaths();
|
|
132
|
+
expect(paths).toContain("/home/new.txt");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("copy file end-to-end", async () => {
|
|
136
|
+
await fs.writeFile("original.txt", "copy me");
|
|
137
|
+
await fs.cp("original.txt", "copied.txt");
|
|
138
|
+
expect(await fs.readFile("copied.txt")).toBe("copy me");
|
|
139
|
+
expect(await fs.readFile("original.txt")).toBe("copy me");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("move file end-to-end", async () => {
|
|
143
|
+
await fs.writeFile("before.txt", "moving");
|
|
144
|
+
await fs.mv("before.txt", "after.txt");
|
|
145
|
+
expect(await fs.exists("/home/before.txt")).toBe(false);
|
|
146
|
+
expect(await fs.readFile("after.txt")).toBe("moving");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("append creates and appends", async () => {
|
|
150
|
+
await fs.appendFile("log.txt", "line1\n");
|
|
151
|
+
await fs.appendFile("log.txt", "line2\n");
|
|
152
|
+
expect(await fs.readFile("log.txt")).toBe("line1\nline2\n");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("chmod changes permissions", async () => {
|
|
156
|
+
await fs.writeFile("script.sh", "#!/bin/sh");
|
|
157
|
+
await fs.chmod("script.sh", 0o755);
|
|
158
|
+
const stat = await fs.stat("script.sh");
|
|
159
|
+
expect(stat.mode).toBe(0o755);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("utimes changes mtime", async () => {
|
|
163
|
+
await fs.writeFile("file.txt", "data");
|
|
164
|
+
const mtime = new Date(1700000000000);
|
|
165
|
+
await fs.utimes("file.txt", mtime, mtime);
|
|
166
|
+
const stat = await fs.stat("file.txt");
|
|
167
|
+
expect(stat.mtime.getTime()).toBe(1700000000000);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("hard link shares content", async () => {
|
|
171
|
+
await fs.writeFile("original.txt", "shared");
|
|
172
|
+
await fs.link("original.txt", "linked.txt");
|
|
173
|
+
expect(await fs.readFile("linked.txt")).toBe("shared");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("realpath resolves symlinks", async () => {
|
|
177
|
+
await fs.writeFile("real.txt", "data");
|
|
178
|
+
await fs.symlink("/home/real.txt", "alias.txt");
|
|
179
|
+
const resolved = await fs.realpath("alias.txt");
|
|
180
|
+
expect(resolved).toBe("/home/real.txt");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("binary file roundtrip", async () => {
|
|
184
|
+
const binary = new Uint8Array([0, 1, 127, 128, 255]);
|
|
185
|
+
await fs.writeFile("binary.bin", binary);
|
|
186
|
+
const result = await fs.readFileBuffer("binary.bin");
|
|
187
|
+
expect(result).toEqual(binary);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Bash } from "just-bash";
|
|
3
|
+
import { DurableFs } from "../src/durable-fs.js";
|
|
4
|
+
import { createDirectStub, createTestFsObject } from "./helpers.js";
|
|
5
|
+
|
|
6
|
+
let bash: Bash;
|
|
7
|
+
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
const { obj } = await createTestFsObject();
|
|
10
|
+
const stub = createDirectStub(obj);
|
|
11
|
+
const fs = new DurableFs(stub, "/home");
|
|
12
|
+
await fs.sync();
|
|
13
|
+
bash = new Bash({ fs: fs as never, cwd: "/home" });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("just-bash smoke tests with DurableFs", () => {
|
|
17
|
+
test('echo "hello" > file.txt && cat file.txt', async () => {
|
|
18
|
+
const result = await bash.exec('echo "hello" > file.txt && cat file.txt');
|
|
19
|
+
expect(result.stdout.trim()).toBe("hello");
|
|
20
|
+
expect(result.exitCode).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("mkdir -p a/b/c && ls a/b", async () => {
|
|
24
|
+
await bash.exec("mkdir -p a/b/c");
|
|
25
|
+
const result = await bash.exec("ls a/b");
|
|
26
|
+
expect(result.stdout.trim()).toBe("c");
|
|
27
|
+
expect(result.exitCode).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("cp file.txt copy.txt && cat copy.txt", async () => {
|
|
31
|
+
await bash.exec('echo "original" > file.txt');
|
|
32
|
+
await bash.exec("cp file.txt copy.txt");
|
|
33
|
+
const result = await bash.exec("cat copy.txt");
|
|
34
|
+
expect(result.stdout.trim()).toBe("original");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("append with >>", async () => {
|
|
38
|
+
await bash.exec('echo "line1" > f.txt');
|
|
39
|
+
await bash.exec('echo "line2" >> f.txt');
|
|
40
|
+
const result = await bash.exec("cat f.txt");
|
|
41
|
+
expect(result.stdout).toContain("line1");
|
|
42
|
+
expect(result.stdout).toContain("line2");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("rm -rf removes directory tree", async () => {
|
|
46
|
+
await bash.exec("mkdir -p /tmp/test/sub");
|
|
47
|
+
await bash.exec('echo "data" > /tmp/test/sub/file.txt');
|
|
48
|
+
await bash.exec("rm -rf /tmp/test");
|
|
49
|
+
const result = await bash.exec("ls /tmp/test 2>&1; echo $?");
|
|
50
|
+
// Should fail since /tmp/test was removed
|
|
51
|
+
expect(result.stdout).toContain("2");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("pwd reflects cwd option", async () => {
|
|
55
|
+
const result = await bash.exec("pwd");
|
|
56
|
+
expect(result.stdout.trim()).toBe("/home");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("write and read binary-safe through hex", async () => {
|
|
60
|
+
await bash.exec('printf "hello\\nworld" > multi.txt');
|
|
61
|
+
const result = await bash.exec("cat multi.txt");
|
|
62
|
+
expect(result.stdout).toContain("hello");
|
|
63
|
+
expect(result.stdout).toContain("world");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("mv renames file", async () => {
|
|
67
|
+
await bash.exec('echo "moveme" > old.txt');
|
|
68
|
+
await bash.exec("mv old.txt new.txt");
|
|
69
|
+
const result = await bash.exec("cat new.txt");
|
|
70
|
+
expect(result.stdout.trim()).toBe("moveme");
|
|
71
|
+
|
|
72
|
+
// old file should be gone
|
|
73
|
+
const check = await bash.exec("cat old.txt 2>/dev/null");
|
|
74
|
+
expect(check.exitCode).not.toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("multiple commands in sequence", async () => {
|
|
78
|
+
const result = await bash.exec(`
|
|
79
|
+
mkdir -p project/src
|
|
80
|
+
echo 'console.log("hi")' > project/src/index.js
|
|
81
|
+
cat project/src/index.js
|
|
82
|
+
`);
|
|
83
|
+
expect(result.stdout).toContain('console.log("hi")');
|
|
84
|
+
});
|
|
85
|
+
});
|
package/tests/setup.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// This file must be imported (via preload) before any test files.
|
|
2
|
+
// It mocks cloudflare:workers so FsObject can be imported in tests.
|
|
3
|
+
import { mock } from "bun:test";
|
|
4
|
+
|
|
5
|
+
mock.module("cloudflare:workers", () => ({
|
|
6
|
+
DurableObject: class DurableObject {
|
|
7
|
+
ctx: unknown;
|
|
8
|
+
env: unknown;
|
|
9
|
+
constructor(ctx: unknown, env: unknown) {
|
|
10
|
+
this.ctx = ctx;
|
|
11
|
+
this.env = env;
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
}));
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true,
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"isolatedModules": true,
|
|
18
|
+
"types": ["@cloudflare/workers-types"]
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*.ts"],
|
|
21
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
22
|
+
}
|
package/wrangler.toml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Example wrangler.toml — consumers should add these bindings to their own config.
|
|
2
|
+
# This file is provided as a reference.
|
|
3
|
+
|
|
4
|
+
name = "durable-bash-example"
|
|
5
|
+
compatibility_date = "2025-03-01"
|
|
6
|
+
|
|
7
|
+
[[durable_objects.bindings]]
|
|
8
|
+
name = "FS"
|
|
9
|
+
class_name = "FsObject"
|
|
10
|
+
|
|
11
|
+
[[migrations]]
|
|
12
|
+
tag = "v1"
|
|
13
|
+
new_sqlite_classes = ["FsObject"]
|