node-liblzma 2.2.0 → 3.1.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 (54) hide show
  1. package/README.md +446 -766
  2. package/lib/cli/nxz.js +410 -85
  3. package/lib/cli/nxz.js.map +1 -1
  4. package/lib/lzma.browser.d.ts +24 -0
  5. package/lib/lzma.browser.d.ts.map +1 -0
  6. package/lib/lzma.browser.js +30 -0
  7. package/lib/lzma.browser.js.map +1 -0
  8. package/lib/lzma.d.ts.map +1 -1
  9. package/lib/lzma.inline.d.ts +30 -0
  10. package/lib/lzma.inline.d.ts.map +1 -0
  11. package/lib/lzma.inline.js +68 -0
  12. package/lib/lzma.inline.js.map +1 -0
  13. package/lib/lzma.js +20 -13
  14. package/lib/lzma.js.map +1 -1
  15. package/lib/pool.d.ts.map +1 -1
  16. package/lib/pool.js +2 -1
  17. package/lib/pool.js.map +1 -1
  18. package/lib/wasm/bindings.d.ts +109 -0
  19. package/lib/wasm/bindings.d.ts.map +1 -0
  20. package/lib/wasm/bindings.js +320 -0
  21. package/lib/wasm/bindings.js.map +1 -0
  22. package/lib/wasm/compress.d.ts +32 -0
  23. package/lib/wasm/compress.d.ts.map +1 -0
  24. package/lib/wasm/compress.js +47 -0
  25. package/lib/wasm/compress.js.map +1 -0
  26. package/lib/wasm/decompress.d.ts +32 -0
  27. package/lib/wasm/decompress.d.ts.map +1 -0
  28. package/lib/wasm/decompress.js +45 -0
  29. package/lib/wasm/decompress.js.map +1 -0
  30. package/lib/wasm/index.d.ts +14 -0
  31. package/lib/wasm/index.d.ts.map +1 -0
  32. package/lib/wasm/index.js +18 -0
  33. package/lib/wasm/index.js.map +1 -0
  34. package/lib/wasm/liblzma.inline.d.ts +10 -0
  35. package/lib/wasm/liblzma.inline.d.ts.map +1 -0
  36. package/lib/wasm/liblzma.inline.js +10 -0
  37. package/lib/wasm/liblzma.inline.js.map +1 -0
  38. package/lib/wasm/memory.d.ts +57 -0
  39. package/lib/wasm/memory.d.ts.map +1 -0
  40. package/lib/wasm/memory.js +110 -0
  41. package/lib/wasm/memory.js.map +1 -0
  42. package/lib/wasm/stream.d.ts +35 -0
  43. package/lib/wasm/stream.d.ts.map +1 -0
  44. package/lib/wasm/stream.js +168 -0
  45. package/lib/wasm/stream.js.map +1 -0
  46. package/lib/wasm/types.d.ts +77 -0
  47. package/lib/wasm/types.d.ts.map +1 -0
  48. package/lib/wasm/types.js +55 -0
  49. package/lib/wasm/types.js.map +1 -0
  50. package/lib/wasm/utils.d.ts +62 -0
  51. package/lib/wasm/utils.d.ts.map +1 -0
  52. package/lib/wasm/utils.js +164 -0
  53. package/lib/wasm/utils.js.map +1 -0
  54. package/package.json +31 -8
package/lib/cli/nxz.js CHANGED
@@ -3,10 +3,25 @@
3
3
  * nxz - Node.js XZ compression CLI
4
4
  * A portable xz-like command line tool using node-liblzma
5
5
  */
