@bod.ee/db 0.11.1 → 0.12.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/CLAUDE.md +1 -0
- package/docs/keyauth-integration.md +141 -0
- package/package.json +1 -1
- package/src/client/BodClient.ts +4 -0
- package/src/client/BodClientCached.ts +91 -3
- package/src/server/Transport.ts +8 -0
- package/src/server/VFSEngine.ts +27 -0
- package/src/shared/protocol.ts +1 -0
package/CLAUDE.md
CHANGED
|
@@ -72,6 +72,7 @@ config.ts — demo instance config (open rules, indexes, fts, v
|
|
|
72
72
|
- **MCP**: `MCPAdapter` wraps a `BodClient` as a JSON-RPC MCP server (stdio + HTTP). Connects to a running BodDB instance over WebSocket — no embedded DB. Entry point: `mcp.ts`. Tools: CRUD (6), FTS (2), vectors (2), streams (4), MQ (7) = 21 tools. Use `--stdio` for Claude Code/Desktop, `--http` for remote agents.
|
|
73
73
|
- **VFS (Virtual File System)**: `VFSEngine` — files stored outside SQLite via pluggable `VFSBackend` interface. `LocalBackend` stores at `<storageRoot>/<fileId>` using `Bun.file`/`Bun.write`. Metadata at `_vfs/<virtualPath>/` (size, mime, mtime, fileId, isDir) — gets subs/rules/replication for free. `fileId = pushId` so move/rename is metadata-only. REST: `POST/GET/DELETE /files/<path>`, `?stat=1`, `?list=1`, `?mkdir=1`, `PUT ?move=<dst>`. WS chunked fallback: base64-encoded `vfs-upload-init/chunk/done`, `vfs-download-init` → `vfs-download-chunk` push messages. Client: `VFSClient` via `client.vfs()` — `upload/download` (REST) + `uploadWS/downloadWS` (WS) + `stat/list/mkdir/delete/move`.
|
|
74
74
|
- **Replication**: `ReplicationEngine` — single primary + N read replicas + multi-source feed subscriptions. Star topology. Primary emits write events to `_repl` stream via `onWrite` hooks. Replicas bootstrap via `streamMaterialize('_repl', { keepKey: 'path' })`, then subscribe for ongoing events. Write proxy: replica forwards writes to primary via BodClient, primary applies + emits, replica consumes. `_replaying` flag prevents re-emission loops. `_emitting` guard prevents recursion from `db.push('_repl')`. Updates flattened to per-path set events for correct compaction keying. Sweep delete events replicated. Excluded prefixes: `_repl`, `_streams`, `_mq`, `_auth`. **Sources**: `ReplicationSource[]` — subscribe to specific paths from multiple remote DBs. Each source is an independent BodClient that filters `_repl` events by path prefix, with optional `localPrefix` remapping (e.g. remote `users/u1` → local `db-a/users/u1`). Sources connect in parallel; individual failures don't block others. Sources are independent of role — a DB can be primary AND consume sources.
|
|
75
|
+
- **KeyAuth integration guide**: `docs/keyauth-integration.md` — flows for signup, signin, new device, autoAuth, IAM roles, common mistakes.
|
|
75
76
|
- **KeyAuth**: `KeyAuthEngine` — portable Ed25519 identity & IAM. Identity hierarchy: Root (server-level, key on filesystem), Account (portable, password-encrypted private key in DB or device-generated), Device (delegate, linked via password unlock). Challenge-response auth: server sends nonce → client signs with Ed25519 → server verifies + creates session. Self-signed tokens (no JWT lib): `base64url(payload).base64url(Ed25519_sign)`. Data model at `_auth/` prefix (protected from external writes). Device reverse-index at `_auth/deviceIndex/{dfp}` for O(1) lookup. Password change is atomic (single `db.update()`). IAM: roles with path-based permissions, account role assignment. `_auth/` excluded from replication. Transport guards: `auth-link-device` and `auth-change-password` require authenticated session; non-root users can only change own password. **Device registration**: `registerDevice(publicKey)` — client-generated keypair, no password, idempotent; `allowOpenRegistration: false` requires authenticated session. **Browser crypto**: `keyAuth.browser.ts` uses `@noble/ed25519` with DER↔raw key bridge for server compatibility. **BodClient autoAuth**: `autoAuth: true` auto-generates keypair (localStorage), registers, authenticates — zero-config device identity. `client.auth.*` convenience methods for all auth ops. **IAM transport ops**: `auth-create-role`, `auth-delete-role`, `auth-update-roles` (root only), `auth-list-accounts`, `auth-list-roles`. Device accounts (no encrypted key) safely reject `linkDevice`/`changePassword`.
|
|
76
77
|
|
|
77
78
|
## MCP Server
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# KeyAuth Integration Guide
|
|
2
|
+
|
|
3
|
+
BodDB's auth system has two identity layers — **accounts** (portable, password-protected) and **devices** (device-bound, no password). Understanding which to use is critical.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Identity Model
|
|
8
|
+
|
|
9
|
+
| Type | Private key lives | Auth method | Portable? |
|
|
10
|
+
|------|------------------|-------------|-----------|
|
|
11
|
+
| **Account** | Encrypted in DB (password-protected) | Password (to unlock/link) | Yes — any device |
|
|
12
|
+
| **Device** | Client only (localStorage / memory) | Challenge-response (Ed25519) | No — tied to this client |
|
|
13
|
+
|
|
14
|
+
**Root** — first account ever created is auto-elevated to root. Server-level privileges.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## When to use what
|
|
19
|
+
|
|
20
|
+
**Use `autoAuth: true` (device-only)** when:
|
|
21
|
+
- You need zero-config anonymous identity (e.g. telemetry, guest sessions)
|
|
22
|
+
- Identity loss is acceptable (clearing localStorage = new identity)
|
|
23
|
+
- No cross-device portability needed
|
|
24
|
+
|
|
25
|
+
**Use account + linked device** when:
|
|
26
|
+
- Users have passwords and expect to sign in from multiple devices
|
|
27
|
+
- Identity must survive localStorage wipe
|
|
28
|
+
- You need IAM roles assigned to a persistent identity
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Flows
|
|
33
|
+
|
|
34
|
+
### Signup (new user, first device)
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const client = new BodClient({ url: 'ws://localhost:4400' });
|
|
38
|
+
await client.connect();
|
|
39
|
+
|
|
40
|
+
// 1. Generate device keypair (client-side, stored in localStorage)
|
|
41
|
+
const kp = await generateDeviceKeypair(); // your key generation
|
|
42
|
+
|
|
43
|
+
// 2. Create account — auto-links this device
|
|
44
|
+
const account = await client.auth.createAccount(
|
|
45
|
+
password,
|
|
46
|
+
[], // roles (root assigns later)
|
|
47
|
+
'alice', // displayName
|
|
48
|
+
kp.publicKey // auto-links device → no separate linkDevice call needed
|
|
49
|
+
);
|
|
50
|
+
// account.isRoot === true if this is the very first account on the server
|
|
51
|
+
|
|
52
|
+
// 3. Authenticate with the device keypair
|
|
53
|
+
const { token } = await client.auth.authenticate(kp.publicKey, kp.signFn);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Sign in (same device, returning user)
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// Device keypair is already in localStorage — just authenticate
|
|
60
|
+
const { token } = await client.auth.authenticate(kp.publicKey, kp.signFn);
|
|
61
|
+
// Send token on subsequent requests (handled automatically by BodClient)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Sign in from a new device
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// On the NEW device:
|
|
68
|
+
const newKp = await generateDeviceKeypair();
|
|
69
|
+
|
|
70
|
+
// Link it to the existing account (requires password)
|
|
71
|
+
await client.auth.linkDeviceByName('alice', password, newKp.publicKey, 'MacBook Pro');
|
|
72
|
+
// or by fingerprint:
|
|
73
|
+
await client.auth.linkDevice(accountFingerprint, password, newKp.publicKey, 'MacBook Pro');
|
|
74
|
+
|
|
75
|
+
// Now authenticate with the new device — no password needed again
|
|
76
|
+
const { token } = await client.auth.authenticate(newKp.publicKey, newKp.signFn);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### autoAuth (zero-config device identity)
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// Keypair auto-generated + stored in localStorage, registered + authenticated on connect
|
|
83
|
+
const client = new BodClient({ url: 'ws://localhost:4400', autoAuth: true });
|
|
84
|
+
await client.connect();
|
|
85
|
+
// client.deviceFingerprint and client.authToken are set automatically
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
> ⚠️ `autoAuth` creates a device-only identity. There is no account behind it — no password, no cross-device portability. Clearing localStorage permanently loses this identity.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Common mistakes
|
|
93
|
+
|
|
94
|
+
| Mistake | Problem | Fix |
|
|
95
|
+
|---------|---------|-----|
|
|
96
|
+
| Using `autoAuth` for user accounts | Identity lost on localStorage clear | Use `createAccount` + `linkDevice` |
|
|
97
|
+
| Calling `createAccount` without `devicePublicKey` | Have to call `linkDevice` separately | Pass `devicePublicKey` to `createAccount` |
|
|
98
|
+
| Not knowing account fingerprint on new device | Can't call `linkDevice` | Use `linkDeviceByName(displayName, ...)` instead |
|
|
99
|
+
| Assuming `autoAuth` device has an account | Device accounts reject `linkDevice`/`changePassword` | They're standalone — no account behind them |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Server setup
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { BodDB } from 'bod-db';
|
|
107
|
+
import { KeyAuthEngine } from 'bod-db/server';
|
|
108
|
+
|
|
109
|
+
const db = new BodDB({
|
|
110
|
+
keyAuth: {
|
|
111
|
+
allowOpenRegistration: false, // require auth session to register devices (recommended)
|
|
112
|
+
maxAuthFailures: 10,
|
|
113
|
+
authLockoutSeconds: 300,
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`allowOpenRegistration: true` (default) allows any client to register a device without being authenticated first — fine for open apps, restrict for closed ones.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## IAM roles
|
|
123
|
+
|
|
124
|
+
Roles are assigned to **accounts** (not devices). Devices inherit the account's roles via the linked account fingerprint.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// Root only
|
|
128
|
+
await client.auth.createRole({
|
|
129
|
+
id: 'editor',
|
|
130
|
+
name: 'Editor',
|
|
131
|
+
permissions: [
|
|
132
|
+
{ path: 'posts/$postId', read: true, write: true },
|
|
133
|
+
{ path: 'users/$uid/profile', read: true },
|
|
134
|
+
]
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Assign to account (root only)
|
|
138
|
+
await client.auth.updateRoles(accountFingerprint, ['editor']);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Rules engine receives `auth.roles`, `auth.fingerprint`, `auth.accountFingerprint` in context.
|
package/package.json
CHANGED
package/src/client/BodClient.ts
CHANGED
|
@@ -829,6 +829,10 @@ export class VFSClient {
|
|
|
829
829
|
return await this.client._send('vfs-list', { path }) as FileStat[];
|
|
830
830
|
}
|
|
831
831
|
|
|
832
|
+
async tree(path: string, opts?: { hiddenPaths?: string[]; hideDotfiles?: boolean }): Promise<any[]> {
|
|
833
|
+
return await this.client._send('vfs-tree', { path, ...opts }) as any[];
|
|
834
|
+
}
|
|
835
|
+
|
|
832
836
|
async delete(path: string): Promise<void> {
|
|
833
837
|
await this.client._send('vfs-delete', { path });
|
|
834
838
|
}
|
|
@@ -182,7 +182,7 @@ export class BodClientCached {
|
|
|
182
182
|
|
|
183
183
|
vfs(): CachedVFSClient {
|
|
184
184
|
if (!this._vfs) {
|
|
185
|
-
this._vfs = new CachedVFSClient(this.client.vfs(), this.options.maxAge);
|
|
185
|
+
this._vfs = new CachedVFSClient(this.client.vfs(), this.options.maxAge, this.idb);
|
|
186
186
|
this._vfsUnsub = this.client.onChild('_vfs', () => {
|
|
187
187
|
// _vfs events are coarse (top-level key only), clear all VFS caches
|
|
188
188
|
this._vfs?.clear();
|
|
@@ -252,18 +252,34 @@ export class CachedVFSClient {
|
|
|
252
252
|
constructor(
|
|
253
253
|
private raw: VFSClient,
|
|
254
254
|
private maxAge: number,
|
|
255
|
+
private idb: IDBDatabase | null = null,
|
|
255
256
|
) {}
|
|
256
257
|
|
|
257
258
|
async stat(path: string): Promise<FileStat | null> {
|
|
258
259
|
const cached = this.statCache.get(path);
|
|
259
260
|
if (cached && Date.now() - cached.cachedAt < this.maxAge) {
|
|
260
261
|
this._stats.hits++;
|
|
261
|
-
this.raw.stat(path).then(r =>
|
|
262
|
+
this.raw.stat(path).then(r => {
|
|
263
|
+
this.statCache.set(path, { data: r, cachedAt: Date.now() });
|
|
264
|
+
this.idbSetVfs(`vfs:stat:${path}`, { data: r, cachedAt: Date.now() });
|
|
265
|
+
}).catch(() => {});
|
|
262
266
|
return cached.data;
|
|
263
267
|
}
|
|
268
|
+
// Check IDB before network
|
|
269
|
+
const idbEntry = await this.idbGetVfs(`vfs:stat:${path}`);
|
|
270
|
+
if (idbEntry && Date.now() - idbEntry.cachedAt < this.maxAge) {
|
|
271
|
+
this._stats.hits++;
|
|
272
|
+
this.statCache.set(path, idbEntry);
|
|
273
|
+
this.raw.stat(path).then(r => {
|
|
274
|
+
this.statCache.set(path, { data: r, cachedAt: Date.now() });
|
|
275
|
+
this.idbSetVfs(`vfs:stat:${path}`, { data: r, cachedAt: Date.now() });
|
|
276
|
+
}).catch(() => {});
|
|
277
|
+
return idbEntry.data as FileStat | null;
|
|
278
|
+
}
|
|
264
279
|
this._stats.misses++;
|
|
265
280
|
const result = await this.raw.stat(path);
|
|
266
281
|
this.statCache.set(path, { data: result, cachedAt: Date.now() });
|
|
282
|
+
this.idbSetVfs(`vfs:stat:${path}`, { data: result, cachedAt: Date.now() });
|
|
267
283
|
return result;
|
|
268
284
|
}
|
|
269
285
|
|
|
@@ -271,15 +287,34 @@ export class CachedVFSClient {
|
|
|
271
287
|
const cached = this.listCache.get(path);
|
|
272
288
|
if (cached && Date.now() - cached.cachedAt < this.maxAge) {
|
|
273
289
|
this._stats.hits++;
|
|
274
|
-
this.raw.list(path).then(r =>
|
|
290
|
+
this.raw.list(path).then(r => {
|
|
291
|
+
this.listCache.set(path, { data: r, cachedAt: Date.now() });
|
|
292
|
+
this.idbSetVfs(`vfs:list:${path}`, { data: r, cachedAt: Date.now() });
|
|
293
|
+
}).catch(() => {});
|
|
275
294
|
return cached.data;
|
|
276
295
|
}
|
|
296
|
+
// Check IDB before network
|
|
297
|
+
const idbEntry = await this.idbGetVfs(`vfs:list:${path}`);
|
|
298
|
+
if (idbEntry && Date.now() - idbEntry.cachedAt < this.maxAge) {
|
|
299
|
+
this._stats.hits++;
|
|
300
|
+
this.listCache.set(path, idbEntry as { data: FileStat[]; cachedAt: number });
|
|
301
|
+
this.raw.list(path).then(r => {
|
|
302
|
+
this.listCache.set(path, { data: r, cachedAt: Date.now() });
|
|
303
|
+
this.idbSetVfs(`vfs:list:${path}`, { data: r, cachedAt: Date.now() });
|
|
304
|
+
}).catch(() => {});
|
|
305
|
+
return idbEntry.data as FileStat[];
|
|
306
|
+
}
|
|
277
307
|
this._stats.misses++;
|
|
278
308
|
const result = await this.raw.list(path);
|
|
279
309
|
this.listCache.set(path, { data: result, cachedAt: Date.now() });
|
|
310
|
+
this.idbSetVfs(`vfs:list:${path}`, { data: result, cachedAt: Date.now() });
|
|
280
311
|
return result;
|
|
281
312
|
}
|
|
282
313
|
|
|
314
|
+
async tree(path: string, opts?: { hiddenPaths?: string[]; hideDotfiles?: boolean }): Promise<any[]> {
|
|
315
|
+
return this.raw.tree(path, opts);
|
|
316
|
+
}
|
|
317
|
+
|
|
283
318
|
async upload(path: string, data: Uint8Array, mime?: string): Promise<FileStat> {
|
|
284
319
|
const r = await this.raw.upload(path, data, mime);
|
|
285
320
|
this.invalidatePath(path);
|
|
@@ -296,6 +331,7 @@ export class CachedVFSClient {
|
|
|
296
331
|
this.invalidatePath(path);
|
|
297
332
|
// Also invalidate the dir's own list (was empty/nonexistent before)
|
|
298
333
|
this.listCache.delete(path);
|
|
334
|
+
this.idbDeleteVfs(`vfs:list:${path}`);
|
|
299
335
|
return r;
|
|
300
336
|
}
|
|
301
337
|
|
|
@@ -313,14 +349,17 @@ export class CachedVFSClient {
|
|
|
313
349
|
invalidatePath(filePath: string) {
|
|
314
350
|
this._stats.invalidations++;
|
|
315
351
|
this.statCache.delete(filePath);
|
|
352
|
+
this.idbDeleteVfs(`vfs:stat:${filePath}`);
|
|
316
353
|
const parent = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
|
|
317
354
|
this.listCache.delete(parent);
|
|
355
|
+
this.idbDeleteVfs(`vfs:list:${parent}`);
|
|
318
356
|
}
|
|
319
357
|
|
|
320
358
|
clear() {
|
|
321
359
|
this._stats.pushClears++;
|
|
322
360
|
this.listCache.clear();
|
|
323
361
|
this.statCache.clear();
|
|
362
|
+
this.idbClearVfs();
|
|
324
363
|
}
|
|
325
364
|
|
|
326
365
|
/** Cache stats for diagnostics. Use in browser: `__bodCache.vfs().stats` */
|
|
@@ -334,4 +373,53 @@ export class CachedVFSClient {
|
|
|
334
373
|
statCacheSize: this.statCache.size,
|
|
335
374
|
};
|
|
336
375
|
}
|
|
376
|
+
|
|
377
|
+
// --- IDB helpers for VFS cache ---
|
|
378
|
+
|
|
379
|
+
private idbGetVfs(key: string): Promise<{ data: unknown; cachedAt: number } | null> {
|
|
380
|
+
if (!this.idb) return Promise.resolve(null);
|
|
381
|
+
try {
|
|
382
|
+
const tx = this.idb.transaction('entries', 'readonly');
|
|
383
|
+
const req = tx.objectStore('entries').get(key);
|
|
384
|
+
return new Promise(resolve => {
|
|
385
|
+
req.onsuccess = () => {
|
|
386
|
+
const entry = req.result;
|
|
387
|
+
resolve(entry ? { data: entry.data, cachedAt: entry.cachedAt } : null);
|
|
388
|
+
};
|
|
389
|
+
req.onerror = () => resolve(null);
|
|
390
|
+
});
|
|
391
|
+
} catch { return Promise.resolve(null); }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private idbSetVfs(key: string, value: { data: unknown; cachedAt: number }): void {
|
|
395
|
+
if (!this.idb) return;
|
|
396
|
+
try {
|
|
397
|
+
const tx = this.idb.transaction('entries', 'readwrite');
|
|
398
|
+
tx.objectStore('entries').put({ path: key, data: value.data, cachedAt: value.cachedAt, updatedAt: Date.now() });
|
|
399
|
+
} catch {}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private idbDeleteVfs(key: string): void {
|
|
403
|
+
if (!this.idb) return;
|
|
404
|
+
try {
|
|
405
|
+
const tx = this.idb.transaction('entries', 'readwrite');
|
|
406
|
+
tx.objectStore('entries').delete(key);
|
|
407
|
+
} catch {}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private idbClearVfs(): void {
|
|
411
|
+
if (!this.idb) return;
|
|
412
|
+
try {
|
|
413
|
+
// Delete all vfs: prefixed entries
|
|
414
|
+
const tx = this.idb.transaction('entries', 'readwrite');
|
|
415
|
+
const store = tx.objectStore('entries');
|
|
416
|
+
const req = store.openCursor();
|
|
417
|
+
req.onsuccess = () => {
|
|
418
|
+
const cursor = req.result;
|
|
419
|
+
if (!cursor) return;
|
|
420
|
+
if (typeof cursor.key === 'string' && cursor.key.startsWith('vfs:')) cursor.delete();
|
|
421
|
+
cursor.continue();
|
|
422
|
+
};
|
|
423
|
+
} catch {}
|
|
424
|
+
}
|
|
337
425
|
}
|
package/src/server/Transport.ts
CHANGED
|
@@ -753,6 +753,14 @@ export class Transport {
|
|
|
753
753
|
}
|
|
754
754
|
return reply(self.db.vfs.list(msg.path));
|
|
755
755
|
}
|
|
756
|
+
case 'vfs-tree': {
|
|
757
|
+
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
758
|
+
if (self.rules && !self.rules.check('read', `_vfs/${msg.path}`, ws.data.auth)) {
|
|
759
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
760
|
+
}
|
|
761
|
+
const hiddenPaths = msg.hiddenPaths ? new Set(msg.hiddenPaths) : undefined;
|
|
762
|
+
return reply(self.db.vfs.tree(msg.path, { hiddenPaths, hideDotfiles: msg.hideDotfiles }));
|
|
763
|
+
}
|
|
756
764
|
case 'vfs-delete': {
|
|
757
765
|
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
758
766
|
if (self.rules && !self.rules.check('write', `_vfs/${msg.path}`, ws.data.auth)) {
|
package/src/server/VFSEngine.ts
CHANGED
|
@@ -183,6 +183,33 @@ export class VFSEngine {
|
|
|
183
183
|
return results;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
tree(virtualPath: string, opts?: { hiddenPaths?: Set<string>; hideDotfiles?: boolean }): any[] {
|
|
187
|
+
const hiddenPaths = opts?.hiddenPaths ?? new Set<string>();
|
|
188
|
+
const hideDotfiles = opts?.hideDotfiles ?? false;
|
|
189
|
+
|
|
190
|
+
const walk = (vPath: string): any[] => {
|
|
191
|
+
const stats = this.list(vPath);
|
|
192
|
+
stats.sort((a, b) => {
|
|
193
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
194
|
+
return a.name.localeCompare(b.name);
|
|
195
|
+
});
|
|
196
|
+
const result: any[] = [];
|
|
197
|
+
for (const s of stats) {
|
|
198
|
+
if (hiddenPaths.has(s.name)) continue;
|
|
199
|
+
if (hideDotfiles && s.name.startsWith('.')) continue;
|
|
200
|
+
if (s.isDir) {
|
|
201
|
+
const childPath = vPath ? `${vPath}/${s.name}` : s.name;
|
|
202
|
+
result.push({ name: s.name, type: 'directory', children: walk(childPath) });
|
|
203
|
+
} else {
|
|
204
|
+
result.push({ name: s.name, type: 'file' });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return walk(normalizePath(virtualPath));
|
|
211
|
+
}
|
|
212
|
+
|
|
186
213
|
mkdir(virtualPath: string): FileStat {
|
|
187
214
|
const vp = normalizePath(virtualPath);
|
|
188
215
|
const name = vp.split('/').pop()!;
|
package/src/shared/protocol.ts
CHANGED
|
@@ -36,6 +36,7 @@ export type ClientMessage =
|
|
|
36
36
|
| { id: string; op: 'vfs-download-init'; path: string }
|
|
37
37
|
| { id: string; op: 'vfs-stat'; path: string }
|
|
38
38
|
| { id: string; op: 'vfs-list'; path: string }
|
|
39
|
+
| { id: string; op: 'vfs-tree'; path: string; hiddenPaths?: string[]; hideDotfiles?: boolean }
|
|
39
40
|
| { id: string; op: 'vfs-delete'; path: string }
|
|
40
41
|
| { id: string; op: 'vfs-mkdir'; path: string }
|
|
41
42
|
| { id: string; op: 'vfs-move'; path: string; dst: string }
|