@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,249 @@
|
|
|
1
|
+
import type { FsObject } from "./fs-object.js";
|
|
2
|
+
import { normalizePath } from "./fs-object.js";
|
|
3
|
+
import type { FsStatData } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/** Minimal stub interface for the FsObject Durable Object */
|
|
6
|
+
type FsObjectStub = DurableObjectStub<FsObject>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* IFileSystem implementation backed by a Durable Object.
|
|
10
|
+
* Every filesystem operation is delegated to the FsObject DO over RPC.
|
|
11
|
+
*
|
|
12
|
+
* Use the static `create()` factory for easy setup:
|
|
13
|
+
* ```ts
|
|
14
|
+
* const fs = await DurableFs.create(env.FS, "my-agent");
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export class DurableFs {
|
|
18
|
+
private _cachedPaths = new Set<string>();
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private stub: FsObjectStub,
|
|
22
|
+
private cwd = "/",
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a ready-to-use DurableFs from a DO namespace and instance name.
|
|
27
|
+
* Handles stub creation and initial sync in one call.
|
|
28
|
+
*/
|
|
29
|
+
static async create(
|
|
30
|
+
namespace: DurableObjectNamespace<FsObject>,
|
|
31
|
+
name: string,
|
|
32
|
+
cwd = "/",
|
|
33
|
+
): Promise<DurableFs> {
|
|
34
|
+
const id = namespace.idFromName(name);
|
|
35
|
+
const stub = namespace.get(id);
|
|
36
|
+
const fs = new DurableFs(stub, cwd);
|
|
37
|
+
await fs.sync();
|
|
38
|
+
return fs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Refresh the local path cache from the DO.
|
|
43
|
+
* Called automatically by `create()`. Only needed if you modify
|
|
44
|
+
* the DO outside of this DurableFs instance.
|
|
45
|
+
*/
|
|
46
|
+
async sync(): Promise<void> {
|
|
47
|
+
const paths = await this.stub.getAllPaths();
|
|
48
|
+
this._cachedPaths = new Set(paths);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async readFile(
|
|
52
|
+
path: string,
|
|
53
|
+
_options?: { encoding?: string | null } | string,
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
const result = await this.stub.readFile(this.resolve(path));
|
|
56
|
+
return result.content;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async readFileBuffer(path: string): Promise<Uint8Array> {
|
|
60
|
+
const result = await this.stub.readFileBuffer(this.resolve(path));
|
|
61
|
+
return result.content;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async writeFile(
|
|
65
|
+
path: string,
|
|
66
|
+
content: string | Uint8Array,
|
|
67
|
+
_options?: { encoding?: string } | string,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const resolved = this.resolve(path);
|
|
70
|
+
await this.stub.writeFile(resolved, content);
|
|
71
|
+
this.addToCache(resolved);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async appendFile(
|
|
75
|
+
path: string,
|
|
76
|
+
content: string | Uint8Array,
|
|
77
|
+
_options?: { encoding?: string } | string,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const resolved = this.resolve(path);
|
|
80
|
+
await this.stub.appendFile(resolved, content);
|
|
81
|
+
this.addToCache(resolved);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async exists(path: string): Promise<boolean> {
|
|
85
|
+
return this.stub.exists(this.resolve(path));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async stat(path: string): Promise<FsStat> {
|
|
89
|
+
const data = await this.stub.stat(this.resolve(path));
|
|
90
|
+
return toFsStat(data);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async lstat(path: string): Promise<FsStat> {
|
|
94
|
+
const data = await this.stub.lstat(this.resolve(path));
|
|
95
|
+
return toFsStat(data);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
|
99
|
+
const resolved = this.resolve(path);
|
|
100
|
+
await this.stub.mkdir(resolved, options);
|
|
101
|
+
this.addToCache(resolved);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async readdir(path: string): Promise<string[]> {
|
|
105
|
+
return this.stub.readdir(this.resolve(path));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async readdirWithFileTypes(path: string): Promise<DirentEntry[]> {
|
|
109
|
+
const entries = await this.stub.readdirWithFileTypes(this.resolve(path));
|
|
110
|
+
return entries.map((e) => ({
|
|
111
|
+
name: e.name,
|
|
112
|
+
isFile: e.isFile,
|
|
113
|
+
isDirectory: e.isDirectory,
|
|
114
|
+
isSymbolicLink: e.isSymlink,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async rm(
|
|
119
|
+
path: string,
|
|
120
|
+
options?: { recursive?: boolean; force?: boolean },
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const resolved = this.resolve(path);
|
|
123
|
+
await this.stub.rm(resolved, options);
|
|
124
|
+
this.removeFromCache(resolved);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async cp(
|
|
128
|
+
src: string,
|
|
129
|
+
dest: string,
|
|
130
|
+
options?: { recursive?: boolean },
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
await this.stub.cp(this.resolve(src), this.resolve(dest), options);
|
|
133
|
+
// Re-sync cache since cp can create many paths
|
|
134
|
+
await this.sync();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async mv(src: string, dest: string): Promise<void> {
|
|
138
|
+
await this.stub.mv(this.resolve(src), this.resolve(dest));
|
|
139
|
+
// Re-sync cache since mv can move many paths
|
|
140
|
+
await this.sync();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
resolvePath(base: string, path: string): string {
|
|
144
|
+
if (path.startsWith("/")) return normalizePath(path);
|
|
145
|
+
return normalizePath(`${base}/${path}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getAllPaths(): string[] {
|
|
149
|
+
return [...this._cachedPaths].sort();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async chmod(path: string, mode: number): Promise<void> {
|
|
153
|
+
await this.stub.chmod(this.resolve(path), mode);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async symlink(target: string, linkPath: string): Promise<void> {
|
|
157
|
+
const resolved = this.resolve(linkPath);
|
|
158
|
+
await this.stub.symlink(target, resolved);
|
|
159
|
+
this.addToCache(resolved);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async link(existingPath: string, newPath: string): Promise<void> {
|
|
163
|
+
const resolvedNew = this.resolve(newPath);
|
|
164
|
+
await this.stub.link(this.resolve(existingPath), resolvedNew);
|
|
165
|
+
this.addToCache(resolvedNew);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async readlink(path: string): Promise<string> {
|
|
169
|
+
return this.stub.readlink(this.resolve(path));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async realpath(path: string): Promise<string> {
|
|
173
|
+
return this.stub.realpath(this.resolve(path));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async utimes(path: string, atime: Date, mtime: Date): Promise<void> {
|
|
177
|
+
await this.stub.utimes(
|
|
178
|
+
this.resolve(path),
|
|
179
|
+
atime.getTime(),
|
|
180
|
+
mtime.getTime(),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Private helpers ---
|
|
185
|
+
|
|
186
|
+
private resolve(path: string): string {
|
|
187
|
+
return this.resolvePath(this.cwd, path);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Add a path and all its ancestor directories to the cache.
|
|
192
|
+
* The DO auto-creates parent dirs on write, so the cache must reflect them.
|
|
193
|
+
*/
|
|
194
|
+
private addToCache(path: string): void {
|
|
195
|
+
this._cachedPaths.add(path);
|
|
196
|
+
let dir = path;
|
|
197
|
+
while (true) {
|
|
198
|
+
const idx = dir.lastIndexOf("/");
|
|
199
|
+
if (idx <= 0) {
|
|
200
|
+
this._cachedPaths.add("/");
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
dir = dir.substring(0, idx);
|
|
204
|
+
if (this._cachedPaths.has(dir)) break;
|
|
205
|
+
this._cachedPaths.add(dir);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private removeFromCache(path: string): void {
|
|
210
|
+
this._cachedPaths.delete(path);
|
|
211
|
+
const prefix = `${path}/`;
|
|
212
|
+
// Safe to delete during Set iteration per ES spec — the iterator
|
|
213
|
+
// will not visit deleted entries but will complete correctly.
|
|
214
|
+
for (const p of this._cachedPaths) {
|
|
215
|
+
if (p.startsWith(prefix)) {
|
|
216
|
+
this._cachedPaths.delete(p);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** FsStat shape matching the just-bash IFileSystem interface */
|
|
223
|
+
export interface FsStat {
|
|
224
|
+
isFile: boolean;
|
|
225
|
+
isDirectory: boolean;
|
|
226
|
+
isSymbolicLink: boolean;
|
|
227
|
+
mode: number;
|
|
228
|
+
size: number;
|
|
229
|
+
mtime: Date;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** DirentEntry shape matching the just-bash IFileSystem interface */
|
|
233
|
+
export interface DirentEntry {
|
|
234
|
+
name: string;
|
|
235
|
+
isFile: boolean;
|
|
236
|
+
isDirectory: boolean;
|
|
237
|
+
isSymbolicLink: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function toFsStat(data: FsStatData): FsStat {
|
|
241
|
+
return {
|
|
242
|
+
isFile: !data.isDir && !data.isSymlink,
|
|
243
|
+
isDirectory: data.isDir,
|
|
244
|
+
isSymbolicLink: data.isSymlink,
|
|
245
|
+
mode: data.mode,
|
|
246
|
+
size: data.size,
|
|
247
|
+
mtime: new Date(data.mtimeMs),
|
|
248
|
+
};
|
|
249
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export class FsError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
public readonly code: string,
|
|
4
|
+
message: string,
|
|
5
|
+
public readonly path?: string,
|
|
6
|
+
) {
|
|
7
|
+
super(`${code}: ${message}${path ? `, '${path}'` : ""}`);
|
|
8
|
+
this.name = "FsError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const ENOENT = (path: string) =>
|
|
13
|
+
new FsError("ENOENT", "no such file or directory", path);
|
|
14
|
+
export const EEXIST = (path: string) =>
|
|
15
|
+
new FsError("EEXIST", "file already exists", path);
|
|
16
|
+
export const EISDIR = (path: string) =>
|
|
17
|
+
new FsError("EISDIR", "illegal operation on a directory", path);
|
|
18
|
+
export const ENOTDIR = (path: string) =>
|
|
19
|
+
new FsError("ENOTDIR", "not a directory", path);
|
|
20
|
+
export const ENOTEMPTY = (path: string) =>
|
|
21
|
+
new FsError("ENOTEMPTY", "directory not empty", path);
|