@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.
Files changed (105) hide show
  1. package/README.md +169 -58
  2. package/desktop/dist/index.html +13 -0
  3. package/desktop/dist/main.js +20298 -0
  4. package/desktop/dist/style.css +1515 -0
  5. package/desktop/frontend/index.html +13 -0
  6. package/package.json +17 -5
  7. package/public/fonts/OFL.txt +93 -0
  8. package/public/licenses/LUCIDE.txt +43 -0
  9. package/public/main.js +5066 -1888
  10. package/public/style.css +660 -46
  11. package/scripts/build-desktop.js +31 -0
  12. package/src/js/tcmd/components/App.html +56 -6
  13. package/src/js/tcmd/components/App.js +233 -58
  14. package/src/js/tcmd/components/App.xml +27 -22
  15. package/src/js/tcmd/components/BookmarkList.html +4 -5
  16. package/src/js/tcmd/components/BookmarkList.js +14 -40
  17. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  18. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  19. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  20. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  21. package/src/js/tcmd/components/CommandArgRow.html +18 -7
  22. package/src/js/tcmd/components/CommandArgRow.js +100 -44
  23. package/src/js/tcmd/components/CommandArgRow.xml +39 -15
  24. package/src/js/tcmd/components/CommandBar.html +1 -1
  25. package/src/js/tcmd/components/CommandBar.js +31 -25
  26. package/src/js/tcmd/components/CommandBar.xml +2 -2
  27. package/src/js/tcmd/components/CommandItem.js +24 -25
  28. package/src/js/tcmd/components/CommandItem.xml +2 -2
  29. package/src/js/tcmd/components/CommandPanel.html +13 -13
  30. package/src/js/tcmd/components/CommandPanel.js +57 -33
  31. package/src/js/tcmd/components/CommandPanel.xml +30 -20
  32. package/src/js/tcmd/components/Confirm.html +2 -2
  33. package/src/js/tcmd/components/EntryDetail.js +21 -7
  34. package/src/js/tcmd/components/FilePicker.html +10 -0
  35. package/src/js/tcmd/components/FilePicker.js +43 -0
  36. package/src/js/tcmd/components/FilePicker.xml +13 -0
  37. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  38. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  39. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  40. package/src/js/tcmd/components/FilePreview.html +1 -2
  41. package/src/js/tcmd/components/FilePreview.js +40 -4
  42. package/src/js/tcmd/components/FilePreview.xml +17 -10
  43. package/src/js/tcmd/components/FileRow.html +1 -0
  44. package/src/js/tcmd/components/FileRow.js +60 -9
  45. package/src/js/tcmd/components/Frame.js +57 -31
  46. package/src/js/tcmd/components/Help.html +12 -4
  47. package/src/js/tcmd/components/Help.js +198 -47
  48. package/src/js/tcmd/components/Help.xml +13 -4
  49. package/src/js/tcmd/components/HelpRow.html +8 -0
  50. package/src/js/tcmd/components/HelpRow.xml +7 -0
  51. package/src/js/tcmd/components/OverlayList.html +8 -0
  52. package/src/js/tcmd/components/OverlayList.js +69 -0
  53. package/src/js/tcmd/components/OverlayList.xml +13 -0
  54. package/src/js/tcmd/components/OverlayRow.html +5 -0
  55. package/src/js/tcmd/components/OverlayRow.js +47 -0
  56. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  57. package/src/js/tcmd/components/format.js +4 -23
  58. package/src/js/tcmd/components/icons.js +137 -0
  59. package/src/js/tcmd/components/index.js +8 -3
  60. package/src/js/tcmd/components/rows.js +149 -25
  61. package/src/js/tcmd/core/base64.js +64 -0
  62. package/src/js/tcmd/core/bookmarks.js +45 -8
  63. package/src/js/tcmd/core/commands.js +612 -136
  64. package/src/js/tcmd/core/concurrency.js +59 -0
  65. package/src/js/tcmd/core/decompress.js +117 -0
  66. package/src/js/tcmd/core/directory.js +81 -5
  67. package/src/js/tcmd/core/format.js +36 -0
  68. package/src/js/tcmd/core/fs.js +410 -53
  69. package/src/js/tcmd/core/journal.js +93 -0
  70. package/src/js/tcmd/core/keymap.js +39 -3
  71. package/src/js/tcmd/core/operations.js +735 -63
  72. package/src/js/tcmd/core/os.js +29 -3
  73. package/src/js/tcmd/core/pane.js +4 -2
  74. package/src/js/tcmd/core/panel.js +179 -93
  75. package/src/js/tcmd/core/path.js +30 -3
  76. package/src/js/tcmd/core/permissions.js +107 -0
  77. package/src/js/tcmd/core/picker.js +156 -0
  78. package/src/js/tcmd/core/preview.js +102 -0
  79. package/src/js/tcmd/core/session.js +286 -116
  80. package/src/js/tcmd/core/watch.js +153 -0
  81. package/src/js/tcmd/desktop/backend/api.js +165 -0
  82. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  83. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  84. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  85. package/src/js/tcmd/desktop/fs.js +91 -0
  86. package/src/js/tcmd/desktop/main.js +194 -0
  87. package/src/js/tcmd/desktop/os.js +33 -0
  88. package/src/js/tcmd/tui/app.js +111 -74
  89. package/src/js/tcmd/tui/catalogue.js +232 -23
  90. package/src/js/tcmd/tui/index.js +18 -4
  91. package/src/js/tcmd/tui/opentui.js +33 -0
  92. package/src/js/tcmd/tui/syntax.js +91 -0
  93. package/src/js/tcmd/tui/theme.js +27 -2
  94. package/src/js/tcmd/vendor/bzip2.js +276 -0
  95. package/src/js/tcmd/web/app.js +114 -56
  96. package/src/js/tcmd/web/catalogue.js +41 -4
  97. package/src/js/tcmd/web/effects.js +94 -14
  98. package/src/js/tcmd/web/header.html +1 -1
  99. package/src/js/tcmd/web/http.js +13 -0
  100. package/src/js/tcmd/web/index.js +2 -2
  101. package/src/js/tcmd/web/opfs.js +297 -30
  102. package/src/js/tcmd/web/seed.js +1 -1
  103. package/src/js/tcmd/web/server.js +118 -17
  104. package/src/js/tcmd/web/theme.js +3 -0
  105. 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 { 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"
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 === sep || resolved === base || resolved.startsWith(base + sep)
48
+ return isWithin(base, resolved)
45
49
  }
46
50
 
47
51
  // Function: confine
48
- // Resolves a client path and rejects it when it escapes the root.
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/du": (_request, url) => fs.du(confine(url.searchParams.get("path"))),
86
- "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"))),
87
139
  "POST /api/copy": async (request) => {
88
140
  const body = await jsonBody(request)
89
- 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)
90
143
  },
91
144
  "POST /api/move": async (request) => {
92
145
  const body = await jsonBody(request)
93
- 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))
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) {
@@ -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
+ }