@statewalker/webrun-files-composite 0.7.1 → 0.8.1
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/LICENSE +21 -0
- package/README.md +219 -144
- package/dist/cjs/index.cjs +770 -28
- package/dist/composite-files-api.d.ts +50 -4
- package/dist/composite-files-api.d.ts.map +1 -1
- package/dist/cow-files-api.d.ts +36 -0
- package/dist/cow-files-api.d.ts.map +1 -0
- package/dist/esm/index.js +762 -29
- package/dist/filtered-files-api.d.ts +167 -0
- package/dist/filtered-files-api.d.ts.map +1 -0
- package/dist/glob-to-regexp.d.ts +56 -0
- package/dist/glob-to-regexp.d.ts.map +1 -0
- package/dist/guarded-files-api.d.ts +64 -0
- package/dist/guarded-files-api.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/overlay-files-api.d.ts +20 -0
- package/dist/overlay-files-api.d.ts.map +1 -0
- package/dist/read-only-files-api.d.ts +21 -0
- package/dist/read-only-files-api.d.ts.map +1 -0
- package/dist/types.d.ts +50 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -15
- package/src/composite-files-api.ts +50 -29
- package/src/cow-files-api.ts +244 -0
- package/src/filtered-files-api.ts +257 -0
- package/src/glob-to-regexp.ts +182 -0
- package/src/guarded-files-api.ts +131 -0
- package/src/index.ts +14 -0
- package/src/overlay-files-api.ts +121 -0
- package/src/read-only-files-api.ts +30 -0
- package/src/types.ts +52 -3
package/dist/esm/index.js
CHANGED
|
@@ -21,11 +21,63 @@ function normalizePath(filePath) {
|
|
|
21
21
|
function joinPath(...segments) {
|
|
22
22
|
return normalizePath(segments.join("/"));
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Returns the directory portion of a path.
|
|
26
|
+
*/
|
|
27
|
+
function dirname(path) {
|
|
28
|
+
const normalized = normalizePath(path);
|
|
29
|
+
const lastSlash = normalized.lastIndexOf("/");
|
|
30
|
+
if (lastSlash <= 0) return "/";
|
|
31
|
+
return normalized.substring(0, lastSlash);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Returns the filename portion of a path.
|
|
35
|
+
*/
|
|
36
|
+
function basename(path, ext) {
|
|
37
|
+
const normalized = normalizePath(path);
|
|
38
|
+
const lastSlash = normalized.lastIndexOf("/");
|
|
39
|
+
const name = lastSlash === -1 ? normalized : normalized.substring(lastSlash + 1);
|
|
40
|
+
if (ext && name.endsWith(ext)) return name.substring(0, name.length - ext.length);
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
24
43
|
//#endregion
|
|
25
44
|
//#region src/composite-files-api.ts
|
|
45
|
+
/**
|
|
46
|
+
* Composite `FilesApi` that routes calls to one of several backends based
|
|
47
|
+
* on a path prefix. Mounts are matched by **longest prefix wins**, so a
|
|
48
|
+
* mount at `/a/b` takes precedence over a mount at `/a` for paths under
|
|
49
|
+
* `/a/b/...`. The mount point itself appears in listings as a synthetic
|
|
50
|
+
* directory and cannot be removed.
|
|
51
|
+
*
|
|
52
|
+
* Each backend can use a sub-directory of its own filesystem as the mount
|
|
53
|
+
* root via `fsPath` (constructor `rootPath` for the implicit root mount,
|
|
54
|
+
* `fsPath` argument for additional mounts). Cross-mount `move` is
|
|
55
|
+
* implemented as copy-then-delete; there is no atomicity guarantee.
|
|
56
|
+
*
|
|
57
|
+
* Access control and visibility filtering are intentionally **not** part of
|
|
58
|
+
* this class — wrap with {@link GuardedFilesApi} or {@link FilteredFilesApi}
|
|
59
|
+
* (or both) instead.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* const fs = new CompositeFilesApi(localFs, "/projects")
|
|
64
|
+
* .mount("/docs", s3Fs, "/documentation")
|
|
65
|
+
* .mount("/cache", memFs);
|
|
66
|
+
* await fs.write("/readme.md", data); // → localFs:/projects/readme.md
|
|
67
|
+
* await fs.write("/docs/api.md", data); // → s3Fs:/documentation/api.md
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
26
70
|
var CompositeFilesApi = class {
|
|
27
71
|
mounts;
|
|
28
|
-
|
|
72
|
+
/**
|
|
73
|
+
* @param root Default backend used for any path that does not match a
|
|
74
|
+
* more specific mount. All paths are routed here unless `mount()`
|
|
75
|
+
* intercepts them.
|
|
76
|
+
* @param rootPath Optional sub-directory of the root backend to use as
|
|
77
|
+
* the composite filesystem's `/`. For example, `rootPath = "/projects"`
|
|
78
|
+
* makes the composite path `/readme.md` resolve to `/projects/readme.md`
|
|
79
|
+
* in the root backend. Defaults to `"/"` (no remapping).
|
|
80
|
+
*/
|
|
29
81
|
constructor(root, rootPath) {
|
|
30
82
|
this.mounts = [{
|
|
31
83
|
prefix: "/",
|
|
@@ -33,6 +85,22 @@ var CompositeFilesApi = class {
|
|
|
33
85
|
basePath: normalizePath(rootPath ?? "/")
|
|
34
86
|
}];
|
|
35
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Attaches a backend to handle every composite path under `path`. The
|
|
90
|
+
* mount prefix is normalized; paths under it are resolved against the
|
|
91
|
+
* mount's `fsPath` sub-directory (defaulting to `"/"`).
|
|
92
|
+
*
|
|
93
|
+
* @param path Composite-namespace prefix (e.g. `"/docs"`). Mounting at
|
|
94
|
+
* `"/"` is forbidden — use the constructor `root` argument instead.
|
|
95
|
+
* @param api The backend `FilesApi` to delegate to for paths under
|
|
96
|
+
* `path`. Wrap it in {@link FilteredFilesApi} / {@link GuardedFilesApi}
|
|
97
|
+
* first if you want mount-local filtering or guards.
|
|
98
|
+
* @param fsPath Sub-directory of the mounted backend used as its mount
|
|
99
|
+
* root, e.g. `mount("/docs", s3, "/documentation")` makes
|
|
100
|
+
* `/docs/api.md` resolve to `/documentation/api.md` on `s3`.
|
|
101
|
+
* @returns `this`, for chaining.
|
|
102
|
+
* @throws If `path` normalizes to `"/"`.
|
|
103
|
+
*/
|
|
36
104
|
mount(path, api, fsPath) {
|
|
37
105
|
const prefix = normalizePath(path);
|
|
38
106
|
if (prefix === "/") throw new Error("Cannot mount at root — root is set via constructor");
|
|
@@ -44,14 +112,6 @@ var CompositeFilesApi = class {
|
|
|
44
112
|
this.mounts.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
45
113
|
return this;
|
|
46
114
|
}
|
|
47
|
-
guard(operations, check, message) {
|
|
48
|
-
this.guards.push({
|
|
49
|
-
operations,
|
|
50
|
-
check,
|
|
51
|
-
message
|
|
52
|
-
});
|
|
53
|
-
return this;
|
|
54
|
-
}
|
|
55
115
|
resolve(path) {
|
|
56
116
|
const normalized = normalizePath(path);
|
|
57
117
|
for (const mount of this.mounts) {
|
|
@@ -89,33 +149,19 @@ var CompositeFilesApi = class {
|
|
|
89
149
|
}
|
|
90
150
|
return result;
|
|
91
151
|
}
|
|
92
|
-
checkGuard(operation, path) {
|
|
93
|
-
const normalized = normalizePath(path);
|
|
94
|
-
for (const guard of this.guards) {
|
|
95
|
-
if (!guard.operations.includes(operation)) continue;
|
|
96
|
-
if (!guard.check(normalized)) {
|
|
97
|
-
const msg = guard.message ?? "Access denied";
|
|
98
|
-
throw new Error(`${msg}: ${normalized}`);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
152
|
read(path, options) {
|
|
103
|
-
this.checkGuard("read", path);
|
|
104
153
|
const { api, resolvedPath } = this.resolve(path);
|
|
105
154
|
return api.read(resolvedPath, options);
|
|
106
155
|
}
|
|
107
156
|
async write(path, content) {
|
|
108
|
-
this.checkGuard("write", path);
|
|
109
157
|
const { api, resolvedPath } = this.resolve(path);
|
|
110
158
|
return api.write(resolvedPath, content);
|
|
111
159
|
}
|
|
112
160
|
async mkdir(path) {
|
|
113
|
-
this.checkGuard("mkdir", path);
|
|
114
161
|
const { api, resolvedPath } = this.resolve(path);
|
|
115
162
|
return api.mkdir(resolvedPath);
|
|
116
163
|
}
|
|
117
164
|
async *list(path, options) {
|
|
118
|
-
this.checkGuard("list", path);
|
|
119
165
|
const normalized = normalizePath(path);
|
|
120
166
|
const { api, resolvedPath } = this.resolve(path);
|
|
121
167
|
const childMounts = this.childMountPrefixes(normalized);
|
|
@@ -180,13 +226,10 @@ var CompositeFilesApi = class {
|
|
|
180
226
|
async remove(path) {
|
|
181
227
|
const normalized = normalizePath(path);
|
|
182
228
|
if (this.isMountPoint(normalized)) throw new Error(`Cannot remove mount point: ${normalized}`);
|
|
183
|
-
this.checkGuard("remove", path);
|
|
184
229
|
const { api, resolvedPath } = this.resolve(path);
|
|
185
230
|
return api.remove(resolvedPath);
|
|
186
231
|
}
|
|
187
232
|
async move(source, target) {
|
|
188
|
-
this.checkGuard("move", source);
|
|
189
|
-
this.checkGuard("move", target);
|
|
190
233
|
const src = this.resolve(source);
|
|
191
234
|
const tgt = this.resolve(target);
|
|
192
235
|
if (src.api === tgt.api) return src.api.move(src.resolvedPath, tgt.resolvedPath);
|
|
@@ -195,8 +238,6 @@ var CompositeFilesApi = class {
|
|
|
195
238
|
return copied;
|
|
196
239
|
}
|
|
197
240
|
async copy(source, target) {
|
|
198
|
-
this.checkGuard("copy", source);
|
|
199
|
-
this.checkGuard("copy", target);
|
|
200
241
|
const src = this.resolve(source);
|
|
201
242
|
const tgt = this.resolve(target);
|
|
202
243
|
if (src.api === tgt.api) return src.api.copy(src.resolvedPath, tgt.resolvedPath);
|
|
@@ -235,4 +276,696 @@ var CompositeFilesApi = class {
|
|
|
235
276
|
}
|
|
236
277
|
};
|
|
237
278
|
//#endregion
|
|
238
|
-
|
|
279
|
+
//#region src/cow-files-api.ts
|
|
280
|
+
/**
|
|
281
|
+
* Copy-on-write view: a writable layer over a read-only `base`. The `base`
|
|
282
|
+
* is **never** mutated — every change (write, delete, move) is recorded in
|
|
283
|
+
* the `writable` layer, using marker files to record deletions.
|
|
284
|
+
*
|
|
285
|
+
* Resolution precedence for any path:
|
|
286
|
+
* 1. a covering whiteout / opaque marker ⇒ the path is absent;
|
|
287
|
+
* 2. otherwise the `writable` layer, if it has the path;
|
|
288
|
+
* 3. otherwise fall through to `base`.
|
|
289
|
+
*
|
|
290
|
+
* Deletions are persisted as marker files inside the writable layer, so they
|
|
291
|
+
* survive across process restarts and work over any `FilesApi` backend.
|
|
292
|
+
* Markers are hidden from `read` / `list` / `stats` / `exists`.
|
|
293
|
+
*
|
|
294
|
+
* Use {@link cow} to construct one.
|
|
295
|
+
*/
|
|
296
|
+
var CowFilesApi = class {
|
|
297
|
+
base;
|
|
298
|
+
writable;
|
|
299
|
+
whiteoutPrefix;
|
|
300
|
+
opaqueName;
|
|
301
|
+
constructor(base, writable, opts) {
|
|
302
|
+
this.base = base;
|
|
303
|
+
this.writable = writable;
|
|
304
|
+
this.whiteoutPrefix = opts?.whiteoutPrefix ?? ".wh.";
|
|
305
|
+
this.opaqueName = opts?.opaqueName ?? ".wh..opq";
|
|
306
|
+
}
|
|
307
|
+
whiteoutPath(path) {
|
|
308
|
+
const p = normalizePath(path);
|
|
309
|
+
return joinPath(dirname(p), `${this.whiteoutPrefix}${basename(p)}`);
|
|
310
|
+
}
|
|
311
|
+
opaquePath(dir) {
|
|
312
|
+
return joinPath(normalizePath(dir), this.opaqueName);
|
|
313
|
+
}
|
|
314
|
+
isMarker(name) {
|
|
315
|
+
return name.startsWith(this.whiteoutPrefix) || name === this.opaqueName;
|
|
316
|
+
}
|
|
317
|
+
*ancestorsInclusive(dir) {
|
|
318
|
+
let cur = normalizePath(dir);
|
|
319
|
+
yield cur;
|
|
320
|
+
while (cur !== "/") {
|
|
321
|
+
cur = dirname(cur);
|
|
322
|
+
yield cur;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async hasDirectWhiteout(path) {
|
|
326
|
+
return this.writable.exists(this.whiteoutPath(path));
|
|
327
|
+
}
|
|
328
|
+
/** True when an opaque marker on `dir` or any ancestor hides base content. */
|
|
329
|
+
async isBaseContentHidden(dir) {
|
|
330
|
+
for (const ancestor of this.ancestorsInclusive(dir)) if (await this.writable.exists(this.opaquePath(ancestor))) return true;
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
/** True when the base version of `path` is hidden by a marker. */
|
|
334
|
+
async isBaseHidden(path) {
|
|
335
|
+
if (await this.hasDirectWhiteout(path)) return true;
|
|
336
|
+
return this.isBaseContentHidden(dirname(normalizePath(path)));
|
|
337
|
+
}
|
|
338
|
+
async resolveLayer(path) {
|
|
339
|
+
if (await this.writable.exists(path)) return "writable";
|
|
340
|
+
if (await this.isBaseHidden(path)) return "absent";
|
|
341
|
+
if (await this.base.exists(path)) return "base";
|
|
342
|
+
return "absent";
|
|
343
|
+
}
|
|
344
|
+
/** Removes a covering whiteout marker so a write/mkdir resurrects the path. */
|
|
345
|
+
async clearWhiteout(path) {
|
|
346
|
+
const marker = this.whiteoutPath(path);
|
|
347
|
+
if (await this.writable.exists(marker)) await this.writable.remove(marker);
|
|
348
|
+
}
|
|
349
|
+
async *read(path, options) {
|
|
350
|
+
const layer = await this.resolveLayer(path);
|
|
351
|
+
if (layer === "writable") yield* this.writable.read(path, options);
|
|
352
|
+
else if (layer === "base") yield* this.base.read(path, options);
|
|
353
|
+
}
|
|
354
|
+
async write(path, content) {
|
|
355
|
+
await this.clearWhiteout(path);
|
|
356
|
+
await this.writable.write(path, content);
|
|
357
|
+
}
|
|
358
|
+
async mkdir(path) {
|
|
359
|
+
await this.clearWhiteout(path);
|
|
360
|
+
await this.writable.mkdir(path);
|
|
361
|
+
}
|
|
362
|
+
async stats(path) {
|
|
363
|
+
const layer = await this.resolveLayer(path);
|
|
364
|
+
if (layer === "writable") return this.writable.stats(path);
|
|
365
|
+
if (layer === "base") return this.base.stats(path);
|
|
366
|
+
}
|
|
367
|
+
async exists(path) {
|
|
368
|
+
return await this.resolveLayer(path) !== "absent";
|
|
369
|
+
}
|
|
370
|
+
async *list(path, options) {
|
|
371
|
+
yield* this.listDir(normalizePath(path), options?.recursive ?? false);
|
|
372
|
+
}
|
|
373
|
+
async *listDir(dir, recursive) {
|
|
374
|
+
if ((await this.stats(dir))?.kind !== "directory") return;
|
|
375
|
+
for (const entry of await this.mergeChildren(dir)) {
|
|
376
|
+
yield entry;
|
|
377
|
+
if (recursive && entry.kind === "directory") yield* this.listDir(entry.path, true);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** Direct children of `dir`: writable entries win, base entries fill in. */
|
|
381
|
+
async mergeChildren(dir) {
|
|
382
|
+
const merged = /* @__PURE__ */ new Map();
|
|
383
|
+
if ((await this.writable.stats(dir))?.kind === "directory") for await (const entry of this.writable.list(dir)) {
|
|
384
|
+
if (this.isMarker(entry.name)) continue;
|
|
385
|
+
merged.set(entry.name, entry);
|
|
386
|
+
}
|
|
387
|
+
if (!await this.isBaseContentHidden(dir) && (await this.base.stats(dir))?.kind === "directory") for await (const entry of this.base.list(dir)) {
|
|
388
|
+
if (merged.has(entry.name)) continue;
|
|
389
|
+
if (await this.hasDirectWhiteout(entry.path)) continue;
|
|
390
|
+
merged.set(entry.name, entry);
|
|
391
|
+
}
|
|
392
|
+
return [...merged.values()];
|
|
393
|
+
}
|
|
394
|
+
async remove(path) {
|
|
395
|
+
if (await this.resolveLayer(path) === "absent") return false;
|
|
396
|
+
if (await this.writable.exists(path)) await this.writable.remove(path);
|
|
397
|
+
const baseStats = await this.base.stats(path);
|
|
398
|
+
if (baseStats) {
|
|
399
|
+
const marker = baseStats.kind === "directory" ? this.opaquePath(path) : this.whiteoutPath(path);
|
|
400
|
+
await this.writable.write(marker, []);
|
|
401
|
+
}
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
async move(source, target) {
|
|
405
|
+
const stats = await this.stats(source);
|
|
406
|
+
if (!stats) return false;
|
|
407
|
+
await this.copyInto(source, target, stats);
|
|
408
|
+
await this.remove(source);
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
async copy(source, target) {
|
|
412
|
+
const stats = await this.stats(source);
|
|
413
|
+
if (!stats) return false;
|
|
414
|
+
await this.copyInto(source, target, stats);
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
/** Copies the composite view of `src` into the writable layer at `tgt`. */
|
|
418
|
+
async copyInto(src, tgt, stats) {
|
|
419
|
+
if (stats.kind === "file") {
|
|
420
|
+
await this.write(tgt, this.read(src));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
await this.mkdir(tgt);
|
|
424
|
+
for await (const entry of this.list(src)) await this.copyInto(entry.path, joinPath(tgt, entry.name), entry);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
/**
|
|
428
|
+
* Builds a copy-on-write `FilesApi`: a `writable` layer over a read-only
|
|
429
|
+
* `base`. Reads fall through to `base`; every write goes to `writable`;
|
|
430
|
+
* `base` is never mutated. Deletions are persisted as marker files in
|
|
431
|
+
* `writable` (a per-path whiteout for files, one opaque marker for a deleted
|
|
432
|
+
* base directory), so they survive over any backend and across restarts.
|
|
433
|
+
*
|
|
434
|
+
* @param base The read-only lower layer. Never mutated.
|
|
435
|
+
* @param writable The upper layer that captures all changes and markers.
|
|
436
|
+
* @param opts Marker naming overrides (see {@link CowOptions}).
|
|
437
|
+
* @returns A read/write `FilesApi` composing the two layers.
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* ```ts
|
|
441
|
+
* const fs = cow(releaseFiles, new MemFilesApi());
|
|
442
|
+
* await fs.write("/a.txt", data); // captured in the writable layer
|
|
443
|
+
* await fs.remove("/base-only"); // whiteout marker; base untouched
|
|
444
|
+
* ```
|
|
445
|
+
*/
|
|
446
|
+
function cow(base, writable, opts) {
|
|
447
|
+
return new CowFilesApi(base, writable, opts);
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/glob-to-regexp.ts
|
|
451
|
+
/**
|
|
452
|
+
* Compiles a glob pattern into a `RegExp`.
|
|
453
|
+
*
|
|
454
|
+
* @example
|
|
455
|
+
* ```ts
|
|
456
|
+
* globToRegExp("*.js"); // /^.*\.js$/
|
|
457
|
+
* globToRegExp("*.js", { globstar: true }); // /^([^/]*)\.js$/
|
|
458
|
+
* globToRegExp("/foo/**", { globstar: true }) // /^\/foo\/((?:[^/]*(?:\/|$))*)$/
|
|
459
|
+
* globToRegExp("foo{bar,baz}", { extended: true }); // /^foo(bar|baz)$/
|
|
460
|
+
* ```
|
|
461
|
+
*/
|
|
462
|
+
function globToRegExp(glob, opts = {}) {
|
|
463
|
+
if (typeof glob !== "string") throw new TypeError("Expected a string");
|
|
464
|
+
const str = String(glob);
|
|
465
|
+
let reStr = "";
|
|
466
|
+
const extended = !!opts.extended;
|
|
467
|
+
const globstar = !!opts.globstar;
|
|
468
|
+
let inGroup = false;
|
|
469
|
+
const flags = typeof opts.flags === "string" ? opts.flags : "";
|
|
470
|
+
for (let i = 0; i < str.length; i++) {
|
|
471
|
+
const c = str[i];
|
|
472
|
+
switch (c) {
|
|
473
|
+
case "/":
|
|
474
|
+
case "$":
|
|
475
|
+
case "^":
|
|
476
|
+
case "+":
|
|
477
|
+
case ".":
|
|
478
|
+
case "(":
|
|
479
|
+
case ")":
|
|
480
|
+
case "=":
|
|
481
|
+
case "!":
|
|
482
|
+
case "|":
|
|
483
|
+
reStr += `\\${c}`;
|
|
484
|
+
break;
|
|
485
|
+
case "?":
|
|
486
|
+
reStr += extended ? "." : `\\${c}`;
|
|
487
|
+
break;
|
|
488
|
+
case "[":
|
|
489
|
+
case "]":
|
|
490
|
+
reStr += extended ? c : `\\${c}`;
|
|
491
|
+
break;
|
|
492
|
+
case "{":
|
|
493
|
+
if (extended) {
|
|
494
|
+
inGroup = true;
|
|
495
|
+
reStr += "(";
|
|
496
|
+
} else reStr += `\\${c}`;
|
|
497
|
+
break;
|
|
498
|
+
case "}":
|
|
499
|
+
if (extended) {
|
|
500
|
+
inGroup = false;
|
|
501
|
+
reStr += ")";
|
|
502
|
+
} else reStr += `\\${c}`;
|
|
503
|
+
break;
|
|
504
|
+
case ",":
|
|
505
|
+
if (inGroup) reStr += "|";
|
|
506
|
+
else reStr += `\\${c}`;
|
|
507
|
+
break;
|
|
508
|
+
case "*": {
|
|
509
|
+
const prevChar = str[i - 1];
|
|
510
|
+
let starCount = 1;
|
|
511
|
+
while (str[i + 1] === "*") {
|
|
512
|
+
starCount++;
|
|
513
|
+
i++;
|
|
514
|
+
}
|
|
515
|
+
const nextChar = str[i + 1];
|
|
516
|
+
if (!globstar) reStr += ".*";
|
|
517
|
+
else if (starCount > 1 && (prevChar === "/" || prevChar === void 0) && (nextChar === "/" || nextChar === void 0)) {
|
|
518
|
+
reStr += "((?:[^/]*(?:\\/|$))*)";
|
|
519
|
+
i++;
|
|
520
|
+
} else reStr += "([^/]*)";
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
default: reStr += c;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!flags?.includes("g")) reStr = `^${reStr}$`;
|
|
527
|
+
return new RegExp(reStr, flags);
|
|
528
|
+
}
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/filtered-files-api.ts
|
|
531
|
+
/**
|
|
532
|
+
* Builds a {@link PathFilter} that hides any path whose normalized form
|
|
533
|
+
* equals one of the provided prefixes or lives under `${prefix}/`.
|
|
534
|
+
*
|
|
535
|
+
* Prefixes are normalized through `normalizePath` (so `"foo"`, `"/foo"`, and
|
|
536
|
+
* `"/foo/"` are equivalent). Empty / root entries are dropped — they would
|
|
537
|
+
* otherwise hide every path.
|
|
538
|
+
*
|
|
539
|
+
* Matching is **boundary-aware**: the prefix `"/priv"` does not match the
|
|
540
|
+
* path `"/private"` because there is no `/` boundary between them.
|
|
541
|
+
*
|
|
542
|
+
* @param pathPrefixes Path prefixes whose contents (and the prefix itself)
|
|
543
|
+
* should be hidden. Pass none to hide nothing.
|
|
544
|
+
*
|
|
545
|
+
* @example
|
|
546
|
+
* ```ts
|
|
547
|
+
* const filter = newPathFilter("/.git", "/node_modules");
|
|
548
|
+
* filter("/src/index.ts"); // true
|
|
549
|
+
* filter("/.git"); // false
|
|
550
|
+
* filter("/.git/HEAD"); // false
|
|
551
|
+
* filter("/notgit"); // true (boundary-aware, no false match)
|
|
552
|
+
* ```
|
|
553
|
+
*/
|
|
554
|
+
function newPathFilter(...pathPrefixes) {
|
|
555
|
+
const normalized = pathPrefixes.map((p) => normalizePath(p)).filter((p) => p !== "/");
|
|
556
|
+
return (path) => {
|
|
557
|
+
const target = normalizePath(path);
|
|
558
|
+
for (const prefix of normalized) {
|
|
559
|
+
if (target === prefix) return false;
|
|
560
|
+
if (target.startsWith(`${prefix}/`)) return false;
|
|
561
|
+
}
|
|
562
|
+
return true;
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Builds a {@link PathFilter} that hides any path whose normalized form
|
|
567
|
+
* matches at least one of the provided regular expressions.
|
|
568
|
+
*
|
|
569
|
+
* The path is normalized through `normalizePath` before matching, so a
|
|
570
|
+
* regexp anchored on `^/` always sees a leading slash and never a trailing
|
|
571
|
+
* one. The regexp's `lastIndex` is irrelevant — the filter calls `test`
|
|
572
|
+
* via a fresh evaluation each time, but stateful (`/g`, `/y`) regexps
|
|
573
|
+
* still mutate `lastIndex` across calls; pass non-stateful regexps unless
|
|
574
|
+
* you know what you are doing.
|
|
575
|
+
*
|
|
576
|
+
* @param pathRegexps Regular expressions whose match means "hide this
|
|
577
|
+
* path". Pass none to hide nothing.
|
|
578
|
+
*
|
|
579
|
+
* @example
|
|
580
|
+
* ```ts
|
|
581
|
+
* // Hide every dotfile and every *.log file
|
|
582
|
+
* const filter = newRegexpPathFilter(/\/\.[^/]+$/, /\.log$/);
|
|
583
|
+
* filter("/src/index.ts"); // true
|
|
584
|
+
* filter("/.env"); // false (matches /\/\.[^/]+$/)
|
|
585
|
+
* filter("/build.log"); // false (matches /\.log$/)
|
|
586
|
+
* ```
|
|
587
|
+
*/
|
|
588
|
+
function newRegexpPathFilter(...pathRegexps) {
|
|
589
|
+
return (path) => {
|
|
590
|
+
const target = normalizePath(path);
|
|
591
|
+
for (const regexp of pathRegexps) if (regexp.test(target)) return false;
|
|
592
|
+
return true;
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Builds a {@link PathFilter} that hides any path whose normalized form
|
|
597
|
+
* matches at least one of the provided glob patterns.
|
|
598
|
+
*
|
|
599
|
+
* Each glob is compiled with `extended: true` and `globstar: true`, the
|
|
600
|
+
* standard "filesystem-style" mode:
|
|
601
|
+
*
|
|
602
|
+
* - `*` matches any number of characters within a single path segment
|
|
603
|
+
* (does **not** cross `/`).
|
|
604
|
+
* - `**` between slashes matches zero or more whole path segments.
|
|
605
|
+
* - `?` matches exactly one character.
|
|
606
|
+
* - `[abc]` / `[a-z]` matches a single character in the set / range.
|
|
607
|
+
* - `{a,b,c}` matches one of the alternatives.
|
|
608
|
+
*
|
|
609
|
+
* Because matching is done on the **normalized** path (which always starts
|
|
610
|
+
* with `/`), patterns that should match anywhere in the tree need a
|
|
611
|
+
* leading `**/`, e.g. `**/*.log` to hide every `.log` file at any depth.
|
|
612
|
+
*
|
|
613
|
+
* Gotcha: `/foo/**` matches descendants of `/foo` but **not** `/foo`
|
|
614
|
+
* itself, because the glob requires a `/` after `foo` before `**` can
|
|
615
|
+
* match. To hide both the directory and its contents, list both prefixes:
|
|
616
|
+
* `newGlobPathFilter("/foo", "/foo/**")`. {@link newPathFilter} doesn't
|
|
617
|
+
* have this problem and may be a better fit for prefix-only hiding.
|
|
618
|
+
*
|
|
619
|
+
* @param pathGlobs Glob patterns whose match means "hide this path". Pass
|
|
620
|
+
* none to hide nothing.
|
|
621
|
+
*
|
|
622
|
+
* @example
|
|
623
|
+
* ```ts
|
|
624
|
+
* const filter = newGlobPathFilter("**/*.log", "/.git", "/.git/**");
|
|
625
|
+
* filter("/src/index.ts"); // true
|
|
626
|
+
* filter("/build.log"); // false (matches **/*.log)
|
|
627
|
+
* filter("/.git"); // false (matches /.git)
|
|
628
|
+
* filter("/.git/HEAD"); // false (matches /.git/**)
|
|
629
|
+
* ```
|
|
630
|
+
*/
|
|
631
|
+
function newGlobPathFilter(...pathGlobs) {
|
|
632
|
+
return newRegexpPathFilter(...pathGlobs.map((glob) => globToRegExp(glob, {
|
|
633
|
+
extended: true,
|
|
634
|
+
globstar: true
|
|
635
|
+
})));
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* `FilesApi` decorator that hides every path the supplied {@link PathFilter}
|
|
639
|
+
* rejects. Hidden paths are treated as if they do not exist:
|
|
640
|
+
*
|
|
641
|
+
* - `read` / `list` yield empty iterables.
|
|
642
|
+
* - `stats` returns `undefined`; `exists` returns `false`.
|
|
643
|
+
* - `remove` returns `false` (no error, nothing changed).
|
|
644
|
+
* - `move` / `copy` return `false` if either endpoint is hidden.
|
|
645
|
+
* - `write` / `mkdir` reject with an `Error` (since silently dropping a
|
|
646
|
+
* write would lose data).
|
|
647
|
+
* - `list` recursively skips entries whose paths are hidden, so iterating a
|
|
648
|
+
* visible parent never reveals a hidden child.
|
|
649
|
+
*
|
|
650
|
+
* Wrap any `FilesApi` to scope its visibility without changing the
|
|
651
|
+
* underlying storage; the wrapped instance still holds the data, it is just
|
|
652
|
+
* not reachable through this decorator.
|
|
653
|
+
*
|
|
654
|
+
* Pair with one of the built-in {@link PathFilter} factories
|
|
655
|
+
* ({@link newPathFilter}, {@link newRegexpPathFilter},
|
|
656
|
+
* {@link newGlobPathFilter}) or pass any predicate of shape
|
|
657
|
+
* `(path) => boolean | Promise<boolean>`.
|
|
658
|
+
*
|
|
659
|
+
* @example
|
|
660
|
+
* ```ts
|
|
661
|
+
* import {
|
|
662
|
+
* FilteredFilesApi,
|
|
663
|
+
* newGlobPathFilter,
|
|
664
|
+
* newPathFilter,
|
|
665
|
+
* newRegexpPathFilter,
|
|
666
|
+
* } from "@statewalker/webrun-files-composite";
|
|
667
|
+
*
|
|
668
|
+
* // Hide by path prefix
|
|
669
|
+
* const noVcs = new FilteredFilesApi(sourceFiles, newPathFilter("/.git", "/.cache"));
|
|
670
|
+
* await noVcs.exists("/.git"); // false
|
|
671
|
+
* await noVcs.write("/.git/x", data); // throws "Path is hidden"
|
|
672
|
+
*
|
|
673
|
+
* // Hide by regexp
|
|
674
|
+
* const noLogs = new FilteredFilesApi(sourceFiles, newRegexpPathFilter(/\.log$/));
|
|
675
|
+
*
|
|
676
|
+
* // Hide by glob (extended + globstar mode)
|
|
677
|
+
* const noJunk = new FilteredFilesApi(
|
|
678
|
+
* sourceFiles,
|
|
679
|
+
* newGlobPathFilter("**/*.log", "/.git", "/.git/**"),
|
|
680
|
+
* );
|
|
681
|
+
* ```
|
|
682
|
+
*/
|
|
683
|
+
var FilteredFilesApi = class {
|
|
684
|
+
source;
|
|
685
|
+
pathFilter;
|
|
686
|
+
/**
|
|
687
|
+
* @param source The underlying `FilesApi` whose paths will be selectively
|
|
688
|
+
* hidden. Operations always delegate to this instance; the decorator
|
|
689
|
+
* only adds the visibility check.
|
|
690
|
+
* @param pathFilter Predicate that decides per-call whether a normalized
|
|
691
|
+
* path is visible. See {@link PathFilter}.
|
|
692
|
+
*/
|
|
693
|
+
constructor(source, pathFilter) {
|
|
694
|
+
this.source = source;
|
|
695
|
+
this.pathFilter = pathFilter;
|
|
696
|
+
}
|
|
697
|
+
async isHidden(path) {
|
|
698
|
+
return await this.pathFilter(normalizePath(path)) === false;
|
|
699
|
+
}
|
|
700
|
+
async *read(path, options) {
|
|
701
|
+
if (await this.isHidden(path)) return;
|
|
702
|
+
yield* this.source.read(path, options);
|
|
703
|
+
}
|
|
704
|
+
async write(path, content) {
|
|
705
|
+
if (await this.isHidden(path)) throw new Error(`Path is hidden: ${path}`);
|
|
706
|
+
await this.source.write(path, content);
|
|
707
|
+
}
|
|
708
|
+
async mkdir(path) {
|
|
709
|
+
if (await this.isHidden(path)) throw new Error(`Path is hidden: ${path}`);
|
|
710
|
+
await this.source.mkdir(path);
|
|
711
|
+
}
|
|
712
|
+
async *list(path, options) {
|
|
713
|
+
if (await this.isHidden(path)) return;
|
|
714
|
+
for await (const entry of this.source.list(path, options)) {
|
|
715
|
+
if (await this.isHidden(entry.path)) continue;
|
|
716
|
+
yield entry;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
async stats(path) {
|
|
720
|
+
if (await this.isHidden(path)) return void 0;
|
|
721
|
+
return this.source.stats(path);
|
|
722
|
+
}
|
|
723
|
+
async exists(path) {
|
|
724
|
+
if (await this.isHidden(path)) return false;
|
|
725
|
+
return this.source.exists(path);
|
|
726
|
+
}
|
|
727
|
+
async remove(path) {
|
|
728
|
+
if (await this.isHidden(path)) return false;
|
|
729
|
+
return this.source.remove(path);
|
|
730
|
+
}
|
|
731
|
+
async move(source, target) {
|
|
732
|
+
if (await this.isHidden(source) || await this.isHidden(target)) return false;
|
|
733
|
+
return this.source.move(source, target);
|
|
734
|
+
}
|
|
735
|
+
async copy(source, target) {
|
|
736
|
+
if (await this.isHidden(source) || await this.isHidden(target)) return false;
|
|
737
|
+
return this.source.copy(source, target);
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
//#endregion
|
|
741
|
+
//#region src/guarded-files-api.ts
|
|
742
|
+
/**
|
|
743
|
+
* `FilesApi` decorator that runs every call through an ordered list of
|
|
744
|
+
* {@link FileGuard}s. A guard fires when its `operations` set intersects the
|
|
745
|
+
* effective operation(s) for the current call. The first guard whose
|
|
746
|
+
* `check` returns `false` aborts the call by throwing an `Error` with that
|
|
747
|
+
* guard's `message` (defaulting to `"Access denied"`) followed by the
|
|
748
|
+
* normalized path.
|
|
749
|
+
*
|
|
750
|
+
* Effective operations per call:
|
|
751
|
+
*
|
|
752
|
+
* | Method | Operations checked |
|
|
753
|
+
* | ------------- | --------------------------------------------------- |
|
|
754
|
+
* | `read` | `read` |
|
|
755
|
+
* | `write` | `write` |
|
|
756
|
+
* | `mkdir` | `mkdir` |
|
|
757
|
+
* | `remove` | `remove` |
|
|
758
|
+
* | `list` | `list` on the path AND on each directory entry |
|
|
759
|
+
* | `stats` | `list` (a stat reveals existence like a tiny list) |
|
|
760
|
+
* | `exists` | `read` (existence is a read of metadata) |
|
|
761
|
+
* | `move(s, t)` | `move`+`read` on source; `move`+`write` on target |
|
|
762
|
+
* | `copy(s, t)` | `copy`+`read` on source; `copy`+`write` on target |
|
|
763
|
+
*
|
|
764
|
+
* The expanded checks for `move`/`copy` mean a guard that blocks `read` on
|
|
765
|
+
* a path also prevents move/copy *from* that path, and a `write`-blocking
|
|
766
|
+
* guard prevents move/copy *to* it. Likewise, an `exists` call respects any
|
|
767
|
+
* read guard, and `stats` respects any list guard.
|
|
768
|
+
*
|
|
769
|
+
* @example
|
|
770
|
+
* ```ts
|
|
771
|
+
* const api = new GuardedFilesApi(source, [
|
|
772
|
+
* {
|
|
773
|
+
* operations: ["write", "remove", "move", "mkdir"],
|
|
774
|
+
* check: (p) => !p.startsWith("/.system/"),
|
|
775
|
+
* message: "system folder is read-only",
|
|
776
|
+
* },
|
|
777
|
+
* ]);
|
|
778
|
+
* await api.write("/.system/cfg", data); // throws "system folder is read-only: /.system/cfg"
|
|
779
|
+
* ```
|
|
780
|
+
*/
|
|
781
|
+
var GuardedFilesApi = class {
|
|
782
|
+
source;
|
|
783
|
+
guards;
|
|
784
|
+
/**
|
|
785
|
+
* @param source The underlying `FilesApi` whose calls will be policed.
|
|
786
|
+
* Allowed operations delegate straight through.
|
|
787
|
+
* @param guards Ordered list of access policies. The wrapper takes a
|
|
788
|
+
* defensive copy, so mutating the array afterwards has no effect.
|
|
789
|
+
* An empty list disables every check (the wrapper becomes a passthrough).
|
|
790
|
+
*/
|
|
791
|
+
constructor(source, guards) {
|
|
792
|
+
this.source = source;
|
|
793
|
+
this.guards = [...guards];
|
|
794
|
+
}
|
|
795
|
+
checkGuard(path, ...operations) {
|
|
796
|
+
const normalized = normalizePath(path);
|
|
797
|
+
for (const guard of this.guards) {
|
|
798
|
+
if (!operations.some((op) => guard.operations.includes(op))) continue;
|
|
799
|
+
if (!guard.check(normalized)) {
|
|
800
|
+
const msg = guard.message ?? "Access denied";
|
|
801
|
+
throw new Error(`${msg}: ${normalized}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
read(path, options) {
|
|
806
|
+
this.checkGuard(path, "read");
|
|
807
|
+
return this.source.read(path, options);
|
|
808
|
+
}
|
|
809
|
+
async write(path, content) {
|
|
810
|
+
this.checkGuard(path, "write");
|
|
811
|
+
return this.source.write(path, content);
|
|
812
|
+
}
|
|
813
|
+
async mkdir(path) {
|
|
814
|
+
this.checkGuard(path, "mkdir");
|
|
815
|
+
return this.source.mkdir(path);
|
|
816
|
+
}
|
|
817
|
+
async *list(path, options) {
|
|
818
|
+
this.checkGuard(path, "list");
|
|
819
|
+
for await (const info of this.source.list(path, options)) {
|
|
820
|
+
if (info.kind === "directory") this.checkGuard(info.path, "list");
|
|
821
|
+
yield info;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
stats(path) {
|
|
825
|
+
this.checkGuard(path, "list");
|
|
826
|
+
return this.source.stats(path);
|
|
827
|
+
}
|
|
828
|
+
exists(path) {
|
|
829
|
+
this.checkGuard(path, "read");
|
|
830
|
+
return this.source.exists(path);
|
|
831
|
+
}
|
|
832
|
+
async remove(path) {
|
|
833
|
+
this.checkGuard(path, "remove");
|
|
834
|
+
return this.source.remove(path);
|
|
835
|
+
}
|
|
836
|
+
async move(source, target) {
|
|
837
|
+
this.checkGuard(source, "move", "read");
|
|
838
|
+
this.checkGuard(target, "move", "write");
|
|
839
|
+
return this.source.move(source, target);
|
|
840
|
+
}
|
|
841
|
+
async copy(source, target) {
|
|
842
|
+
this.checkGuard(source, "copy", "read");
|
|
843
|
+
this.checkGuard(target, "copy", "write");
|
|
844
|
+
return this.source.copy(source, target);
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
//#endregion
|
|
848
|
+
//#region src/overlay-files-api.ts
|
|
849
|
+
/**
|
|
850
|
+
* Read-only union of several `FilesApi` layers. A path is resolved
|
|
851
|
+
* **top → bottom**: the first layer that has it wins for
|
|
852
|
+
* `read` / `stats` / `exists`, and its entry (including `kind`) wins any
|
|
853
|
+
* name clash in `list`. Listings merge and dedupe across every layer.
|
|
854
|
+
*
|
|
855
|
+
* The union never writes — every mutating call throws. Use
|
|
856
|
+
* {@link overlay} to construct one.
|
|
857
|
+
*/
|
|
858
|
+
var OverlayFilesApi = class {
|
|
859
|
+
layers;
|
|
860
|
+
constructor(layers) {
|
|
861
|
+
this.layers = layers;
|
|
862
|
+
}
|
|
863
|
+
deny(op, path) {
|
|
864
|
+
throw new Error(`overlay is read-only (${op}): ${normalizePath(path)}`);
|
|
865
|
+
}
|
|
866
|
+
async *read(path, options) {
|
|
867
|
+
for (const layer of this.layers) if (await layer.exists(path)) {
|
|
868
|
+
yield* layer.read(path, options);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
async stats(path) {
|
|
873
|
+
for (const layer of this.layers) {
|
|
874
|
+
const stats = await layer.stats(path);
|
|
875
|
+
if (stats) return stats;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
async exists(path) {
|
|
879
|
+
for (const layer of this.layers) if (await layer.exists(path)) return true;
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
async *list(path, options) {
|
|
883
|
+
yield* this.listDir(normalizePath(path), options?.recursive ?? false);
|
|
884
|
+
}
|
|
885
|
+
async *listDir(dir, recursive) {
|
|
886
|
+
if ((await this.stats(dir))?.kind !== "directory") return;
|
|
887
|
+
for (const entry of await this.mergeChildren(dir)) {
|
|
888
|
+
yield entry;
|
|
889
|
+
if (recursive && entry.kind === "directory") yield* this.listDir(entry.path, true);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/** Direct children of `dir`, deduped by name with the topmost layer winning. */
|
|
893
|
+
async mergeChildren(dir) {
|
|
894
|
+
const merged = /* @__PURE__ */ new Map();
|
|
895
|
+
for (const layer of this.layers) {
|
|
896
|
+
if ((await layer.stats(dir))?.kind !== "directory") continue;
|
|
897
|
+
for await (const entry of layer.list(dir)) if (!merged.has(entry.name)) merged.set(entry.name, entry);
|
|
898
|
+
}
|
|
899
|
+
return [...merged.values()];
|
|
900
|
+
}
|
|
901
|
+
async write(path) {
|
|
902
|
+
this.deny("write", path);
|
|
903
|
+
}
|
|
904
|
+
async mkdir(path) {
|
|
905
|
+
this.deny("mkdir", path);
|
|
906
|
+
}
|
|
907
|
+
async remove(path) {
|
|
908
|
+
return this.deny("remove", path);
|
|
909
|
+
}
|
|
910
|
+
async move(source) {
|
|
911
|
+
return this.deny("move", source);
|
|
912
|
+
}
|
|
913
|
+
async copy(source) {
|
|
914
|
+
return this.deny("copy", source);
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
/**
|
|
918
|
+
* Builds a **read-only** union view over `top` and any number of `lower`
|
|
919
|
+
* layers. Reads resolve top → bottom (first layer that has the path wins);
|
|
920
|
+
* `list` merges and dedupes across all layers with `top` winning a clash
|
|
921
|
+
* (including a file-vs-directory `kind` clash). Every write is denied.
|
|
922
|
+
*
|
|
923
|
+
* @param top The highest-priority layer; its entries shadow the rest.
|
|
924
|
+
* @param lower Additional layers, consulted in order after `top`.
|
|
925
|
+
* @returns A read-only `FilesApi` union.
|
|
926
|
+
*
|
|
927
|
+
* @example
|
|
928
|
+
* ```ts
|
|
929
|
+
* const view = overlay(userFiles, defaultFiles);
|
|
930
|
+
* await view.read("/config.json"); // userFiles if present, else defaultFiles
|
|
931
|
+
* await view.write("/x", data); // throws (read-only)
|
|
932
|
+
* ```
|
|
933
|
+
*/
|
|
934
|
+
function overlay(top, ...lower) {
|
|
935
|
+
return new OverlayFilesApi([top, ...lower]);
|
|
936
|
+
}
|
|
937
|
+
//#endregion
|
|
938
|
+
//#region src/read-only-files-api.ts
|
|
939
|
+
/**
|
|
940
|
+
* Wraps a `FilesApi` in a read-only view: every mutating operation
|
|
941
|
+
* (`write`, `mkdir`, `remove`, `move`, `copy`) throws, while reads
|
|
942
|
+
* (`read`, `list`, `stats`, `exists`) pass straight through to `api`.
|
|
943
|
+
*
|
|
944
|
+
* Implemented as a {@link GuardedFilesApi} with a single deny-all guard on
|
|
945
|
+
* the mutating operations, so `move`/`copy` are blocked on either endpoint.
|
|
946
|
+
*
|
|
947
|
+
* @param api The underlying `FilesApi` to expose read-only.
|
|
948
|
+
* @returns A `FilesApi` that never mutates `api`.
|
|
949
|
+
*
|
|
950
|
+
* @example
|
|
951
|
+
* ```ts
|
|
952
|
+
* const ro = readOnly(sourceFiles);
|
|
953
|
+
* await ro.read("/a.txt"); // ok
|
|
954
|
+
* await ro.write("/a.txt", data); // throws "read-only: /a.txt"
|
|
955
|
+
* ```
|
|
956
|
+
*/
|
|
957
|
+
function readOnly(api) {
|
|
958
|
+
return new GuardedFilesApi(api, [{
|
|
959
|
+
operations: [
|
|
960
|
+
"write",
|
|
961
|
+
"mkdir",
|
|
962
|
+
"remove",
|
|
963
|
+
"move",
|
|
964
|
+
"copy"
|
|
965
|
+
],
|
|
966
|
+
check: () => false,
|
|
967
|
+
message: "read-only"
|
|
968
|
+
}]);
|
|
969
|
+
}
|
|
970
|
+
//#endregion
|
|
971
|
+
export { CompositeFilesApi, FilteredFilesApi, GuardedFilesApi, cow, globToRegExp, newGlobPathFilter, newPathFilter, newRegexpPathFilter, overlay, readOnly };
|