@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.
- package/README.md +256 -62
- package/bin/tcmd +10 -2
- package/bin/tcmd.js +13 -2
- package/desktop/dist/index.html +13 -0
- package/desktop/dist/main.js +20298 -0
- package/desktop/dist/style.css +1515 -0
- package/desktop/frontend/index.html +13 -0
- package/package.json +19 -6
- package/public/fonts/OFL.txt +93 -0
- package/public/index.html +14 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +20679 -0
- package/public/style.css +1515 -0
- package/scripts/build-desktop.js +31 -0
- package/scripts/serve-web.js +61 -0
- package/src/js/tcmd/components/App.html +80 -14
- package/src/js/tcmd/components/App.js +339 -114
- package/src/js/tcmd/components/App.xml +76 -22
- package/src/js/tcmd/components/BookmarkList.html +5 -5
- package/src/js/tcmd/components/BookmarkList.js +15 -41
- package/src/js/tcmd/components/BookmarkList.xml +1 -2
- package/src/js/tcmd/components/BookmarkRow.html +2 -1
- package/src/js/tcmd/components/BookmarkRow.js +14 -9
- package/src/js/tcmd/components/BookmarkRow.xml +1 -1
- package/src/js/tcmd/components/Breadcrumbs.js +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +28 -0
- package/src/js/tcmd/components/CommandArgRow.js +146 -30
- package/src/js/tcmd/components/CommandArgRow.xml +55 -4
- package/src/js/tcmd/components/CommandBar.html +2 -2
- package/src/js/tcmd/components/CommandBar.js +33 -27
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.html +1 -1
- package/src/js/tcmd/components/CommandItem.js +25 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +29 -0
- package/src/js/tcmd/components/CommandPanel.js +78 -52
- package/src/js/tcmd/components/CommandPanel.xml +49 -25
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.html +7 -7
- package/src/js/tcmd/components/EntryDetail.js +22 -8
- package/src/js/tcmd/components/FileList.html +5 -1
- package/src/js/tcmd/components/FileList.js +2 -1
- package/src/js/tcmd/components/FilePicker.html +10 -0
- package/src/js/tcmd/components/FilePicker.js +43 -0
- package/src/js/tcmd/components/FilePicker.xml +13 -0
- package/src/js/tcmd/components/FilePickerRow.html +6 -0
- package/src/js/tcmd/components/FilePickerRow.js +27 -0
- package/src/js/tcmd/components/FilePickerRow.xml +10 -0
- package/src/js/tcmd/components/FilePreview.html +14 -4
- package/src/js/tcmd/components/FilePreview.js +111 -12
- package/src/js/tcmd/components/FilePreview.xml +28 -8
- package/src/js/tcmd/components/FileRow.html +2 -1
- package/src/js/tcmd/components/FileRow.js +70 -12
- package/src/js/tcmd/components/FilterBar.html +1 -1
- package/src/js/tcmd/components/FilterBar.js +14 -4
- package/src/js/tcmd/components/FilterBar.xml +1 -1
- package/src/js/tcmd/components/Frame.js +324 -0
- package/src/js/tcmd/components/Help.html +15 -0
- package/src/js/tcmd/components/Help.js +260 -0
- package/src/js/tcmd/components/Help.xml +13 -4
- package/src/js/tcmd/components/HelpRow.html +8 -0
- package/src/js/tcmd/components/HelpRow.xml +7 -0
- package/src/js/tcmd/components/OverlayList.html +8 -0
- package/src/js/tcmd/components/OverlayList.js +69 -0
- package/src/js/tcmd/components/{CommandPalette.xml → OverlayList.xml} +2 -5
- package/src/js/tcmd/components/OverlayRow.html +5 -0
- package/src/js/tcmd/components/OverlayRow.js +47 -0
- package/src/js/tcmd/components/OverlayRow.xml +9 -0
- package/src/js/tcmd/components/Summary.html +1 -1
- package/src/js/tcmd/components/Summary.js +1 -6
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +176 -5
- package/src/js/tcmd/components/index.js +9 -5
- package/src/js/tcmd/components/rows.js +176 -48
- package/src/js/tcmd/core/base64.js +64 -0
- package/src/js/tcmd/core/bookmarks.js +45 -8
- package/src/js/tcmd/core/commands.js +978 -260
- package/src/js/tcmd/core/concurrency.js +59 -0
- package/src/js/tcmd/core/decompress.js +117 -0
- package/src/js/tcmd/core/directory.js +219 -93
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +438 -54
- package/src/js/tcmd/core/history.js +58 -0
- package/src/js/tcmd/core/index.js +3 -9
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +42 -6
- package/src/js/tcmd/core/operations.js +813 -48
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +33 -8
- package/src/js/tcmd/core/panel.js +280 -104
- package/src/js/tcmd/core/path.js +30 -3
- package/src/js/tcmd/core/permissions.js +107 -0
- package/src/js/tcmd/core/picker.js +156 -0
- package/src/js/tcmd/core/preview.js +102 -0
- package/src/js/tcmd/core/script.js +7 -95
- package/src/js/tcmd/core/selection.js +9 -1
- package/src/js/tcmd/core/session.js +498 -200
- package/src/js/tcmd/core/watch.js +153 -0
- package/src/js/tcmd/desktop/backend/api.js +165 -0
- package/src/js/tcmd/desktop/backend/fs.js +626 -0
- package/src/js/tcmd/desktop/backend/main.ts +9 -0
- package/src/js/tcmd/desktop/bookmarks.js +23 -0
- package/src/js/tcmd/desktop/fs.js +91 -0
- package/src/js/tcmd/desktop/main.js +194 -0
- package/src/js/tcmd/desktop/os.js +33 -0
- package/src/js/tcmd/index.js +0 -1
- package/src/js/tcmd/tui/app.js +118 -60
- package/src/js/tcmd/tui/catalogue.js +238 -42
- package/src/js/tcmd/tui/index.js +18 -19
- package/src/js/tcmd/tui/opentui.js +33 -0
- package/src/js/tcmd/tui/syntax.js +91 -0
- package/src/js/tcmd/tui/theme.js +32 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +225 -41
- package/src/js/tcmd/web/bookmarks.js +36 -0
- package/src/js/tcmd/web/catalogue.js +81 -38
- package/src/js/tcmd/web/effects.js +223 -0
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/header.js +15 -11
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/main.js +6 -66
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +4 -4
- package/src/js/tcmd/web/server.js +124 -21
- package/src/js/tcmd/web/theme.js +4 -0
- package/tinyjs.json +16 -0
- package/src/js/tcmd/components/CommandPalette.html +0 -7
- package/src/js/tcmd/components/CommandPalette.js +0 -58
- package/src/js/tcmd/components/PaletteRow.html +0 -3
- package/src/js/tcmd/components/PaletteRow.js +0 -43
- package/src/js/tcmd/components/PaletteRow.xml +0 -4
- package/src/js/tcmd/tui/frame.js +0 -269
- package/src/js/tcmd/tui/help.js +0 -32
package/src/js/tcmd/web/opfs.js
CHANGED
|
@@ -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,
|
|
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
|
|
136
|
-
for await (const handle of directory.values())
|
|
137
|
-
return
|
|
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 =
|
|
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
|
|
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,
|
|
209
|
-
//
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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)
|
|
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
|
package/src/js/tcmd/web/seed.js
CHANGED
|
@@ -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
|
|
27
|
-
"-
|
|
28
|
-
"-
|
|
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
|
|
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
|
|
12
|
-
// token, injected into `index.html` at serve
|
|
13
|
-
// `root` is set, every path is confined to
|
|
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
|
|
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
|
|
48
|
+
return isWithin(base, resolved)
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
// Function: confine
|
|
46
|
-
// Resolves a client path and rejects
|
|
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/
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|