@ecmaos/coreutils 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +37 -0
  3. package/LICENSE +1 -1
  4. package/dist/commands/cal.js +2 -2
  5. package/dist/commands/cal.js.map +1 -1
  6. package/dist/commands/cat.js +2 -2
  7. package/dist/commands/cd.js +2 -2
  8. package/dist/commands/chmod.d.ts.map +1 -1
  9. package/dist/commands/chmod.js +16 -11
  10. package/dist/commands/chmod.js.map +1 -1
  11. package/dist/commands/cp.js +2 -2
  12. package/dist/commands/cp.js.map +1 -1
  13. package/dist/commands/cron.d.ts +4 -0
  14. package/dist/commands/cron.d.ts.map +1 -0
  15. package/dist/commands/cron.js +439 -0
  16. package/dist/commands/cron.js.map +1 -0
  17. package/dist/commands/date.js +2 -2
  18. package/dist/commands/date.js.map +1 -1
  19. package/dist/commands/echo.js +2 -2
  20. package/dist/commands/echo.js.map +1 -1
  21. package/dist/commands/false.js +2 -2
  22. package/dist/commands/fetch.d.ts +4 -0
  23. package/dist/commands/fetch.d.ts.map +1 -0
  24. package/dist/commands/fetch.js +210 -0
  25. package/dist/commands/fetch.js.map +1 -0
  26. package/dist/commands/format.d.ts +4 -0
  27. package/dist/commands/format.d.ts.map +1 -0
  28. package/dist/commands/format.js +178 -0
  29. package/dist/commands/format.js.map +1 -0
  30. package/dist/commands/hash.d.ts +4 -0
  31. package/dist/commands/hash.d.ts.map +1 -0
  32. package/dist/commands/hash.js +200 -0
  33. package/dist/commands/hash.js.map +1 -0
  34. package/dist/commands/head.js +2 -2
  35. package/dist/commands/id.js +2 -2
  36. package/dist/commands/id.js.map +1 -1
  37. package/dist/commands/less.d.ts.map +1 -1
  38. package/dist/commands/less.js +53 -2
  39. package/dist/commands/less.js.map +1 -1
  40. package/dist/commands/ls.d.ts.map +1 -1
  41. package/dist/commands/ls.js +41 -15
  42. package/dist/commands/ls.js.map +1 -1
  43. package/dist/commands/man.d.ts +4 -0
  44. package/dist/commands/man.d.ts.map +1 -0
  45. package/dist/commands/man.js +564 -0
  46. package/dist/commands/man.js.map +1 -0
  47. package/dist/commands/mkdir.js +2 -2
  48. package/dist/commands/mkdir.js.map +1 -1
  49. package/dist/commands/mktemp.d.ts +4 -0
  50. package/dist/commands/mktemp.d.ts.map +1 -0
  51. package/dist/commands/mktemp.js +229 -0
  52. package/dist/commands/mktemp.js.map +1 -0
  53. package/dist/commands/nc.js +2 -2
  54. package/dist/commands/nc.js.map +1 -1
  55. package/dist/commands/open.d.ts +4 -0
  56. package/dist/commands/open.d.ts.map +1 -0
  57. package/dist/commands/open.js +74 -0
  58. package/dist/commands/open.js.map +1 -0
  59. package/dist/commands/passkey.js +3 -3
  60. package/dist/commands/play.d.ts +4 -0
  61. package/dist/commands/play.d.ts.map +1 -0
  62. package/dist/commands/play.js +231 -0
  63. package/dist/commands/play.js.map +1 -0
  64. package/dist/commands/pwd.js +2 -2
  65. package/dist/commands/pwd.js.map +1 -1
  66. package/dist/commands/rm.d.ts.map +1 -1
  67. package/dist/commands/rm.js +57 -12
  68. package/dist/commands/rm.js.map +1 -1
  69. package/dist/commands/rmdir.js +2 -2
  70. package/dist/commands/rmdir.js.map +1 -1
  71. package/dist/commands/sockets.js +1 -1
  72. package/dist/commands/stat.d.ts.map +1 -1
  73. package/dist/commands/stat.js +37 -15
  74. package/dist/commands/stat.js.map +1 -1
  75. package/dist/commands/tail.js +2 -2
  76. package/dist/commands/tar.d.ts +4 -0
  77. package/dist/commands/tar.d.ts.map +1 -0
  78. package/dist/commands/tar.js +743 -0
  79. package/dist/commands/tar.js.map +1 -0
  80. package/dist/commands/touch.js +2 -2
  81. package/dist/commands/touch.js.map +1 -1
  82. package/dist/commands/true.js +2 -2
  83. package/dist/commands/unzip.d.ts +4 -0
  84. package/dist/commands/unzip.d.ts.map +1 -0
  85. package/dist/commands/unzip.js +443 -0
  86. package/dist/commands/unzip.js.map +1 -0
  87. package/dist/commands/user.js +1 -1
  88. package/dist/commands/video.d.ts +4 -0
  89. package/dist/commands/video.d.ts.map +1 -0
  90. package/dist/commands/video.js +250 -0
  91. package/dist/commands/video.js.map +1 -0
  92. package/dist/commands/view.d.ts +4 -0
  93. package/dist/commands/view.d.ts.map +1 -0
  94. package/dist/commands/view.js +488 -0
  95. package/dist/commands/view.js.map +1 -0
  96. package/dist/commands/web.d.ts +4 -0
  97. package/dist/commands/web.d.ts.map +1 -0
  98. package/dist/commands/web.js +348 -0
  99. package/dist/commands/web.js.map +1 -0
  100. package/dist/commands/whoami.js +2 -2
  101. package/dist/commands/whoami.js.map +1 -1
  102. package/dist/commands/zip.d.ts +4 -0
  103. package/dist/commands/zip.d.ts.map +1 -0
  104. package/dist/commands/zip.js +264 -0
  105. package/dist/commands/zip.js.map +1 -0
  106. package/dist/index.d.ts +14 -0
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +44 -2
  109. package/dist/index.js.map +1 -1
  110. package/package.json +7 -4
  111. package/src/commands/cal.ts +2 -2
  112. package/src/commands/cat.ts +2 -2
  113. package/src/commands/cd.ts +2 -2
  114. package/src/commands/chmod.ts +19 -11
  115. package/src/commands/cp.ts +2 -2
  116. package/src/commands/cron.ts +499 -0
  117. package/src/commands/date.ts +2 -2
  118. package/src/commands/echo.ts +2 -2
  119. package/src/commands/false.ts +2 -2
  120. package/src/commands/fetch.ts +205 -0
  121. package/src/commands/format.ts +204 -0
  122. package/src/commands/hash.ts +215 -0
  123. package/src/commands/head.ts +2 -2
  124. package/src/commands/id.ts +2 -2
  125. package/src/commands/less.ts +50 -2
  126. package/src/commands/ls.ts +40 -14
  127. package/src/commands/man.ts +651 -0
  128. package/src/commands/mkdir.ts +2 -2
  129. package/src/commands/mktemp.ts +235 -0
  130. package/src/commands/nc.ts +2 -2
  131. package/src/commands/open.ts +84 -0
  132. package/src/commands/passkey.ts +3 -3
  133. package/src/commands/play.ts +249 -0
  134. package/src/commands/pwd.ts +2 -2
  135. package/src/commands/rm.ts +54 -12
  136. package/src/commands/rmdir.ts +2 -2
  137. package/src/commands/sockets.ts +1 -1
  138. package/src/commands/stat.ts +44 -16
  139. package/src/commands/tail.ts +2 -2
  140. package/src/commands/tar.ts +780 -0
  141. package/src/commands/touch.ts +2 -2
  142. package/src/commands/true.ts +2 -2
  143. package/src/commands/unzip.ts +517 -0
  144. package/src/commands/user.ts +1 -1
  145. package/src/commands/video.ts +267 -0
  146. package/src/commands/view.ts +526 -0
  147. package/src/commands/web.ts +377 -0
  148. package/src/commands/whoami.ts +2 -2
  149. package/src/commands/zip.ts +319 -0
  150. package/src/index.ts +44 -2
