@ssebastien/tcmd 0.1.3 → 0.1.7

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.
Files changed (134) hide show
  1. package/README.md +256 -62
  2. package/bin/tcmd +10 -2
  3. package/bin/tcmd.js +13 -2
  4. package/desktop/dist/index.html +13 -0
  5. package/desktop/dist/main.js +20298 -0
  6. package/desktop/dist/style.css +1515 -0
  7. package/desktop/frontend/index.html +13 -0
  8. package/package.json +19 -6
  9. package/public/fonts/OFL.txt +93 -0
  10. package/public/index.html +14 -0
  11. package/public/licenses/LUCIDE.txt +43 -0
  12. package/public/main.js +20679 -0
  13. package/public/style.css +1515 -0
  14. package/scripts/build-desktop.js +31 -0
  15. package/scripts/serve-web.js +61 -0
  16. package/src/js/tcmd/components/App.html +80 -14
  17. package/src/js/tcmd/components/App.js +339 -114
  18. package/src/js/tcmd/components/App.xml +76 -22
  19. package/src/js/tcmd/components/BookmarkList.html +5 -5
  20. package/src/js/tcmd/components/BookmarkList.js +15 -41
  21. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  22. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  23. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  24. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  25. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  26. package/src/js/tcmd/components/CommandArgRow.html +28 -0
  27. package/src/js/tcmd/components/CommandArgRow.js +146 -30
  28. package/src/js/tcmd/components/CommandArgRow.xml +55 -4
  29. package/src/js/tcmd/components/CommandBar.html +2 -2
  30. package/src/js/tcmd/components/CommandBar.js +33 -27
  31. package/src/js/tcmd/components/CommandBar.xml +2 -2
  32. package/src/js/tcmd/components/CommandItem.html +1 -1
  33. package/src/js/tcmd/components/CommandItem.js +25 -25
  34. package/src/js/tcmd/components/CommandItem.xml +2 -2
  35. package/src/js/tcmd/components/CommandPanel.html +29 -0
  36. package/src/js/tcmd/components/CommandPanel.js +78 -52
  37. package/src/js/tcmd/components/CommandPanel.xml +49 -25
  38. package/src/js/tcmd/components/Confirm.html +2 -2
  39. package/src/js/tcmd/components/EntryDetail.html +7 -7
  40. package/src/js/tcmd/components/EntryDetail.js +22 -8
  41. package/src/js/tcmd/components/FileList.html +5 -1
  42. package/src/js/tcmd/components/FileList.js +2 -1
  43. package/src/js/tcmd/components/FilePicker.html +10 -0
  44. package/src/js/tcmd/components/FilePicker.js +43 -0
  45. package/src/js/tcmd/components/FilePicker.xml +13 -0
  46. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  47. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  48. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  49. package/src/js/tcmd/components/FilePreview.html +14 -4
  50. package/src/js/tcmd/components/FilePreview.js +111 -12
  51. package/src/js/tcmd/components/FilePreview.xml +28 -8
  52. package/src/js/tcmd/components/FileRow.html +2 -1
  53. package/src/js/tcmd/components/FileRow.js +70 -12
  54. package/src/js/tcmd/components/FilterBar.html +1 -1
  55. package/src/js/tcmd/components/FilterBar.js +14 -4
  56. package/src/js/tcmd/components/FilterBar.xml +1 -1
  57. package/src/js/tcmd/components/Frame.js +324 -0
  58. package/src/js/tcmd/components/Help.html +15 -0
  59. package/src/js/tcmd/components/Help.js +260 -0
  60. package/src/js/tcmd/components/Help.xml +13 -4
  61. package/src/js/tcmd/components/HelpRow.html +8 -0
  62. package/src/js/tcmd/components/HelpRow.xml +7 -0
  63. package/src/js/tcmd/components/OverlayList.html +8 -0
  64. package/src/js/tcmd/components/OverlayList.js +69 -0
  65. package/src/js/tcmd/components/{CommandPalette.xml → OverlayList.xml} +2 -5
  66. package/src/js/tcmd/components/OverlayRow.html +5 -0
  67. package/src/js/tcmd/components/OverlayRow.js +47 -0
  68. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  69. package/src/js/tcmd/components/Summary.html +1 -1
  70. package/src/js/tcmd/components/Summary.js +1 -6
  71. package/src/js/tcmd/components/format.js +4 -23
  72. package/src/js/tcmd/components/icons.js +176 -5
  73. package/src/js/tcmd/components/index.js +9 -5
  74. package/src/js/tcmd/components/rows.js +176 -48
  75. package/src/js/tcmd/core/base64.js +64 -0
  76. package/src/js/tcmd/core/bookmarks.js +45 -8
  77. package/src/js/tcmd/core/commands.js +978 -260
  78. package/src/js/tcmd/core/concurrency.js +59 -0
  79. package/src/js/tcmd/core/decompress.js +117 -0
  80. package/src/js/tcmd/core/directory.js +219 -93
  81. package/src/js/tcmd/core/format.js +36 -0
  82. package/src/js/tcmd/core/fs.js +438 -54
  83. package/src/js/tcmd/core/history.js +58 -0
  84. package/src/js/tcmd/core/index.js +3 -9
  85. package/src/js/tcmd/core/journal.js +93 -0
  86. package/src/js/tcmd/core/keymap.js +42 -6
  87. package/src/js/tcmd/core/operations.js +813 -48
  88. package/src/js/tcmd/core/os.js +29 -3
  89. package/src/js/tcmd/core/pane.js +33 -8
  90. package/src/js/tcmd/core/panel.js +280 -104
  91. package/src/js/tcmd/core/path.js +30 -3
  92. package/src/js/tcmd/core/permissions.js +107 -0
  93. package/src/js/tcmd/core/picker.js +156 -0
  94. package/src/js/tcmd/core/preview.js +102 -0
  95. package/src/js/tcmd/core/script.js +7 -95
  96. package/src/js/tcmd/core/selection.js +9 -1
  97. package/src/js/tcmd/core/session.js +498 -200
  98. package/src/js/tcmd/core/watch.js +153 -0
  99. package/src/js/tcmd/desktop/backend/api.js +165 -0
  100. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  101. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  102. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  103. package/src/js/tcmd/desktop/fs.js +91 -0
  104. package/src/js/tcmd/desktop/main.js +194 -0
  105. package/src/js/tcmd/desktop/os.js +33 -0
  106. package/src/js/tcmd/index.js +0 -1
  107. package/src/js/tcmd/tui/app.js +118 -60
  108. package/src/js/tcmd/tui/catalogue.js +238 -42
  109. package/src/js/tcmd/tui/index.js +18 -19
  110. package/src/js/tcmd/tui/opentui.js +33 -0
  111. package/src/js/tcmd/tui/syntax.js +91 -0
  112. package/src/js/tcmd/tui/theme.js +32 -2
  113. package/src/js/tcmd/vendor/bzip2.js +276 -0
  114. package/src/js/tcmd/web/app.js +225 -41
  115. package/src/js/tcmd/web/bookmarks.js +36 -0
  116. package/src/js/tcmd/web/catalogue.js +81 -38
  117. package/src/js/tcmd/web/effects.js +223 -0
  118. package/src/js/tcmd/web/header.html +1 -1
  119. package/src/js/tcmd/web/header.js +15 -11
  120. package/src/js/tcmd/web/http.js +13 -0
  121. package/src/js/tcmd/web/index.js +2 -2
  122. package/src/js/tcmd/web/main.js +6 -66
  123. package/src/js/tcmd/web/opfs.js +297 -30
  124. package/src/js/tcmd/web/seed.js +4 -4
  125. package/src/js/tcmd/web/server.js +124 -21
  126. package/src/js/tcmd/web/theme.js +4 -0
  127. package/tinyjs.json +16 -0
  128. package/src/js/tcmd/components/CommandPalette.html +0 -7
  129. package/src/js/tcmd/components/CommandPalette.js +0 -58
  130. package/src/js/tcmd/components/PaletteRow.html +0 -3
  131. package/src/js/tcmd/components/PaletteRow.js +0 -43
  132. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  133. package/src/js/tcmd/tui/frame.js +0 -269
  134. package/src/js/tcmd/tui/help.js +0 -32
