stegdoc 1.0.1 → 4.0.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.
@@ -1,16 +1,19 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
+ const { pipeline } = require('stream/promises');
3
4
  const chalk = require('chalk');
4
5
  const ora = require('ora');
5
6
  const AdmZip = require('adm-zip');
6
7
  const { createDocxWithBase64 } = require('../lib/docx-handler');
7
- const { createXlsxWithBase64 } = require('../lib/xlsx-handler');
8
+ const { createXlsxPartStreaming } = require('../lib/xlsx-handler');
8
9
  const { createMetadata, serializeMetadata } = require('../lib/metadata');
9
- const { generateHash, generateContentHash, parseSizeToBytes, formatBytes, generateFilename } = require('../lib/utils');
10
- const { encrypt, packEncryptionMeta } = require('../lib/crypto');
11
- const { compress, isCompressedMime } = require('../lib/compression');
10
+ const crypto = require('crypto');
11
+ const { generateHash, parseSizeToBytes, formatBytes, generateFilename } = require('../lib/utils');
12
+ const { packEncryptionMeta, generateSalt, createEncryptStream } = require('../lib/crypto');
13
+ const { isCompressedMime, createCompressStream } = require('../lib/compression');
12
14
  const { resetTimeWindow } = require('../lib/decoy-generator');
13
15
  const { shouldRunInteractive, promptEncodeOptions } = require('../lib/interactive');
16
+ const { Base64EncodeTransform, ChunkCollector, BinaryChunkCollector } = require('../lib/streams');
14
17
 
