@ssebastien/tcmd 0.1.4 → 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 +169 -58
- 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 +17 -5
- package/public/fonts/OFL.txt +93 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +5066 -1888
- package/public/style.css +660 -46
- package/scripts/build-desktop.js +31 -0
- package/src/js/tcmd/components/App.html +56 -6
- package/src/js/tcmd/components/App.js +233 -58
- package/src/js/tcmd/components/App.xml +27 -22
- package/src/js/tcmd/components/BookmarkList.html +4 -5
- package/src/js/tcmd/components/BookmarkList.js +14 -40
- 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/CommandArgRow.html +18 -7
- package/src/js/tcmd/components/CommandArgRow.js +100 -44
- package/src/js/tcmd/components/CommandArgRow.xml +39 -15
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +31 -25
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.js +24 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +13 -13
- package/src/js/tcmd/components/CommandPanel.js +57 -33
- package/src/js/tcmd/components/CommandPanel.xml +30 -20
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.js +21 -7
- 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 +1 -2
- package/src/js/tcmd/components/FilePreview.js +40 -4
- package/src/js/tcmd/components/FilePreview.xml +17 -10
- package/src/js/tcmd/components/FileRow.html +1 -0
- package/src/js/tcmd/components/FileRow.js +60 -9
- package/src/js/tcmd/components/Frame.js +57 -31
- package/src/js/tcmd/components/Help.html +12 -4
- package/src/js/tcmd/components/Help.js +198 -47
- 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/OverlayList.xml +13 -0
- 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/format.js +4 -23
- package/src/js/tcmd/components/icons.js +137 -0
- package/src/js/tcmd/components/index.js +8 -3
- package/src/js/tcmd/components/rows.js +149 -25
- 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 +612 -136
- 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 +81 -5
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +410 -53
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +39 -3
- package/src/js/tcmd/core/operations.js +735 -63
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +4 -2
- package/src/js/tcmd/core/panel.js +179 -93
- 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/session.js +286 -116
- 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/tui/app.js +111 -74
- package/src/js/tcmd/tui/catalogue.js +232 -23
- package/src/js/tcmd/tui/index.js +18 -4
- 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 +27 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +114 -56
- package/src/js/tcmd/web/catalogue.js +41 -4
- package/src/js/tcmd/web/effects.js +94 -14
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +1 -1
- package/src/js/tcmd/web/server.js +118 -17
- package/src/js/tcmd/web/theme.js +3 -0
- package/tinyjs.json +16 -0
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
// tool, not a sandbox: it serves the same files `bun run tcmd` can reach, so
|
|
16
16
|
// do not expose the port.
|
|
17
17
|
|
|
18
|
-
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"
|
|
19
21
|
import { createNodeFs } from "../core/fs.js"
|
|
20
22
|
|
|
21
23
|
// Constant: TOKEN_PLACEHOLDER
|
|
@@ -36,23 +38,67 @@ function createWebServer({
|
|
|
36
38
|
const fs = createNodeFs()
|
|
37
39
|
const base = resolvePath(root)
|
|
38
40
|
const rootHref = publicDir instanceof URL ? publicDir : new URL(`${publicDir.replace(/\/?$/, "/")}`, "file:///")
|
|
41
|
+
let realBasePromise
|
|
39
42
|
|
|
40
43
|
// Function: withinRoot
|
|
41
44
|
// True when `path` is the configured root or inside it.
|
|
42
45
|
function withinRoot(path) {
|
|
46
|
+
if (typeof path !== "string" || !path) return false
|
|
43
47
|
const resolved = resolvePath(path)
|
|
44
|
-
return base
|
|
48
|
+
return isWithin(base, resolved)
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
// Function: confine
|
|
48
|
-
// Resolves a client path and rejects
|
|
49
|
-
function confine(path) {
|
|
52
|
+
// Resolves a client path and rejects lexical or symlink escapes from root.
|
|
53
|
+
async function confine(path) {
|
|
50
54
|
if (typeof path !== "string" || !path) throw httpError(400, "missing path")
|
|
51
55
|
const resolved = resolvePath(path)
|
|
52
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)
|
|
53
61
|
return resolved
|
|
54
62
|
}
|
|
55
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
|
+
|
|
56
102
|
// Function: jsonBody
|
|
57
103
|
// Reads a JSON request body; other content types are refused so cross-site
|
|
58
104
|
// form posts cannot reach the mutations.
|
|
@@ -75,42 +121,92 @@ function createWebServer({
|
|
|
75
121
|
home: withinRoot(fs.home()) ? fs.home() : base,
|
|
76
122
|
cwd: withinRoot(fs.cwd()) ? fs.cwd() : base,
|
|
77
123
|
}),
|
|
78
|
-
"GET /api/readDir": (_request, url) => fs.readDir(confine(url.searchParams.get("path"))),
|
|
79
|
-
"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"))),
|
|
80
127
|
"GET /api/readTextHead": async (_request, url) => {
|
|
81
|
-
const path = confine(url.searchParams.get("path"))
|
|
128
|
+
const path = await confine(url.searchParams.get("path"))
|
|
82
129
|
const maxBytes = clampBytes(url.searchParams.get("maxBytes"))
|
|
83
130
|
return fs.readTextHead(path, maxBytes)
|
|
84
131
|
},
|
|
85
|
-
"GET /api/
|
|
86
|
-
|
|
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"))),
|
|
87
139
|
"POST /api/copy": async (request) => {
|
|
88
140
|
const body = await jsonBody(request)
|
|
89
|
-
|
|
141
|
+
const [from, to] = await Promise.all([confine(body.from), confine(body.to)])
|
|
142
|
+
return fs.copy(from, to)
|
|
90
143
|
},
|
|
91
144
|
"POST /api/move": async (request) => {
|
|
92
145
|
const body = await jsonBody(request)
|
|
93
|
-
|
|
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))
|
|
94
177
|
},
|
|
95
178
|
"POST /api/trash": async (request) => {
|
|
96
179
|
const body = await jsonBody(request)
|
|
97
|
-
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 })
|
|
98
194
|
},
|
|
99
195
|
"POST /api/erase": async (request) => {
|
|
100
196
|
const body = await jsonBody(request)
|
|
101
|
-
return fs.erase(confinePaths(body.paths))
|
|
197
|
+
return fs.erase(await confinePaths(body.paths))
|
|
102
198
|
},
|
|
103
199
|
"POST /api/touch": async (request) => {
|
|
104
200
|
const body = await jsonBody(request)
|
|
105
|
-
return fs.touch(confine(body.path))
|
|
201
|
+
return fs.touch(await confine(body.path))
|
|
106
202
|
},
|
|
107
203
|
}
|
|
108
204
|
|
|
109
205
|
// Function: confinePaths
|
|
110
206
|
// Validates a `paths` array before a bulk operation.
|
|
111
|
-
function confinePaths(paths) {
|
|
207
|
+
async function confinePaths(paths) {
|
|
112
208
|
if (!Array.isArray(paths)) throw httpError(400, "expected a paths array")
|
|
113
|
-
return paths.map(confine)
|
|
209
|
+
return Promise.all(paths.map(confine))
|
|
114
210
|
}
|
|
115
211
|
|
|
116
212
|
// Function: handleApi
|
|
@@ -125,7 +221,7 @@ function createWebServer({
|
|
|
125
221
|
const value = await route(request, url)
|
|
126
222
|
return json(value === undefined ? { ok: true } : value)
|
|
127
223
|
} catch (error) {
|
|
128
|
-
const status = error?.status ?? (error?.code === "ENOENT" ? 404 : 500)
|
|
224
|
+
const status = error?.status ?? (error?.code === "ENOENT" ? 404 : error?.code === "EEXIST" ? 409 : 500)
|
|
129
225
|
return json({ error: `${error?.message ?? error}` }, status)
|
|
130
226
|
}
|
|
131
227
|
}
|
|
@@ -172,6 +268,11 @@ function json(payload, status = 200) {
|
|
|
172
268
|
return Response.json(payload, { status, headers: { "Cache-Control": "no-store" } })
|
|
173
269
|
}
|
|
174
270
|
|
|
271
|
+
function isWithin(root, path) {
|
|
272
|
+
const relativePath = relative(root, path)
|
|
273
|
+
return relativePath === "" || (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath))
|
|
274
|
+
}
|
|
275
|
+
|
|
175
276
|
// Function: httpError
|
|
176
277
|
// Error carrying the HTTP status the API should answer with.
|
|
177
278
|
function httpError(status, message) {
|
package/src/js/tcmd/web/theme.js
CHANGED
|
@@ -14,7 +14,10 @@ const webTheme = {
|
|
|
14
14
|
currentClass: "is-current",
|
|
15
15
|
dirClass: "is-dir",
|
|
16
16
|
errorClass: "is-error",
|
|
17
|
+
execClass: "is-exec",
|
|
17
18
|
focusedClass: "is-focused",
|
|
19
|
+
lucideIcons: true,
|
|
20
|
+
readonlyClass: "is-readonly",
|
|
18
21
|
rootClass: "is-root",
|
|
19
22
|
selectedClass: "is-selected",
|
|
20
23
|
}
|
package/tinyjs.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tcmd",
|
|
3
|
+
"title": "TinyCommand",
|
|
4
|
+
"id": "io.github.sebastien.tcmd",
|
|
5
|
+
"version": "0.1.4",
|
|
6
|
+
"minTinyjsVersion": "0.57.0",
|
|
7
|
+
"size": "1280x800",
|
|
8
|
+
"chrome": { "menu": false },
|
|
9
|
+
"backend": "src/js/tcmd/desktop/backend/main.ts",
|
|
10
|
+
"openFolders": true,
|
|
11
|
+
"frontend": {
|
|
12
|
+
"build": "bun run desktop:bundle",
|
|
13
|
+
"dir": "desktop/dist",
|
|
14
|
+
"dist": "desktop/dist"
|
|
15
|
+
}
|
|
16
|
+
}
|