@@ -15,9 +15,17 @@
15
15
  // creating parents) backs the demo seeding in `web/main.js`.
16
16
  //
17
17
  // Trash: OPFS has no system trash, so `trash(paths)` moves entries into a
18
- // `/.trash` directory at the storage root, where they stay browsable.
18
+ // `/.trash` directory at the storage root, recording original paths in
19
+ // `/.trash/index.json` so `trashList()`/`restore()` can browse and undelete
20
+ // them across reloads.
19
21
 
22
+ import { mapLimit } from "../core/concurrency.js"
20
23
  import { basename, extname, join, normalize, parent, split } from "../core/path.js"
24
+ import { PREVIEW_BYTES } from "../core/preview.js"
25
+
26
+ // Constant: OPFS_READDIR_CONCURRENCY
27
+ // Bounded window for the per-entry `getFile` metadata calls of a listing.
28
+ const OPFS_READDIR_CONCURRENCY = 64
21
29
 
22
30
  // Function: createOpfsFs
23
31
  // Builds the filesystem adapter. `root` overrides the OPFS root handle (tests);
@@ -129,23 +137,36 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
129
137
  }
130
138
 
131
139
  // Function: readDir
132
- // Lists `path` as FileEntry-like rows.
140
+ // Lists `path` as FileEntry-like rows. Directory handles arrive through an
141
+ // async iterator, but each file's metadata (`getFile`) is an independent
142
+ // async call, so the entries are mapped with a bounded window instead of
143
+ // one await at a time.
133
144
  async function readDir(path) {
134
145
  const directory = await resolveDir(normalize(path))
135
- const entries = []
136
- for await (const handle of directory.values()) entries.push(await toEntry(path, handle))
137
- return entries
146
+ const handles = []
147
+ for await (const handle of directory.values()) handles.push(handle)
148
+ return mapLimit(handles, OPFS_READDIR_CONCURRENCY, (handle) => toEntry(path, handle))
138
149
  }