6
- import { createReadStream, createWriteStream, existsSync, readFileSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
6
+ import { closeSync, createReadStream, createWriteStream, existsSync, fstatSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
7
+ import { performance } from 'node:perf_hooks';
7
8
  import { pipeline } from 'node:stream/promises';
8
9
  import { parseArgs } from 'node:util';
9
10
  import { check, createUnxz, createXz, hasThreads, isXZ, parseFileIndex, preset, unxzSync, versionString, xzSync, } from '../lzma.js';
11
+ let tarXzModule = null;
12
+ async function loadTarXz() {
13
+ if (!tarXzModule) {
14
+ try {
15
+ // Variable import specifier prevents TypeScript from resolving at compile time
16
+ const modName = 'tar-xz';
17
+ tarXzModule = (await import(modName));
18
+ }
19
+ catch {
20
+ throw new Error('tar-xz package not available. Install it with: pnpm add tar-xz');
21
+ }
22
+ }
23
+ return tarXzModule;
24
+ }
10
25
  /** Size threshold for using streams vs sync (1 MB) */
11
26
  const STREAM_THRESHOLD_BYTES = 1024 * 1024;
12
27
  /** Exit codes matching xz conventions */
@@ -65,10 +80,13 @@ function parseCliArgs(args) {
65
80
  compress: { type: 'boolean', short: 'z', default: false },
66
81
  decompress: { type: 'boolean', short: 'd', default: false },
67
82
  list: { type: 'boolean', short: 'l', default: false },
83
+ benchmark: { type: 'boolean', short: 'B', default: false },
84
+ tar: { type: 'boolean', short: 'T', default: false },
68
85
  keep: { type: 'boolean', short: 'k', default: false },
69
86
  force: { type: 'boolean', short: 'f', default: false },
70
87
  stdout: { type: 'boolean', short: 'c', default: false },
71
88
  output: { type: 'string', short: 'o' },
89
+ strip: { type: 'string', default: '0' },
72
90
  verbose: { type: 'boolean', short: 'v', default: false },
73
91
  quiet: { type: 'boolean', short: 'q', default: false },
74
92
  help: { type: 'boolean', short: 'h', default: false },
@@ -82,6 +100,8 @@ function parseCliArgs(args) {
82
100
  compress: values.compress === true,
83
101
  decompress: values.decompress === true,
84
102
  list: values.list === true,
103
+ benchmark: values.benchmark === true,
104
+ tar: values.tar === true,
85
105
  keep: values.keep === true,
86
106
  force: values.force === true,
87
107
  stdout: values.stdout === true,
@@ -92,6 +112,7 @@ function parseCliArgs(args) {
92
112
  version: values.version === true,
93
113
  preset: presetLevel,
94
114
  extreme: values.extreme === true,
115
+ strip: Number.parseInt(String(values.strip ?? '0'), 10),
95
116
  files: positionals,
96
117
  };
97
118
  }
@@ -109,12 +130,18 @@ Operation mode:
109
130
  -z, --compress force compression
110
131
  -d, --decompress force decompression
111
132
  -l, --list list information about .xz files
133
+ -B, --benchmark benchmark native vs WASM compression
134
+
135
+ Archive mode (tar.xz):
136
+ -T, --tar treat file as tar.xz archive
137
+ Auto-detected for .tar.xz and .txz files
138
+ --strip=N strip N leading path components on extract (default: 0)
112
139
 
113
140
  Operation modifiers:
114
141
  -k, --keep keep (don't delete) input files
115
142
  -f, --force force overwrite of output file
116
143
  -c, --stdout write to standard output and don't delete input files
117
- -o, --output=FILE write output to FILE
144
+ -o, --output=FILE write output to FILE (or directory for tar extract)
118
145
 
119
146
  Compression presets:
120
147
  -0 ... -9 compression preset level (default: 6)
@@ -128,6 +155,14 @@ Other options:
128
155
 
129
156
  With no FILE, or when FILE is -, read standard input.
130
157
 
158
+ Examples:
159
+ nxz file.txt compress file.txt to file.txt.xz
160
+ nxz -d file.xz decompress file.xz
161
+ nxz -T -z dir/ create archive.tar.xz from dir/
162
+ nxz -l archive.tar.xz list contents of archive
163
+ nxz -d archive.tar.xz extract archive to current directory
164
+ nxz -d -o dest/ arch.txz extract archive to dest/
165
+
131
166
  Report bugs at: https://github.com/oorabona/node-liblzma/issues`);
132
167
  }
133
168
  /**
@@ -153,10 +188,33 @@ Thread support: ${threadSupport}
153
188
  Copyright (C) ${year} Olivier ORABONA
154
189
  License: LGPL-3.0`);
155
190
  }
191
+ /**
192
+ * Check if file is a tar.xz archive based on extension
193
+ */
194
+ function isTarXzFile(filename) {
195
+ return filename.endsWith('.tar.xz') || filename.endsWith('.txz');
196
+ }
156
197
  /**
157
198
  * Determine operation mode based on options and file extension
158
199
  */
159
200
  function determineMode(options, filename) {
201
+ if (options.benchmark)
202
+ return 'benchmark';
203
+ // Tar mode: explicit -T flag or auto-detected from extension
204
+ const isTar = options.tar || isTarXzFile(filename);
205
+ if (isTar) {
206
+ if (options.list)
207
+ return 'tar-list';
208
+ if (options.decompress)
209
+ return 'tar-extract';
210
+ if (options.compress)
211
+ return 'tar-create';
212
+ // Auto-detect: if file exists and is .tar.xz/.txz, extract; otherwise create
213
+ if (isTarXzFile(filename)) {
214
+ return options.list ? 'tar-list' : 'tar-extract';
215
+ }
216
+ return 'tar-create';
217
+ }
160
218
  if (options.list)
161
219
  return 'list';
162
220
  if (options.decompress)
@@ -240,71 +298,96 @@ function listFile(filename, options) {
240
298
  /**
241
299
  * Compress a file
242
300
  */
301
+ /** Resolve output file and check for overwrites. Returns null for stdout mode. */
302
+ function resolveOutputFile(inputFile, mode, options) {
303
+ if (options.stdout)
304
+ return null;
305
+ return options.output ?? getOutputFilename(inputFile, mode);
306
+ }
307
+ /** Attach verbose progress tracking to a stream. */
308
+ function attachProgress(stream, inputFile, totalSize) {
309
+ let lastPercent = -1;
310
+ stream.on('progress', ({ bytesRead, bytesWritten }) => {
311
+ const percent = Math.floor((bytesRead / totalSize) * 100);
312
+ if (percent !== lastPercent) {
313
+ lastPercent = percent;
314
+ process.stderr.write(`\r${inputFile}: ${percent}% (${formatBytes(bytesRead)} -> ${formatBytes(bytesWritten)})`);
315
+ }
316
+ });
317
+ }
318
+ /** Clean up a partial output file and reset tracking state. */
319
+ function cleanupPartialOutput(outputFile) {
320
+ if (outputFile && existsSync(outputFile)) {
321
+ try {
322
+ unlinkSync(outputFile);
323
+ }
324
+ catch {
325
+ // Ignore cleanup errors
326
+ }
327
+ }
328
+ currentOutputFile = null;
329
+ }
330
+ /** Read file content using fd-based operations (avoids TOCTOU race). Returns [data, fileSize]. */
331
+ function readFileSafe(inputFile) {
332
+ const fd = openSync(inputFile, 'r');
333
+ try {
334
+ const size = fstatSync(fd).size;
335
+ const data = readFileSync(fd);
336
+ return { data, size };
337
+ }
338
+ finally {
339
+ closeSync(fd);
340
+ }
341
+ }
342
+ /** Write compressed/decompressed output to stdout or file. */
343
+ function writeOutput(output, outputFile) {
344
+ if (outputFile) {
345
+ writeFileSync(outputFile, output);
346
+ }
347
+ else {
348
+ process.stdout.write(output);
349
+ }
350
+ }
351
+ /** Delete the original file after successful compression/decompression. */
352
+ function removeOriginalIfNeeded(inputFile, options) {
353
+ if (!options.keep && !options.stdout) {
354
+ unlinkSync(inputFile);
355
+ }
356
+ }
243
357
  async function compressFile(inputFile, options) {
244
- const outputFile = options.stdout
245
- ? null
246
- : (options.output ?? getOutputFilename(inputFile, 'compress'));
247
- // Check if output exists
358
+ const outputFile = resolveOutputFile(inputFile, 'compress', options);
248
359
  if (outputFile && existsSync(outputFile) && !options.force) {
249
360
  warn(`nxz: ${outputFile}: File already exists; use -f to overwrite`);
250
361
  return EXIT_ERROR;
251
362
  }
252
- // Track output file for SIGINT cleanup
253
363
  currentOutputFile = outputFile;
254
364
  try {
255
- const stat = statSync(inputFile);
365
+ const { data, size } = readFileSafe(inputFile);
256
366
  const presetValue = options.extreme ? options.preset | preset.EXTREME : options.preset;
257
- if (options.stdout) {
258
- // Write to stdout
259
- const input = readFileSync(inputFile);
260
- const compressed = xzSync(input, { preset: presetValue });
261
- process.stdout.write(compressed);
262
- }
263
- else if (stat.size <= STREAM_THRESHOLD_BYTES) {
264
- // Small file: use sync
265
- const input = readFileSync(inputFile);
266
- const compressed = xzSync(input, { preset: presetValue });
267
- writeFileSync(outputFile, compressed);
367
+ if (!outputFile || size <= STREAM_THRESHOLD_BYTES) {
368
+ // Stdout or small file: sync compression
369
+ const compressed = xzSync(data, { preset: presetValue });
370
+ writeOutput(compressed, outputFile);
268
371
  }
269
372
  else {
270
- // Large file: use streams
373
+ // Large file: stream compression with optional progress
271
374
  const compressor = createXz({ preset: presetValue });
272
375
  if (options.verbose) {
273
- let lastPercent = -1;
274
- compressor.on('progress', ({ bytesRead, bytesWritten }) => {
275
- const percent = Math.floor((bytesRead / stat.size) * 100);
276
- if (percent !== lastPercent) {
277
- lastPercent = percent;
278
- process.stderr.write(`\r${inputFile}: ${percent}% (${formatBytes(bytesRead)} -> ${formatBytes(bytesWritten)})`);
279
- }
280
- });
376
+ attachProgress(compressor, inputFile, size);
281
377
  }
282
378
  await pipeline(createReadStream(inputFile), compressor, createWriteStream(outputFile));
283
379
  if (options.verbose) {
284
380
  process.stderr.write('\n');
285
381
  }
286
382
  }
287
- // Delete original unless -k or -c
288
- if (!options.keep && !options.stdout) {
289
- unlinkSync(inputFile);
290
- }
291
- // Clear tracking after successful completion
383
+ removeOriginalIfNeeded(inputFile, options);
292
384
  currentOutputFile = null;
293
385
  return EXIT_SUCCESS;
294
386
  }
295
387
  catch (err) {
296
388
  const message = err instanceof Error ? err.message : String(err);
297
389
  warn(`nxz: ${inputFile}: ${message}`);
298
- // Cleanup partial output
299
- if (outputFile && existsSync(outputFile)) {
300
- try {
301
- unlinkSync(outputFile);
302
- }
303
- catch {
304
- // Ignore cleanup errors
305
- }
306
- }
307
- currentOutputFile = null;
390
+ cleanupPartialOutput(outputFile);
308
391
  return EXIT_ERROR;
309
392
  }
310
393
  }
@@ -312,99 +395,333 @@ async function compressFile(inputFile, options) {
312
395
  * Decompress a file
313
396
  */
314
397
  async function decompressFile(inputFile, options) {
315
- const outputFile = options.stdout
316
- ? null
317
- : (options.output ?? getOutputFilename(inputFile, 'decompress'));
318
- // Check if output exists
398
+ const outputFile = resolveOutputFile(inputFile, 'decompress', options);
319
399
  if (outputFile && existsSync(outputFile) && !options.force) {
320
400
  warn(`nxz: ${outputFile}: File already exists; use -f to overwrite`);
321
401
  return EXIT_ERROR;
322
402
  }
323
- // Track output file for SIGINT cleanup
324
403
  currentOutputFile = outputFile;
325
404
  try {
326
- // Validate XZ format
327
- const fd = readFileSync(inputFile);
328
- if (!isXZ(fd)) {
405
+ const { data, size } = readFileSafe(inputFile);
406
+ if (!isXZ(data)) {
329
407
  warn(`nxz: ${inputFile}: File format not recognized`);
330
408
  currentOutputFile = null;
331
409
  return EXIT_ERROR;
332
410
  }
333
- const stat = statSync(inputFile);
334
- if (options.stdout) {
335
- // Write to stdout
336
- const decompressed = unxzSync(fd);
337
- process.stdout.write(decompressed);
338
- }
339
- else if (stat.size <= STREAM_THRESHOLD_BYTES) {
340
- // Small file: use sync
341
- const decompressed = unxzSync(fd);
342
- writeFileSync(outputFile, decompressed);
411
+ if (!outputFile || size <= STREAM_THRESHOLD_BYTES) {
412
+ // Stdout or small file: sync decompression
413
+ const decompressed = unxzSync(data);
414
+ writeOutput(decompressed, outputFile);
343
415
  }
344
416
  else {
345
- // Large file: use streams with progress
417
+ // Large file: stream decompression with optional progress
346
418
  const decompressor = createUnxz();
347
419
  if (options.verbose) {
348
- let lastPercent = -1;
349
- decompressor.on('progress', ({ bytesRead, bytesWritten }) => {
350
- const percent = Math.floor((bytesRead / stat.size) * 100);
351
- if (percent !== lastPercent) {
352
- lastPercent = percent;
353
- process.stderr.write(`\r${inputFile}: ${percent}% (${formatBytes(bytesRead)} -> ${formatBytes(bytesWritten)})`);
354
- }
355
- });
420
+ attachProgress(decompressor, inputFile, size);
356
421
  }
357
422
  await pipeline(createReadStream(inputFile), decompressor, createWriteStream(outputFile));
358
423
  if (options.verbose) {
359
424
  process.stderr.write('\n');
360
425
  }
361
426
  }
362
- // Delete original unless -k or -c
363
- if (!options.keep && !options.stdout) {
364
- unlinkSync(inputFile);
365
- }
366
- // Clear tracking after successful completion
427
+ removeOriginalIfNeeded(inputFile, options);
367
428
  currentOutputFile = null;
368
429
  return EXIT_SUCCESS;
369
430
  }
370
431
  catch (err) {
371
432
  const message = err instanceof Error ? err.message : String(err);
372
433
  warn(`nxz: ${inputFile}: ${message}`);
373
- // Cleanup partial output
374
- if (outputFile && existsSync(outputFile)) {
375
- try {
376
- unlinkSync(outputFile);
434
+ cleanupPartialOutput(outputFile);
435
+ return EXIT_ERROR;
436
+ }
437
+ }
438
+ /**
439
+ * Process a single file
440
+ */
441
+ /** Measure execution time of an async function in milliseconds. */
442
+ async function measureAsync(fn) {
443
+ const start = performance.now();
444
+ const result = await fn();
445
+ return { result, ms: performance.now() - start };
446
+ }
447
+ /** Measure execution time of a sync function in milliseconds. */
448
+ function measureSync(fn) {
449
+ const start = performance.now();
450
+ const result = fn();
451
+ return { result, ms: performance.now() - start };
452
+ }
453
+ /** Format milliseconds for display. */
454
+ function formatMs(ms) {
455
+ if (ms < 1)
456
+ return `${(ms * 1000).toFixed(0)} µs`;
457
+ if (ms < 1000)
458
+ return `${ms.toFixed(1)} ms`;
459
+ return `${(ms / 1000).toFixed(2)} s`;
460
+ }
461
+ /** Format a ratio as "Nx faster" or "Nx slower". */
462
+ function formatSpeedup(baseline, candidate) {
463
+ if (candidate === 0 || baseline === 0)
464
+ return 'N/A';
465
+ const ratio = baseline / candidate;
466
+ if (ratio >= 1)
467
+ return `${ratio.toFixed(1)}x faster`;
468
+ return `${(1 / ratio).toFixed(1)}x slower`;
469
+ }
470
+ /**
471
+ * Benchmark native vs WASM compression/decompression on a file.
472
+ */
473
+ async function benchmarkFile(inputFile, options) {
474
+ // Initialize WASM module with Node.js file-based loader
475
+ const { initModule, resetModule } = await import('../wasm/bindings.js');
476
+ const { xzAsync: wasmXzAsync, unxzAsync: wasmUnxzAsync } = await import('../wasm/index.js');
477
+ const { readFileSync: fsReadFileSync } = await import('node:fs');
478
+ const { fileURLToPath } = await import('node:url');
479
+ const { dirname, join } = await import('node:path');
480
+ const wasmDir = dirname(fileURLToPath(import.meta.url));
481
+ const wasmPath = join(wasmDir, '..', 'wasm', 'liblzma.wasm');
482
+ resetModule();
483
+ await initModule(async () => {
484
+ const { default: createLZMA } = await import('../wasm/liblzma.js');
485
+ const wasmBinary = fsReadFileSync(wasmPath);
486
+ return (await createLZMA({ wasmBinary }));
487
+ });
488
+ const { data, size } = readFileSafe(inputFile);
489
+ const presetValue = options.extreme ? options.preset | preset.EXTREME : options.preset;
490
+ console.error(`\nBenchmark: ${inputFile} (${formatBytes(size)}, preset ${options.preset}${options.extreme ? 'e' : ''})\n`);
491
+ // --- Compression ---
492
+ const nativeCompress = measureSync(() => xzSync(data, { preset: presetValue }));
493
+ const wasmCompress = await measureAsync(() => wasmXzAsync(data, { preset: presetValue }));
494
+ // --- Decompression ---
495
+ const nativeDecompress = measureSync(() => unxzSync(nativeCompress.result));
496
+ const wasmDecompress = await measureAsync(() => wasmUnxzAsync(wasmCompress.result));
497
+ // --- Verify correctness ---
498
+ const nativeOk = Buffer.compare(nativeDecompress.result, data) === 0;
499
+ const wasmOk = Buffer.compare(Buffer.from(wasmDecompress.result), data) === 0;
500
+ // --- Cross-decompression ---
501
+ const crossNativeToWasm = await measureAsync(() => wasmUnxzAsync(nativeCompress.result));
502
+ const crossWasmToNative = measureSync(() => unxzSync(Buffer.from(wasmCompress.result)));
503
+ const crossOk1 = Buffer.compare(Buffer.from(crossNativeToWasm.result), data) === 0;
504
+ const crossOk2 = Buffer.compare(crossWasmToNative.result, data) === 0;
505
+ // --- Output table ---
506
+ const col1 = 22;
507
+ const col2 = 16;
508
+ const col3 = 16;
509
+ const col4 = 18;
510
+ const sep = '-'.repeat(col1 + col2 + col3 + col4 + 7);
511
+ const row = (label, native, wasm, diff) => ` ${label.padEnd(col1)} ${native.padStart(col2)} ${wasm.padStart(col3)} ${diff.padStart(col4)}`;
512
+ console.error(sep);
513
+ console.error(row('', 'Native', 'WASM', 'Comparison'));
514
+ console.error(sep);
515
+ console.error(row('Compress time', formatMs(nativeCompress.ms), formatMs(wasmCompress.ms), formatSpeedup(wasmCompress.ms, nativeCompress.ms)));
516
+ console.error(row('Compressed size', formatBytes(nativeCompress.result.length), formatBytes(wasmCompress.result.length), nativeCompress.result.length === wasmCompress.result.length
517
+ ? 'identical'
518
+ : `${((wasmCompress.result.length / nativeCompress.result.length - 1) * 100).toFixed(1)}%`));
519
+ console.error(row('Decompress time', formatMs(nativeDecompress.ms), formatMs(wasmDecompress.ms), formatSpeedup(wasmDecompress.ms, nativeDecompress.ms)));
520
+ console.error(row('Roundtrip OK', nativeOk ? 'YES' : 'FAIL', wasmOk ? 'YES' : 'FAIL', ''));
521
+ console.error(sep);
522
+ console.error(row('Cross: Native→WASM', '', formatMs(crossNativeToWasm.ms), crossOk1 ? 'OK' : 'FAIL'));
523
+ console.error(row('Cross: WASM→Native', formatMs(crossWasmToNative.ms), '', crossOk2 ? 'OK' : 'FAIL'));
524
+ console.error(sep);
525
+ const allOk = nativeOk && wasmOk && crossOk1 && crossOk2;
526
+ console.error(`\n Verdict: ${allOk ? 'ALL PASS — Both backends produce valid output' : 'FAIL — Data mismatch detected'}\n`);
527
+ // Reset WASM module to not interfere with other operations
528
+ resetModule();
529
+ return allOk ? EXIT_SUCCESS : EXIT_ERROR;
530
+ }
531
+ /**
532
+ * List contents of a tar.xz archive
533
+ */
534
+ async function listTarFile(filename, options) {
535
+ try {
536
+ const tarXz = await loadTarXz();
537
+ const entries = await tarXz.list({ file: filename });
538
+ if (options.verbose) {
539
+ // Verbose format with permissions, size, date
540
+ for (const entry of entries) {
541
+ const typeChar = entry.type === '5' ? 'd' : '-';
542
+ const modeStr = entry.mode?.toString(8).padStart(4, '0') ?? '0644';
543
+ const size = formatBytes(entry.size).padStart(10);
544
+ const date = entry.mtime ? new Date(entry.mtime * 1000).toISOString().slice(0, 16) : '';
545
+ console.log(`${typeChar}${modeStr} ${size} ${date} ${entry.name}`);
377
546
  }
378
- catch {
379
- // Ignore cleanup errors
547
+ }
548
+ else {
549
+ // Simple format: just names
550
+ for (const entry of entries) {
551
+ console.log(entry.name);
552
+ }
553
+ }
554
+ if (!options.quiet) {
555
+ console.error(`\nTotal: ${entries.length} entries`);
556
+ }
557
+ return EXIT_SUCCESS;
558
+ }
559
+ catch (err) {
560
+ const message = err instanceof Error ? err.message : String(err);
561
+ warn(`nxz: ${filename}: ${message}`);
562
+ return EXIT_ERROR;
563
+ }
564
+ }
565
+ /**
566
+ * Create a tar.xz archive from files/directories
567
+ */
568
+ function findCommonParent(paths) {
569
+ if (paths.length === 0)
570
+ return process.cwd();
571
+ if (paths.length === 1)
572
+ return paths[0];
573
+ const parts = paths.map((p) => p.split('/'));
574
+ const common = [];
575
+ for (let i = 0; i < parts[0].length; i++) {
576
+ const segment = parts[0][i];
577
+ if (parts.every((p) => p[i] === segment)) {
578
+ common.push(segment);
579
+ }
580
+ else {
581
+ break;
582
+ }
583
+ }
584
+ return common.join('/') || '/';
585
+ }
586
+ async function createTarFile(files, options) {
587
+ try {
588
+ const tarXz = await loadTarXz();
589
+ const path = await import('node:path');
590
+ // Determine output filename
591
+ let outputFile = options.output;
592
+ if (!outputFile) {
593
+ // Use first file/dir name as base
594
+ const base = path.basename(files[0]).replace(/\/$/, '');
595
+ outputFile = `${base}.tar.xz`;
596
+ }
597
+ if (existsSync(outputFile) && !options.force) {
598
+ warn(`nxz: ${outputFile}: File already exists; use -f to overwrite`);
599
+ return EXIT_ERROR;
600
+ }
601
+ currentOutputFile = outputFile;
602
+ // Resolve all files to absolute paths for consistent handling
603
+ const resolvedFiles = files.map((f) => path.resolve(f));
604
+ // Determine cwd (common parent directory)
605
+ let cwd;
606
+ if (resolvedFiles.length === 1 && statSync(resolvedFiles[0]).isDirectory()) {
607
+ cwd = resolvedFiles[0];
608
+ }
609
+ else {
610
+ // Find common parent of all files
611
+ const parents = resolvedFiles.map((f) => (statSync(f).isDirectory() ? f : path.dirname(f)));
612
+ // Use the first file's parent as cwd for simple cases
613
+ cwd = parents.length === 1 ? parents[0] : findCommonParent(parents);
614
+ }
615
+ // Collect files to archive (relative to cwd)
616
+ const filesToArchive = [];
617
+ for (const file of resolvedFiles) {
618
+ if (statSync(file).isDirectory()) {
619
+ const { readdirSync } = await import('node:fs');
620
+ const entries = readdirSync(file, { recursive: true, withFileTypes: true });
621
+ const dirRelative = path.relative(cwd, file);
622
+ for (const entry of entries) {
623
+ if (entry.isFile()) {
624
+ const entryPath = entry.parentPath === file
625
+ ? entry.name
626
+ : `${entry.parentPath.slice(file.length + 1)}/${entry.name}`;
627
+ filesToArchive.push(dirRelative ? `${dirRelative}/${entryPath}` : entryPath);
628
+ }
629
+ }
630
+ }
631
+ else {
632
+ filesToArchive.push(path.relative(cwd, file));
380
633
  }
381
634
  }
635
+ const presetValue = options.extreme ? options.preset | preset.EXTREME : options.preset;
636
+ if (options.verbose) {
637
+ console.error(`Creating ${outputFile} from ${filesToArchive.length} files...`);
638
+ }
639
+ await tarXz.create({
640
+ file: outputFile,
641
+ cwd,
642
+ files: filesToArchive,
643
+ preset: presetValue,
644
+ });
645
+ if (options.verbose) {
646
+ const stats = statSync(outputFile);
647
+ console.error(`Created ${outputFile} (${formatBytes(stats.size)})`);
648
+ }
382
649
  currentOutputFile = null;
650
+ return EXIT_SUCCESS;
651
+ }
652
+ catch (err) {
653
+ const message = err instanceof Error ? err.message : String(err);
654
+ warn(`nxz: ${message}`);
655
+ cleanupPartialOutput(currentOutputFile);
383
656
  return EXIT_ERROR;
384
657
  }
385
658
  }
386
659
  /**
387
- * Process a single file
660
+ * Extract a tar.xz archive
388
661
  */
662
+ async function extractTarFile(filename, options) {
663
+ try {
664
+ const tarXz = await loadTarXz();
665
+ // Determine output directory
666
+ const outputDir = options.output ?? process.cwd();
667
+ // Create output directory if it doesn't exist
668
+ if (!existsSync(outputDir)) {
669
+ const { mkdirSync } = await import('node:fs');
670
+ mkdirSync(outputDir, { recursive: true });
671
+ }
672
+ if (options.verbose) {
673
+ console.error(`Extracting ${filename} to ${outputDir}...`);
674
+ }
675
+ const entries = await tarXz.extract({
676
+ file: filename,
677
+ cwd: outputDir,
678
+ strip: options.strip,
679
+ });
680
+ if (options.verbose) {
681
+ for (const entry of entries) {
682
+ console.error(` ${entry.name}`);
683
+ }
684
+ console.error(`\nExtracted ${entries.length} entries`);
685
+ }
686
+ // Delete original if not keeping
687
+ removeOriginalIfNeeded(filename, options);
688
+ return EXIT_SUCCESS;
689
+ }
690
+ catch (err) {
691
+ const message = err instanceof Error ? err.message : String(err);
692
+ warn(`nxz: ${filename}: ${message}`);
693
+ return EXIT_ERROR;
694
+ }
695
+ }
389
696
  async function processFile(filename, options) {
390
697
  // Check file exists
391
698
  if (!existsSync(filename)) {
392
699
  warn(`nxz: ${filename}: No such file or directory`);
393
700
  return EXIT_ERROR;
394
701
  }
395
- // Check if it's a directory
396
- if (statSync(filename).isDirectory()) {
702
+ const isDir = statSync(filename).isDirectory();
703
+ const mode = determineMode(options, filename);
704
+ // Directories are only allowed for tar-create mode
705
+ if (isDir && mode !== 'tar-create') {
397
706
  warn(`nxz: ${filename}: Is a directory, skipping`);
398
707
  return EXIT_ERROR;
399
708
  }
400
- const mode = determineMode(options, filename);
401
709
  switch (mode) {
402
710
  case 'list':
403
711
  return listFile(filename, options);
712
+ case 'benchmark':
713
+ return benchmarkFile(filename, options);
404
714
  case 'compress':
405
715
  return compressFile(filename, options);
406
716
  case 'decompress':
407
717
  return decompressFile(filename, options);
718
+ case 'tar-list':
719
+ return listTarFile(filename, options);
720
+ case 'tar-extract':
721
+ return extractTarFile(filename, options);
722
+ case 'tar-create':
723
+ // tar-create is handled separately since it takes multiple files
724
+ return EXIT_SUCCESS;
408
725
  }
409
726
  }
410
727
  /**
@@ -462,6 +779,14 @@ async function main() {
462
779
  const exitCode = await processStdin(options);
463
780
  process.exit(exitCode);
464
781
  }
782
+ // Check for tar-create mode: -T with files that aren't .tar.xz archives
783
+ if (options.files.length > 0) {
784
+ const mode = determineMode(options, options.files[0]);
785
+ if (mode === 'tar-create') {
786
+ const exitCode = await createTarFile(options.files, options);
787
+ process.exit(exitCode);
788
+ }
789
+ }
465
790
  // Process each file
466
791
  let exitCode = EXIT_SUCCESS;
467
792
  for (const file of options.files) {