@push.rocks/smartarchive 4.2.4 → 5.0.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 (39) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/bzip2/bititerator.d.ts +6 -1
  3. package/dist_ts/bzip2/bititerator.js +30 -24
  4. package/dist_ts/bzip2/bzip2.d.ts +27 -12
  5. package/dist_ts/bzip2/bzip2.js +325 -263
  6. package/dist_ts/bzip2/index.d.ts +4 -1
  7. package/dist_ts/bzip2/index.js +36 -38
  8. package/dist_ts/classes.archiveanalyzer.d.ts +25 -5
  9. package/dist_ts/classes.archiveanalyzer.js +19 -8
  10. package/dist_ts/classes.bzip2tools.d.ts +1 -1
  11. package/dist_ts/classes.gziptools.d.ts +43 -6
  12. package/dist_ts/classes.gziptools.js +76 -24
  13. package/dist_ts/classes.smartarchive.d.ts +198 -16
  14. package/dist_ts/classes.smartarchive.js +652 -79
  15. package/dist_ts/classes.tartools.d.ts +31 -4
  16. package/dist_ts/classes.tartools.js +93 -32
  17. package/dist_ts/classes.ziptools.d.ts +47 -12
  18. package/dist_ts/classes.ziptools.js +128 -23
  19. package/dist_ts/errors.d.ts +44 -0
  20. package/dist_ts/errors.js +62 -0
  21. package/dist_ts/index.d.ts +4 -0
  22. package/dist_ts/index.js +9 -1
  23. package/dist_ts/interfaces.d.ts +119 -0
  24. package/dist_ts/interfaces.js +2 -0
  25. package/package.json +1 -1
  26. package/readme.hints.md +69 -23
  27. package/readme.md +360 -274
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/bzip2/bititerator.ts +43 -27
  30. package/ts/bzip2/bzip2.ts +289 -171
  31. package/ts/bzip2/index.ts +52 -50
  32. package/ts/classes.archiveanalyzer.ts +42 -28
  33. package/ts/classes.gziptools.ts +91 -33
  34. package/ts/classes.smartarchive.ts +765 -140
  35. package/ts/classes.tartools.ts +124 -52
  36. package/ts/classes.ziptools.ts +147 -34
  37. package/ts/errors.ts +70 -0
  38. package/ts/index.ts +11 -0
  39. package/ts/interfaces.ts +136 -0
@@ -1,238 +1,863 @@
1
1
  import * as plugins from './plugins.js';
2
- import * as paths from './paths.js';
2
+ import type {
3
+ IArchiveEntry,
4
+ IArchiveEntryInfo,
5
+ IArchiveInfo,
6
+ TArchiveFormat,
7
+ TCompressionLevel,
8
+ TEntryFilter,
9
+ } from './interfaces.js';
3
10
 
4
11
  import { Bzip2Tools } from './classes.bzip2tools.js';
5
12
  import { GzipTools } from './classes.gziptools.js';
6
13
  import { TarTools } from './classes.tartools.js';
7
14
  import { ZipTools } from './classes.ziptools.js';
15
+ import { ArchiveAnalyzer, type IAnalyzedResult } from './classes.archiveanalyzer.js';
8
16
 
9
- import {
10
- ArchiveAnalyzer,
11
- type IAnalyzedResult,
12
- } from './classes.archiveanalyzer.js';
13
-
14
- import type { from } from '@push.rocks/smartrx/dist_ts/smartrx.plugins.rxjs.js';
17
+ /**
18
+ * Pending directory entry for async resolution
19
+ */
20
+ interface IPendingDirectory {
21
+ sourcePath: string;
22
+ archiveBase?: string;
23
+ }
15
24
 