139
150
 
140
151
  // Function: readTextHead
141
152
  // Leading `maxBytes` of a text file, decoded as UTF-8.
142
- async function readTextHead(path, maxBytes = 65536) {
153
+ async function readTextHead(path, maxBytes = PREVIEW_BYTES) {
143
154
  const handle = await resolveFile(normalize(path))
144
155
  if (!handle) throw notFound(path)
145
156
  const file = await handle.getFile()
146
157
  return file.slice(0, maxBytes).text()
147
158
  }
148
159
 
160
+ // Function: readBytes
161
+ // Leading `maxBytes` of a file as raw bytes, for the compressed previews
162
+ // `core/session` decompresses.
163
+ async function readBytes(path, maxBytes = PREVIEW_BYTES) {
164
+ const handle = await resolveFile(normalize(path))
165
+ if (!handle) throw notFound(path)
166
+ const file = await handle.getFile()
167
+ return new Uint8Array(await file.slice(0, maxBytes).arrayBuffer())
168
+ }
169
+
149
170
  // Function: du
150
171
  // Recursive size in bytes; unreadable entries contribute 0.
151
172
  async function du(path) {
@@ -176,14 +197,19 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
176
197
  // Function: copy
177
198
  // Recursive copy of a file or directory; an existing directory target is
178
199
  // merged into (the operations layer already appends the basename when the
179
- // user copies "into" a directory).
200
+ // user copies "into" a directory), but colliding entries are never replaced.
180
201
  async function copy(from, to) {
202
+ assertOutsideTrash(to)
203
+ return withPathLock(to, () => copyEntry(from, to))
204
+ }
205
+
206
+ async function copyEntry(from, to) {
207
+ await assertCopySafe(from, to)
181
208
  const stats = await stat(from)
182
- if (!stats) throw notFound(from)
183
209
  if (stats.isDir) {
184
210
  if (!(await stat(to))?.isDir) await ensureDir(to)
185
211
  for await (const handle of (await resolveDir(from)).values()) {
186
- await copy(join(from, handle.name), join(to, handle.name))
212
+ await copyEntry(join(from, handle.name), join(to, handle.name))
187
213
  }
188
214
  return
189
215
  }
@@ -195,6 +221,25 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
195
221
  await writable.close()
196
222
  }
197
223
 
224
+ async function assertCopySafe(from, to) {
225
+ const sourcePath = normalize(from)
226
+ const targetPath = normalize(to)
227
+ const source = await stat(sourcePath)
228
+ if (!source) throw notFound(from)
229
+ if (
230
+ sourcePath === targetPath ||
231
+ (source.isDir && targetPath.startsWith(sourcePath === "/" ? "/" : `${sourcePath}/`))
232
+ ) {
233
+ throw new Error(`cannot copy a path into itself: ${to}`)
234
+ }
235
+ const destination = await stat(targetPath)
236
+ if (!destination) return
237
+ if (!source.isDir || !destination.isDir) throw pathExistsError(to)
238
+ for (const entry of await readDir(sourcePath)) {
239
+ await assertCopySafe(entry.path, join(targetPath, entry.name))
240
+ }
241
+ }
242
+
198
243
  // Function: eraseOne
199
244
  // Removes one entry (recursively for directories); the root is a no-op.
200
245
  async function eraseOne(path) {
@@ -204,19 +249,174 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
204
249
  await directory.removeEntry(basename(normalized), { recursive: true })
205
250
  }
206
251
 
252
+ // Function: mkdir
253
+ // Creates `path` (and its parents) as a directory.
254
+ async function mkdir(path) {
255
+ const normalized = normalize(path)
256
+ assertOutsideTrash(normalized)
257
+ if (normalized === "/") return
258
+ return withPathLock(normalized, () => ensureDir(normalized))
259
+ }
260
+
261
+ // Constant: TRASH_DIR / TRASH_INDEX
262
+ // Trashed entries live under `/.trash/<name>` and the original paths are
263
+ // recorded in `/.trash/index.json`, so a restore survives a reload.
264
+ const TRASH_DIR = "/.trash"
265
+ const TRASH_INDEX = `${TRASH_DIR}/index.json`
266
+
267
+ // Function: readTrashIndex
268
+ // The trash index rows, or [] when the index is missing.
269
+ async function readTrashIndex() {
270
+ try {
271
+ const handle = await resolveFile(TRASH_INDEX)
272
+ if (!handle) return []
273
+ const parsed = JSON.parse(await (await handle.getFile()).text())
274
+ if (!Array.isArray(parsed)) throw new Error("invalid OPFS trash index")
275
+ return parsed
276
+ } catch (error) {
277
+ if (error?.code === "ENOENT" || error?.name === "NotFoundError") return []
278
+ throw error
279
+ }
280
+ }
281
+
282
+ async function writeTrashIndex(rows) {
283
+ await writeTextFile(TRASH_INDEX, JSON.stringify(rows))
284
+ }
285
+
207
286
  // Function: trash
208
- // Moves entries into `/.trash` under a timestamped name, keeping them
209
- // available for inspection instead of deleting them.
287
+ // Moves entries into `/.trash` under a timestamped name, recording each
288
+ // original path in the index so it can be listed and restored.
210
289
  let trashSeq = 0
290
+ let trashQueue = Promise.resolve()
291
+ const pathQueues = new Map()
292
+ function withPathLocks(paths, action) {
293
+ const keys = [
294
+ ...new Set(
295
+ paths.flatMap((path) => {
296
+ const chain = []
297
+ let current = normalize(path)
298
+ while (current !== "/") {
299
+ chain.push(current)
300
+ current = parent(current) ?? "/"
301
+ }
302
+ if (!chain.length) chain.push("/")
303
+ return chain
304
+ }),
305
+ ),
306
+ ].sort()
307
+ const locks = globalThis.navigator?.locks
308
+ const run = (index) => {
309
+ if (index === keys.length) return action()
310
+ const key = keys[index]
311
+ if (locks?.request) return locks.request(`tcmd-opfs-path:${key}`, { mode: "exclusive" }, () => run(index + 1))
312
+ const previous = pathQueues.get(key) ?? Promise.resolve()
313
+ let release
314
+ const gate = new Promise((resolve) => (release = resolve))
315
+ const tail = previous.then(() => gate)
316
+ pathQueues.set(key, tail)
317
+ return previous.then(() => run(index + 1)).finally(() => {
318
+ release()
319
+ if (pathQueues.get(key) === tail) pathQueues.delete(key)
320
+ })
321
+ }
322
+ return run(0)
323
+ }
324
+ const withPathLock = (path, action) => withPathLocks([path], action)
325
+
326
+ function withTrashLock(action) {
327
+ const locks = globalThis.navigator?.locks
328
+ if (locks?.request) return locks.request("tcmd-opfs-trash-index", { mode: "exclusive" }, action)
329
+ // ponytail: fallback serialization is adapter-local; Web Locks handles tabs where available.
330
+ const pending = trashQueue.then(action, action)
331
+ trashQueue = pending.catch(() => {})
332
+ return pending
333
+ }
334
+
211
335
  async function trash(paths) {
212
- await ensureDir("/.trash")
213
- for (const path of paths) {
214
- const normalized = normalize(path)
215
- if (normalized === "/") continue
216
- const name = `${Date.now()}-${++trashSeq}-${basename(normalized)}`
217
- await copy(normalized, join("/.trash", name))
218
- await eraseOne(normalized)
336
+ return withTrashLock(async () => {
337
+ await ensureDir(TRASH_DIR)
338
+ const index = await readTrashIndex()
339
+ const trashed = []
340
+ for (const path of paths) {
341
+ const normalized = normalize(path)
342
+ if (normalized === "/") continue
343
+ assertOutsideTrash(normalized)
344
+ await withPathLocks([normalized, TRASH_DIR], async () => {
345
+ let name
346
+ let to
347
+ do {
348
+ name = `${Date.now()}-${++trashSeq}-${basename(normalized)}`
349
+ to = join(TRASH_DIR, name)
350
+ } while (await stat(to))
351
+ await copyEntry(normalized, to)
352
+ const row = { name, originalPath: normalized, deletedAt: new Date().toISOString() }
353
+ index.push(row)
354
+ try {
355
+ await writeTrashIndex(index)
356
+ } catch (error) {
357
+ index.pop()
358
+ await eraseOne(to)
359
+ throw error
360
+ }
361
+ trashed.push({ from: normalized, to, name })
362
+ // Persist the restore record before removing the source. If removing
363
+ // it fails, both copies remain discoverable rather than losing the
364
+ // only restore metadata.
365
+ await eraseOne(normalized)
366
+ })
367
+ }
368
+ return trashed
369
+ })
370
+ }
371
+
372
+ // Function: trashList
373
+ // The trash rows, newest first. Entries whose copy is gone are skipped.
374
+ async function trashList() {
375
+ const index = await readTrashIndex()
376
+ const rows = []
377
+ for (const row of index) {
378
+ const trashPath = join(TRASH_DIR, row.name)
379
+ const stats = await stat(trashPath)
380
+ if (!stats) continue
381
+ rows.push({
382
+ name: row.name,
383
+ originalPath: row.originalPath ?? null,
384
+ deletedAt: row.deletedAt ?? null,
385
+ trashPath,
386
+ isDir: Boolean(stats.isDir),
387
+ })
219
388
  }
389
+ return rows.sort((a, b) => `${b.deletedAt ?? ""}`.localeCompare(`${a.deletedAt ?? ""}`))
390
+ }
391
+
392
+ // Function: restore
393
+ // Moves trashed entries back to their recorded original paths.
394
+ async function restore(trashPaths) {
395
+ return withTrashLock(async () => {
396
+ const wanted = new Set((trashPaths ?? []).map((path) => normalize(path)))
397
+ const index = await readTrashIndex()
398
+ const rows = index.filter((row) => wanted.has(normalize(join(TRASH_DIR, row.name))) && row.originalPath)
399
+ if (rows.length !== wanted.size) throw new Error("unknown trash entry")
400
+ const destinations = rows.map((row) => normalize(row.originalPath))
401
+ if (new Set(destinations).size !== destinations.length) throw pathExistsError(destinations[0])
402
+ for (const destination of destinations) assertOutsideTrash(destination)
403
+ assertNoNestedDestinations(destinations)
404
+ for (const row of rows) {
405
+ if (await stat(row.originalPath)) throw pathExistsError(row.originalPath)
406
+ }
407
+ let remaining = index.slice()
408
+ for (const row of rows) {
409
+ const trashPath = join(TRASH_DIR, row.name)
410
+ await withPathLocks([trashPath, row.originalPath], async () => {
411
+ await ensureDir(parent(row.originalPath) ?? "/")
412
+ if (await stat(row.originalPath)) throw pathExistsError(row.originalPath)
413
+ await copyEntry(trashPath, row.originalPath)
414
+ remaining = remaining.filter((candidate) => candidate !== row)
415
+ await writeTrashIndex(remaining)
416
+ await eraseOne(trashPath)
417
+ })
418
+ }
419
+ })
220
420
  }
221
421
 
222
422
  // Function: touch
@@ -224,21 +424,29 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
224
424
  // (OPFS exposes no utimes).
225
425
  async function touch(path) {
226
426
  const normalized = normalize(path)
227
- const stats = await stat(normalized)
228
- if (stats?.isDir) return
229
- const directory = await resolveDir(parent(normalized) ?? "/")
230
- const handle = await directory.getFileHandle(basename(normalized), { create: true })
231
- if (!stats) return
232
- const bytes = await (await handle.getFile()).arrayBuffer()
233
- const writable = await handle.createWritable()
234
- await writable.write(bytes)
235
- await writable.close()
427
+ assertOutsideTrash(normalized)
428
+ return withPathLock(normalized, async () => {
429
+ const stats = await stat(normalized)
430
+ if (stats?.isDir) return
431
+ const directory = await resolveDir(parent(normalized) ?? "/")
432
+ const handle = await directory.getFileHandle(basename(normalized), { create: true })
433
+ if (!stats) return
434
+ const bytes = await (await handle.getFile()).arrayBuffer()
435
+ const writable = await handle.createWritable()
436
+ await writable.write(bytes)
437
+ await writable.close()
438
+ })
236
439
  }
237
440
 
238
441
  // Function: writeText
239
442
  // Creates (or replaces) a UTF-8 text file, creating parent directories.
240
443
  async function writeText(path, text) {
241
444
  const normalized = normalize(path)
445
+ assertOutsideTrash(normalized)
446
+ return withPathLock(normalized, () => writeTextFile(normalized, text))
447
+ }
448
+
449
+ async function writeTextFile(normalized, text) {
242
450
  const directory = await resolveDir(parent(normalized) ?? "/", { create: true })
243
451
  const handle = await directory.getFileHandle(basename(normalized), { create: true })
244
452
  const writable = await handle.createWritable()
@@ -246,21 +454,64 @@ function createOpfsFs({ root, home = "/home/tester" } = {}) {
246
454
  await writable.close()
247
455
  }
248
456
 
457
+ function assertOutsideTrash(path) {
458
+ const normalized = normalize(path)
459
+ if (normalized === TRASH_DIR || normalized.startsWith(`${TRASH_DIR}/`)) {
460
+ const error = new Error(`reserved OPFS trash path: ${normalized}`)
461
+ error.code = "EPERM"
462
+ throw error
463
+ }
464
+ }
465
+
249
466
  return {
250
467
  readDir,
251
468
  stat,
469
+ async realpath(path) {
470
+ const normalized = normalize(path)
471
+ if (!(await stat(normalized))) throw notFound(path)
472
+ return normalized
473
+ },
252
474
  isDir: async (path) => Boolean((await stat(path))?.isDir),
253
475
  readTextHead,
476
+ readBytes,
254
477
  du,
255
478
  disk,
256
479
  copy,
257
480
  move: async (from, to) => {
258
- await copy(from, to)
259
- await eraseOne(from)
481
+ assertOutsideTrash(from)
482
+ assertOutsideTrash(to)
483
+ return withPathLocks([from, to], async () => {
484
+ if (await stat(to)) throw pathExistsError(to)
485
+ await copyEntry(from, to)
486
+ await eraseOne(from)
487
+ })
488
+ },
489
+ // OPFS has no links, ownership or permission modes. The operations
490
+ // report a clear error instead of silently doing nothing.
491
+ hardlink: async () => {
492
+ throw new Error("hard links are not available in the browser filesystem")
493
+ },
494
+ symlink: async () => {
495
+ throw new Error("symbolic links are not available in the browser filesystem")
260
496
  },
497
+ chmod: async () => {
498
+ throw new Error("permission modes are not available in the browser filesystem")
499
+ },
500
+ chown: async () => {
501
+ throw new Error("ownership is not available in the browser filesystem")
502
+ },
503
+ unlink: async (path) => {
504
+ throw new Error(`not a symbolic link: ${path}`)
505
+ },
506
+ mkdir,
261
507
  trash,
508
+ trashList,
509
+ restore,
262
510
  erase: async (paths) => {
263
- for (const path of paths) await eraseOne(path)
511
+ for (const path of paths) {
512
+ assertOutsideTrash(path)
513
+ await withPathLock(path, () => eraseOne(path))
514
+ }
264
515
  },
265
516
  touch,
266
517
  writeText,
@@ -277,6 +528,22 @@ function notFound(path) {
277
528
  return error
278
529
  }
279
530
 
531
+ function pathExistsError(path) {
532
+ const error = new Error(`destination already exists: ${path}`)
533
+ error.code = "EEXIST"
534
+ return error
535
+ }
536
+
537
+ function assertNoNestedDestinations(paths) {
538
+ for (let i = 0; i < paths.length; i++) {
539
+ for (let j = i + 1; j < paths.length; j++) {
540
+ if (paths[j].startsWith(`${paths[i]}/`) || paths[i].startsWith(`${paths[j]}/`)) {
541
+ throw pathExistsError(paths[j])
542
+ }
543
+ }
544
+ }
545
+ }
546
+
280
547
  export { createOpfsFs }
281
548
 
282
549
  // EOF
@@ -23,9 +23,9 @@ const DEMO_TREE = {
23
23
  "- Left/Backspace go to the parent directory",
24
24
  "- Tab switch pane",
25
25
  "- Space mark the current entry",
26
- "- b / g record / jump to a bookmark",
27
- "- ^P command palette",
28
- "- : scripting mode (copy, move, delete, ...)",
26
+ "- b bookmarks (filter, add current path, Enter open, Del remove)",
27
+ "- : command panel (operations and app actions)",
28
+ "- ! shell command",
29
29
  "- q quit (unmounts the UI; reload to start over)",
30
30
  "",
31
31
  "Open/edit/run are not available in the browser: the web uses the null",
@@ -47,7 +47,7 @@ const DEMO_TREE = {
47
47
  "----",
48
48
  "[x] two panes",
49
49
  "[x] named bookmarks",
50
- "[x] command palette",
50
+ "[x] command panel",
51
51
  "[ ] quick find",
52
52
  ].join("\n"),
53
53
  "/documents/welcome.txt": [
@@ -8,13 +8,16 @@
8
8
  // filesystem API (`/api/*`) over the Node filesystem adapter, so browsers
9
9
  // without OPFS (or users who want to browse real files) can run the web UI.
10
10
  //
11
- // Security: binds to loopback only by default and requires the per-process
12
- // token, injected into `index.html` at serve time, on every API request. When
13
- // `root` is set, every path is confined to it. This is a local development
11
+ // Security: binds to loopback only by default (`HOST` overrides the address)
12
+ // and requires the per-process token, injected into `index.html` at serve
13
+ // time, on every API request. When `root` is set, every path is confined to
14
+ // it. This is a local development
14
15
  // tool, not a sandbox: it serves the same files `bun run tcmd` can reach, so
15
16
  // do not expose the port.
16
17
 
17
- import { resolve as resolvePath, sep } from "node:path"
18
+ import nodeFs from "node:fs/promises"
19
+ import { dirname, isAbsolute, relative, resolve as resolvePath, sep } from "node:path"
20
+ import { encodeBase64 } from "../core/base64.js"
18
21
  import { createNodeFs } from "../core/fs.js"
19
22
 
20
23
  // Constant: TOKEN_PLACEHOLDER
@@ -24,33 +27,78 @@ const TOKEN_PLACEHOLDER = "__TCMD_TOKEN__"
24
27
  // Function: createWebServer
25
28
  // Builds the server. `port` 0 picks a free port (tests); `root` defaults to
26
29
  // "/" (the whole filesystem, like the TUI); `token` defaults to a fresh UUID.
30
+ // `hostname` and `port` default to the HOST and PORT environment variables.
27
31
  function createWebServer({
28
32
  root = process.env.TCMD_WEB_ROOT ?? "/",
29
33
  port = Number(process.env.PORT ?? 8787),
30
- hostname = "127.0.0.1",
34
+ hostname = process.env.HOST || "127.0.0.1",
31
35
  publicDir = new URL("../../../../public/", import.meta.url),
32
36
  token = crypto.randomUUID(),
33
37
  } = {}) {
34
38
  const fs = createNodeFs()
35
39
  const base = resolvePath(root)
36
40
  const rootHref = publicDir instanceof URL ? publicDir : new URL(`${publicDir.replace(/\/?$/, "/")}`, "file:///")
41
+ let realBasePromise
37
42
 
38
43
  // Function: withinRoot
39
44
  // True when `path` is the configured root or inside it.
40
45
  function withinRoot(path) {
46
+ if (typeof path !== "string" || !path) return false
41
47
  const resolved = resolvePath(path)
42
- return base === sep || resolved === base || resolved.startsWith(base + sep)
48
+ return isWithin(base, resolved)
43
49
  }
44
50
 
45
51
  // Function: confine
46
- // Resolves a client path and rejects it when it escapes the root.
47
- function confine(path) {
52
+ // Resolves a client path and rejects lexical or symlink escapes from root.
53
+ async function confine(path) {
48
54
  if (typeof path !== "string" || !path) throw httpError(400, "missing path")
49
55
  const resolved = resolvePath(path)
50
56
  if (!withinRoot(resolved)) throw httpError(403, `path outside ${base}`)
57
+ realBasePromise ??= nodeFs.realpath(base)
58
+ const realBase = await realBasePromise
59
+ const candidate = resolvePath(realBase, relative(base, resolved))
60
+ await checkSymlinks(candidate, realBase)
51
61
  return resolved
52
62
  }
53
63
 
64
+ // Function: confineSymlinkTarget
65
+ // Validates a symlink target without rewriting it: an absolute target is
66
+ // resolved as-is, a relative one against the link's directory, and either
67
+ // must stay inside the configured root. The original target text is passed
68
+ // to `fs.symlink`, so relative links keep working if the tree moves.
69
+ function confineSymlinkTarget(target, link) {
70
+ if (typeof target !== "string" || !target) throw httpError(400, "missing target")
71
+ const resolved = isAbsolute(target) ? resolvePath(target) : resolvePath(dirname(link), target)
72
+ if (!isWithin(base, resolved)) throw httpError(403, `target outside ${base}`)
73
+ return target
74
+ }
75
+
76
+ // Function: checkSymlinks
77
+ // Resolves each existing symlink component and ensures its target stays in
78
+ // root. Dangling links are checked too, so a later write cannot follow one
79
+ // outside the configured tree.
80
+ async function checkSymlinks(path, realBase, seen = new Set()) {
81
+ if (!isWithin(realBase, path)) throw httpError(403, `path outside ${base}`)
82
+ let current = realBase
83
+ for (const part of relative(realBase, path).split(sep).filter(Boolean)) {
84
+ current = resolvePath(current, part)
85
+ let stats
86
+ try {
87
+ stats = await nodeFs.lstat(current)
88
+ } catch (error) {
89
+ if (error?.code === "ENOENT") return
90
+ throw error
91
+ }
92
+ if (!stats.isSymbolicLink()) continue
93
+ if (seen.has(current) || seen.size >= 40) throw httpError(403, "symbolic link loop")
94
+ const nextSeen = new Set(seen)
95
+ nextSeen.add(current)
96
+ const target = resolvePath(dirname(current), await nodeFs.readlink(current))
97
+ await checkSymlinks(target, realBase, nextSeen)
98
+ current = target
99
+ }
100
+ }
101
+
54
102
  // Function: jsonBody
55
103
  // Reads a JSON request body; other content types are refused so cross-site
56
104
  // form posts cannot reach the mutations.
@@ -73,42 +121,92 @@ function createWebServer({
73
121
  home: withinRoot(fs.home()) ? fs.home() : base,
74
122
  cwd: withinRoot(fs.cwd()) ? fs.cwd() : base,
75
123
  }),
76
- "GET /api/readDir": (_request, url) => fs.readDir(confine(url.searchParams.get("path"))),
77
- "GET /api/stat": (_request, url) => fs.stat(confine(url.searchParams.get("path"))),
124
+ "GET /api/readDir": async (_request, url) => fs.readDir(await confine(url.searchParams.get("path"))),
125
+ "GET /api/stat": async (_request, url) => fs.stat(await confine(url.searchParams.get("path"))),
126
+ "GET /api/realpath": async (_request, url) => fs.realpath(await confine(url.searchParams.get("path"))),
78
127
  "GET /api/readTextHead": async (_request, url) => {
79
- const path = confine(url.searchParams.get("path"))
128
+ const path = await confine(url.searchParams.get("path"))
80
129
  const maxBytes = clampBytes(url.searchParams.get("maxBytes"))
81
130
  return fs.readTextHead(path, maxBytes)
82
131
  },
83
- "GET /api/du": (_request, url) => fs.du(confine(url.searchParams.get("path"))),
84
- "GET /api/disk": (_request, url) => fs.disk(confine(url.searchParams.get("path"))),
132
+ "GET /api/readBytes": async (_request, url) => {
133
+ const path = await confine(url.searchParams.get("path"))
134
+ const maxBytes = clampBytes(url.searchParams.get("maxBytes"))
135
+ return { base64: encodeBase64(await fs.readBytes(path, maxBytes)) }
136
+ },
137
+ "GET /api/du": async (_request, url) => fs.du(await confine(url.searchParams.get("path"))),
138
+ "GET /api/disk": async (_request, url) => fs.disk(await confine(url.searchParams.get("path"))),
85
139
  "POST /api/copy": async (request) => {
86
140
  const body = await jsonBody(request)
87
- return fs.copy(confine(body.from), confine(body.to))
141
+ const [from, to] = await Promise.all([confine(body.from), confine(body.to)])
142
+ return fs.copy(from, to)
88
143
  },
89
144
  "POST /api/move": async (request) => {
90
145
  const body = await jsonBody(request)
91
- return fs.move(confine(body.from), confine(body.to))
146
+ const [from, to] = await Promise.all([confine(body.from), confine(body.to)])
147
+ return fs.move(from, to)
148
+ },
149
+ "POST /api/hardlink": async (request) => {
150
+ const body = await jsonBody(request)
151
+ const [from, to] = await Promise.all([confine(body.from), confine(body.to)])
152
+ return fs.hardlink(from, to)
153
+ },
154
+ "POST /api/symlink": async (request) => {
155
+ const body = await jsonBody(request)
156
+ const link = await confine(body.link)
157
+ return fs.symlink(confineSymlinkTarget(body.target, link), link)
158
+ },
159
+ "POST /api/chmod": async (request) => {
160
+ const body = await jsonBody(request)
161
+ const mode = Number(body.mode)
162
+ if (!Number.isInteger(mode) || mode < 0 || mode > 0o7777) throw httpError(400, "invalid mode")
163
+ return fs.chmod(await confine(body.path), mode)
164
+ },
165
+ "POST /api/chown": async (request) => {
166
+ const body = await jsonBody(request)
167
+ if (typeof body.owner !== "string" || !body.owner) throw httpError(400, "missing owner")
168
+ return fs.chown(await confine(body.path), body.owner)
169
+ },
170
+ "POST /api/unlink": async (request) => {
171
+ const body = await jsonBody(request)
172
+ return fs.unlink(await confine(body.path))
173
+ },
174
+ "POST /api/mkdir": async (request) => {
175
+ const body = await jsonBody(request)
176
+ return fs.mkdir(await confine(body.path))
92
177
  },
93
178
  "POST /api/trash": async (request) => {
94
179
  const body = await jsonBody(request)
95
- return fs.trash(confinePaths(body.paths))
180
+ return fs.trash(await confinePaths(body.paths))
181
+ },
182
+ "GET /api/trashList": async () => (await fs.trashList()).filter((row) => withinRoot(row.originalPath)),
183
+ "POST /api/restore": async (request) => {
184
+ const body = await jsonBody(request)
185
+ if (!Array.isArray(body.paths)) throw httpError(400, "expected a paths array")
186
+ // Only trash entries whose original destinations are inside this
187
+ // server's root are visible or restorable.
188
+ const known = new Set((await fs.trashList()).filter((row) => withinRoot(row.originalPath)).map((row) => row.trashPath))
189
+ for (const path of body.paths) {
190
+ if (!known.has(path)) throw httpError(403, `unknown trash entry: ${path}`)
191
+ }
192
+ // Revalidate the metadata destination at the point it is used, too.
193
+ return fs.restore(body.paths, { validateDestination: confine })
96
194
  },
97
195
  "POST /api/erase": async (request) => {
98
196
  const body = await jsonBody(request)
99
- return fs.erase(confinePaths(body.paths))
197
+ return fs.erase(await confinePaths(body.paths))
100
198
  },
101
199
  "POST /api/touch": async (request) => {
102
200
  const body = await jsonBody(request)
103
- return fs.touch(confine(body.path))
201
+ return fs.touch(await confine(body.path))
104
202
  },
105
203
  }
106
204
 
107
205
  // Function: confinePaths
108
206
  // Validates a `paths` array before a bulk operation.
109
- function confinePaths(paths) {
207
+ async function confinePaths(paths) {
110
208
  if (!Array.isArray(paths)) throw httpError(400, "expected a paths array")
111
- return paths.map(confine)
209
+ return Promise.all(paths.map(confine))
112
210
  }
113
211
 
114
212
  // Function: handleApi
@@ -123,7 +221,7 @@ function createWebServer({
123
221
  const value = await route(request, url)
124
222
  return json(value === undefined ? { ok: true } : value)
125
223
  } catch (error) {
126
- const status = error?.status ?? (error?.code === "ENOENT" ? 404 : 500)
224
+ const status = error?.status ?? (error?.code === "ENOENT" ? 404 : error?.code === "EEXIST" ? 409 : 500)
127
225
  return json({ error: `${error?.message ?? error}` }, status)
128
226
  }
129
227
  }
@@ -170,6 +268,11 @@ function json(payload, status = 200) {
170
268
  return Response.json(payload, { status, headers: { "Cache-Control": "no-store" } })
171
269
  }
172
270
 
271
+ function isWithin(root, path) {
272
+ const relativePath = relative(root, path)
273
+ return relativePath === "" || (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath))
274
+ }
275
+
173
276
  // Function: httpError
174
277
  // Error carrying the HTTP status the API should answer with.
175
278
  function httpError(status, message) {