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