15
18
  /**
16
19
  * Zip a folder into a buffer
@@ -24,16 +27,43 @@ function zipFolder(folderPath) {
24
27
  }
25
28
 
26
29
  /**
27
- * Encode a file to XLSX/DOCX format with optional AES encryption and compression
30
+ * Detect file type from the first 4KB without reading the entire file
31
+ * @param {string} filePath - Path to file
32
+ * @returns {Promise<object|null>} File type result or null
33
+ */
34
+ async function detectFileType(filePath) {
35
+ try {
36
+ const { fileTypeFromBuffer } = await import('file-type');
37
+ const fd = await fs.promises.open(filePath, 'r');
38
+ const buf = Buffer.alloc(4100);
39
+ await fd.read(buf, 0, 4100, 0);
40
+ await fd.close();
41
+ return await fileTypeFromBuffer(buf);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Compute SHA-256 hash of a file using streaming (no full file in memory)
49
+ * @param {string} filePath - Path to file
50
+ * @returns {Promise<string>} Hex SHA-256 hash
51
+ */
52
+ async function computeFileHash(filePath) {
53
+ return new Promise((resolve, reject) => {
54
+ const hash = crypto.createHash('sha256');
55
+ const stream = fs.createReadStream(filePath);
56
+ stream.on('data', (chunk) => hash.update(chunk));
57
+ stream.on('end', () => resolve(hash.digest('hex')));
58
+ stream.on('error', reject);
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Encode a file to XLSX/DOCX format with optional AES encryption and compression.
64
+ * Uses streaming pipeline for XLSX to support multi-GB files.
28
65
  * @param {string} inputFile - Path to input file
29
66
  * @param {object} options - Command options
30
- * @param {string} options.outputDir - Output directory
31
- * @param {string} options.chunkSize - Chunk size (e.g., "5MB")
32
- * @param {string} options.format - Output format ('xlsx' or 'docx')
33
- * @param {string} options.password - Encryption password (optional)
34
- * @param {boolean} options.force - Overwrite existing files without asking
35
- * @param {boolean} options.quiet - Minimal output
36
- * @param {boolean} options.yes - Skip interactive prompts, use defaults
37
67
  */
38
68
  async function encodeCommand(inputFile, options) {
39
69
  // Check if we should run interactive mode
@@ -69,261 +99,227 @@ async function encodeCommand(inputFile, options) {
69
99
  }
70
100
 
71
101
  const useEncryption = !!options.password;
72
-
73
- // Store chunk input for later processing (after we know content size)
74
102
  const chunkInput = (options.chunkSize || '').toString().trim();
75
103
 
76
- let fileBuffer;
104
+ let streamSource;
77
105
  let filename;
78
106
  let extension;
79
- let size;
107
+ let fileSize;
108
+ let tempZipPath = null;
80
109
 
81
110
  if (isDirectory) {
82
- // Zip the folder
111
+ // Zip the folder to a temp file, then stream from it
83
112
  spinner.text = 'Zipping folder...';
84
113
  const folderName = path.basename(inputFile);
85
- fileBuffer = zipFolder(inputFile);
114
+ const zipBuffer = zipFolder(inputFile);
86
115
  filename = `${folderName}.zip`;
87
116
  extension = '.zip';
88
- size = fileBuffer.length;
89
- spinner.succeed && spinner.succeed(`Folder zipped: ${folderName}/ → ${formatBytes(size)}`);
117
+ fileSize = zipBuffer.length;
118
+
119
+ // Write zip to temp file for streaming
120
+ tempZipPath = path.join(require('os').tmpdir(), `stegdoc_${Date.now()}.zip`);
121
+ fs.writeFileSync(tempZipPath, zipBuffer);
122
+ streamSource = tempZipPath;
123
+
124
+ spinner.succeed && spinner.succeed(`Folder zipped: ${folderName}/ → ${formatBytes(fileSize)}`);
90
125
  } else {
91
- // Read file as buffer
92
- spinner.text = 'Reading file...';
93
- fileBuffer = fs.readFileSync(inputFile);
126
+ streamSource = inputFile;
94
127
  filename = path.basename(inputFile);
95
128
  extension = path.extname(inputFile);
96
- size = fileBuffer.length;
97
- spinner.succeed && spinner.succeed(`File read: ${filename} (${formatBytes(size)})`);
129
+ fileSize = fs.statSync(inputFile).size;
130
+ spinner.succeed && spinner.succeed(`File detected: ${filename} (${formatBytes(fileSize)})`);
98
131
  }
99
132
 
100
- // Generate content hash for integrity verification
101
- const contentHash = generateContentHash(fileBuffer);
102
-
103
- // Detect if file is already compressed
133
+ // Detect file type for compression decision (read only first 4KB)
104
134
  spinner.text = 'Checking file type...';
105
- let fileType = null;
106
135
  let useCompression = true;
136
+ const fileType = await detectFileType(streamSource);
107
137
 
108
- try {
109
- // file-type is ESM-only, need dynamic import
110
- const { fileTypeFromBuffer } = await import('file-type');
111
- fileType = await fileTypeFromBuffer(fileBuffer);
112
-
113
- if (fileType && isCompressedMime(fileType.mime)) {
114
- useCompression = false;
115
- spinner.info && spinner.info(`Skipping compression (${fileType.ext} is already compressed)`);
116
- }
117
- } catch {
118
- // If file-type detection fails, still try compression
138
+ if (fileType && isCompressedMime(fileType.mime)) {
139
+ useCompression = false;
140
+ spinner.info && spinner.info(`Skipping compression (${fileType.ext} is already compressed)`);
119
141
  }
120
142
 
121
- // Compress if beneficial
122
- let processedBuffer = fileBuffer;
123
- if (useCompression) {
124
- spinner.text = 'Compressing...';
125
- const compressedBuffer = await compress(fileBuffer);
126
-
127
- // Only use compression if it actually reduces size
128
- if (compressedBuffer.length < fileBuffer.length) {
129
- const savedPercent = ((1 - compressedBuffer.length / fileBuffer.length) * 100).toFixed(1);
130
- processedBuffer = compressedBuffer;
131
- spinner.succeed && spinner.succeed(`Compressed: ${formatBytes(fileBuffer.length)} → ${formatBytes(compressedBuffer.length)} (${savedPercent}% saved)`);
132
- } else {
133
- useCompression = false;
134
- spinner.info && spinner.info('Compression skipped (no size benefit)');
143
+ // For DOCX format, warn about large files and use legacy in-memory path
144
+ if (format === 'docx') {
145
+ if (fileSize > 200 * 1024 * 1024) {
146
+ spinner.info && spinner.info('Warning: DOCX format is not recommended for files >200MB. Consider using XLSX.');
135
147
  }
148
+ // Use legacy in-memory path for DOCX
149
+ await encodeLegacyDocx(streamSource, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles);
150
+ if (tempZipPath) cleanupTemp(tempZipPath);
151
+ return;
136
152
  }
137
153
 
138
- // Convert to base64
139
- const base64 = processedBuffer.toString('base64');
140
-
141
- let contentToStore;
142
- let encryptionMeta = null;
143
-
144
- if (useEncryption) {
145
- spinner.text = 'Encrypting content...';
146
-
147
- // Encrypt the base64 content
148
- const { ciphertext, iv, salt, authTag } = encrypt(base64, options.password);
149
- encryptionMeta = packEncryptionMeta({ iv, salt, authTag });
150
- contentToStore = ciphertext;
151
-
152
- spinner.succeed && spinner.succeed('Content encrypted with AES-256-GCM');
153
- } else {
154
- contentToStore = base64;
155
- spinner.info && spinner.info('No password provided - content will NOT be encrypted');
156
- }
157
-
158
- spinner.text = 'Preparing output...';
159
-
160
- // Generate hash for this encoding session
154
+ // === XLSX Streaming Pipeline ===
161
155
  const hash = generateHash();
162
-
163
- // Determine output directory
164
156
  const outputDir = options.outputDir || process.cwd();
165
157
 
166
- // Now parse chunk size (we need content size for "X parts" format)
167
- const contentSize = contentToStore.length;
168
- const defaultChunkSize = 5 * 1024 * 1024; // 5MB default
169
- let chunkSizeBytes;
158
+ // Parse chunk size
170
159
  const chunkInputLower = chunkInput.toLowerCase();
160
+ let chunkSizeBytes;
171
161
 
172
162
  if (chunkInputLower === '0' || chunkInputLower === 'max' || chunkInputLower === 'single' || chunkInputLower === 'none' || chunkInputLower === '') {
173
- chunkSizeBytes = Infinity; // No splitting
163
+ chunkSizeBytes = Infinity;
174
164
  } else if (/^\d+\s*parts?$/i.test(chunkInput)) {
175
- // "X parts" format - divide content evenly
176
165
  const numParts = parseInt(chunkInput, 10);
177
166
  if (numParts < 1) {
178
167
  throw new Error('Number of parts must be at least 1');
179
168
  }
180
- chunkSizeBytes = Math.ceil(contentSize / numParts);
181
- spinner.info && spinner.info(`Splitting into ${numParts} parts (~${formatBytes(chunkSizeBytes)} content each)`);
169
+ // Estimate output size for part calculation
170
+ const estimatedBase64Size = Math.ceil(fileSize * 4 / 3);
171
+ chunkSizeBytes = Math.ceil(estimatedBase64Size / numParts);
172
+ spinner.info && spinner.info(`Splitting into ~${numParts} parts (~${formatBytes(chunkSizeBytes)} content each)`);
182
173
  } else if (chunkInput) {
183
174
  chunkSizeBytes = parseSizeToBytes(chunkInput);
184
175
  } else {
185
- chunkSizeBytes = defaultChunkSize;
176
+ chunkSizeBytes = 5 * 1024 * 1024; // 5MB default
186
177
  }
187
178
 
188
- // Check if we need to split
189
- const needsSplit = contentSize > chunkSizeBytes;
179
+ // Pre-compute content hash (fast streaming SHA-256 pass)
180
+ spinner.text = 'Computing file hash...';
181
+ const contentHash = await computeFileHash(streamSource);
190
182
 
191
- // Helper to check file exists and handle overwrite
192
- const checkOverwrite = (filePath) => {
193
- if (fs.existsSync(filePath) && !options.force) {
194
- throw new Error(`File already exists: ${filePath}. Use --force to overwrite.`);
195
- }
196
- };
197
-
198
- if (needsSplit) {
199
- // Split into chunks
200
- const chunks = [];
201
- let offset = 0;
202
- while (offset < contentToStore.length) {
203
- chunks.push(contentToStore.slice(offset, offset + chunkSizeBytes));
204
- offset += chunkSizeBytes;
205
- }
206
- const totalParts = chunks.length;
183
+ // Generate session salt for encryption (shared across all parts)
184
+ const sessionSalt = useEncryption ? generateSalt() : null;
207
185
 
208
- spinner.succeed && spinner.succeed(`File will be split into ${totalParts} parts`);
186
+ spinner.text = useCompression ? 'Compressing and encoding...' : 'Encoding...';
209
187
 
210
- // Create output file for each chunk
211
- for (let i = 0; i < chunks.length; i++) {
212
- const partNumber = i + 1;
213
- const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber} of ${totalParts}...`).start();
188
+ // We don't know totalParts upfront (depends on compression ratio).
189
+ // We write each part with partNumber set, and totalParts as null.
190
+ // Decode discovers all parts by filename pattern matching (already supported).
191
+ const partFiles = [];
192
+
193
+ if (useEncryption) {
194
+ // Encrypted pipeline: compress → collect binary chunks → encrypt per-part → base64 → xlsx
195
+ // Binary chunk size accounts for base64 expansion (~4/3x)
196
+ const binaryChunkSize = chunkSizeBytes === Infinity ? Infinity : Math.floor(chunkSizeBytes * 3 / 4);
197
+
198
+ const onBinaryChunkReady = async (binaryBuffer, index) => {
199
+ const partNumber = index + 1;
200
+ const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
201
+
202
+ // Encrypt this chunk independently
203
+ const { stream: cipher, iv, salt, getAuthTag } = createEncryptStream(options.password, sessionSalt);
204
+ const encrypted = Buffer.concat([cipher.update(binaryBuffer), cipher.final()]);
205
+ const authTag = getAuthTag();
206
+ const base64Chunk = encrypted.toString('base64');
214
207
 
215
208
  const metadata = createMetadata({
216
209
  originalFilename: filename,
217
210
  originalExtension: extension,
218
211
  hash,
219
212
  partNumber,
220
- totalParts,
221
- originalSize: size,
213
+ totalParts: null,
214
+ originalSize: fileSize,
222
215
  format,
223
- encrypted: useEncryption,
216
+ encrypted: true,
224
217
  compressed: useCompression,
225
218
  contentHash,
226
219
  });
227
220
 
228
- const outputFilename = generateFilename(hash, partNumber, totalParts, format);
221
+ const encryptionMeta = packEncryptionMeta({ iv, salt, authTag });
222
+ const outputFilename = generateFilename(hash, partNumber, null, format);
229
223
  const outputPath = path.join(outputDir, outputFilename);
230
224
 
231
- checkOverwrite(outputPath);
232
-
233
- if (format === 'xlsx') {
234
- await createXlsxWithBase64({
235
- base64Content: chunks[i],
236
- encryptionMeta: encryptionMeta || '',
237
- metadata: serializeMetadata(metadata),
238
- outputPath,
239
- });
240
- } else {
241
- // For DOCX, include encryption meta in the content if encrypted
242
- const docxContent = useEncryption
243
- ? `${encryptionMeta}|||${chunks[i]}`
244
- : chunks[i];
245
- await createDocxWithBase64({
246
- base64Content: docxContent,
247
- metadata,
248
- outputPath,
249
- });
225
+ if (fs.existsSync(outputPath) && !options.force) {
226
+ throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
250
227
  }
251
228
 
229
+ await createXlsxPartStreaming({
230
+ base64Content: base64Chunk,
231
+ encryptionMeta,
232
+ metadataJson: serializeMetadata(metadata),
233
+ outputPath,
234
+ });
235
+
236
+ partFiles.push(outputPath);
252
237
  createdFiles.push(outputPath);
253
- partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(chunks[i].length)})`);
254
- }
238
+ partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(base64Chunk.length)} encoded)`);
239
+ };
255
240
 
256
- if (!quiet) {
257
- console.log();
258
- console.log(chalk.green.bold('✓ Encoding complete!'));
259
- console.log(chalk.cyan(` Format: ${format.toUpperCase()}`));
260
- console.log(chalk.cyan(` Hash: ${hash}`));
261
- console.log(chalk.cyan(` Parts: ${totalParts}`));
262
- console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
263
- console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
264
- console.log(chalk.cyan(` Location: ${outputDir}`));
265
- if (useEncryption) {
266
- console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
267
- }
241
+ const collector = new BinaryChunkCollector(binaryChunkSize, onBinaryChunkReady);
242
+
243
+ // Build pipeline
244
+ const streams = [fs.createReadStream(streamSource)];
245
+ if (useCompression) {
246
+ streams.push(createCompressStream());
268
247
  }
248
+ streams.push(collector);
249
+
250
+ await pipeline(...streams);
269
251
  } else {
270
- // Single file
271
- spinner.text = `Creating ${format.toUpperCase()} file...`;
272
-
273
- const metadata = createMetadata({
274
- originalFilename: filename,
275
- originalExtension: extension,
276
- hash,
277
- partNumber: null,
278
- totalParts: null,
279
- originalSize: size,
280
- format,
281
- encrypted: useEncryption,
282
- compressed: useCompression,
283
- contentHash,
284
- });
285
-
286
- const outputFilename = generateFilename(hash, null, null, format);
287
- const outputPath = path.join(outputDir, outputFilename);
288
-
289
- checkOverwrite(outputPath);
290
-
291
- if (format === 'xlsx') {
292
- await createXlsxWithBase64({
293
- base64Content: contentToStore,
294
- encryptionMeta: encryptionMeta || '',
295
- metadata: serializeMetadata(metadata),
296
- outputPath,
252
+ // Unencrypted pipeline: compress → base64 → collect string chunks → xlsx
253
+ const onChunkReady = async (base64Chunk, index) => {
254
+ const partNumber = index + 1;
255
+ const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
256
+
257
+ const metadata = createMetadata({
258
+ originalFilename: filename,
259
+ originalExtension: extension,
260
+ hash,
261
+ partNumber,
262
+ totalParts: null,
263
+ originalSize: fileSize,
264
+ format,
265
+ encrypted: false,
266
+ compressed: useCompression,
267
+ contentHash,
297
268
  });
298
- } else {
299
- // For DOCX, include encryption meta in the content if encrypted
300
- const docxContent = useEncryption
301
- ? `${encryptionMeta}|||${contentToStore}`
302
- : contentToStore;
303
- await createDocxWithBase64({
304
- base64Content: docxContent,
305
- metadata,
269
+
270
+ const outputFilename = generateFilename(hash, partNumber, null, format);
271
+ const outputPath = path.join(outputDir, outputFilename);
272
+
273
+ if (fs.existsSync(outputPath) && !options.force) {
274
+ throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
275
+ }
276
+
277
+ await createXlsxPartStreaming({
278
+ base64Content: base64Chunk,
279
+ encryptionMeta: '',
280
+ metadataJson: serializeMetadata(metadata),
306
281
  outputPath,
307
282
  });
283
+
284
+ partFiles.push(outputPath);
285
+ createdFiles.push(outputPath);
286
+ partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(base64Chunk.length)} encoded)`);
287
+ };
288
+
289
+ const collector = new ChunkCollector(chunkSizeBytes, onChunkReady);
290
+
291
+ // Build pipeline
292
+ const streams = [fs.createReadStream(streamSource)];
293
+ if (useCompression) {
294
+ streams.push(createCompressStream());
308
295
  }
296
+ streams.push(new Base64EncodeTransform());
297
+ streams.push(collector);
309
298
 
310
- createdFiles.push(outputPath);
311
- spinner.succeed && spinner.succeed('Encoding complete!');
312
-
313
- if (!quiet) {
314
- console.log();
315
- console.log(chalk.green.bold(' File encoded successfully!'));
316
- console.log(chalk.cyan(` Format: ${format.toUpperCase()}`));
317
- console.log(chalk.cyan(` Hash: ${hash}`));
318
- console.log(chalk.cyan(` Output: ${outputFilename}`));
319
- console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
320
- console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
321
- console.log(chalk.cyan(` Location: ${outputDir}`));
322
- if (useEncryption) {
323
- console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
324
- }
299
+ await pipeline(...streams);
300
+ }
301
+
302
+ const totalParts = partFiles.length;
303
+
304
+ spinner.succeed && spinner.succeed('Encoding complete!');
305
+
306
+ if (!quiet) {
307
+ console.log();
308
+ console.log(chalk.green.bold('✓ File encoded successfully!'));
309
+ console.log(chalk.cyan(` Format: ${format.toUpperCase()}`));
310
+ console.log(chalk.cyan(` Hash: ${hash}`));
311
+ if (totalParts > 1) {
312
+ console.log(chalk.cyan(` Parts: ${totalParts}`));
313
+ }
314
+ console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
315
+ console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
316
+ console.log(chalk.cyan(` Location: ${outputDir}`));
317
+ if (useEncryption) {
318
+ console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
325
319
  }
326
320
  }
321
+
322
+ if (tempZipPath) cleanupTemp(tempZipPath);
327
323
  } catch (error) {
328
324
  spinner.fail && spinner.fail('Encoding failed');
329
325
 
@@ -343,4 +339,111 @@ async function encodeCommand(inputFile, options) {
343
339
  }
344
340
  }
345
341
 
342
+ /**
343
+ * Legacy in-memory encode path for DOCX format
344
+ */
345
+ async function encodeLegacyDocx(inputPath, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles) {
346
+ const { compress } = require('../lib/compression');
347
+ const { encrypt, packEncryptionMeta: packMeta } = require('../lib/crypto');
348
+ const { generateContentHash } = require('../lib/utils');
349
+ const { createDocxWithBase64 } = require('../lib/docx-handler');
350
+
351
+ const fileBuffer = fs.readFileSync(inputPath);
352
+ const contentHash = generateContentHash(fileBuffer);
353
+
354
+ // Compress if beneficial
355
+ let processedBuffer = fileBuffer;
356
+ if (useCompression) {
357
+ spinner.text = 'Compressing...';
358
+ const compressedBuffer = await compress(fileBuffer);
359
+ if (compressedBuffer.length < fileBuffer.length) {
360
+ processedBuffer = compressedBuffer;
361
+ spinner.succeed && spinner.succeed(`Compressed: ${formatBytes(fileBuffer.length)} → ${formatBytes(compressedBuffer.length)}`);
362
+ } else {
363
+ useCompression = false;
364
+ spinner.info && spinner.info('Compression skipped (no size benefit)');
365
+ }
366
+ }
367
+
368
+ // Convert to base64
369
+ const base64 = processedBuffer.toString('base64');
370
+
371
+ let contentToStore;
372
+ let encryptionMeta = null;
373
+
374
+ if (useEncryption) {
375
+ spinner.text = 'Encrypting content...';
376
+ const { ciphertext, iv, salt, authTag } = encrypt(base64, options.password);
377
+ encryptionMeta = packMeta({ iv, salt, authTag });
378
+ contentToStore = ciphertext;
379
+ spinner.succeed && spinner.succeed('Content encrypted with AES-256-GCM');
380
+ } else {
381
+ contentToStore = base64;
382
+ }
383
+
384
+ const hash = generateHash();
385
+ const outputDir = options.outputDir || process.cwd();
386
+ const format = 'docx';
387
+
388
+ const metadata = createMetadata({
389
+ originalFilename: filename,
390
+ originalExtension: extension,
391
+ hash,
392
+ partNumber: null,
393
+ totalParts: null,
394
+ originalSize: fileSize,
395
+ format,
396
+ encrypted: useEncryption,
397
+ compressed: useCompression,
398
+ contentHash,
399
+ });
400
+
401
+ const outputFilename = generateFilename(hash, null, null, format);
402
+ const outputPath = path.join(outputDir, outputFilename);
403
+
404
+ if (fs.existsSync(outputPath) && !options.force) {
405
+ throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
406
+ }
407
+
408
+ const docxContent = useEncryption
409
+ ? `${encryptionMeta}|||${contentToStore}`
410
+ : contentToStore;
411
+
412
+ await createDocxWithBase64({
413
+ base64Content: docxContent,
414
+ metadata,
415
+ outputPath,
416
+ });
417
+
418
+ createdFiles.push(outputPath);
419
+ spinner.succeed && spinner.succeed('Encoding complete!');
420
+
421
+ if (!quiet) {
422
+ console.log();
423
+ console.log(chalk.green.bold('✓ File encoded successfully!'));
424
+ console.log(chalk.cyan(` Format: DOCX`));
425
+ console.log(chalk.cyan(` Hash: ${hash}`));
426
+ console.log(chalk.cyan(` Output: ${outputFilename}`));
427
+ console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
428
+ console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
429
+ console.log(chalk.cyan(` Location: ${outputDir}`));
430
+ if (useEncryption) {
431
+ console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
432
+ }
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Cleanup temporary file
438
+ */
439
+ function cleanupTemp(tempPath) {
440
+ try {
441
+ if (fs.existsSync(tempPath)) {
442
+ fs.unlinkSync(tempPath);
443
+ }
444
+ } catch {
445
+ // Ignore
446
+ }
447
+ }
448
+
346
449
  module.exports = encodeCommand;
@@ -63,20 +63,21 @@ async function infoCommand(inputFile, options) {
63
63
  }
64
64
  console.log();
65
65
 
66
- // Multi-part info
67
- if (isMultiPart(metadata)) {
66
+ // Multi-part info — check both totalParts and partNumber (v4 may have null totalParts)
67
+ const hasMultipleParts = isMultiPart(metadata) || metadata.partNumber !== null;
68
+ if (hasMultipleParts) {
68
69
  console.log(chalk.bold.white('Multi-part File:'));
69
- console.log(chalk.cyan(` This is part: ${metadata.partNumber} of ${metadata.totalParts}`));
70
- console.log(chalk.cyan(` Hash: ${metadata.hash}`));
71
-
72
- // Find other parts
73
70
  const inputDir = path.dirname(inputFile);
74
71
  const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
72
+ const totalParts = metadata.totalParts || allParts.length;
73
+
74
+ console.log(chalk.cyan(` This is part: ${metadata.partNumber} of ${totalParts}`));
75
+ console.log(chalk.cyan(` Hash: ${metadata.hash}`));
75
76
 
76
77
  console.log();
77
78
  console.log(chalk.bold.white('Parts found in directory:'));
78
79
 
79
- for (let i = 1; i <= metadata.totalParts; i++) {
80
+ for (let i = 1; i <= totalParts; i++) {
80
81
  const part = allParts.find(p => p.partNumber === i);
81
82
  if (part) {
82
83
  console.log(chalk.green(` ✓ Part ${i}: ${part.filename}`));
@@ -85,12 +86,12 @@ async function infoCommand(inputFile, options) {
85
86
  }
86
87
  }
87
88
 
88
- if (allParts.length === metadata.totalParts) {
89
+ if (allParts.length >= totalParts) {
89
90
  console.log();
90
91
  console.log(chalk.green.bold('All parts found - ready to decode'));
91
92
  } else {
92
93
  console.log();
93
- console.log(chalk.yellow.bold(`Missing ${metadata.totalParts - allParts.length} part(s)`));
94
+ console.log(chalk.yellow.bold(`Missing ${totalParts - allParts.length} part(s)`));
94
95
  }
95
96
  } else {
96
97
  console.log(chalk.bold.white('Single File:'));