@@ -0,0 +1,780 @@
1
+ import path from 'path'
2
+ import type { Kernel, Process, Shell, Terminal } from '@ecmaos/types'
3
+ import { TerminalEvents } from '@ecmaos/types'
4
+ import { TerminalCommand } from '../shared/terminal-command.js'
5
+ import { writelnStdout, writelnStderr } from '../shared/helpers.js'
6
+ import { createTarPacker, createTarDecoder } from 'modern-tar'
7
+
8
+ function printUsage(process: Process | undefined, terminal: Terminal): void {
9
+ const usage = `Usage: tar [OPTION]... [FILE]...
10
+ Create, extract, or list tar archives.
11
+
12
+ -c, --create create a new archive
13
+ -x, --extract extract files from an archive
14
+ -t, --list list the contents of an archive
15
+ -f, --file use archive file (required for create, optional for extract/list - uses stdin if omitted)
16
+ -z filter the archive through gzip
17
+ -v, --verbose verbosely list files processed
18
+ -C, --directory change to directory before extracting
19
+ -h, --help display this help and exit`
20
+ writelnStderr(process, terminal, usage)
21
+ }
22
+
23
+ interface TarOptions {
24
+ create: boolean
25
+ extract: boolean
26
+ list: boolean
27
+ file: string | null
28
+ gzip: boolean
29
+ verbose: boolean
30
+ directory: string | null
31
+ }
32
+
33
+ function parseArgs(argv: string[]): { options: TarOptions; files: string[] } {
34
+ const options: TarOptions = {
35
+ create: false,
36
+ extract: false,
37
+ list: false,
38
+ file: null,
39
+ gzip: false,
40
+ verbose: false,
41
+ directory: null
42
+ }
43
+
44
+ const files: string[] = []
45
+ let i = 0
46
+
47
+ while (i < argv.length) {
48
+ const arg = argv[i]
49
+ if (!arg || typeof arg !== 'string') {
50
+ i++
51
+ continue
52
+ }
53
+
54
+ if (arg === '--help' || arg === '-h') {
55
+ i++
56
+ continue
57
+ } else if (arg === '-c' || arg === '--create') {
58
+ options.create = true
59
+ i++
60
+ } else if (arg === '-x' || arg === '--extract') {
61
+ options.extract = true
62
+ i++
63
+ } else if (arg === '-t' || arg === '--list') {
64
+ options.list = true
65
+ i++
66
+ } else if (arg === '-f' || arg === '--file') {
67
+ if (i + 1 < argv.length) {
68
+ i++
69
+ const nextArg = argv[i]
70
+ options.file = (typeof nextArg === 'string' ? nextArg : null) || null
71
+ } else {
72
+ options.file = null
73
+ }
74
+ i++
75
+ } else if (arg === '-z') {
76
+ options.gzip = true
77
+ i++
78
+ } else if (arg === '-v' || arg === '--verbose') {
79
+ options.verbose = true
80
+ i++
81
+ } else if (arg === '-C' || arg === '--directory') {
82
+ if (i + 1 < argv.length) {
83
+ i++
84
+ const nextArg = argv[i]
85
+ options.directory = (typeof nextArg === 'string' ? nextArg : null) || null
86
+ } else {
87
+ options.directory = null
88
+ }
89
+ i++
90
+ } else if (arg.startsWith('-')) {
91
+ // Handle combined flags like -czf
92
+ const flagString = arg.slice(1)
93
+ let flagIndex = 0
94
+ while (flagIndex < flagString.length) {
95
+ const flag = flagString[flagIndex]
96
+ if (flag === 'c') {
97
+ options.create = true
98
+ flagIndex++
99
+ } else if (flag === 'x') {
100
+ options.extract = true
101
+ flagIndex++
102
+ } else if (flag === 't') {
103
+ options.list = true
104
+ flagIndex++
105
+ } else if (flag === 'f') {
106
+ // -f needs to be followed by filename
107
+ // Check if there's a path in the same string after 'f'
108
+ const remaining = flagString.slice(flagIndex + 1)
109
+ if (remaining.length > 0 && !remaining.startsWith('-')) {
110
+ // Path is in the same string
111
+ options.file = remaining
112
+ flagIndex = flagString.length // Done processing this arg
113
+ } else if (i + 1 < argv.length) {
114
+ // Check next argument
115
+ const nextArg = argv[i + 1]
116
+ if (typeof nextArg === 'string' && !nextArg.startsWith('-')) {
117
+ i++
118
+ options.file = nextArg
119
+ flagIndex++
120
+ } else {
121
+ flagIndex++
122
+ }
123
+ } else {
124
+ flagIndex++
125
+ }
126
+ } else if (flag === 'z') {
127
+ options.gzip = true
128
+ flagIndex++
129
+ } else if (flag === 'v') {
130
+ options.verbose = true
131
+ flagIndex++
132
+ } else if (flag === 'C') {
133
+ // -C needs to be followed by directory
134
+ // Check if there's a path in the same string after 'C'
135
+ const remaining = flagString.slice(flagIndex + 1)
136
+ if (remaining.length > 0 && !remaining.startsWith('-')) {
137
+ // Path is in the same string (e.g., -xz-C/tmp/dir)
138
+ options.directory = remaining
139
+ flagIndex = flagString.length // Done processing this arg
140
+ } else if (i + 1 < argv.length) {
141
+ // Check next argument
142
+ const nextArg = argv[i + 1]
143
+ if (typeof nextArg === 'string' && !nextArg.startsWith('-')) {
144
+ i++
145
+ options.directory = nextArg
146
+ flagIndex++
147
+ } else {
148
+ flagIndex++
149
+ }
150
+ } else {
151
+ flagIndex++
152
+ }
153
+ } else {
154
+ // Unknown flag, skip it
155
+ flagIndex++
156
+ }
157
+ }
158
+ i++
159
+ } else {
160
+ files.push(arg)
161
+ i++
162
+ }
163
+ }
164
+
165
+ return { options, files }
166
+ }
167
+
168
+ async function collectFiles(
169
+ shell: Shell,
170
+ filePaths: string[],
171
+ basePath: string = ''
172
+ ): Promise<Array<{ path: string; fullPath: string; isDirectory: boolean }>> {
173
+ const result: Array<{ path: string; fullPath: string; isDirectory: boolean }> = []
174
+
175
+ for (const filePath of filePaths) {
176
+ const fullPath = path.resolve(shell.cwd, filePath)
177
+ try {
178
+ const stat = await shell.context.fs.promises.stat(fullPath)
179
+
180
+ if (stat.isDirectory()) {
181
+ // Add directory entry
182
+ const relativePath = path.join(basePath, filePath)
183
+ result.push({
184
+ path: relativePath.endsWith('/') ? relativePath : relativePath + '/',
185
+ fullPath,
186
+ isDirectory: true
187
+ })
188
+
189
+ // Recursively collect directory contents
190
+ const entries = await shell.context.fs.promises.readdir(fullPath)
191
+ const subFiles: string[] = []
192
+ for (const entry of entries) {
193
+ subFiles.push(path.join(fullPath, entry))
194
+ }
195
+ const subResults = await collectFiles(shell, subFiles.map(f => path.relative(shell.cwd, f)), path.join(basePath, filePath))
196
+ result.push(...subResults)
197
+ } else {
198
+ // Add file entry
199
+ result.push({
200
+ path: path.join(basePath, filePath),
201
+ fullPath,
202
+ isDirectory: false
203
+ })
204
+ }
205
+ } catch (error) {
206
+ // Skip files that can't be accessed
207
+ continue
208
+ }
209
+ }
210
+
211
+ return result
212
+ }
213
+
214
+ async function createArchive(
215
+ shell: Shell,
216
+ terminal: Terminal,
217
+ process: Process | undefined,
218
+ archivePath: string,
219
+ filePaths: string[],
220
+ options: TarOptions
221
+ ): Promise<number> {
222
+ if (filePaths.length === 0) {
223
+ await writelnStderr(process, terminal, 'tar: no files specified')
224
+ return 1
225
+ }
226
+
227
+ try {
228
+ const fullArchivePath = path.resolve(shell.cwd, archivePath)
229
+
230
+ // Collect all files to archive
231
+ const filesToArchive = await collectFiles(shell, filePaths)
232
+
233
+ if (filesToArchive.length === 0) {
234
+ await writelnStderr(process, terminal, 'tar: no files to archive')
235
+ return 1
236
+ }
237
+
238
+ // Create tar packer
239
+ const { readable: tarStream, controller } = createTarPacker()
240
+
241
+ // Apply gzip compression if requested
242
+ let finalStream: ReadableStream<Uint8Array> = tarStream
243
+ if (options.gzip) {
244
+ finalStream = tarStream.pipeThrough(new CompressionStream('gzip') as any)
245
+ }
246
+
247
+ // Start writing the archive in the background
248
+ const writePromise = (async () => {
249
+ const archiveHandle = await shell.context.fs.promises.open(fullArchivePath, 'w')
250
+ const writer = archiveHandle.writableWebStream?.()?.getWriter()
251
+
252
+ if (!writer) {
253
+ // Fallback: read stream and write in chunks
254
+ const reader = finalStream.getReader()
255
+ try {
256
+ while (true) {
257
+ const { done, value } = await reader.read()
258
+ if (done) break
259
+ await archiveHandle.writeFile(value)
260
+ }
261
+ } finally {
262
+ reader.releaseLock()
263
+ await archiveHandle.close()
264
+ }
265
+ } else {
266
+ try {
267
+ const reader = finalStream.getReader()
268
+ try {
269
+ while (true) {
270
+ const { done, value } = await reader.read()
271
+ if (done) break
272
+ await writer.write(value)
273
+ }
274
+ } finally {
275
+ reader.releaseLock()
276
+ }
277
+ await writer.close()
278
+ } finally {
279
+ writer.releaseLock()
280
+ await archiveHandle.close()
281
+ }
282
+ }
283
+ })()
284
+
285
+ // Add entries to the tar archive
286
+ for (const file of filesToArchive) {
287
+ if (file.isDirectory) {
288
+ // Add directory entry
289
+ const dirName = file.path.endsWith('/') ? file.path : file.path + '/'
290
+ controller.add({
291
+ name: dirName,
292
+ type: 'directory',
293
+ size: 0
294
+ })
295
+ if (options.verbose) {
296
+ await writelnStdout(process, terminal, dirName)
297
+ }
298
+ } else {
299
+ try {
300
+ const handle = await shell.context.fs.promises.open(file.fullPath, 'r')
301
+ const stat = await shell.context.fs.promises.stat(file.fullPath)
302
+
303
+ // Create a readable stream from the file
304
+ const fileStream = new ReadableStream<Uint8Array>({
305
+ async start(controller) {
306
+ try {
307
+ const chunkSize = 64 * 1024 // 64KB chunks
308
+ let offset = 0
309
+
310
+ while (offset < stat.size) {
311
+ const buffer = new Uint8Array(chunkSize)
312
+ const readSize = Math.min(chunkSize, stat.size - offset)
313
+ await handle.read(buffer, 0, readSize, offset)
314
+ const chunk = buffer.subarray(0, readSize)
315
+ controller.enqueue(chunk)
316
+ offset += readSize
317
+ }
318
+ controller.close()
319
+ } catch (error) {
320
+ controller.error(error)
321
+ } finally {
322
+ await handle.close()
323
+ }
324
+ }
325
+ })
326
+
327
+ // Add file entry to tar
328
+ const entryStream = controller.add({
329
+ name: file.path,
330
+ type: 'file',
331
+ size: stat.size
332
+ })
333
+
334
+ // Copy file stream to entry stream
335
+ const fileReader = fileStream.getReader()
336
+ const entryWriter = entryStream.getWriter()
337
+ try {
338
+ while (true) {
339
+ const { done, value } = await fileReader.read()
340
+ if (done) break
341
+ await entryWriter.write(value)
342
+ }
343
+ await entryWriter.close()
344
+ } finally {
345
+ fileReader.releaseLock()
346
+ entryWriter.releaseLock()
347
+ }
348
+
349
+ if (options.verbose) {
350
+ await writelnStdout(process, terminal, file.path)
351
+ }
352
+ } catch (error) {
353
+ await writelnStderr(process, terminal, `tar: ${file.path}: ${error instanceof Error ? error.message : 'Unknown error'}`)
354
+ }
355
+ }
356
+ }
357
+
358
+ // Finalize the tar archive
359
+ controller.finalize()
360
+
361
+ // Wait for writing to complete
362
+ await writePromise
363
+
364
+
365
+ return 0
366
+ } catch (error) {
367
+ await writelnStderr(process, terminal, `tar: ${error instanceof Error ? error.message : 'Unknown error'}`)
368
+ return 1
369
+ }
370
+ }
371
+
372
+ async function extractArchive(
373
+ kernel: Kernel,
374
+ shell: Shell,
375
+ terminal: Terminal,
376
+ process: Process | undefined,
377
+ archivePath: string | null,
378
+ options: TarOptions
379
+ ): Promise<number> {
380
+ try {
381
+ // Create readable stream from archive file or stdin
382
+ let archiveStream: ReadableStream<Uint8Array>
383
+
384
+ if (archivePath) {
385
+ // Read from file
386
+ const fullArchivePath = path.resolve(shell.cwd, archivePath)
387
+
388
+ // Check if archive exists
389
+ try {
390
+ await shell.context.fs.promises.stat(fullArchivePath)
391
+ } catch {
392
+ await writelnStderr(process, terminal, `tar: ${archivePath}: Cannot open: No such file or directory`)
393
+ return 1
394
+ }
395
+
396
+ // Read archive file
397
+ const archiveHandle = await shell.context.fs.promises.open(fullArchivePath, 'r')
398
+ const stat = await shell.context.fs.promises.stat(fullArchivePath)
399
+
400
+ if (archiveHandle.readableWebStream) {
401
+ archiveStream = archiveHandle.readableWebStream() as any as ReadableStream<Uint8Array>
402
+ } else {
403
+ // Fallback: create stream manually
404
+ archiveStream = new ReadableStream<Uint8Array>({
405
+ async start(controller) {
406
+ try {
407
+ const chunkSize = 64 * 1024 // 64KB chunks
408
+ let offset = 0
409
+
410
+ while (offset < stat.size) {
411
+ const buffer = new Uint8Array(chunkSize)
412
+ const readSize = Math.min(chunkSize, stat.size - offset)
413
+ await archiveHandle.read(buffer, 0, readSize, offset)
414
+ const chunk = buffer.subarray(0, readSize)
415
+ controller.enqueue(chunk)
416
+ offset += readSize
417
+ }
418
+ controller.close()
419
+ } catch (error) {
420
+ controller.error(error)
421
+ } finally {
422
+ await archiveHandle.close()
423
+ }
424
+ }
425
+ })
426
+ }
427
+ } else {
428
+ // Read from stdin
429
+ if (!process || !process.stdin) {
430
+ await writelnStderr(process, terminal, 'tar: no input provided')
431
+ return 1
432
+ }
433
+ archiveStream = process.stdin
434
+ }
435
+
436
+ // Apply gzip decompression if requested
437
+ let tarStream: ReadableStream<Uint8Array> = archiveStream
438
+ if (options.gzip) {
439
+ tarStream = archiveStream.pipeThrough(new DecompressionStream('gzip') as any) as ReadableStream<Uint8Array>
440
+ }
441
+
442
+ // Extract using modern-tar decoder
443
+ let hasError = false
444
+ let interrupted = false
445
+ const interruptHandler = () => { interrupted = true }
446
+ kernel.terminal.events.on(TerminalEvents.INTERRUPT, interruptHandler)
447
+
448
+ try {
449
+ const decoder = createTarDecoder()
450
+ const entriesStream = tarStream.pipeThrough(decoder)
451
+ const entriesReader = entriesStream.getReader()
452
+
453
+ try {
454
+ while (true) {
455
+ if (interrupted) break
456
+ const { done, value: entry } = await entriesReader.read()
457
+ if (done) break
458
+ if (!entry) continue
459
+
460
+ if (options.verbose) {
461
+ await writelnStdout(process, terminal, entry.header.name)
462
+ }
463
+
464
+ try {
465
+ // Normalize the entry name: strip leading slashes and resolve relative to extraction directory
466
+ let entryName = entry.header.name
467
+ // Remove leading slashes to make it relative
468
+ while (entryName.startsWith('/')) {
469
+ entryName = entryName.slice(1)
470
+ }
471
+ // Skip empty entries (like just "/")
472
+ if (!entryName) {
473
+ await entry.body.cancel()
474
+ continue
475
+ }
476
+
477
+ // Determine extraction base directory
478
+ const extractBase = options.directory
479
+ ? path.resolve(shell.cwd, options.directory)
480
+ : shell.cwd
481
+
482
+ const targetPath = path.resolve(extractBase, entryName)
483
+
484
+ // Security check: ensure target path is within extract base (prevent directory traversal)
485
+ const resolvedBase = path.resolve(extractBase)
486
+ const resolvedTarget = path.resolve(targetPath)
487
+ if (!resolvedTarget.startsWith(resolvedBase + path.sep) && resolvedTarget !== resolvedBase) {
488
+ await writelnStderr(process, terminal, `tar: ${entry.header.name}: path outside extraction directory`)
489
+ await entry.body.cancel()
490
+ hasError = true
491
+ continue
492
+ }
493
+
494
+ if (entry.header.type === 'directory' || entry.header.name.endsWith('/')) {
495
+ // Create directory
496
+ try {
497
+ await shell.context.fs.promises.mkdir(targetPath, { recursive: true })
498
+ } catch (error) {
499
+ // Directory might already exist, ignore
500
+ }
501
+ // Drain the body stream for directories
502
+ await entry.body.cancel()
503
+ } else if (entry.header.type === 'file') {
504
+ // Extract file
505
+ const dirPath = path.dirname(targetPath)
506
+ try {
507
+ await shell.context.fs.promises.mkdir(dirPath, { recursive: true })
508
+ } catch {
509
+ // Directory might already exist
510
+ }
511
+
512
+ // Read file content from entry stream
513
+ const chunks: Uint8Array[] = []
514
+ const reader = entry.body.getReader()
515
+ try {
516
+ while (true) {
517
+ const { done, value } = await reader.read()
518
+ if (done) break
519
+ if (value) chunks.push(value)
520
+ }
521
+ } finally {
522
+ reader.releaseLock()
523
+ }
524
+
525
+ // Combine chunks and write file
526
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
527
+ const fileData = new Uint8Array(totalLength)
528
+ let offset = 0
529
+ for (const chunk of chunks) {
530
+ fileData.set(chunk, offset)
531
+ offset += chunk.length
532
+ }
533
+
534
+ await shell.context.fs.promises.writeFile(targetPath, fileData)
535
+ } else {
536
+ // For other entry types (symlinks, etc.), drain the body
537
+ await entry.body.cancel()
538
+ }
539
+ } catch (error) {
540
+ await writelnStderr(process, terminal, `tar: ${entry.header.name}: ${error instanceof Error ? error.message : 'Unknown error'}`)
541
+ hasError = true
542
+ // Drain the body stream on error
543
+ try {
544
+ await entry.body.cancel()
545
+ } catch {
546
+ // Ignore cancel errors
547
+ }
548
+ }
549
+ }
550
+ } finally {
551
+ entriesReader.releaseLock()
552
+ }
553
+ } finally {
554
+ kernel.terminal.events.off(TerminalEvents.INTERRUPT, interruptHandler)
555
+ }
556
+
557
+ return hasError ? 1 : 0
558
+ } catch (error) {
559
+ await writelnStderr(process, terminal, `tar: ${error instanceof Error ? error.message : 'Unknown error'}`)
560
+ return 1
561
+ }
562
+ }
563
+
564
+ async function listArchive(
565
+ kernel: Kernel,
566
+ shell: Shell,
567
+ terminal: Terminal,
568
+ process: Process | undefined,
569
+ archivePath: string | null,
570
+ options: TarOptions
571
+ ): Promise<number> {
572
+ try {
573
+ // Create readable stream from archive file or stdin
574
+ let archiveStream: ReadableStream<Uint8Array>
575
+
576
+ if (archivePath) {
577
+ // Read from file
578
+ const fullArchivePath = path.resolve(shell.cwd, archivePath)
579
+
580
+ // Check if archive exists
581
+ try {
582
+ await shell.context.fs.promises.stat(fullArchivePath)
583
+ } catch {
584
+ await writelnStderr(process, terminal, `tar: ${archivePath}: Cannot open: No such file or directory`)
585
+ return 1
586
+ }
587
+
588
+ // Read archive file
589
+ const archiveHandle = await shell.context.fs.promises.open(fullArchivePath, 'r')
590
+ const stat = await shell.context.fs.promises.stat(fullArchivePath)
591
+
592
+ if (archiveHandle.readableWebStream) {
593
+ archiveStream = archiveHandle.readableWebStream() as any as ReadableStream<Uint8Array>
594
+ } else {
595
+ // Fallback: create stream manually
596
+ archiveStream = new ReadableStream<Uint8Array>({
597
+ async start(controller) {
598
+ try {
599
+ const chunkSize = 64 * 1024 // 64KB chunks
600
+ let offset = 0
601
+
602
+ while (offset < stat.size) {
603
+ const buffer = new Uint8Array(chunkSize)
604
+ const readSize = Math.min(chunkSize, stat.size - offset)
605
+ await archiveHandle.read(buffer, 0, readSize, offset)
606
+ const chunk = buffer.subarray(0, readSize)
607
+ controller.enqueue(chunk)
608
+ offset += readSize
609
+ }
610
+ controller.close()
611
+ } catch (error) {
612
+ controller.error(error)
613
+ } finally {
614
+ await archiveHandle.close()
615
+ }
616
+ }
617
+ })
618
+ }
619
+ } else {
620
+ // Read from stdin
621
+ if (!process || !process.stdin) {
622
+ await writelnStderr(process, terminal, 'tar: no input provided')
623
+ return 1
624
+ }
625
+ archiveStream = process.stdin
626
+ }
627
+
628
+ // Apply gzip decompression if requested
629
+ let tarStream: ReadableStream<Uint8Array> = archiveStream
630
+ if (options.gzip) {
631
+ tarStream = archiveStream.pipeThrough(new DecompressionStream('gzip') as any) as ReadableStream<Uint8Array>
632
+ }
633
+
634
+ // List contents using modern-tar decoder
635
+ let hasError = false
636
+ let interrupted = false
637
+ const interruptHandler = () => { interrupted = true }
638
+ kernel.terminal.events.on(TerminalEvents.INTERRUPT, interruptHandler)
639
+
640
+ try {
641
+ const decoder = createTarDecoder()
642
+ const entriesStream = tarStream.pipeThrough(decoder)
643
+ const entriesReader = entriesStream.getReader()
644
+
645
+ try {
646
+ while (true) {
647
+ if (interrupted) break
648
+ const { done, value: entry } = await entriesReader.read()
649
+ if (done) break
650
+ if (!entry) continue
651
+
652
+ await writelnStdout(process, terminal, entry.header.name)
653
+ // Drain the body stream since we're just listing
654
+ await entry.body.cancel()
655
+ }
656
+ } finally {
657
+ entriesReader.releaseLock()
658
+ }
659
+ } catch (error) {
660
+ await writelnStderr(process, terminal, `tar: ${error instanceof Error ? error.message : 'Unknown error'}`)
661
+ hasError = true
662
+ } finally {
663
+ kernel.terminal.events.off(TerminalEvents.INTERRUPT, interruptHandler)
664
+ }
665
+
666
+ return hasError ? 1 : 0
667
+ } catch (error) {
668
+ await writelnStderr(process, terminal, `tar: ${error instanceof Error ? error.message : 'Unknown error'}`)
669
+ return 1
670
+ }
671
+ }
672
+
673
+ export function createCommand(kernel: Kernel, shell: Shell, terminal: Terminal): TerminalCommand {
674
+ return new TerminalCommand({
675
+ command: 'tar',
676
+ description: 'Create, extract, or list tar archives',
677
+ kernel,
678
+ shell,
679
+ terminal,
680
+ run: async (pid: number, argv: string[]) => {
681
+ const process = kernel.processes.get(pid) as Process | undefined
682
+
683
+ if (argv.length > 0 && (argv[0] === '--help' || argv[0] === '-h')) {
684
+ printUsage(process, terminal)
685
+ return 0
686
+ }
687
+
688
+ const { options, files } = parseArgs(argv)
689
+
690
+ // Validate operation mode
691
+ const operationCount = [options.create, options.extract, options.list].filter(Boolean).length
692
+ if (operationCount === 0) {
693
+ await writelnStderr(process, terminal, 'tar: You must specify one of the -c, -x, or -t options')
694
+ await writelnStderr(process, terminal, "Try 'tar --help' for more information.")
695
+ return 1
696
+ }
697
+
698
+ if (operationCount > 1) {
699
+ await writelnStderr(process, terminal, 'tar: You may not specify more than one of -c, -x, or -t')
700
+ return 1
701
+ }
702
+
703
+ // Validate file option for create (create always needs a file)
704
+ if (options.create && !options.file) {
705
+ await writelnStderr(process, terminal, 'tar: option requires an argument -- f')
706
+ await writelnStderr(process, terminal, "Try 'tar --help' for more information.")
707
+ return 1
708
+ }
709
+
710
+ // For extract and list, if no file is specified, use stdin
711
+
712
+ // Expand glob patterns in file list
713
+ const expandGlob = async (pattern: string): Promise<string[]> => {
714
+ if (!pattern.includes('*') && !pattern.includes('?')) {
715
+ return [pattern]
716
+ }
717
+
718
+ const lastSlashIndex = pattern.lastIndexOf('/')
719
+ const searchDir = lastSlashIndex !== -1
720
+ ? path.resolve(shell.cwd, pattern.substring(0, lastSlashIndex + 1))
721
+ : shell.cwd
722
+ const globPattern = lastSlashIndex !== -1
723
+ ? pattern.substring(lastSlashIndex + 1)
724
+ : pattern
725
+
726
+ try {
727
+ const entries = await shell.context.fs.promises.readdir(searchDir)
728
+ const regexPattern = globPattern
729
+ .replace(/\./g, '\\.')
730
+ .replace(/\*/g, '.*')
731
+ .replace(/\?/g, '.')
732
+ const regex = new RegExp(`^${regexPattern}$`)
733
+
734
+ const matches = entries.filter(entry => regex.test(entry))
735
+
736
+ if (lastSlashIndex !== -1) {
737
+ const dirPart = pattern.substring(0, lastSlashIndex + 1)
738
+ return matches.map(match => dirPart + match)
739
+ }
740
+ return matches
741
+ } catch (error) {
742
+ return []
743
+ }
744
+ }
745
+
746
+ // Expand glob patterns in file list (shell should have already expanded them, but keep as fallback)
747
+ const expandedFiles: string[] = []
748
+ for (const filePattern of files) {
749
+ if (typeof filePattern !== 'string') {
750
+ // Skip non-string entries (shouldn't happen, but handle gracefully)
751
+ continue
752
+ }
753
+
754
+ // Check if this looks like a glob pattern (shell should have expanded it, but handle as fallback)
755
+ const expanded = await expandGlob(filePattern)
756
+ if (expanded.length === 0) {
757
+ // If glob doesn't match anything, include the pattern as-is (might be a literal path)
758
+ expandedFiles.push(filePattern)
759
+ } else {
760
+ expandedFiles.push(...expanded)
761
+ }
762
+ }
763
+
764
+ // Execute operation
765
+ if (options.create) {
766
+ if (!options.file) {
767
+ await writelnStderr(process, terminal, 'tar: option requires an argument -- f')
768
+ return 1
769
+ }
770
+ return await createArchive(shell, terminal, process, options.file, expandedFiles, options)
771
+ } else if (options.extract) {
772
+ return await extractArchive(kernel, shell, terminal, process, options.file, options)
773
+ } else if (options.list) {
774
+ return await listArchive(kernel, shell, terminal, process, options.file, options)
775
+ }
776
+
777
+ return 1
778
+ }
779
+ })
780
+ }