@push.rocks/smartarchive 5.0.0 → 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.
@@ -1,12 +1,11 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type {
3
- IArchiveCreationOptions,
4
3
  IArchiveEntry,
5
- IArchiveExtractionOptions,
6
4
  IArchiveEntryInfo,
7
5
  IArchiveInfo,
8
6
  TArchiveFormat,
9
7
  TCompressionLevel,
8
+ TEntryFilter,
10
9
  } from './interfaces.js';
11
10
 
12
11
  import { Bzip2Tools } from './classes.bzip2tools.js';
@@ -16,297 +15,285 @@ import { ZipTools } from './classes.ziptools.js';
16
15
  import { ArchiveAnalyzer, type IAnalyzedResult } from './classes.archiveanalyzer.js';
17
16
 
18
17
  /**
19
- * Main class for archive manipulation
18
+ * Pending directory entry for async resolution
19
+ */
20
+ interface IPendingDirectory {
21
+ sourcePath: string;
22
+ archiveBase?: string;
23
+ }
24
+
25
+ /**
26
+ * Main class for archive manipulation with fluent API
20
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
+ * ```
21
45
  */
22
46
  export class SmartArchive {
23
47
  // ============================================
24
- // STATIC FACTORY METHODS - EXTRACTION
48
+ // STATIC ENTRY POINT
25
49
  // ============================================
26
50
 
27
51
  /**
28
- * Create SmartArchive from a URL
52
+ * Create a new SmartArchive instance for fluent configuration
29
53
  */
30
- public static async fromUrl(urlArg: string): Promise<SmartArchive> {
31
- const smartArchiveInstance = new SmartArchive();
32
- smartArchiveInstance.sourceUrl = urlArg;
33
- return smartArchiveInstance;
54
+ public static create(): SmartArchive {
55
+ return new SmartArchive();
34
56
  }
35
57
 
36
- /**
37
- * Create SmartArchive from a local file path
38
- */
39
- public static async fromFile(filePathArg: string): Promise<SmartArchive> {
40
- const smartArchiveInstance = new SmartArchive();
41
- smartArchiveInstance.sourceFilePath = filePathArg;
42
- return smartArchiveInstance;
43
- }
58
+ // ============================================
59
+ // TOOLS (public for internal use)
60
+ // ============================================
44
61
 
45
- /**
46
- * Create SmartArchive from a readable stream
47
- */
48
- public static async fromStream(
49
- streamArg: plugins.stream.Readable | plugins.stream.Duplex | plugins.stream.Transform
50
- ): Promise<SmartArchive> {
51
- const smartArchiveInstance = new SmartArchive();
52
- smartArchiveInstance.sourceStream = streamArg;
53
- return smartArchiveInstance;
54
- }
62
+ public tarTools = new TarTools();
63
+ public zipTools = new ZipTools();
64
+ public gzipTools = new GzipTools();
65
+ public bzip2Tools = new Bzip2Tools(this);
66
+ public archiveAnalyzer = new ArchiveAnalyzer(this);
55
67
 
56
- /**
57
- * Create SmartArchive from an in-memory buffer
58
- */
59
- public static async fromBuffer(buffer: Buffer): Promise<SmartArchive> {
60
- const smartArchiveInstance = new SmartArchive();
61
- smartArchiveInstance.sourceStream = plugins.stream.Readable.from(buffer);
62
- return smartArchiveInstance;
63
- }
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;
64
75
 
65
76
  // ============================================
66
- // STATIC FACTORY METHODS - CREATION
77
+ // CREATION STATE
67
78
  // ============================================
68
79
 
69
- /**
70
- * Create a new archive from a directory
71
- */
72
- public static async fromDirectory(
73
- directoryPath: string,
74
- options: IArchiveCreationOptions
75
- ): Promise<SmartArchive> {
76
- const smartArchiveInstance = new SmartArchive();
77
- smartArchiveInstance.creationOptions = options;
80
+ private archiveBuffer?: Buffer;
81
+ private creationFormat?: TArchiveFormat;
82
+ private _compressionLevel: TCompressionLevel = 6;
83
+ private pendingEntries: IArchiveEntry[] = [];
84
+ private pendingDirectories: IPendingDirectory[] = [];
78
85
 
79
- const tarTools = new TarTools();
86
+ // ============================================
87
+ // FLUENT STATE
88
+ // ============================================
80
89
 
81
- if (options.format === 'tar' || options.format === 'tar.gz' || options.format === 'tgz') {
82
- if (options.format === 'tar') {
83
- const pack = await tarTools.packDirectory(directoryPath);
84
- pack.finalize();
85
- smartArchiveInstance.archiveBuffer = await SmartArchive.streamToBuffer(pack);
86
- } else {
87
- smartArchiveInstance.archiveBuffer = await tarTools.packDirectoryToTarGz(
88
- directoryPath,
89
- options.compressionLevel
90
- );
91
- }
92
- } else if (options.format === 'zip') {
93
- const zipTools = new ZipTools();
94
- const fileTree = await plugins.listFileTree(directoryPath, '**/*');
95
- const entries: IArchiveEntry[] = [];
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;
96
97
 
97
- for (const filePath of fileTree) {
98
- const absolutePath = plugins.path.join(directoryPath, filePath);
99
- const content = await plugins.fsPromises.readFile(absolutePath);
100
- entries.push({
101
- archivePath: filePath,
102
- content,
103
- });
104
- }
98
+ constructor() {}
105
99
 
106
- smartArchiveInstance.archiveBuffer = await zipTools.createZip(entries, options.compressionLevel);
107
- } else {
108
- throw new Error(`Unsupported format for directory packing: ${options.format}`);
109
- }
100
+ // ============================================
101
+ // SOURCE METHODS (set extraction mode)
102
+ // ============================================
110
103
 
111
- return smartArchiveInstance;
104
+ /**
105
+ * Load archive from URL
106
+ */
107
+ public url(urlArg: string): this {
108
+ this.ensureNotInCreateMode('url');
109
+ this._mode = 'extract';
110
+ this.sourceUrl = urlArg;
111
+ return this;
112
112
  }
113
113
 
114
114
  /**
115
- * Create a new archive from an array of entries
115
+ * Load archive from file path
116
116
  */
117
- public static async fromFiles(
118
- files: IArchiveEntry[],
119
- options: IArchiveCreationOptions
120
- ): Promise<SmartArchive> {
121
- const smartArchiveInstance = new SmartArchive();
122
- smartArchiveInstance.creationOptions = options;
123
-
124
- if (options.format === 'tar' || options.format === 'tar.gz' || options.format === 'tgz') {
125
- const tarTools = new TarTools();
126
- if (options.format === 'tar') {
127
- smartArchiveInstance.archiveBuffer = await tarTools.packFiles(files);
128
- } else {
129
- smartArchiveInstance.archiveBuffer = await tarTools.packFilesToTarGz(files, options.compressionLevel);
130
- }
131
- } else if (options.format === 'zip') {
132
- const zipTools = new ZipTools();
133
- smartArchiveInstance.archiveBuffer = await zipTools.createZip(files, options.compressionLevel);
134
- } else if (options.format === 'gz') {
135
- if (files.length !== 1) {
136
- throw new Error('GZIP format only supports a single file');
137
- }
138
- const gzipTools = new GzipTools();
139
- let content: Buffer;
140
- if (typeof files[0].content === 'string') {
141
- content = Buffer.from(files[0].content);
142
- } else if (Buffer.isBuffer(files[0].content)) {
143
- content = files[0].content;
144
- } else {
145
- throw new Error('GZIP format requires string or Buffer content');
146
- }
147
- smartArchiveInstance.archiveBuffer = await gzipTools.compress(content, options.compressionLevel);
148
- } else {
149
- throw new Error(`Unsupported format: ${options.format}`);
150
- }
151
-
152
- return smartArchiveInstance;
117
+ public file(pathArg: string): this {
118
+ this.ensureNotInCreateMode('file');
119
+ this._mode = 'extract';
120
+ this.sourceFilePath = pathArg;
121
+ return this;
153
122
  }
154
123
 
155
124
  /**
156
- * Start building an archive incrementally using a builder pattern
125
+ * Load archive from readable stream
157
126
  */
158
- public static create(options: IArchiveCreationOptions): SmartArchive {
159
- const smartArchiveInstance = new SmartArchive();
160
- smartArchiveInstance.creationOptions = options;
161
- smartArchiveInstance.pendingEntries = [];
162
- return smartArchiveInstance;
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;
163
132
  }
164
133
 
165
134
  /**
166
- * Helper to convert a stream to buffer
135
+ * Load archive from buffer
167
136
  */
168
- private static async streamToBuffer(stream: plugins.stream.Readable): Promise<Buffer> {
169
- const chunks: Buffer[] = [];
170
- return new Promise((resolve, reject) => {
171
- stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
172
- stream.on('end', () => resolve(Buffer.concat(chunks)));
173
- stream.on('error', reject);
174
- });
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;
175
142
  }
176
143
 
177
144
  // ============================================
178
- // INSTANCE PROPERTIES
145
+ // FORMAT METHODS (set creation mode)
179
146
  // ============================================
180
147
 
181
- public tarTools = new TarTools();
182
- public zipTools = new ZipTools();
183
- public gzipTools = new GzipTools();
184
- public bzip2Tools = new Bzip2Tools(this);
185
- public archiveAnalyzer = new ArchiveAnalyzer(this);
186
-
187
- public sourceUrl?: string;
188
- public sourceFilePath?: string;
189
- public sourceStream?: plugins.stream.Readable | plugins.stream.Duplex | plugins.stream.Transform;
190
-
191
- private archiveBuffer?: Buffer;
192
- private creationOptions?: IArchiveCreationOptions;
193
- private pendingEntries?: IArchiveEntry[];
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
+ }
194
157
 
195
- constructor() {}
158
+ /**
159
+ * Set compression level (0-9)
160
+ */
161
+ public compression(level: TCompressionLevel): this {
162
+ this._compressionLevel = level;
163
+ return this;
164
+ }
196
165
 
197
166
  // ============================================
198
- // BUILDER METHODS (for incremental creation)
167
+ // CONTENT METHODS (creation mode)
199
168
  // ============================================
200
169
 
201
170
  /**
202
- * Add a file to the archive (builder pattern)
171
+ * Add a single file entry to the archive
203
172
  */
204
- public addFile(archivePath: string, content: string | Buffer): this {
205
- if (!this.pendingEntries) {
206
- throw new Error('addFile can only be called on archives created with SmartArchive.create()');
207
- }
173
+ public entry(archivePath: string, content: string | Buffer): this {
174
+ this.ensureNotInExtractMode('entry');
175
+ if (!this._mode) this._mode = 'create';
208
176
  this.pendingEntries.push({ archivePath, content });
209
177
  return this;
210
178
  }
211
179
 
212
180
  /**
213
- * Add a SmartFile to the archive (builder pattern)
181
+ * Add multiple entries to the archive
214
182
  */
215
- public addSmartFile(file: plugins.smartfile.SmartFile, archivePath?: string): this {
216
- if (!this.pendingEntries) {
217
- throw new Error('addSmartFile can only be called on archives created with SmartArchive.create()');
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 });
218
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';
219
208
  this.pendingEntries.push({
220
- archivePath: archivePath || file.relative,
221
- content: file,
209
+ archivePath: archivePath || fileArg.relative,
210
+ content: fileArg,
222
211
  });
223
212
  return this;
224
213
  }
225
214
 
226
215
  /**
227
- * Add a StreamFile to the archive (builder pattern)
216
+ * Add a StreamFile to the archive
228
217
  */
229
- public addStreamFile(file: plugins.smartfile.StreamFile, archivePath?: string): this {
230
- if (!this.pendingEntries) {
231
- throw new Error('addStreamFile can only be called on archives created with SmartArchive.create()');
232
- }
218
+ public addStreamFile(fileArg: plugins.smartfile.StreamFile, archivePath?: string): this {
219
+ this.ensureNotInExtractMode('addStreamFile');
220
+ if (!this._mode) this._mode = 'create';
233
221
  this.pendingEntries.push({
234
- archivePath: archivePath || file.relativeFilePath,
235
- content: file,
222
+ archivePath: archivePath || fileArg.relativeFilePath,
223
+ content: fileArg,
236
224
  });
237
225
  return this;
238
226
  }
239
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;
238
+ }
239
+
240
240
  /**
241
- * Build the archive from pending entries
241
+ * Include only entries matching the pattern
242
242
  */
243
- public async build(): Promise<SmartArchive> {
244
- if (!this.pendingEntries || !this.creationOptions) {
245
- throw new Error('build can only be called on archives created with SmartArchive.create()');
246
- }
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
+ }
247
248
 
248
- const built = await SmartArchive.fromFiles(this.pendingEntries, this.creationOptions);
249
- this.archiveBuffer = built.archiveBuffer;
250
- this.pendingEntries = undefined;
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);
251
255
  return this;
252
256
  }
253
257
 
254
258
  // ============================================
255
- // EXTRACTION METHODS
259
+ // EXTRACTION OPTIONS
256
260
  // ============================================
257
261
 
258
262
  /**
259
- * Get the original archive stream
263
+ * Strip N leading path components from extracted files
260
264
  */
261
- public async toStream(): Promise<plugins.stream.Readable> {
262
- if (this.archiveBuffer) {
263
- return plugins.stream.Readable.from(this.archiveBuffer);
264
- }
265
- if (this.sourceStream) {
266
- return this.sourceStream;
267
- }
268
- if (this.sourceUrl) {
269
- const response = await plugins.smartrequest.SmartRequest.create()
270
- .url(this.sourceUrl)
271
- .get();
272
- const webStream = response.stream();
273
- return plugins.stream.Readable.fromWeb(webStream as any);
274
- }
275
- if (this.sourceFilePath) {
276
- return plugins.fs.createReadStream(this.sourceFilePath);
277
- }
278
- throw new Error('No archive source configured');
265
+ public stripComponents(n: number): this {
266
+ this._stripComponents = n;
267
+ return this;
279
268
  }
280
269
 
281
270
  /**
282
- * Get archive as a Buffer
271
+ * Overwrite existing files during extraction
283
272
  */
284
- public async toBuffer(): Promise<Buffer> {
285
- if (this.archiveBuffer) {
286
- return this.archiveBuffer;
287
- }
288
- const stream = await this.toStream();
289
- return SmartArchive.streamToBuffer(stream);
273
+ public overwrite(value: boolean = true): this {
274
+ this._overwrite = value;
275
+ return this;
290
276
  }
291
277
 
292
278
  /**
293
- * Write archive to a file
279
+ * Set output filename for single-file archives (gz, bz2)
294
280
  */
295
- public async toFile(filePath: string): Promise<void> {
296
- const buffer = await this.toBuffer();
297
- await plugins.fsPromises.mkdir(plugins.path.dirname(filePath), { recursive: true });
298
- await plugins.fsPromises.writeFile(filePath, buffer);
281
+ public fileName(name: string): this {
282
+ this._fileName = name;
283
+ return this;
299
284
  }
300
285
 
286
+ // ============================================
287
+ // TERMINAL METHODS - EXTRACTION
288
+ // ============================================
289
+
301
290
  /**
302
- * Extract archive to filesystem
291
+ * Extract archive to filesystem directory
303
292
  */
304
- public async extractToDirectory(
305
- targetDir: string,
306
- options?: Partial<IArchiveExtractionOptions>
307
- ): Promise<void> {
293
+ public async extract(targetDir: string): Promise<void> {
294
+ this.ensureExtractionSource();
308
295
  const done = plugins.smartpromise.defer<void>();
309
- const streamFileStream = await this.extractToStream();
296
+ const streamFileStream = await this.toStreamFiles();
310
297
 
311
298
  streamFileStream.pipe(
312
299
  new plugins.smartstream.SmartDuplex({
@@ -314,27 +301,28 @@ export class SmartArchive {
314
301
  writeFunction: async (streamFileArg: plugins.smartfile.StreamFile) => {
315
302
  const innerDone = plugins.smartpromise.defer<void>();
316
303
  const streamFile = streamFileArg;
317
- let relativePath = streamFile.relativeFilePath || options?.fileName || 'extracted_file';
304
+ let relativePath = streamFile.relativeFilePath || this._fileName || 'extracted_file';
318
305
 
319
- // Apply stripComponents if specified
320
- if (options?.stripComponents && options.stripComponents > 0) {
306
+ // Apply stripComponents
307
+ if (this._stripComponents > 0) {
321
308
  const parts = relativePath.split('/');
322
- relativePath = parts.slice(options.stripComponents).join('/');
309
+ relativePath = parts.slice(this._stripComponents).join('/');
323
310
  if (!relativePath) {
324
311
  innerDone.resolve();
325
312
  return;
326
313
  }
327
314
  }
328
315
 
329
- // Apply filter if specified
330
- if (options?.filter) {
316
+ // Apply filter
317
+ const filterFn = this.buildFilterFunction();
318
+ if (filterFn) {
331
319
  const entryInfo: IArchiveEntryInfo = {
332
320
  path: relativePath,
333
321
  size: 0,
334
322
  isDirectory: false,
335
323
  isFile: true,
336
324
  };
337
- if (!options.filter(entryInfo)) {
325
+ if (!filterFn(entryInfo)) {
338
326
  innerDone.resolve();
339
327
  return;
340
328
  }
@@ -363,7 +351,9 @@ export class SmartArchive {
363
351
  /**
364
352
  * Extract archive to a stream of StreamFile objects
365
353
  */
366
- public async extractToStream(): Promise<plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>> {
354
+ public async toStreamFiles(): Promise<plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>> {
355
+ this.ensureExtractionSource();
356
+
367
357
  const streamFileIntake = new plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>({
368
358
  objectMode: true,
369
359
  });
@@ -377,7 +367,7 @@ export class SmartArchive {
377
367
  }
378
368
  };
379
369
 
380
- const archiveStream = await this.toStream();
370
+ const archiveStream = await this.getSourceStream();
381
371
  const createAnalyzedStream = () => this.archiveAnalyzer.getAnalyzedStream();
382
372
 
383
373
  const createUnpackStream = () =>
@@ -447,20 +437,43 @@ export class SmartArchive {
447
437
  /**
448
438
  * Extract archive to an array of SmartFile objects (in-memory)
449
439
  */
450
- public async extractToSmartFiles(): Promise<plugins.smartfile.SmartFile[]> {
451
- const streamFiles = await this.extractToStream();
440
+ public async toSmartFiles(): Promise<plugins.smartfile.SmartFile[]> {
441
+ this.ensureExtractionSource();
442
+ const streamFiles = await this.toStreamFiles();
452
443
  const smartFiles: plugins.smartfile.SmartFile[] = [];
444
+ const filterFn = this.buildFilterFunction();
445
+ const pendingConversions: Promise<void>[] = [];
453
446
 
454
447
  return new Promise((resolve, reject) => {
455
- streamFiles.on('data', async (streamFile: plugins.smartfile.StreamFile) => {
456
- try {
457
- const smartFile = await streamFile.toSmartFile();
458
- smartFiles.push(smartFile);
459
- } catch (err) {
460
- reject(err);
461
- }
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);
462
476
  });
463
- streamFiles.on('end', () => resolve(smartFiles));
464
477
  streamFiles.on('error', reject);
465
478
  });
466
479
  }
@@ -469,7 +482,8 @@ export class SmartArchive {
469
482
  * Extract a single file from the archive by path
470
483
  */
471
484
  public async extractFile(filePath: string): Promise<plugins.smartfile.SmartFile | null> {
472
- const streamFiles = await this.extractToStream();
485
+ this.ensureExtractionSource();
486
+ const streamFiles = await this.toStreamFiles();
473
487
 
474
488
  return new Promise((resolve, reject) => {
475
489
  let found = false;
@@ -497,14 +511,114 @@ export class SmartArchive {
497
511
  }
498
512
 
499
513
  // ============================================
500
- // ANALYSIS METHODS
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
501
614
  // ============================================
502
615
 
503
616
  /**
504
617
  * Analyze the archive and return metadata
505
618
  */
506
619
  public async analyze(): Promise<IArchiveInfo> {
507
- const stream = await this.toStream();
620
+ this.ensureExtractionSource();
621
+ const stream = await this.getSourceStream();
508
622
  const firstChunk = await this.readFirstChunk(stream);
509
623
  const fileType = await plugins.fileType.fileTypeFromBuffer(firstChunk);
510
624
 
@@ -544,11 +658,12 @@ export class SmartArchive {
544
658
  }
545
659
 
546
660
  /**
547
- * List all entries in the archive without extracting
661
+ * List all entries in the archive
548
662
  */
549
- public async listEntries(): Promise<IArchiveEntryInfo[]> {
663
+ public async list(): Promise<IArchiveEntryInfo[]> {
664
+ this.ensureExtractionSource();
550
665
  const entries: IArchiveEntryInfo[] = [];
551
- const streamFiles = await this.extractToStream();
666
+ const streamFiles = await this.toStreamFiles();
552
667
 
553
668
  return new Promise((resolve, reject) => {
554
669
  streamFiles.on('data', (streamFile: plugins.smartfile.StreamFile) => {
@@ -568,12 +683,171 @@ export class SmartArchive {
568
683
  * Check if a specific file exists in the archive
569
684
  */
570
685
  public async hasFile(filePath: string): Promise<boolean> {
571
- const entries = await this.listEntries();
686
+ this.ensureExtractionSource();
687
+ const entries = await this.list();
572
688
  return entries.some((e) => e.path === filePath || e.path.endsWith(filePath));
573
689
  }
574
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
+
575
849
  /**
576
- * Helper to read first chunk from stream
850
+ * Read first chunk from stream
577
851
  */
578
852
  private async readFirstChunk(stream: plugins.stream.Readable): Promise<Buffer> {
579
853
  return new Promise((resolve, reject) => {