25
+ /**
26
+ * Main class for archive manipulation with fluent API
27
+ * Supports TAR, ZIP, GZIP, and BZIP2 formats
28
+ *
29
+ * @example Extraction from URL
30
+ * ```typescript
31
+ * await SmartArchive.create()
32
+ * .url('https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz')
33
+ * .stripComponents(1)
34
+ * .extract('./node_modules/lodash');
35
+ * ```
36
+ *
37
+ * @example Creation with thenable
38
+ * ```typescript
39
+ * const archive = await SmartArchive.create()
40
+ * .format('tar.gz')
41
+ * .compression(9)
42
+ * .entry('config.json', JSON.stringify(config))
43
+ * .directory('./src');
44
+ * ```
45
+ */
16
46
  export class SmartArchive {
17
- // STATIC
18
- public static async fromArchiveUrl(urlArg: string): Promise<SmartArchive> {
19
- const smartArchiveInstance = new SmartArchive();
20
- smartArchiveInstance.sourceUrl = urlArg;
21
- return smartArchiveInstance;
22
- }
47
+ // ============================================
48
+ // STATIC ENTRY POINT
49
+ // ============================================
23
50
 
24
- public static async fromArchiveFile(
25
- filePathArg: string,
26
- ): Promise<SmartArchive> {
27
- const smartArchiveInstance = new SmartArchive();
28
- smartArchiveInstance.sourceFilePath = filePathArg;
29
- return smartArchiveInstance;
51
+ /**
52
+ * Create a new SmartArchive instance for fluent configuration
53
+ */
54
+ public static create(): SmartArchive {
55
+ return new SmartArchive();
30
56
  }
31
57
 
32
- public static async fromArchiveStream(
33
- streamArg:
34
- | plugins.stream.Readable
35
- | plugins.stream.Duplex
36
- | plugins.stream.Transform,
37
- ): Promise<SmartArchive> {
38
- const smartArchiveInstance = new SmartArchive();
39
- smartArchiveInstance.sourceStream = streamArg;
40
- return smartArchiveInstance;
41
- }
58
+ // ============================================
59
+ // TOOLS (public for internal use)
60
+ // ============================================
42
61
 
43
- // INSTANCE
44
62
  public tarTools = new TarTools();
45
63
  public zipTools = new ZipTools();
46
64
  public gzipTools = new GzipTools();
47
65
  public bzip2Tools = new Bzip2Tools(this);
48
66
  public archiveAnalyzer = new ArchiveAnalyzer(this);
49
67
 
50
- public sourceUrl: string;
51
- public sourceFilePath: string;
52
- public sourceStream:
53
- | plugins.stream.Readable
54
- | plugins.stream.Duplex
55
- | plugins.stream.Transform;
68
+ // ============================================
69
+ // SOURCE STATE (extraction mode)
70
+ // ============================================
71
+
72
+ private sourceUrl?: string;
73
+ private sourceFilePath?: string;
74
+ private sourceStream?: plugins.stream.Readable | plugins.stream.Duplex | plugins.stream.Transform;
75
+
76
+ // ============================================
77
+ // CREATION STATE
78
+ // ============================================
79
+
80
+ private archiveBuffer?: Buffer;
81
+ private creationFormat?: TArchiveFormat;
82
+ private _compressionLevel: TCompressionLevel = 6;
83
+ private pendingEntries: IArchiveEntry[] = [];
84
+ private pendingDirectories: IPendingDirectory[] = [];
56
85
 
57
- public archiveName: string;
58
- public singleFileMode: boolean = false;
86
+ // ============================================
87
+ // FLUENT STATE
88
+ // ============================================
59
89
 
60
- public addedDirectories: string[] = [];
61
- public addedFiles: (
62
- | plugins.smartfile.SmartFile
63
- | plugins.smartfile.StreamFile
64
- )[] = [];
65
- public addedUrls: string[] = [];
90
+ private _mode: 'extract' | 'create' | null = null;
91
+ private _filters: TEntryFilter[] = [];
92
+ private _excludePatterns: RegExp[] = [];
93
+ private _includePatterns: RegExp[] = [];
94
+ private _stripComponents: number = 0;
95
+ private _overwrite: boolean = false;
96
+ private _fileName?: string;
66
97
 
67
98
  constructor() {}
68
99
 
100
+ // ============================================
101
+ // SOURCE METHODS (set extraction mode)
102
+ // ============================================
103
+
69
104
  /**
70
- * gets the original archive stream
105
+ * Load archive from URL
71
106
  */
72
- public async getArchiveStream() {
73
- if (this.sourceStream) {
74
- return this.sourceStream;
75
- }
76
- if (this.sourceUrl) {
77
- const response = await plugins.smartrequest.SmartRequest.create()
78
- .url(this.sourceUrl)
79
- .get();
80
- const webStream = response.stream();
81
- // @ts-ignore - Web stream to Node.js stream conversion
82
- const urlStream = plugins.stream.Readable.fromWeb(webStream);
83
- return urlStream;
84
- }
85
- if (this.sourceFilePath) {
86
- const fileStream = plugins.fs.createReadStream(this.sourceFilePath);
87
- return fileStream;
107
+ public url(urlArg: string): this {
108
+ this.ensureNotInCreateMode('url');
109
+ this._mode = 'extract';
110
+ this.sourceUrl = urlArg;
111
+ return this;
112
+ }
113
+
114
+ /**
115
+ * Load archive from file path
116
+ */
117
+ public file(pathArg: string): this {
118
+ this.ensureNotInCreateMode('file');
119
+ this._mode = 'extract';
120
+ this.sourceFilePath = pathArg;
121
+ return this;
122
+ }
123
+
124
+ /**
125
+ * Load archive from readable stream
126
+ */
127
+ public stream(streamArg: plugins.stream.Readable | plugins.stream.Duplex | plugins.stream.Transform): this {
128
+ this.ensureNotInCreateMode('stream');
129
+ this._mode = 'extract';
130
+ this.sourceStream = streamArg;
131
+ return this;
132
+ }
133
+
134
+ /**
135
+ * Load archive from buffer
136
+ */
137
+ public buffer(bufferArg: Buffer): this {
138
+ this.ensureNotInCreateMode('buffer');
139
+ this._mode = 'extract';
140
+ this.sourceStream = plugins.stream.Readable.from(bufferArg);
141
+ return this;
142
+ }
143
+
144
+ // ============================================
145
+ // FORMAT METHODS (set creation mode)
146
+ // ============================================
147
+
148
+ /**
149
+ * Set output format for archive creation
150
+ */
151
+ public format(fmt: TArchiveFormat): this {
152
+ this.ensureNotInExtractMode('format');
153
+ this._mode = 'create';
154
+ this.creationFormat = fmt;
155
+ return this;
156
+ }
157
+
158
+ /**
159
+ * Set compression level (0-9)
160
+ */
161
+ public compression(level: TCompressionLevel): this {
162
+ this._compressionLevel = level;
163
+ return this;
164
+ }
165
+
166
+ // ============================================
167
+ // CONTENT METHODS (creation mode)
168
+ // ============================================
169
+
170
+ /**
171
+ * Add a single file entry to the archive
172
+ */
173
+ public entry(archivePath: string, content: string | Buffer): this {
174
+ this.ensureNotInExtractMode('entry');
175
+ if (!this._mode) this._mode = 'create';
176
+ this.pendingEntries.push({ archivePath, content });
177
+ return this;
178
+ }
179
+
180
+ /**
181
+ * Add multiple entries to the archive
182
+ */
183
+ public entries(entriesArg: Array<{ archivePath: string; content: string | Buffer }>): this {
184
+ this.ensureNotInExtractMode('entries');
185
+ if (!this._mode) this._mode = 'create';
186
+ for (const e of entriesArg) {
187
+ this.pendingEntries.push({ archivePath: e.archivePath, content: e.content });
88
188
  }
189
+ return this;
190
+ }
191
+
192
+ /**
193
+ * Add an entire directory to the archive (queued, resolved at build time)
194
+ */
195
+ public directory(sourcePath: string, archiveBase?: string): this {
196
+ this.ensureNotInExtractMode('directory');
197
+ if (!this._mode) this._mode = 'create';
198
+ this.pendingDirectories.push({ sourcePath, archiveBase });
199
+ return this;
200
+ }
201
+
202
+ /**
203
+ * Add a SmartFile to the archive
204
+ */
205
+ public addSmartFile(fileArg: plugins.smartfile.SmartFile, archivePath?: string): this {
206
+ this.ensureNotInExtractMode('addSmartFile');
207
+ if (!this._mode) this._mode = 'create';
208
+ this.pendingEntries.push({
209
+ archivePath: archivePath || fileArg.relative,
210
+ content: fileArg,
211
+ });
212
+ return this;
213
+ }
214
+
215
+ /**
216
+ * Add a StreamFile to the archive
217
+ */
218
+ public addStreamFile(fileArg: plugins.smartfile.StreamFile, archivePath?: string): this {
219
+ this.ensureNotInExtractMode('addStreamFile');
220
+ if (!this._mode) this._mode = 'create';
221
+ this.pendingEntries.push({
222
+ archivePath: archivePath || fileArg.relativeFilePath,
223
+ content: fileArg,
224
+ });
225
+ return this;
226
+ }
227
+
228
+ // ============================================
229
+ // FILTER METHODS (both modes)
230
+ // ============================================
231
+
232
+ /**
233
+ * Filter entries by predicate function
234
+ */
235
+ public filter(predicate: TEntryFilter): this {
236
+ this._filters.push(predicate);
237
+ return this;
89
238
  }
90
239
 
91
- public async exportToTarGzStream() {
92
- const tarPackStream = await this.tarTools.getPackStream();
93
- const gzipStream = await this.gzipTools.getCompressionStream();
94
- // const archiveStream = tarPackStream.pipe(gzipStream);
95
- // return archiveStream;
240
+ /**
241
+ * Include only entries matching the pattern
242
+ */
243
+ public include(pattern: string | RegExp): this {
244
+ const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
245
+ this._includePatterns.push(regex);
246
+ return this;
247
+ }
248
+
249
+ /**
250
+ * Exclude entries matching the pattern
251
+ */
252
+ public exclude(pattern: string | RegExp): this {
253
+ const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
254
+ this._excludePatterns.push(regex);
255
+ return this;
256
+ }
257
+
258
+ // ============================================
259
+ // EXTRACTION OPTIONS
260
+ // ============================================
261
+
262
+ /**
263
+ * Strip N leading path components from extracted files
264
+ */
265
+ public stripComponents(n: number): this {
266
+ this._stripComponents = n;
267
+ return this;
268
+ }
269
+
270
+ /**
271
+ * Overwrite existing files during extraction
272
+ */
273
+ public overwrite(value: boolean = true): this {
274
+ this._overwrite = value;
275
+ return this;
96
276
  }
97
277
 
98
- public async exportToFs(
99
- targetDir: string,
100
- fileNameArg?: string,
101
- ): Promise<void> {
278
+ /**
279
+ * Set output filename for single-file archives (gz, bz2)
280
+ */
281
+ public fileName(name: string): this {
282
+ this._fileName = name;
283
+ return this;
284
+ }
285
+
286
+ // ============================================
287
+ // TERMINAL METHODS - EXTRACTION
288
+ // ============================================
289
+
290
+ /**
291
+ * Extract archive to filesystem directory
292
+ */
293
+ public async extract(targetDir: string): Promise<void> {
294
+ this.ensureExtractionSource();
102
295
  const done = plugins.smartpromise.defer<void>();
103
- const streamFileStream = await this.exportToStreamOfStreamFiles();
296
+ const streamFileStream = await this.toStreamFiles();
297
+
104
298
  streamFileStream.pipe(
105
299
  new plugins.smartstream.SmartDuplex({
106
300
  objectMode: true,
107
- writeFunction: async (
108
- streamFileArg: plugins.smartfile.StreamFile,
109
- streamtools,
110
- ) => {
111
- const done = plugins.smartpromise.defer<void>();
112
- console.log(
113
- streamFileArg.relativeFilePath
114
- ? streamFileArg.relativeFilePath
115
- : 'no relative path',
116
- );
301
+ writeFunction: async (streamFileArg: plugins.smartfile.StreamFile) => {
302
+ const innerDone = plugins.smartpromise.defer<void>();
117
303
  const streamFile = streamFileArg;
304
+ let relativePath = streamFile.relativeFilePath || this._fileName || 'extracted_file';
305
+
306
+ // Apply stripComponents
307
+ if (this._stripComponents > 0) {
308
+ const parts = relativePath.split('/');
309
+ relativePath = parts.slice(this._stripComponents).join('/');
310
+ if (!relativePath) {
311
+ innerDone.resolve();
312
+ return;
313
+ }
314
+ }
315
+
316
+ // Apply filter
317
+ const filterFn = this.buildFilterFunction();
318
+ if (filterFn) {
319
+ const entryInfo: IArchiveEntryInfo = {
320
+ path: relativePath,
321
+ size: 0,
322
+ isDirectory: false,
323
+ isFile: true,
324
+ };
325
+ if (!filterFn(entryInfo)) {
326
+ innerDone.resolve();
327
+ return;
328
+ }
329
+ }
330
+
118
331
  const readStream = await streamFile.createReadStream();
119
332
  await plugins.fsPromises.mkdir(targetDir, { recursive: true });
120
- const writePath = plugins.path.join(
121
- targetDir,
122
- streamFile.relativeFilePath || fileNameArg,
123
- );
333
+ const writePath = plugins.path.join(targetDir, relativePath);
124
334
  await plugins.fsPromises.mkdir(plugins.path.dirname(writePath), { recursive: true });
125
335
  const writeStream = plugins.fs.createWriteStream(writePath);
126
336
  readStream.pipe(writeStream);
127
337
  writeStream.on('finish', () => {
128
- done.resolve();
338
+ innerDone.resolve();
129
339
  });
130
- await done.promise;
340
+ await innerDone.promise;
131
341
  },
132
342
  finalFunction: async () => {
133
343
  done.resolve();
134
344
  },
135
- }),
345
+ })
136
346
  );
347
+
137
348
  return done.promise;
138
349
  }
139
350
 
140
- public async exportToStreamOfStreamFiles() {
141
- const streamFileIntake =
142
- new plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>({
143
- objectMode: true,
144
- });
145
- const archiveStream = await this.getArchiveStream();
351
+ /**
352
+ * Extract archive to a stream of StreamFile objects
353
+ */
354
+ public async toStreamFiles(): Promise<plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>> {
355
+ this.ensureExtractionSource();
356
+
357
+ const streamFileIntake = new plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>({
358
+ objectMode: true,
359
+ });
360
+
361
+ // Guard to prevent multiple signalEnd calls
362
+ let hasSignaledEnd = false;
363
+ const safeSignalEnd = () => {
364
+ if (!hasSignaledEnd) {
365
+ hasSignaledEnd = true;
366
+ streamFileIntake.signalEnd();
367
+ }
368
+ };
369
+
370
+ const archiveStream = await this.getSourceStream();
146
371
  const createAnalyzedStream = () => this.archiveAnalyzer.getAnalyzedStream();
147
372
 
148
- // lets create a function that can be called multiple times to unpack layers of archives
149
373
  const createUnpackStream = () =>
150
- plugins.smartstream.createTransformFunction<IAnalyzedResult, any>(
374
+ plugins.smartstream.createTransformFunction<IAnalyzedResult, void>(
151
375
  async (analyzedResultChunk) => {
152
376
  if (analyzedResultChunk.fileType?.mime === 'application/x-tar') {
153
- const tarStream =
154
- analyzedResultChunk.decompressionStream as plugins.tarStream.Extract;
377
+ const tarStream = analyzedResultChunk.decompressionStream as plugins.tarStream.Extract;
378
+
155
379
  tarStream.on('entry', async (header, stream, next) => {
156
380
  if (header.type === 'directory') {
157
- console.log(
158
- `tar stream directory: ${header.name} ... skipping!`,
159
- );
160
- stream.resume(); // Consume directory stream
381
+ stream.resume();
161
382
  stream.on('end', () => next());
162
383
  return;
163
384
  }
164
- console.log(`tar stream file: ${header.name}`);
165
-
166
- // Create a PassThrough stream to buffer the data
385
+
167
386
  const passThrough = new plugins.stream.PassThrough();
168
- const streamfile = plugins.smartfile.StreamFile.fromStream(
169
- passThrough,
170
- header.name,
171
- );
172
-
173
- // Push the streamfile immediately
387
+ const streamfile = plugins.smartfile.StreamFile.fromStream(passThrough, header.name);
174
388
  streamFileIntake.push(streamfile);
175
-
176
- // Pipe the tar entry stream to the passthrough
177
389
  stream.pipe(passThrough);
178
-
179
- // Move to next entry when this one ends
180
390
  stream.on('end', () => {
181
391
  passThrough.end();
182
392
  next();
183
393
  });
184
394
  });
185
- tarStream.on('finish', function () {
186
- console.log('tar extraction finished');
187
- // Only signal end if this is the final stream
188
- streamFileIntake.signalEnd();
395
+
396
+ tarStream.on('finish', () => {
397
+ safeSignalEnd();
189
398
  });
190
- analyzedResultChunk.resultStream.pipe(
191
- analyzedResultChunk.decompressionStream,
192
- );
399
+
400
+ analyzedResultChunk.resultStream.pipe(analyzedResultChunk.decompressionStream);
193
401
  } else if (analyzedResultChunk.fileType?.mime === 'application/zip') {
194
402
  analyzedResultChunk.resultStream
195
403
  .pipe(analyzedResultChunk.decompressionStream)
196
404
  .pipe(
197
405
  new plugins.smartstream.SmartDuplex({
198
406
  objectMode: true,
199
- writeFunction: async (
200
- streamFileArg: plugins.smartfile.StreamFile,
201
- streamtools,
202
- ) => {
407
+ writeFunction: async (streamFileArg: plugins.smartfile.StreamFile) => {
203
408
  streamFileIntake.push(streamFileArg);
204
409
  },
205
410
  finalFunction: async () => {
206
- streamFileIntake.signalEnd();
411
+ safeSignalEnd();
207
412
  },
208
- }),
413
+ })
209
414
  );
210
- } else if (
211
- analyzedResultChunk.isArchive &&
212
- analyzedResultChunk.decompressionStream
213
- ) {
415
+ } else if (analyzedResultChunk.isArchive && analyzedResultChunk.decompressionStream) {
214
416
  // For nested archives (like gzip containing tar)
215
- const nestedStream = analyzedResultChunk.resultStream
417
+ analyzedResultChunk.resultStream
216
418
  .pipe(analyzedResultChunk.decompressionStream)
217
419
  .pipe(createAnalyzedStream())
218
420
  .pipe(createUnpackStream());
219
-
220
- // Don't signal end here - let the nested unpacker handle it
221
421
  } else {
222
422
  const streamFile = plugins.smartfile.StreamFile.fromStream(
223
423
  analyzedResultChunk.resultStream,
224
- analyzedResultChunk.fileType?.ext,
424
+ analyzedResultChunk.fileType?.ext
225
425
  );
226
426
  streamFileIntake.push(streamFile);
227
- streamFileIntake.signalEnd();
427
+ safeSignalEnd();
228
428
  }
229
429
  },
230
- {
231
- objectMode: true,
232
- },
430
+ { objectMode: true }
233
431
  );
234
432
 
235
433
  archiveStream.pipe(createAnalyzedStream()).pipe(createUnpackStream());
236
434
  return streamFileIntake;
237
435
  }
436
+
437
+ /**
438
+ * Extract archive to an array of SmartFile objects (in-memory)
439
+ */
440
+ public async toSmartFiles(): Promise<plugins.smartfile.SmartFile[]> {
441
+ this.ensureExtractionSource();
442
+ const streamFiles = await this.toStreamFiles();
443
+ const smartFiles: plugins.smartfile.SmartFile[] = [];
444
+ const filterFn = this.buildFilterFunction();
445
+ const pendingConversions: Promise<void>[] = [];
446
+
447
+ return new Promise((resolve, reject) => {
448
+ streamFiles.on('data', (streamFile: plugins.smartfile.StreamFile) => {
449
+ // Track all async conversions to ensure they complete before resolving
450
+ const conversion = (async () => {
451
+ try {
452
+ const smartFile = await streamFile.toSmartFile();
453
+
454
+ // Apply filter if configured
455
+ if (filterFn) {
456
+ const passes = filterFn({
457
+ path: smartFile.relative,
458
+ size: smartFile.contents.length,
459
+ isDirectory: false,
460
+ isFile: true,
461
+ });
462
+ if (!passes) return;
463
+ }
464
+
465
+ smartFiles.push(smartFile);
466
+ } catch (err) {
467
+ reject(err);
468
+ }
469
+ })();
470
+ pendingConversions.push(conversion);
471
+ });
472
+ streamFiles.on('end', async () => {
473
+ // Wait for all conversions to complete before resolving
474
+ await Promise.all(pendingConversions);
475
+ resolve(smartFiles);
476
+ });
477
+ streamFiles.on('error', reject);
478
+ });
479
+ }
480
+
481
+ /**
482
+ * Extract a single file from the archive by path
483
+ */
484
+ public async extractFile(filePath: string): Promise<plugins.smartfile.SmartFile | null> {
485
+ this.ensureExtractionSource();
486
+ const streamFiles = await this.toStreamFiles();
487
+
488
+ return new Promise((resolve, reject) => {
489
+ let found = false;
490
+
491
+ streamFiles.on('data', async (streamFile: plugins.smartfile.StreamFile) => {
492
+ if (streamFile.relativeFilePath === filePath || streamFile.relativeFilePath?.endsWith(filePath)) {
493
+ found = true;
494
+ try {
495
+ const smartFile = await streamFile.toSmartFile();
496
+ resolve(smartFile);
497
+ } catch (err) {
498
+ reject(err);
499
+ }
500
+ }
501
+ });
502
+
503
+ streamFiles.on('end', () => {
504
+ if (!found) {
505
+ resolve(null);
506
+ }
507
+ });
508
+
509
+ streamFiles.on('error', reject);
510
+ });
511
+ }
512
+
513
+ // ============================================
514
+ // TERMINAL METHODS - OUTPUT
515
+ // ============================================
516
+
517
+ /**
518
+ * Build and finalize the archive, returning this instance
519
+ */
520
+ public async build(): Promise<SmartArchive> {
521
+ await this.doBuild();
522
+ return this;
523
+ }
524
+
525
+ /**
526
+ * Internal build implementation (avoids thenable recursion)
527
+ */
528
+ private async doBuild(): Promise<void> {
529
+ if (this._mode === 'extract') {
530
+ // For extraction mode, nothing to build
531
+ return;
532
+ }
533
+
534
+ if (this.archiveBuffer) {
535
+ // Already built
536
+ return;
537
+ }
538
+
539
+ // For creation mode, build the archive buffer
540
+ this.ensureCreationFormat();
541
+ await this.resolveDirectories();
542
+
543
+ const entries = this.getFilteredEntries();
544
+
545
+ if (this.creationFormat === 'tar' || this.creationFormat === 'tar.gz' || this.creationFormat === 'tgz') {
546
+ if (this.creationFormat === 'tar') {
547
+ this.archiveBuffer = await this.tarTools.packFiles(entries);
548
+ } else {
549
+ this.archiveBuffer = await this.tarTools.packFilesToTarGz(entries, this._compressionLevel);
550
+ }
551
+ } else if (this.creationFormat === 'zip') {
552
+ this.archiveBuffer = await this.zipTools.createZip(entries, this._compressionLevel);
553
+ } else if (this.creationFormat === 'gz') {
554
+ if (entries.length !== 1) {
555
+ throw new Error('GZIP format only supports a single file');
556
+ }
557
+ let content: Buffer;
558
+ if (typeof entries[0].content === 'string') {
559
+ content = Buffer.from(entries[0].content);
560
+ } else if (Buffer.isBuffer(entries[0].content)) {
561
+ content = entries[0].content;
562
+ } else {
563
+ throw new Error('GZIP format requires string or Buffer content');
564
+ }
565
+ this.archiveBuffer = await this.gzipTools.compress(content, this._compressionLevel);
566
+ } else {
567
+ throw new Error(`Unsupported format: ${this.creationFormat}`);
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Build archive and return as Buffer
573
+ */
574
+ public async toBuffer(): Promise<Buffer> {
575
+ if (this._mode === 'create' && !this.archiveBuffer) {
576
+ await this.doBuild();
577
+ }
578
+
579
+ if (this.archiveBuffer) {
580
+ return this.archiveBuffer;
581
+ }
582
+
583
+ // For extraction mode, get the source as buffer
584
+ const stream = await this.getSourceStream();
585
+ return this.streamToBuffer(stream);
586
+ }
587
+
588
+ /**
589
+ * Build archive and write to file
590
+ */
591
+ public async toFile(filePath: string): Promise<void> {
592
+ const buffer = await this.toBuffer();
593
+ await plugins.fsPromises.mkdir(plugins.path.dirname(filePath), { recursive: true });
594
+ await plugins.fsPromises.writeFile(filePath, buffer);
595
+ }
596
+
597
+ /**
598
+ * Get archive as a readable stream
599
+ */
600
+ public async toStream(): Promise<plugins.stream.Readable> {
601
+ if (this._mode === 'create' && !this.archiveBuffer) {
602
+ await this.doBuild();
603
+ }
604
+
605
+ if (this.archiveBuffer) {
606
+ return plugins.stream.Readable.from(this.archiveBuffer);
607
+ }
608
+
609
+ return this.getSourceStream();
610
+ }
611
+
612
+ // ============================================
613
+ // TERMINAL METHODS - ANALYSIS
614
+ // ============================================
615
+
616
+ /**
617
+ * Analyze the archive and return metadata
618
+ */
619
+ public async analyze(): Promise<IArchiveInfo> {
620
+ this.ensureExtractionSource();
621
+ const stream = await this.getSourceStream();
622
+ const firstChunk = await this.readFirstChunk(stream);
623
+ const fileType = await plugins.fileType.fileTypeFromBuffer(firstChunk);
624
+
625
+ let format: TArchiveFormat | null = null;
626
+ let isCompressed = false;
627
+ let isArchive = false;
628
+
629
+ if (fileType) {
630
+ switch (fileType.mime) {
631
+ case 'application/gzip':
632
+ format = 'gz';
633
+ isCompressed = true;
634
+ isArchive = true;
635
+ break;
636
+ case 'application/zip':
637
+ format = 'zip';
638
+ isCompressed = true;
639
+ isArchive = true;
640
+ break;
641
+ case 'application/x-tar':
642
+ format = 'tar';
643
+ isArchive = true;
644
+ break;
645
+ case 'application/x-bzip2':
646
+ format = 'bz2';
647
+ isCompressed = true;
648
+ isArchive = true;
649
+ break;
650
+ }
651
+ }
652
+
653
+ return {
654
+ format,
655
+ isCompressed,
656
+ isArchive,
657
+ };
658
+ }
659
+
660
+ /**
661
+ * List all entries in the archive
662
+ */
663
+ public async list(): Promise<IArchiveEntryInfo[]> {
664
+ this.ensureExtractionSource();
665
+ const entries: IArchiveEntryInfo[] = [];
666
+ const streamFiles = await this.toStreamFiles();
667
+
668
+ return new Promise((resolve, reject) => {
669
+ streamFiles.on('data', (streamFile: plugins.smartfile.StreamFile) => {
670
+ entries.push({
671
+ path: streamFile.relativeFilePath || 'unknown',
672
+ size: 0, // Size not available without reading
673
+ isDirectory: false,
674
+ isFile: true,
675
+ });
676
+ });
677
+ streamFiles.on('end', () => resolve(entries));
678
+ streamFiles.on('error', reject);
679
+ });
680
+ }
681
+
682
+ /**
683
+ * Check if a specific file exists in the archive
684
+ */
685
+ public async hasFile(filePath: string): Promise<boolean> {
686
+ this.ensureExtractionSource();
687
+ const entries = await this.list();
688
+ return entries.some((e) => e.path === filePath || e.path.endsWith(filePath));
689
+ }
690
+
691
+
692
+ // ============================================
693
+ // PRIVATE HELPERS
694
+ // ============================================
695
+
696
+ /**
697
+ * Ensure we're not in create mode when calling extraction methods
698
+ */
699
+ private ensureNotInCreateMode(methodName: string): void {
700
+ if (this._mode === 'create') {
701
+ throw new Error(
702
+ `Cannot call .${methodName}() in creation mode. ` +
703
+ `Use extraction methods (.url(), .file(), .stream(), .buffer()) for extraction mode.`
704
+ );
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Ensure we're not in extract mode when calling creation methods
710
+ */
711
+ private ensureNotInExtractMode(methodName: string): void {
712
+ if (this._mode === 'extract') {
713
+ throw new Error(
714
+ `Cannot call .${methodName}() in extraction mode. ` +
715
+ `Use .format() for creation mode.`
716
+ );
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Ensure an extraction source is configured
722
+ */
723
+ private ensureExtractionSource(): void {
724
+ if (!this.sourceUrl && !this.sourceFilePath && !this.sourceStream && !this.archiveBuffer) {
725
+ throw new Error(
726
+ 'No source configured. Call .url(), .file(), .stream(), or .buffer() first.'
727
+ );
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Ensure a format is configured for creation
733
+ */
734
+ private ensureCreationFormat(): void {
735
+ if (!this.creationFormat) {
736
+ throw new Error('No format specified. Call .format() before creating archive.');
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Get the source stream
742
+ */
743
+ private async getSourceStream(): Promise<plugins.stream.Readable> {
744
+ if (this.archiveBuffer) {
745
+ return plugins.stream.Readable.from(this.archiveBuffer);
746
+ }
747
+ if (this.sourceStream) {
748
+ return this.sourceStream;
749
+ }
750
+ if (this.sourceUrl) {
751
+ const response = await plugins.smartrequest.SmartRequest.create()
752
+ .url(this.sourceUrl)
753
+ .get();
754
+ const webStream = response.stream();
755
+ return plugins.stream.Readable.fromWeb(webStream as any);
756
+ }
757
+ if (this.sourceFilePath) {
758
+ return plugins.fs.createReadStream(this.sourceFilePath);
759
+ }
760
+ throw new Error('No archive source configured');
761
+ }
762
+
763
+ /**
764
+ * Build a combined filter function from all configured filters
765
+ */
766
+ private buildFilterFunction(): TEntryFilter | undefined {
767
+ const hasFilters =
768
+ this._filters.length > 0 ||
769
+ this._includePatterns.length > 0 ||
770
+ this._excludePatterns.length > 0;
771
+
772
+ if (!hasFilters) {
773
+ return undefined;
774
+ }
775
+
776
+ return (entry: IArchiveEntryInfo) => {
777
+ // Check include patterns (if any specified, at least one must match)
778
+ if (this._includePatterns.length > 0) {
779
+ const included = this._includePatterns.some((p) => p.test(entry.path));
780
+ if (!included) return false;
781
+ }
782
+
783
+ // Check exclude patterns (none must match)
784
+ for (const pattern of this._excludePatterns) {
785
+ if (pattern.test(entry.path)) return false;
786
+ }
787
+
788
+ // Check custom filters (all must pass)
789
+ for (const filter of this._filters) {
790
+ if (!filter(entry)) return false;
791
+ }
792
+
793
+ return true;
794
+ };
795
+ }
796
+
797
+ /**
798
+ * Resolve pending directories to entries
799
+ */
800
+ private async resolveDirectories(): Promise<void> {
801
+ for (const dir of this.pendingDirectories) {
802
+ const files = await plugins.listFileTree(dir.sourcePath, '**/*');
803
+ for (const filePath of files) {
804
+ const archivePath = dir.archiveBase
805
+ ? plugins.path.join(dir.archiveBase, filePath)
806
+ : filePath;
807
+ const absolutePath = plugins.path.join(dir.sourcePath, filePath);
808
+ const content = await plugins.fsPromises.readFile(absolutePath);
809
+ this.pendingEntries.push({
810
+ archivePath,
811
+ content,
812
+ });
813
+ }
814
+ }
815
+ this.pendingDirectories = [];
816
+ }
817
+
818
+ /**
819
+ * Get entries filtered by include/exclude patterns
820
+ */
821
+ private getFilteredEntries(): IArchiveEntry[] {
822
+ const filterFn = this.buildFilterFunction();
823
+ if (!filterFn) {
824
+ return this.pendingEntries;
825
+ }
826
+
827
+ return this.pendingEntries.filter((entry) =>
828
+ filterFn({
829
+ path: entry.archivePath,
830
+ size: 0,
831
+ isDirectory: false,
832
+ isFile: true,
833
+ })
834
+ );
835
+ }
836
+
837
+ /**
838
+ * Convert a stream to buffer
839
+ */
840
+ private async streamToBuffer(stream: plugins.stream.Readable): Promise<Buffer> {
841
+ const chunks: Buffer[] = [];
842
+ return new Promise((resolve, reject) => {
843
+ stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
844
+ stream.on('end', () => resolve(Buffer.concat(chunks)));
845
+ stream.on('error', reject);
846
+ });
847
+ }
848
+
849
+ /**
850
+ * Read first chunk from stream
851
+ */
852
+ private async readFirstChunk(stream: plugins.stream.Readable): Promise<Buffer> {
853
+ return new Promise((resolve, reject) => {
854
+ const onData = (chunk: Buffer) => {
855
+ stream.removeListener('data', onData);
856
+ stream.removeListener('error', reject);
857
+ resolve(chunk);
858
+ };
859
+ stream.on('data', onData);
860
+ stream.on('error', reject);
861
+ });
862
+ }
238
863
  }