sandlot 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/README.md +616 -0
- package/dist/bundler.d.ts +148 -0
- package/dist/bundler.d.ts.map +1 -0
- package/dist/commands.d.ts +179 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/fs.d.ts +125 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2920 -0
- package/dist/internal.d.ts +74 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +1897 -0
- package/dist/loader.d.ts +164 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/packages.d.ts +199 -0
- package/dist/packages.d.ts.map +1 -0
- package/dist/react.d.ts +159 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +149 -0
- package/dist/sandbox-manager.d.ts +249 -0
- package/dist/sandbox-manager.d.ts.map +1 -0
- package/dist/sandbox.d.ts +193 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/shared-modules.d.ts +129 -0
- package/dist/shared-modules.d.ts.map +1 -0
- package/dist/shared-resources.d.ts +105 -0
- package/dist/shared-resources.d.ts.map +1 -0
- package/dist/ts-libs.d.ts +98 -0
- package/dist/ts-libs.d.ts.map +1 -0
- package/dist/typechecker.d.ts +127 -0
- package/dist/typechecker.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/bundler.ts +513 -0
- package/src/commands.ts +733 -0
- package/src/fs.ts +935 -0
- package/src/index.ts +149 -0
- package/src/internal.ts +116 -0
- package/src/loader.ts +229 -0
- package/src/packages.ts +936 -0
- package/src/react.tsx +331 -0
- package/src/sandbox-manager.ts +490 -0
- package/src/sandbox.ts +402 -0
- package/src/shared-modules.ts +210 -0
- package/src/shared-resources.ts +169 -0
- package/src/ts-libs.ts +320 -0
- package/src/typechecker.ts +635 -0
package/src/fs.ts
ADDED
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IFileSystem,
|
|
3
|
+
FsEntry,
|
|
4
|
+
FsStat,
|
|
5
|
+
MkdirOptions,
|
|
6
|
+
RmOptions,
|
|
7
|
+
CpOptions,
|
|
8
|
+
FileContent,
|
|
9
|
+
InitialFiles,
|
|
10
|
+
FileInit,
|
|
11
|
+
} from "just-bash/browser";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Supported buffer encodings
|
|
15
|
+
*/
|
|
16
|
+
type BufferEncoding = "utf8" | "utf-8" | "ascii" | "binary" | "base64" | "hex" | "latin1";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Options for reading files
|
|
20
|
+
*/
|
|
21
|
+
interface ReadFileOptions {
|
|
22
|
+
encoding?: BufferEncoding | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Options for writing files
|
|
27
|
+
*/
|
|
28
|
+
interface WriteFileOptions {
|
|
29
|
+
encoding?: BufferEncoding;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Directory entry with type information (similar to Node's Dirent)
|
|
34
|
+
*/
|
|
35
|
+
interface DirentEntry {
|
|
36
|
+
name: string;
|
|
37
|
+
isFile: boolean;
|
|
38
|
+
isDirectory: boolean;
|
|
39
|
+
isSymbolicLink: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_FILE_MODE = 0o644;
|
|
43
|
+
const DEFAULT_DIR_MODE = 0o755;
|
|
44
|
+
const DEFAULT_SYMLINK_MODE = 0o777;
|
|
45
|
+
const DEFAULT_MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB default limit
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Options for creating an IndexedDbFs instance
|
|
49
|
+
*/
|
|
50
|
+
export interface IndexedDbFsOptions {
|
|
51
|
+
/** Database name in IndexedDB */
|
|
52
|
+
dbName?: string;
|
|
53
|
+
/** Maximum total size in bytes (default: 50MB) */
|
|
54
|
+
maxSizeBytes?: number;
|
|
55
|
+
/** Initial files to populate (only used if DB is empty) */
|
|
56
|
+
initialFiles?: InitialFiles;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* In-memory filesystem with IndexedDB persistence.
|
|
61
|
+
* All operations are fast (in-memory), persistence is manual via save().
|
|
62
|
+
*/
|
|
63
|
+
export class IndexedDbFs implements IFileSystem {
|
|
64
|
+
private entries: Map<string, FsEntry>;
|
|
65
|
+
private db: IDBDatabase | null = null;
|
|
66
|
+
private dbName: string;
|
|
67
|
+
private maxSizeBytes: number;
|
|
68
|
+
private dirty = false;
|
|
69
|
+
|
|
70
|
+
private constructor(
|
|
71
|
+
entries: Map<string, FsEntry>,
|
|
72
|
+
db: IDBDatabase | null,
|
|
73
|
+
dbName: string,
|
|
74
|
+
maxSizeBytes: number
|
|
75
|
+
) {
|
|
76
|
+
this.entries = entries;
|
|
77
|
+
this.db = db;
|
|
78
|
+
this.dbName = dbName;
|
|
79
|
+
this.maxSizeBytes = maxSizeBytes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create and initialize a new IndexedDbFs instance
|
|
84
|
+
*/
|
|
85
|
+
static async create(options: IndexedDbFsOptions = {}): Promise<IndexedDbFs> {
|
|
86
|
+
const dbName = options.dbName ?? "sandlot-fs";
|
|
87
|
+
const maxSizeBytes = options.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES;
|
|
88
|
+
|
|
89
|
+
const db = await IndexedDbFs.openDatabase(dbName);
|
|
90
|
+
const entries = await IndexedDbFs.loadEntries(db);
|
|
91
|
+
|
|
92
|
+
// If empty and initialFiles provided, populate
|
|
93
|
+
if (entries.size === 0) {
|
|
94
|
+
// Always ensure root exists
|
|
95
|
+
entries.set("/", {
|
|
96
|
+
type: "directory",
|
|
97
|
+
mode: DEFAULT_DIR_MODE,
|
|
98
|
+
mtime: new Date(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (options.initialFiles) {
|
|
102
|
+
for (const [path, value] of Object.entries(options.initialFiles)) {
|
|
103
|
+
const normalizedPath = IndexedDbFs.normalizePath(path);
|
|
104
|
+
const init = IndexedDbFs.parseFileInit(value);
|
|
105
|
+
|
|
106
|
+
// Ensure parent directories exist
|
|
107
|
+
IndexedDbFs.ensureParentDirs(entries, normalizedPath);
|
|
108
|
+
|
|
109
|
+
entries.set(normalizedPath, {
|
|
110
|
+
type: "file",
|
|
111
|
+
content: init.content,
|
|
112
|
+
mode: init.mode ?? DEFAULT_FILE_MODE,
|
|
113
|
+
mtime: init.mtime ?? new Date(),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const fs = new IndexedDbFs(entries, db, dbName, maxSizeBytes);
|
|
120
|
+
return fs;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create an in-memory only instance (no IndexedDB)
|
|
125
|
+
*/
|
|
126
|
+
static createInMemory(options: Omit<IndexedDbFsOptions, "dbName"> = {}): IndexedDbFs {
|
|
127
|
+
const maxSizeBytes = options.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES;
|
|
128
|
+
const entries = new Map<string, FsEntry>();
|
|
129
|
+
|
|
130
|
+
// Always ensure root exists
|
|
131
|
+
entries.set("/", {
|
|
132
|
+
type: "directory",
|
|
133
|
+
mode: DEFAULT_DIR_MODE,
|
|
134
|
+
mtime: new Date(),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (options.initialFiles) {
|
|
138
|
+
for (const [path, value] of Object.entries(options.initialFiles)) {
|
|
139
|
+
const normalizedPath = IndexedDbFs.normalizePath(path);
|
|
140
|
+
const init = IndexedDbFs.parseFileInit(value);
|
|
141
|
+
|
|
142
|
+
// Ensure parent directories exist
|
|
143
|
+
IndexedDbFs.ensureParentDirs(entries, normalizedPath);
|
|
144
|
+
|
|
145
|
+
entries.set(normalizedPath, {
|
|
146
|
+
type: "file",
|
|
147
|
+
content: init.content,
|
|
148
|
+
mode: init.mode ?? DEFAULT_FILE_MODE,
|
|
149
|
+
mtime: init.mtime ?? new Date(),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return new IndexedDbFs(entries, null, "", maxSizeBytes);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ============ Persistence Methods ============
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Save all entries to IndexedDB
|
|
161
|
+
* @returns true if saved, false if no db or not dirty
|
|
162
|
+
*/
|
|
163
|
+
async save(): Promise<boolean> {
|
|
164
|
+
if (!this.db || !this.dirty) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const tx = this.db.transaction("entries", "readwrite");
|
|
169
|
+
const store = tx.objectStore("entries");
|
|
170
|
+
|
|
171
|
+
// Clear and rewrite all entries
|
|
172
|
+
await this.promisifyRequest(store.clear());
|
|
173
|
+
|
|
174
|
+
for (const [path, entry] of this.entries) {
|
|
175
|
+
store.put({ path, entry: this.serializeEntry(entry) });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await this.promisifyTransaction(tx);
|
|
179
|
+
this.dirty = false;
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Reload entries from IndexedDB, discarding unsaved changes
|
|
185
|
+
*/
|
|
186
|
+
async reload(): Promise<void> {
|
|
187
|
+
if (!this.db) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.entries = await IndexedDbFs.loadEntries(this.db);
|
|
192
|
+
this.dirty = false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Check if there are unsaved changes
|
|
197
|
+
*/
|
|
198
|
+
isDirty(): boolean {
|
|
199
|
+
return this.dirty;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get approximate size of all stored data in bytes
|
|
204
|
+
*/
|
|
205
|
+
getSize(): number {
|
|
206
|
+
let size = 0;
|
|
207
|
+
for (const [path, entry] of this.entries) {
|
|
208
|
+
size += path.length * 2; // UTF-16
|
|
209
|
+
if (entry.type === "file") {
|
|
210
|
+
if (typeof entry.content === "string") {
|
|
211
|
+
size += entry.content.length * 2;
|
|
212
|
+
} else {
|
|
213
|
+
size += entry.content.byteLength;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return size;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Close the database connection
|
|
222
|
+
*/
|
|
223
|
+
close(): void {
|
|
224
|
+
if (this.db) {
|
|
225
|
+
this.db.close();
|
|
226
|
+
this.db = null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============ IFileSystem Implementation ============
|
|
231
|
+
|
|
232
|
+
async readFile(
|
|
233
|
+
path: string,
|
|
234
|
+
options?: ReadFileOptions | BufferEncoding
|
|
235
|
+
): Promise<string> {
|
|
236
|
+
const normalizedPath = this.normalizePath(path);
|
|
237
|
+
const entry = this.resolveSymlinks(normalizedPath);
|
|
238
|
+
|
|
239
|
+
if (!entry) {
|
|
240
|
+
throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
241
|
+
}
|
|
242
|
+
if (entry.type !== "file") {
|
|
243
|
+
throw new Error(`EISDIR: illegal operation on a directory, read '${path}'`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const content = entry.content;
|
|
247
|
+
if (typeof content === "string") {
|
|
248
|
+
return content;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Convert Uint8Array to string
|
|
252
|
+
const encoding = this.getEncoding(options) ?? "utf8";
|
|
253
|
+
return this.decodeBuffer(content, encoding);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async readFileBuffer(path: string): Promise<Uint8Array> {
|
|
257
|
+
const normalizedPath = this.normalizePath(path);
|
|
258
|
+
const entry = this.resolveSymlinks(normalizedPath);
|
|
259
|
+
|
|
260
|
+
if (!entry) {
|
|
261
|
+
throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
262
|
+
}
|
|
263
|
+
if (entry.type !== "file") {
|
|
264
|
+
throw new Error(`EISDIR: illegal operation on a directory, read '${path}'`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const content = entry.content;
|
|
268
|
+
if (content instanceof Uint8Array) {
|
|
269
|
+
return content;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Convert string to Uint8Array
|
|
273
|
+
return new TextEncoder().encode(content);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async writeFile(
|
|
277
|
+
path: string,
|
|
278
|
+
content: FileContent,
|
|
279
|
+
options?: WriteFileOptions | BufferEncoding
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
const normalizedPath = this.normalizePath(path);
|
|
282
|
+
this.checkSizeLimit(content);
|
|
283
|
+
this.ensureParentDirs(normalizedPath);
|
|
284
|
+
|
|
285
|
+
const existing = this.entries.get(normalizedPath);
|
|
286
|
+
if (existing && existing.type === "directory") {
|
|
287
|
+
throw new Error(`EISDIR: illegal operation on a directory, open '${path}'`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
this.entries.set(normalizedPath, {
|
|
291
|
+
type: "file",
|
|
292
|
+
content,
|
|
293
|
+
mode: existing?.mode ?? DEFAULT_FILE_MODE,
|
|
294
|
+
mtime: new Date(),
|
|
295
|
+
});
|
|
296
|
+
this.dirty = true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async appendFile(
|
|
300
|
+
path: string,
|
|
301
|
+
content: FileContent,
|
|
302
|
+
options?: WriteFileOptions | BufferEncoding
|
|
303
|
+
): Promise<void> {
|
|
304
|
+
const normalizedPath = this.normalizePath(path);
|
|
305
|
+
let existing: string | Uint8Array = "";
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
existing = await this.readFile(normalizedPath);
|
|
309
|
+
} catch {
|
|
310
|
+
// File doesn't exist, will be created
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const newContent =
|
|
314
|
+
typeof existing === "string" && typeof content === "string"
|
|
315
|
+
? existing + content
|
|
316
|
+
: this.concatBuffers(
|
|
317
|
+
typeof existing === "string" ? new TextEncoder().encode(existing) : existing,
|
|
318
|
+
typeof content === "string" ? new TextEncoder().encode(content) : content
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
await this.writeFile(normalizedPath, newContent, options);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async exists(path: string): Promise<boolean> {
|
|
325
|
+
const normalizedPath = this.normalizePath(path);
|
|
326
|
+
return this.entries.has(normalizedPath);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async stat(path: string): Promise<FsStat> {
|
|
330
|
+
const normalizedPath = this.normalizePath(path);
|
|
331
|
+
const entry = this.resolveSymlinks(normalizedPath);
|
|
332
|
+
|
|
333
|
+
if (!entry) {
|
|
334
|
+
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return this.entryToStat(entry);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async lstat(path: string): Promise<FsStat> {
|
|
341
|
+
const normalizedPath = this.normalizePath(path);
|
|
342
|
+
const entry = this.entries.get(normalizedPath);
|
|
343
|
+
|
|
344
|
+
if (!entry) {
|
|
345
|
+
throw new Error(`ENOENT: no such file or directory, lstat '${path}'`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return this.entryToStat(entry);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async mkdir(path: string, options?: MkdirOptions): Promise<void> {
|
|
352
|
+
const normalizedPath = this.normalizePath(path);
|
|
353
|
+
|
|
354
|
+
if (this.entries.has(normalizedPath)) {
|
|
355
|
+
if (options?.recursive) {
|
|
356
|
+
return; // Already exists, ok with recursive
|
|
357
|
+
}
|
|
358
|
+
throw new Error(`EEXIST: file already exists, mkdir '${path}'`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (options?.recursive) {
|
|
362
|
+
this.ensureParentDirs(normalizedPath);
|
|
363
|
+
} else {
|
|
364
|
+
const parent = this.getParentPath(normalizedPath);
|
|
365
|
+
if (parent && !this.entries.has(parent)) {
|
|
366
|
+
throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
this.entries.set(normalizedPath, {
|
|
371
|
+
type: "directory",
|
|
372
|
+
mode: DEFAULT_DIR_MODE,
|
|
373
|
+
mtime: new Date(),
|
|
374
|
+
});
|
|
375
|
+
this.dirty = true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async readdir(path: string): Promise<string[]> {
|
|
379
|
+
const normalizedPath = this.normalizePath(path);
|
|
380
|
+
const entry = this.resolveSymlinks(normalizedPath);
|
|
381
|
+
|
|
382
|
+
if (!entry) {
|
|
383
|
+
throw new Error(`ENOENT: no such file or directory, scandir '${path}'`);
|
|
384
|
+
}
|
|
385
|
+
if (entry.type !== "directory") {
|
|
386
|
+
throw new Error(`ENOTDIR: not a directory, scandir '${path}'`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const prefix = normalizedPath === "/" ? "/" : normalizedPath + "/";
|
|
390
|
+
const names: string[] = [];
|
|
391
|
+
|
|
392
|
+
for (const entryPath of this.entries.keys()) {
|
|
393
|
+
if (entryPath === normalizedPath) continue;
|
|
394
|
+
if (!entryPath.startsWith(prefix)) continue;
|
|
395
|
+
|
|
396
|
+
const relative = entryPath.slice(prefix.length);
|
|
397
|
+
if (!relative.includes("/")) {
|
|
398
|
+
names.push(relative);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return names.sort();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async readdirWithFileTypes(path: string): Promise<DirentEntry[]> {
|
|
406
|
+
const normalizedPath = this.normalizePath(path);
|
|
407
|
+
const entry = this.resolveSymlinks(normalizedPath);
|
|
408
|
+
|
|
409
|
+
if (!entry) {
|
|
410
|
+
throw new Error(`ENOENT: no such file or directory, scandir '${path}'`);
|
|
411
|
+
}
|
|
412
|
+
if (entry.type !== "directory") {
|
|
413
|
+
throw new Error(`ENOTDIR: not a directory, scandir '${path}'`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const prefix = normalizedPath === "/" ? "/" : normalizedPath + "/";
|
|
417
|
+
const dirents: DirentEntry[] = [];
|
|
418
|
+
|
|
419
|
+
for (const [entryPath, e] of this.entries) {
|
|
420
|
+
if (entryPath === normalizedPath) continue;
|
|
421
|
+
if (!entryPath.startsWith(prefix)) continue;
|
|
422
|
+
|
|
423
|
+
const relative = entryPath.slice(prefix.length);
|
|
424
|
+
if (!relative.includes("/")) {
|
|
425
|
+
dirents.push({
|
|
426
|
+
name: relative,
|
|
427
|
+
isFile: e.type === "file",
|
|
428
|
+
isDirectory: e.type === "directory",
|
|
429
|
+
isSymbolicLink: e.type === "symlink",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return dirents.sort((a, b) => a.name.localeCompare(b.name));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async rm(path: string, options?: RmOptions): Promise<void> {
|
|
438
|
+
const normalizedPath = this.normalizePath(path);
|
|
439
|
+
const entry = this.entries.get(normalizedPath);
|
|
440
|
+
|
|
441
|
+
if (!entry) {
|
|
442
|
+
if (options?.force) return;
|
|
443
|
+
throw new Error(`ENOENT: no such file or directory, rm '${path}'`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (entry.type === "directory") {
|
|
447
|
+
const children = await this.readdir(normalizedPath);
|
|
448
|
+
if (children.length > 0 && !options?.recursive) {
|
|
449
|
+
throw new Error(`ENOTEMPTY: directory not empty, rm '${path}'`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (options?.recursive) {
|
|
453
|
+
// Delete all children
|
|
454
|
+
const prefix = normalizedPath === "/" ? "/" : normalizedPath + "/";
|
|
455
|
+
for (const entryPath of [...this.entries.keys()]) {
|
|
456
|
+
if (entryPath.startsWith(prefix)) {
|
|
457
|
+
this.entries.delete(entryPath);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
this.entries.delete(normalizedPath);
|
|
464
|
+
this.dirty = true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async cp(src: string, dest: string, options?: CpOptions): Promise<void> {
|
|
468
|
+
const srcPath = this.normalizePath(src);
|
|
469
|
+
const destPath = this.normalizePath(dest);
|
|
470
|
+
const entry = this.entries.get(srcPath);
|
|
471
|
+
|
|
472
|
+
if (!entry) {
|
|
473
|
+
throw new Error(`ENOENT: no such file or directory, cp '${src}'`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (entry.type === "directory") {
|
|
477
|
+
if (!options?.recursive) {
|
|
478
|
+
throw new Error(`EISDIR: cp called on directory without recursive '${src}'`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Copy directory and all children
|
|
482
|
+
this.ensureParentDirs(destPath);
|
|
483
|
+
this.entries.set(destPath, { ...entry, mtime: new Date() });
|
|
484
|
+
|
|
485
|
+
const prefix = srcPath === "/" ? "/" : srcPath + "/";
|
|
486
|
+
for (const [entryPath, e] of this.entries) {
|
|
487
|
+
if (entryPath.startsWith(prefix)) {
|
|
488
|
+
const relative = entryPath.slice(srcPath.length);
|
|
489
|
+
const newPath = destPath + relative;
|
|
490
|
+
this.entries.set(newPath, this.cloneEntry(e));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
this.ensureParentDirs(destPath);
|
|
495
|
+
this.entries.set(destPath, this.cloneEntry(entry));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
this.dirty = true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async mv(src: string, dest: string): Promise<void> {
|
|
502
|
+
const srcPath = this.normalizePath(src);
|
|
503
|
+
const destPath = this.normalizePath(dest);
|
|
504
|
+
const entry = this.entries.get(srcPath);
|
|
505
|
+
|
|
506
|
+
if (!entry) {
|
|
507
|
+
throw new Error(`ENOENT: no such file or directory, mv '${src}'`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
this.ensureParentDirs(destPath);
|
|
511
|
+
|
|
512
|
+
if (entry.type === "directory") {
|
|
513
|
+
// Move directory and all children
|
|
514
|
+
const prefix = srcPath === "/" ? "/" : srcPath + "/";
|
|
515
|
+
const toMove: [string, FsEntry][] = [];
|
|
516
|
+
|
|
517
|
+
for (const [entryPath, e] of this.entries) {
|
|
518
|
+
if (entryPath === srcPath || entryPath.startsWith(prefix)) {
|
|
519
|
+
const relative = entryPath.slice(srcPath.length);
|
|
520
|
+
toMove.push([destPath + relative, e]);
|
|
521
|
+
this.entries.delete(entryPath);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
for (const [newPath, e] of toMove) {
|
|
526
|
+
this.entries.set(newPath, e);
|
|
527
|
+
}
|
|
528
|
+
} else {
|
|
529
|
+
this.entries.delete(srcPath);
|
|
530
|
+
this.entries.set(destPath, entry);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
this.dirty = true;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
resolvePath(base: string, path: string): string {
|
|
537
|
+
if (path.startsWith("/")) {
|
|
538
|
+
return this.normalizePath(path);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const baseParts = base.split("/").filter(Boolean);
|
|
542
|
+
const pathParts = path.split("/").filter(Boolean);
|
|
543
|
+
|
|
544
|
+
for (const part of pathParts) {
|
|
545
|
+
if (part === ".") {
|
|
546
|
+
continue;
|
|
547
|
+
} else if (part === "..") {
|
|
548
|
+
baseParts.pop();
|
|
549
|
+
} else {
|
|
550
|
+
baseParts.push(part);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return "/" + baseParts.join("/");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
getAllPaths(): string[] {
|
|
558
|
+
return [...this.entries.keys()].sort();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async chmod(path: string, mode: number): Promise<void> {
|
|
562
|
+
const normalizedPath = this.normalizePath(path);
|
|
563
|
+
const entry = this.entries.get(normalizedPath);
|
|
564
|
+
|
|
565
|
+
if (!entry) {
|
|
566
|
+
throw new Error(`ENOENT: no such file or directory, chmod '${path}'`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
entry.mode = mode;
|
|
570
|
+
entry.mtime = new Date();
|
|
571
|
+
this.dirty = true;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async symlink(target: string, linkPath: string): Promise<void> {
|
|
575
|
+
const normalizedLinkPath = this.normalizePath(linkPath);
|
|
576
|
+
|
|
577
|
+
if (this.entries.has(normalizedLinkPath)) {
|
|
578
|
+
throw new Error(`EEXIST: file already exists, symlink '${linkPath}'`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
this.ensureParentDirs(normalizedLinkPath);
|
|
582
|
+
|
|
583
|
+
this.entries.set(normalizedLinkPath, {
|
|
584
|
+
type: "symlink",
|
|
585
|
+
target,
|
|
586
|
+
mode: DEFAULT_SYMLINK_MODE,
|
|
587
|
+
mtime: new Date(),
|
|
588
|
+
});
|
|
589
|
+
this.dirty = true;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async link(existingPath: string, newPath: string): Promise<void> {
|
|
593
|
+
const srcPath = this.normalizePath(existingPath);
|
|
594
|
+
const destPath = this.normalizePath(newPath);
|
|
595
|
+
|
|
596
|
+
const entry = this.entries.get(srcPath);
|
|
597
|
+
if (!entry) {
|
|
598
|
+
throw new Error(`ENOENT: no such file or directory, link '${existingPath}'`);
|
|
599
|
+
}
|
|
600
|
+
if (entry.type !== "file") {
|
|
601
|
+
throw new Error(`EPERM: operation not permitted, link '${existingPath}'`);
|
|
602
|
+
}
|
|
603
|
+
if (this.entries.has(destPath)) {
|
|
604
|
+
throw new Error(`EEXIST: file already exists, link '${newPath}'`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
this.ensureParentDirs(destPath);
|
|
608
|
+
|
|
609
|
+
// Hard links share the same content reference
|
|
610
|
+
this.entries.set(destPath, {
|
|
611
|
+
type: "file",
|
|
612
|
+
content: entry.content,
|
|
613
|
+
mode: entry.mode,
|
|
614
|
+
mtime: new Date(),
|
|
615
|
+
});
|
|
616
|
+
this.dirty = true;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async readlink(path: string): Promise<string> {
|
|
620
|
+
const normalizedPath = this.normalizePath(path);
|
|
621
|
+
const entry = this.entries.get(normalizedPath);
|
|
622
|
+
|
|
623
|
+
if (!entry) {
|
|
624
|
+
throw new Error(`ENOENT: no such file or directory, readlink '${path}'`);
|
|
625
|
+
}
|
|
626
|
+
if (entry.type !== "symlink") {
|
|
627
|
+
throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return entry.target;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async realpath(path: string): Promise<string> {
|
|
634
|
+
const normalizedPath = this.normalizePath(path);
|
|
635
|
+
const parts = normalizedPath.split("/").filter(Boolean);
|
|
636
|
+
let resolved = "/";
|
|
637
|
+
|
|
638
|
+
for (const part of parts) {
|
|
639
|
+
// Build the next path component
|
|
640
|
+
resolved = resolved === "/" ? `/${part}` : `${resolved}/${part}`;
|
|
641
|
+
|
|
642
|
+
// Check if this path component exists
|
|
643
|
+
const entry = this.entries.get(resolved);
|
|
644
|
+
if (!entry) {
|
|
645
|
+
throw new Error(`ENOENT: no such file or directory, realpath '${path}'`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// If it's a symlink, resolve it
|
|
649
|
+
if (entry.type === "symlink") {
|
|
650
|
+
const target = entry.target;
|
|
651
|
+
// If target is absolute, use it; otherwise resolve relative to parent
|
|
652
|
+
if (target.startsWith("/")) {
|
|
653
|
+
resolved = this.normalizePath(target);
|
|
654
|
+
} else {
|
|
655
|
+
const parent = this.getParentPath(resolved) ?? "/";
|
|
656
|
+
resolved = this.resolvePath(parent, target);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Verify the resolved target exists
|
|
660
|
+
const targetEntry = this.entries.get(resolved);
|
|
661
|
+
if (!targetEntry) {
|
|
662
|
+
throw new Error(`ENOENT: no such file or directory, realpath '${path}'`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return resolved;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async utimes(path: string, atime: Date, mtime: Date): Promise<void> {
|
|
671
|
+
const normalizedPath = this.normalizePath(path);
|
|
672
|
+
const entry = this.entries.get(normalizedPath);
|
|
673
|
+
|
|
674
|
+
if (!entry) {
|
|
675
|
+
throw new Error(`ENOENT: no such file or directory, utimes '${path}'`);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Update mtime (atime is ignored as per interface docs, kept for API compatibility)
|
|
679
|
+
entry.mtime = mtime;
|
|
680
|
+
this.dirty = true;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ============ Private Helpers ============
|
|
684
|
+
|
|
685
|
+
private normalizePath(path: string): string {
|
|
686
|
+
return IndexedDbFs.normalizePath(path);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private static normalizePath(path: string): string {
|
|
690
|
+
// Handle empty or relative paths
|
|
691
|
+
if (!path || path === ".") return "/";
|
|
692
|
+
if (!path.startsWith("/")) {
|
|
693
|
+
path = "/" + path;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const parts = path.split("/").filter(Boolean);
|
|
697
|
+
const normalized: string[] = [];
|
|
698
|
+
|
|
699
|
+
for (const part of parts) {
|
|
700
|
+
if (part === ".") continue;
|
|
701
|
+
if (part === "..") {
|
|
702
|
+
normalized.pop();
|
|
703
|
+
} else {
|
|
704
|
+
normalized.push(part);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return "/" + normalized.join("/");
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private getParentPath(path: string): string | null {
|
|
712
|
+
if (path === "/") return null;
|
|
713
|
+
const lastSlash = path.lastIndexOf("/");
|
|
714
|
+
return lastSlash === 0 ? "/" : path.slice(0, lastSlash);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private ensureParentDirs(path: string): void {
|
|
718
|
+
IndexedDbFs.ensureParentDirs(this.entries, path);
|
|
719
|
+
this.dirty = true;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private static ensureParentDirs(entries: Map<string, FsEntry>, path: string): void {
|
|
723
|
+
const parts = path.split("/").filter(Boolean);
|
|
724
|
+
let current = "";
|
|
725
|
+
|
|
726
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
727
|
+
current += "/" + parts[i];
|
|
728
|
+
if (!entries.has(current)) {
|
|
729
|
+
entries.set(current, {
|
|
730
|
+
type: "directory",
|
|
731
|
+
mode: DEFAULT_DIR_MODE,
|
|
732
|
+
mtime: new Date(),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private resolveSymlinks(path: string, maxDepth = 10): FsEntry | null {
|
|
739
|
+
let current = path;
|
|
740
|
+
let depth = 0;
|
|
741
|
+
|
|
742
|
+
while (depth < maxDepth) {
|
|
743
|
+
const entry = this.entries.get(current);
|
|
744
|
+
if (!entry) return null;
|
|
745
|
+
if (entry.type !== "symlink") return entry;
|
|
746
|
+
|
|
747
|
+
// Resolve symlink
|
|
748
|
+
const target = entry.target;
|
|
749
|
+
current = target.startsWith("/")
|
|
750
|
+
? this.normalizePath(target)
|
|
751
|
+
: this.resolvePath(this.getParentPath(current) ?? "/", target);
|
|
752
|
+
depth++;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
throw new Error(`ELOOP: too many levels of symbolic links, stat '${path}'`);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private entryToStat(entry: FsEntry): FsStat {
|
|
759
|
+
return {
|
|
760
|
+
isFile: entry.type === "file",
|
|
761
|
+
isDirectory: entry.type === "directory",
|
|
762
|
+
isSymbolicLink: entry.type === "symlink",
|
|
763
|
+
mode: entry.mode,
|
|
764
|
+
size: entry.type === "file" ? this.getContentSize(entry.content) : 0,
|
|
765
|
+
mtime: entry.mtime,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private getContentSize(content: string | Uint8Array): number {
|
|
770
|
+
if (typeof content === "string") {
|
|
771
|
+
return new TextEncoder().encode(content).byteLength;
|
|
772
|
+
}
|
|
773
|
+
return content.byteLength;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private cloneEntry(entry: FsEntry): FsEntry {
|
|
777
|
+
if (entry.type === "file") {
|
|
778
|
+
return {
|
|
779
|
+
type: "file",
|
|
780
|
+
content:
|
|
781
|
+
entry.content instanceof Uint8Array
|
|
782
|
+
? new Uint8Array(entry.content)
|
|
783
|
+
: entry.content,
|
|
784
|
+
mode: entry.mode,
|
|
785
|
+
mtime: new Date(),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
return { ...entry, mtime: new Date() };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private checkSizeLimit(content: FileContent): void {
|
|
792
|
+
const currentSize = this.getSize();
|
|
793
|
+
const newSize =
|
|
794
|
+
typeof content === "string"
|
|
795
|
+
? content.length * 2
|
|
796
|
+
: content.byteLength;
|
|
797
|
+
|
|
798
|
+
if (currentSize + newSize > this.maxSizeBytes) {
|
|
799
|
+
throw new Error(
|
|
800
|
+
`ENOSPC: filesystem size limit exceeded (${this.maxSizeBytes} bytes)`
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private getEncoding(
|
|
806
|
+
options?: ReadFileOptions | BufferEncoding
|
|
807
|
+
): BufferEncoding | null {
|
|
808
|
+
if (!options) return null;
|
|
809
|
+
if (typeof options === "string") return options as BufferEncoding;
|
|
810
|
+
return options.encoding ?? null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private decodeBuffer(buffer: Uint8Array, encoding: BufferEncoding): string {
|
|
814
|
+
if (encoding === "utf8" || encoding === "utf-8") {
|
|
815
|
+
return new TextDecoder("utf-8").decode(buffer);
|
|
816
|
+
}
|
|
817
|
+
if (encoding === "base64") {
|
|
818
|
+
let binary = "";
|
|
819
|
+
for (let i = 0; i < buffer.byteLength; i++) {
|
|
820
|
+
binary += String.fromCharCode(buffer[i]!);
|
|
821
|
+
}
|
|
822
|
+
return btoa(binary);
|
|
823
|
+
}
|
|
824
|
+
if (encoding === "hex") {
|
|
825
|
+
return Array.from(buffer)
|
|
826
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
827
|
+
.join("");
|
|
828
|
+
}
|
|
829
|
+
// Default to utf8 for other encodings
|
|
830
|
+
return new TextDecoder("utf-8").decode(buffer);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
834
|
+
const result = new Uint8Array(a.byteLength + b.byteLength);
|
|
835
|
+
result.set(a, 0);
|
|
836
|
+
result.set(b, a.byteLength);
|
|
837
|
+
return result;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private static parseFileInit(value: FileContent | FileInit): FileInit {
|
|
841
|
+
if (typeof value === "string" || value instanceof Uint8Array) {
|
|
842
|
+
return { content: value };
|
|
843
|
+
}
|
|
844
|
+
return value;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ============ IndexedDB Helpers ============
|
|
848
|
+
|
|
849
|
+
private static openDatabase(dbName: string): Promise<IDBDatabase> {
|
|
850
|
+
return new Promise((resolve, reject) => {
|
|
851
|
+
const request = indexedDB.open(dbName, 1);
|
|
852
|
+
|
|
853
|
+
request.onerror = () => reject(request.error);
|
|
854
|
+
request.onsuccess = () => resolve(request.result);
|
|
855
|
+
|
|
856
|
+
request.onupgradeneeded = (event) => {
|
|
857
|
+
const db = (event.target as IDBOpenDBRequest).result;
|
|
858
|
+
if (!db.objectStoreNames.contains("entries")) {
|
|
859
|
+
db.createObjectStore("entries", { keyPath: "path" });
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
private static async loadEntries(db: IDBDatabase): Promise<Map<string, FsEntry>> {
|
|
866
|
+
const tx = db.transaction("entries", "readonly");
|
|
867
|
+
const store = tx.objectStore("entries");
|
|
868
|
+
|
|
869
|
+
return new Promise((resolve, reject) => {
|
|
870
|
+
const request = store.getAll();
|
|
871
|
+
request.onerror = () => reject(request.error);
|
|
872
|
+
request.onsuccess = () => {
|
|
873
|
+
const entries = new Map<string, FsEntry>();
|
|
874
|
+
for (const record of request.result) {
|
|
875
|
+
entries.set(record.path, IndexedDbFs.deserializeEntry(record.entry));
|
|
876
|
+
}
|
|
877
|
+
resolve(entries);
|
|
878
|
+
};
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private serializeEntry(entry: FsEntry): object {
|
|
883
|
+
if (entry.type === "file" && entry.content instanceof Uint8Array) {
|
|
884
|
+
return {
|
|
885
|
+
...entry,
|
|
886
|
+
content: Array.from(entry.content),
|
|
887
|
+
contentType: "uint8array",
|
|
888
|
+
mtime: entry.mtime.toISOString(),
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
return {
|
|
892
|
+
...entry,
|
|
893
|
+
mtime: entry.mtime.toISOString(),
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private static deserializeEntry(data: any): FsEntry {
|
|
898
|
+
const mtime = new Date(data.mtime);
|
|
899
|
+
|
|
900
|
+
if (data.type === "file") {
|
|
901
|
+
let content = data.content;
|
|
902
|
+
if (data.contentType === "uint8array" && Array.isArray(content)) {
|
|
903
|
+
content = new Uint8Array(content);
|
|
904
|
+
}
|
|
905
|
+
return { type: "file", content, mode: data.mode, mtime };
|
|
906
|
+
}
|
|
907
|
+
if (data.type === "symlink") {
|
|
908
|
+
return { type: "symlink", target: data.target, mode: data.mode, mtime };
|
|
909
|
+
}
|
|
910
|
+
return { type: "directory", mode: data.mode, mtime };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private promisifyRequest<T>(request: IDBRequest<T>): Promise<T> {
|
|
914
|
+
return new Promise((resolve, reject) => {
|
|
915
|
+
request.onerror = () => reject(request.error);
|
|
916
|
+
request.onsuccess = () => resolve(request.result);
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
private promisifyTransaction(tx: IDBTransaction): Promise<void> {
|
|
921
|
+
return new Promise((resolve, reject) => {
|
|
922
|
+
tx.onerror = () => reject(tx.error);
|
|
923
|
+
tx.oncomplete = () => resolve();
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Factory function matching the FileSystemFactory type
|
|
930
|
+
*/
|
|
931
|
+
export function createIndexedDbFs(initialFiles?: InitialFiles): IFileSystem {
|
|
932
|
+
// For sync factory usage, return in-memory version
|
|
933
|
+
// Use IndexedDbFs.create() for async with persistence
|
|
934
|
+
return IndexedDbFs.createInMemory({ initialFiles });
|
|
935
|
+
}
|