@ecmaos/coreutils 0.2.1 → 0.3.0

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