UNPKG

zip-a-folder

Version:

Compress a complete folder or a glob list into a zip/tgz/br/7z file

1,433 lines (1,421 loc) 48.5 kB
#!/usr/bin/env node import * as fs from "node:fs"; import * as path from "node:path"; import * as zlib from "node:zlib"; import * as fs$1 from "fs"; import * as path$1 from "path"; import * as zlib$1 from "zlib"; import LZMA from "lzma"; import { globSync } from "tinyglobby"; //#region lib/7z/Native7z.ts /** * 7z archive format constants */ const SIGNATURE = Buffer.from([ 55, 122, 188, 175, 39, 28 ]); const VERSION_MAJOR = 0; const VERSION_MINOR = 4; const kEnd = 0; const kHeader = 1; const kMainStreamsInfo = 4; const kFilesInfo = 5; const kPackInfo = 6; const kUnpackInfo = 7; const kSubStreamsInfo = 8; const kSize = 9; const kFolder = 11; const kCodersUnpackSize = 12; const kNumUnpackStream = 13; const kNames = 17; const kMTime = 20; const kAttributes = 21; const LZMA_CODEC_ID = Buffer.from([ 3, 1, 1 ]); /** * CRC-32 calculation (same as used in ZIP) */ const CRC32_TABLE$1 = []; for (let i = 0; i < 256; i++) { let crc = i; for (let j = 0; j < 8; j++) crc = crc & 1 ? 3988292384 ^ crc >>> 1 : crc >>> 1; CRC32_TABLE$1[i] = crc >>> 0; } function crc32$1(data) { let crc = 4294967295; for (let i = 0; i < data.length; i++) crc = CRC32_TABLE$1[(crc ^ data[i]) & 255] ^ crc >>> 8; return (crc ^ 4294967295) >>> 0; } /** * Simple 7z number encoding (used for headers) */ /* c8 ignore start - large number encoding only triggered with files >127 bytes compressed */ function encode7zNumber(value) { if (value < 128) return Buffer.from([value]); const buf = Buffer.alloc(9); buf[0] = 255; buf.writeBigUInt64LE(BigInt(value), 1); return buf; } /* c8 ignore stop */ /** * Write Windows FILETIME (100-nanosecond intervals since 1601-01-01) */ function dateToFiletime(date) { const EPOCH_DIFF = BigInt(0xa9730b66800); return (BigInt(date.getTime()) + EPOCH_DIFF) * BigInt(1e4); } /** * Native 7z archive writer using pure JavaScript LZMA compression. */ var Native7z = class { /** * @param compressionLevel LZMA compression level 1-9 (default: 5) */ constructor(compressionLevel = 5) { this.files = []; this.compressionLevel = Math.max(1, Math.min(9, compressionLevel)); } /** * Add a file to the archive from filesystem. */ async addFile(entry) { if (entry.isDirectory) return; const data = await fs$1.promises.readFile(entry.fsPath); const compressedData = await this.compressLzma(data); this.files.push({ entry, data, compressedData }); } /** * Compress data using LZMA. */ compressLzma(data) { return new Promise((resolve, reject) => { LZMA.compress(data, this.compressionLevel, (result, error) => { /* c8 ignore start - LZMA error handling is hard to trigger in tests */ if (error || result instanceof Error) { reject(error || result); return; } /* c8 ignore stop */ const unsigned = result.map((b) => b < 0 ? b + 256 : b); resolve(Buffer.from(unsigned)); }); }); } /** * Finalize and write the archive to a stream. */ async writeToStream(outStream) { if (this.files.length === 0) throw new Error("Cannot create empty 7z archive"); const archiveData = await this.buildArchive(); return new Promise((resolve, reject) => { outStream.on("error", reject); outStream.on("finish", resolve); outStream.write(archiveData, (err) => { /* c8 ignore start */ if (err) reject(err); else outStream.end?.(); /* c8 ignore stop */ }); }); } /** * Build the complete 7z archive. */ async buildArchive() { const packedData = Buffer.concat(this.files.map((f) => f.compressedData)); const header = this.buildHeader(); const compressedHeader = await this.compressLzma(header); const nextHeaderOffset = BigInt(packedData.length); const nextHeaderSize = BigInt(compressedHeader.length); const nextHeaderCRC = crc32$1(compressedHeader); const startHeader = Buffer.alloc(20); startHeader.writeBigUInt64LE(nextHeaderOffset, 0); startHeader.writeBigUInt64LE(nextHeaderSize, 8); startHeader.writeUInt32LE(nextHeaderCRC, 16); const startHeaderCRC = crc32$1(startHeader); const signatureHeader = Buffer.alloc(32); SIGNATURE.copy(signatureHeader, 0); signatureHeader[6] = VERSION_MAJOR; signatureHeader[7] = VERSION_MINOR; signatureHeader.writeUInt32LE(startHeaderCRC, 8); startHeader.copy(signatureHeader, 12); return Buffer.concat([ signatureHeader, packedData, compressedHeader ]); } /** * Build the 7z header with file metadata. */ buildHeader() { const parts = []; parts.push(Buffer.from([kHeader])); parts.push(this.buildMainStreamsInfo()); parts.push(this.buildFilesInfo()); parts.push(Buffer.from([kEnd])); return Buffer.concat(parts); } /** * Build MainStreamsInfo section. */ buildMainStreamsInfo() { const parts = []; parts.push(Buffer.from([kMainStreamsInfo])); parts.push(this.buildPackInfo()); parts.push(this.buildUnpackInfo()); parts.push(this.buildSubStreamsInfo()); parts.push(Buffer.from([kEnd])); return Buffer.concat(parts); } /** * Build PackInfo section (packed/compressed sizes). */ buildPackInfo() { const parts = []; parts.push(Buffer.from([kPackInfo])); parts.push(encode7zNumber(0)); parts.push(encode7zNumber(this.files.length)); parts.push(Buffer.from([kSize])); for (const file of this.files) parts.push(encode7zNumber(file.compressedData.length)); parts.push(Buffer.from([kEnd])); return Buffer.concat(parts); } /** * Build UnpackInfo section (codec and uncompressed sizes). */ buildUnpackInfo() { const parts = []; parts.push(Buffer.from([kUnpackInfo])); parts.push(Buffer.from([kFolder])); parts.push(encode7zNumber(this.files.length)); parts.push(Buffer.from([0])); for (const _file of this.files) { parts.push(Buffer.from([1])); const coderFlags = LZMA_CODEC_ID.length; parts.push(Buffer.from([coderFlags])); parts.push(LZMA_CODEC_ID); } parts.push(Buffer.from([kCodersUnpackSize])); for (const file of this.files) parts.push(encode7zNumber(file.data.length)); parts.push(Buffer.from([kEnd])); return Buffer.concat(parts); } /** * Build SubStreamsInfo section. */ buildSubStreamsInfo() { const parts = []; parts.push(Buffer.from([kSubStreamsInfo])); parts.push(Buffer.from([kNumUnpackStream])); for (let i = 0; i < this.files.length; i++) parts.push(encode7zNumber(1)); parts.push(Buffer.from([kEnd])); return Buffer.concat(parts); } /** * Build FilesInfo section (file names, attributes, times). */ buildFilesInfo() { const parts = []; parts.push(Buffer.from([kFilesInfo])); parts.push(encode7zNumber(this.files.length)); parts.push(this.buildFileNames()); parts.push(this.buildMTimes()); parts.push(this.buildAttributes()); parts.push(Buffer.from([kEnd])); return Buffer.concat(parts); } /** * Build file names section (UTF-16LE encoded). */ buildFileNames() { const parts = []; parts.push(Buffer.from([kNames])); const namesData = []; for (const file of this.files) { const name = file.entry.relativePath.replace(/\//g, "\\"); const nameBuffer = Buffer.from(name, "utf16le"); namesData.push(nameBuffer); namesData.push(Buffer.from([0, 0])); } const allNames = Buffer.concat(namesData); parts.push(encode7zNumber(allNames.length + 1)); parts.push(Buffer.from([0])); parts.push(allNames); return Buffer.concat(parts); } /** * Build modification times section. */ buildMTimes() { const parts = []; parts.push(Buffer.from([kMTime])); const dataSize = 2 + 8 * this.files.length; parts.push(encode7zNumber(dataSize)); parts.push(Buffer.from([1])); parts.push(Buffer.from([0])); for (const file of this.files) { const buf = Buffer.alloc(8); buf.writeBigUInt64LE(dateToFiletime(file.entry.stat.mtime)); parts.push(buf); } return Buffer.concat(parts); } /** * Build Windows attributes section. */ buildAttributes() { const parts = []; parts.push(Buffer.from([kAttributes])); const dataSize = 2 + 4 * this.files.length; parts.push(encode7zNumber(dataSize)); parts.push(Buffer.from([1])); parts.push(Buffer.from([0])); for (const file of this.files) { const buf = Buffer.alloc(4); /* c8 ignore next */ const attr = file.entry.isDirectory ? 16 : 32; buf.writeUInt32LE(attr); parts.push(buf); } return Buffer.concat(parts); } }; //#endregion //#region lib/core/FileCollector.ts /** * Recursively collect entries (files + directories) under a directory, * using a worker pool limited by statConcurrency. * * - The root directory itself is NOT returned as an entry. * - Directory entries have relativePath ending with "/". * - Entries matching any pattern in `exclude` are omitted (along with their children). */ async function collectEntriesFromDirectory(rootDir, statConcurrency = 4, exclude) { const root = path$1.resolve(rootDir); const entries = []; const relPaths = globSync("**/*", { cwd: root, onlyFiles: false, dot: true, ignore: exclude }); statConcurrency = Math.max(1, statConcurrency | 0); let index = 0; const worker = async () => { while (true) { const i = index++; if (i >= relPaths.length) break; const rel = relPaths[i]; const fsPath = path$1.join(root, rel); const stat = await fs$1.promises.lstat(fsPath); if (stat.isSymbolicLink()) continue; if (stat.isDirectory()) { /* v8 ignore next */ const relDirPath = rel.endsWith("/") ? rel : rel + "/"; entries.push({ fsPath, relativePath: relDirPath.replace(/\\/g, "/"), isDirectory: true, stat }); } else if (stat.isFile()) entries.push({ fsPath, relativePath: rel.replace(/\\/g, "/"), isDirectory: false, stat }); } }; const workers = Array.from({ length: statConcurrency }, () => worker()); await Promise.all(workers); return entries; } /** * Collect file entries from one or more glob patterns, relative to a given cwd. * * - Uses the "tinyglobby" package. * - Only files are returned (no explicit directory entries). * - Returns [] when there is no match (caller will throw "No glob match found"). */ async function collectGlobEntries(patterns, cwd, statConcurrency = 4, exclude) { const patternList = patterns.split(",").map((p) => p.trim()).filter((p) => p.length > 0); const matchedRelPaths = /* @__PURE__ */ new Set(); for (const pattern of patternList) try { const matches = globSync(pattern, { cwd, onlyFiles: true, dot: false, ignore: exclude }); for (const rel of matches) matchedRelPaths.add(rel.replace(/\\/g, "/")); } catch {} const relPaths = Array.from(matchedRelPaths); if (relPaths.length === 0) return []; const entries = []; let index = 0; statConcurrency = Math.max(1, statConcurrency | 0); const worker = async () => { while (true) { const i = index++; if (i >= relPaths.length) break; const rel = relPaths[i]; const abs = path$1.resolve(cwd, rel); const stat = await fs$1.promises.stat(abs); /* v8 ignore next 3 */ if (!stat.isFile()) continue; entries.push({ fsPath: abs, relativePath: rel, isDirectory: false, stat }); } }; const workers = Array.from({ length: statConcurrency }, () => worker()); await Promise.all(workers); return entries; } //#endregion //#region lib/core/utils.ts /** * Precomputed CRC32 table for efficient checksum calculation. */ const CRC32_TABLE = (() => { const table = /* @__PURE__ */ new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let j = 0; j < 8; j++) if (c & 1) c = 3988292384 ^ c >>> 1; else c = c >>> 1; table[i] = c >>> 0; } return table; })(); /** * Compute CRC32 checksum for a buffer. */ function crc32(buf) { let crc = 4294967295; for (let i = 0; i < buf.length; i++) { const byte = buf[i]; crc = CRC32_TABLE[(crc ^ byte) & 255] ^ crc >>> 8; } return (crc ^ 4294967295) >>> 0; } /** * Convert a Date to DOS time (HH:MM:SS/2). * @param date Date to convert. * @param useLocal When true use local time, otherwise UTC. */ function dateToDosTime(date, useLocal) { const secs = Math.floor((useLocal ? date.getSeconds() : date.getUTCSeconds()) / 2); const mins = useLocal ? date.getMinutes() : date.getUTCMinutes(); return (useLocal ? date.getHours() : date.getUTCHours()) << 11 | mins << 5 | secs; } /** * Convert a Date to DOS date. * @param date Date to convert. * @param useLocal When true use local time, otherwise UTC. */ function dateToDosDate(date, useLocal) { const year = useLocal ? date.getFullYear() : date.getUTCFullYear(); const month = (useLocal ? date.getMonth() : date.getUTCMonth()) + 1; const day = useLocal ? date.getDate() : date.getUTCDate(); return (year < 1980 ? 0 : year - 1980) << 9 | month << 5 | day; } /** * Write an ASCII string into a buffer with a fixed maximum length. */ function writeString(buf, str, offset, length) { const bytes = Buffer.from(str, "utf8"); bytes.copy(buf, offset, 0, Math.min(bytes.length, length)); } /** * Write an octal number into a buffer as ASCII text. */ function writeOctal(buf, value, offset, length) { writeString(buf, value.toString(8).padStart(length - 1, "0"), offset, length - 1); buf[offset + length - 1] = 0; } /** * Write a 64-bit unsigned integer (up to 2^53-1) to a buffer in little-endian order. */ function writeUInt64LE(buf, value, offset) { const low = value >>> 0; const high = Math.floor(value / 4294967296) >>> 0; buf.writeUInt32LE(low, offset); buf.writeUInt32LE(high, offset + 4); } /** * Helper to write data to a Node writable stream and await completion. */ function writeToStream(stream, data) { return new Promise((resolve, reject) => { /* v8 ignore next 4 */ const onError = (err) => { stream.removeListener("error", onError); reject(err); }; stream.once("error", onError); stream.write(data, (err) => { stream.removeListener("error", onError); /* v8 ignore next 3 */ if (err) reject(err); else resolve(); }); }); } /** * Determine if a string "looks like" it contains glob characters. */ function looksLikeGlob(source) { return /[*?[\]{},]/.test(source); } //#endregion //#region lib/tar/NativeTar.ts /** * Create a 512-byte TAR header block for a file or directory. */ function createTarHeader(name, size, mtimeSeconds, mode, isDirectory) { const buf = Buffer.alloc(512, 0); writeString(buf, name, 0, 100); writeOctal(buf, mode & 4095, 100, 8); writeOctal(buf, 0, 108, 8); writeOctal(buf, 0, 116, 8); writeOctal(buf, size, 124, 12); writeOctal(buf, Math.floor(mtimeSeconds), 136, 12); for (let i = 148; i < 156; i++) buf[i] = 32; buf[156] = (isDirectory ? "5" : "0").charCodeAt(0); writeString(buf, "ustar", 257, 6); writeString(buf, "00", 263, 2); let sum = 0; for (let i = 0; i < 512; i++) sum += buf[i]; writeOctal(buf, sum, 148, 8); return buf; } /** * Minimal TAR writer used to build tar streams for .tar / .tgz files. */ var NativeTar = class { constructor(stream) { this.stream = stream; } /** * Add a directory entry (no data, only header). */ async addDirectory(entry) { let name = entry.relativePath.replace(/\\/g, "/"); if (!name.endsWith("/")) name += "/"; const header = createTarHeader(name, 0, entry.stat.mtime.getTime() / 1e3, entry.stat.mode, true); await writeToStream(this.stream, header); } /** * Add a file entry (header + file data + padding to 512 bytes). */ async addFile(entry) { const name = entry.relativePath.replace(/\\/g, "/"); const data = await fs$1.promises.readFile(entry.fsPath); const header = createTarHeader(name, data.length, entry.stat.mtime.getTime() / 1e3, entry.stat.mode, false); await writeToStream(this.stream, header); await writeToStream(this.stream, data); const remainder = data.length % 512; if (remainder !== 0) { const padding = Buffer.alloc(512 - remainder, 0); await writeToStream(this.stream, padding); } } /** * Finalize the TAR archive by writing two 512-byte zero blocks. */ async finalize() { const block = Buffer.alloc(512, 0); await writeToStream(this.stream, block); await writeToStream(this.stream, block); } }; //#endregion //#region lib/zip/NativeZip.ts /** * Low-level ZIP entry representation. */ var ZipEntry = class { constructor(params) { this.localHeaderOffset = 0; this.name = params.name; this.isDirectory = params.isDirectory; this.date = params.date; this.mode = params.mode; this.crc32 = params.crc32; this.compressedSize = params.compressedSize; this.uncompressedSize = params.uncompressedSize; this.compressionMethod = params.compressionMethod; this.compressedData = params.compressedData; } }; /** * Native ZIP writer using only Node's built-in modules. * * - Supports standard ZIP up to 4GB. * - Automatically enables ZIP64 structures when needed (size/offset/entry-count overflow), * or when forceZip64 is set. * - Directories are stored as entries with trailing "/". */ var NativeZip = class { /* istanbul ignore next */ constructor(options = {}) { this.entries = []; this.comment = options.comment; this.useLocalTime = options.forceLocalTime === true; this.forceZip64 = options.forceZip64 === true; this.store = options.store === true; this.zlibOptions = options.zlib; } /** * Add a directory entry to the ZIP archive. * @param archivePath Path inside the archive (with or without trailing "/"). * @param date Modification date. * @param mode File mode (permissions). */ addDirectoryEntry(archivePath, date, mode) { let name = archivePath.replace(/\\/g, "/"); if (!name.endsWith("/")) name += "/"; this.entries.push(new ZipEntry({ name, isDirectory: true, date, mode, crc32: 0, compressedSize: 0, uncompressedSize: 0, compressionMethod: 0, compressedData: Buffer.alloc(0) })); } /** * Add a file from the filesystem to the ZIP archive. * @param filePath Physical file path on disk. * @param archivePath Path inside the archive. * @param date Modification date. * @param mode File mode (permissions). */ async addFileFromFs(filePath, archivePath, date, mode) { const name = archivePath.replace(/\\/g, "/"); const data = await fs$1.promises.readFile(filePath); const sum = crc32(data); let compressed; let method; if (this.store) { compressed = data; method = 0; } else { compressed = zlib$1.deflateRawSync(data, this.zlibOptions); method = 8; } this.entries.push(new ZipEntry({ name, isDirectory: false, date, mode, crc32: sum, compressedSize: compressed.length, uncompressedSize: data.length, compressionMethod: method, compressedData: compressed })); } /** * Write the ZIP archive to a writable stream. * Handles ZIP64 automatically when required. */ writeToStream(stream) { return new Promise((resolve, reject) => { stream.once("error", reject); try { let offset = 0; const centralDirParts = []; const commentBuffer = this.comment ? Buffer.from(this.comment, "utf8") : Buffer.alloc(0); let useZip64 = this.forceZip64; for (const entry of this.entries) { const nameBuffer = Buffer.from(entry.name, "utf8"); const dosTime = dateToDosTime(entry.date, this.useLocalTime); const dosDate = dateToDosDate(entry.date, this.useLocalTime); const needsZip64Sizes = entry.compressedSize >= 4294967295 || entry.uncompressedSize >= 4294967295 || this.forceZip64; const needsZip64Offset = offset >= 4294967295 || this.forceZip64; if (needsZip64Sizes || needsZip64Offset) useZip64 = true; let localExtra = Buffer.alloc(0); if (needsZip64Sizes) { const extra = Buffer.alloc(20); let q = 0; extra.writeUInt16LE(1, q); q += 2; extra.writeUInt16LE(16, q); q += 2; writeUInt64LE(extra, entry.uncompressedSize, q); q += 8; writeUInt64LE(extra, entry.compressedSize, q); q += 8; localExtra = extra; } const localHeader = Buffer.alloc(30); let p = 0; localHeader.writeUInt32LE(67324752, p); p += 4; localHeader.writeUInt16LE(useZip64 ? 45 : 20, p); p += 2; localHeader.writeUInt16LE(0, p); p += 2; localHeader.writeUInt16LE(entry.compressionMethod, p); p += 2; localHeader.writeUInt16LE(dosTime, p); p += 2; localHeader.writeUInt16LE(dosDate, p); p += 2; localHeader.writeUInt32LE(entry.crc32 >>> 0, p); p += 4; if (needsZip64Sizes) { localHeader.writeUInt32LE(4294967295, p); p += 4; localHeader.writeUInt32LE(4294967295, p); p += 4; } else { localHeader.writeUInt32LE(entry.compressedSize >>> 0, p); p += 4; localHeader.writeUInt32LE(entry.uncompressedSize >>> 0, p); p += 4; } localHeader.writeUInt16LE(nameBuffer.length, p); p += 2; localHeader.writeUInt16LE(localExtra.length, p); p += 2; entry.localHeaderOffset = offset; stream.write(localHeader); stream.write(nameBuffer); if (localExtra.length > 0) stream.write(localExtra); offset += localHeader.length + nameBuffer.length + localExtra.length; if (entry.compressedData.length > 0) { stream.write(entry.compressedData); offset += entry.compressedData.length; } const centralHeader = Buffer.alloc(46); p = 0; const centralNeedsZip64Sizes = needsZip64Sizes; const centralNeedsZip64Offset = needsZip64Offset; const centralNeedsZip64 = centralNeedsZip64Sizes || centralNeedsZip64Offset; centralHeader.writeUInt32LE(33639248, p); p += 4; centralHeader.writeUInt16LE(768 | (useZip64 ? 45 : 20), p); p += 2; centralHeader.writeUInt16LE(useZip64 ? 45 : 20, p); p += 2; centralHeader.writeUInt16LE(0, p); p += 2; centralHeader.writeUInt16LE(entry.compressionMethod, p); p += 2; centralHeader.writeUInt16LE(dosTime, p); p += 2; centralHeader.writeUInt16LE(dosDate, p); p += 2; centralHeader.writeUInt32LE(entry.crc32 >>> 0, p); p += 4; if (centralNeedsZip64Sizes) { centralHeader.writeUInt32LE(4294967295, p); p += 4; centralHeader.writeUInt32LE(4294967295, p); p += 4; } else { centralHeader.writeUInt32LE(entry.compressedSize >>> 0, p); p += 4; centralHeader.writeUInt32LE(entry.uncompressedSize >>> 0, p); p += 4; } centralHeader.writeUInt16LE(nameBuffer.length, p); p += 2; let centralExtra = Buffer.alloc(0); if (centralNeedsZip64) { const extraSize = 24; const extra = Buffer.alloc(28); let q = 0; extra.writeUInt16LE(1, q); q += 2; extra.writeUInt16LE(extraSize, q); q += 2; writeUInt64LE(extra, entry.uncompressedSize, q); q += 8; writeUInt64LE(extra, entry.compressedSize, q); q += 8; writeUInt64LE(extra, entry.localHeaderOffset, q); q += 8; centralExtra = extra; } centralHeader.writeUInt16LE(centralExtra.length, p); p += 2; centralHeader.writeUInt16LE(0, p); p += 2; centralHeader.writeUInt16LE(0, p); p += 2; centralHeader.writeUInt16LE(0, p); p += 2; const extAttr = entry.mode << 16 | (entry.isDirectory ? 16 : 0); centralHeader.writeUInt32LE(extAttr >>> 0, p); p += 4; if (centralNeedsZip64Offset) { centralHeader.writeUInt32LE(4294967295, p); p += 4; } else { centralHeader.writeUInt32LE(entry.localHeaderOffset >>> 0, p); p += 4; } centralDirParts.push(centralHeader, nameBuffer); if (centralExtra.length > 0) centralDirParts.push(centralExtra); } const startOfCentralDir = offset; const centralDirectoryBuffer = Buffer.concat(centralDirParts); const sizeOfCentralDir = centralDirectoryBuffer.length; const totalEntries = this.entries.length; /* v8 ignore next 3 */ if (startOfCentralDir >= 4294967295 || sizeOfCentralDir >= 4294967295 || totalEntries >= 65535) useZip64 = true; stream.write(centralDirectoryBuffer); const offsetAfterCentralDir = startOfCentralDir + sizeOfCentralDir; if (useZip64) { const zip64Eocd = Buffer.alloc(56); let pz = 0; zip64Eocd.writeUInt32LE(101075792, pz); pz += 4; writeUInt64LE(zip64Eocd, 44, pz); pz += 8; zip64Eocd.writeUInt16LE(45, pz); pz += 2; zip64Eocd.writeUInt16LE(45, pz); pz += 2; zip64Eocd.writeUInt32LE(0, pz); pz += 4; zip64Eocd.writeUInt32LE(0, pz); pz += 4; writeUInt64LE(zip64Eocd, totalEntries, pz); pz += 8; writeUInt64LE(zip64Eocd, totalEntries, pz); pz += 8; writeUInt64LE(zip64Eocd, sizeOfCentralDir, pz); pz += 8; writeUInt64LE(zip64Eocd, startOfCentralDir, pz); pz += 8; stream.write(zip64Eocd); const zip64Locator = Buffer.alloc(20); let pl = 0; zip64Locator.writeUInt32LE(117853008, pl); pl += 4; zip64Locator.writeUInt32LE(0, pl); pl += 4; writeUInt64LE(zip64Locator, offsetAfterCentralDir, pl); pl += 8; zip64Locator.writeUInt32LE(1, pl); pl += 4; stream.write(zip64Locator); } const eocd = Buffer.alloc(22); let p3 = 0; eocd.writeUInt32LE(101010256, p3); p3 += 4; eocd.writeUInt16LE(0, p3); p3 += 2; eocd.writeUInt16LE(0, p3); p3 += 2; eocd.writeUInt16LE(Math.min(totalEntries, 65535), p3); p3 += 2; eocd.writeUInt16LE(Math.min(totalEntries, 65535), p3); p3 += 2; eocd.writeUInt32LE(Math.min(sizeOfCentralDir, 4294967295), p3); p3 += 4; eocd.writeUInt32LE(Math.min(startOfCentralDir, 4294967295), p3); p3 += 4; eocd.writeUInt16LE(commentBuffer.length, p3); p3 += 2; stream.write(eocd); if (commentBuffer.length > 0) stream.write(commentBuffer); stream.end?.(() => resolve()); } catch (err) { reject(err); } }); } }; //#endregion //#region lib/ZipAFolder.ts /** * High-level facade class that provides ZIP/TAR creation helpers. * * Public API expected by tests: * - ZipAFolder.zip(...) * - ZipAFolder.tar(...) * - zip(...) * - tar(...) */ var ZipAFolder = class { /** * Create a ZIP archive from a directory or glob. * * @param source Directory path OR glob pattern. * @param targetFilePath Path to target zip file. May be empty/undefined if customWriteStream is provided. * @param options ZIP options. */ static async zip(source, targetFilePath, options = {}) { const customWS = options.customWriteStream; const hasTargetPath = !!(targetFilePath && targetFilePath.length > 0); if (!hasTargetPath && !customWS) throw new Error("You must either provide a target file path or a custom write stream to write to."); const statConcurrency = options.statConcurrency ?? 4; const zipStore = options.store === true || options.compression === "uncompressed"; const zlibOptions = { ...options.zlib || {} }; if (!zipStore && options.compression !== void 0) switch (options.compression) { case "medium": if (zlibOptions.level === void 0) zlibOptions.level = zlib$1.constants.Z_DEFAULT_COMPRESSION; break; case "high": if (zlibOptions.level === void 0) zlibOptions.level = zlib$1.constants.Z_BEST_COMPRESSION; break; /* v8 ignore next 3 */ default: break; } const zipper = new NativeZip({ comment: options.comment, forceLocalTime: options.forceLocalTime, forceZip64: options.forceZip64, namePrependSlash: options.namePrependSlash, store: zipStore, zlib: zlibOptions }); const cwd = process.cwd(); const isGlob = looksLikeGlob(source); let entries = []; if (isGlob) { entries = await collectGlobEntries(source, cwd, statConcurrency, options.exclude); if (entries.length === 0) throw new Error("No glob match found"); } else { const sourceDir = path$1.resolve(source); if (!(await fs$1.promises.stat(sourceDir)).isDirectory()) throw new Error("Source must be a directory when no glob pattern is used."); if (hasTargetPath) { const targetAbs = path$1.resolve(targetFilePath); const targetDir = path$1.dirname(targetAbs); const normalizedSourceDir = path$1.resolve(sourceDir); if (targetDir === normalizedSourceDir || targetDir.startsWith(normalizedSourceDir + path$1.sep)) throw new Error("Source and target folder must be different."); } entries = await collectEntriesFromDirectory(sourceDir, statConcurrency, options.exclude); if (options.destPath) { const prefix = options.destPath.replace(/\\/g, "/").replace(/\/+$/, ""); if (prefix.length > 0) entries = entries.map((e) => ({ ...e, relativePath: prefix + "/" + e.relativePath.replace(/\\/g, "/").replace(/^\/+/, "") })); } } if (options.namePrependSlash) entries = entries.map((e) => { /* v8 ignore next */ const rp = e.relativePath.startsWith("/") ? e.relativePath : "/" + e.relativePath; return { ...e, relativePath: rp }; }); for (const e of entries.filter((x) => x.isDirectory)) zipper.addDirectoryEntry(e.relativePath, e.stat.mtime, e.stat.mode); for (const e of entries.filter((x) => !x.isDirectory)) await zipper.addFileFromFs(e.fsPath, e.relativePath, e.stat.mtime, e.stat.mode); if (!customWS && hasTargetPath) { const parentDir = path$1.dirname(path$1.resolve(targetFilePath)); await fs$1.promises.stat(parentDir); } const outStream = customWS ?? fs$1.createWriteStream(targetFilePath); await zipper.writeToStream(outStream); } /** * Create a TAR (optionally gzipped) archive from a directory or glob. * * @param source Directory path OR glob pattern. * @param targetFilePath Path to target tar/tgz file. May be empty/undefined if customWriteStream is provided. * @param options TAR/gzip options. */ static async tar(source, targetFilePath, options = {}) { const customWS = options.customWriteStream; const hasTargetPath = !!(targetFilePath && targetFilePath.length > 0); if (!hasTargetPath && !customWS) throw new Error("You must either provide a target file path or a custom write stream to write to."); const statConcurrency = options.statConcurrency ?? 4; const cwd = process.cwd(); const isGlob = looksLikeGlob(source); let entries = []; if (isGlob) { entries = await collectGlobEntries(source, cwd, statConcurrency, options.exclude); if (entries.length === 0) throw new Error("No glob match found"); } else { const sourceDir = path$1.resolve(source); if (!(await fs$1.promises.stat(sourceDir)).isDirectory()) throw new Error("Source must be a directory when no glob pattern is used."); if (hasTargetPath) { const targetAbs = path$1.resolve(targetFilePath); const targetDir = path$1.dirname(targetAbs); const normalizedSourceDir = path$1.resolve(sourceDir); if (targetDir === normalizedSourceDir || targetDir.startsWith(normalizedSourceDir + path$1.sep)) throw new Error("Source and target folder must be different."); } entries = await collectEntriesFromDirectory(sourceDir, statConcurrency, options.exclude); } let compressionType = "gzip"; if (options.compression === "uncompressed") compressionType = "none"; else if (options.compressionType !== void 0) compressionType = options.compressionType; else if (options.gzip !== void 0) compressionType = options.gzip ? "gzip" : "none"; const gzipOptions = { ...options.gzipOptions || {} }; if (compressionType === "gzip" && options.compression !== void 0) switch (options.compression) { case "medium": if (gzipOptions.level === void 0) gzipOptions.level = zlib$1.constants.Z_DEFAULT_COMPRESSION; break; case "high": if (gzipOptions.level === void 0) gzipOptions.level = zlib$1.constants.Z_BEST_COMPRESSION; break; /* v8 ignore next 2 */ default: break; } const brotliOptions = { ...options.brotliOptions || {} }; if (compressionType === "brotli" && options.compression !== void 0) { if (!brotliOptions.params) brotliOptions.params = {}; if (brotliOptions.params[zlib$1.constants.BROTLI_PARAM_QUALITY] === void 0) switch (options.compression) { case "medium": brotliOptions.params[zlib$1.constants.BROTLI_PARAM_QUALITY] = 6; break; case "high": brotliOptions.params[zlib$1.constants.BROTLI_PARAM_QUALITY] = zlib$1.constants.BROTLI_MAX_QUALITY; break; /* v8 ignore next 2 */ default: break; } } if (!customWS && hasTargetPath) { const parentDir = path$1.dirname(path$1.resolve(targetFilePath)); await fs$1.promises.stat(parentDir); } const finalOut = customWS ?? fs$1.createWriteStream(targetFilePath); let tarDestination; if (compressionType === "gzip") { const gzipStream = zlib$1.createGzip(gzipOptions); gzipStream.pipe(finalOut); tarDestination = gzipStream; } else if (compressionType === "brotli") { const brotliStream = zlib$1.createBrotliCompress(brotliOptions); brotliStream.pipe(finalOut); tarDestination = brotliStream; } else tarDestination = finalOut; const tarWriter = new NativeTar(tarDestination); for (const e of entries.filter((x) => x.isDirectory)) await tarWriter.addDirectory(e); for (const e of entries.filter((x) => !x.isDirectory)) await tarWriter.addFile(e); await tarWriter.finalize(); await new Promise((resolve, reject) => { const finishTarget = compressionType !== "none" ? finalOut : tarDestination; /* v8 ignore next 4 */ const onError = (err) => { cleanup(); reject(err); }; const onFinish = () => { cleanup(); resolve(); }; const cleanup = () => { finishTarget.removeListener("error", onError); finishTarget.removeListener("finish", onFinish); }; finishTarget.once("error", onError); finishTarget.once("finish", onFinish); tarDestination.end?.(); }); } /** * Create a 7z archive from a directory or glob using LZMA compression. * * @param source Directory path OR glob pattern. * @param targetFilePath Path to target .7z file. May be empty/undefined if customWriteStream is provided. * @param options 7z/LZMA options. */ static async sevenZip(source, targetFilePath, options = {}) { const customWS = options.customWriteStream; const hasTargetPath = !!(targetFilePath && targetFilePath.length > 0); if (!hasTargetPath && !customWS) throw new Error("You must either provide a target file path or a custom write stream to write to."); const statConcurrency = options.statConcurrency ?? 4; const cwd = process.cwd(); const isGlob = looksLikeGlob(source); let entries = []; if (isGlob) { entries = await collectGlobEntries(source, cwd, statConcurrency, options.exclude); if (entries.length === 0) throw new Error("No glob match found"); } else { const sourceDir = path$1.resolve(source); if (!(await fs$1.promises.stat(sourceDir)).isDirectory()) throw new Error("Source must be a directory when no glob pattern is used."); if (hasTargetPath) { const targetAbs = path$1.resolve(targetFilePath); const targetDir = path$1.dirname(targetAbs); const normalizedSourceDir = path$1.resolve(sourceDir); if (targetDir === normalizedSourceDir || targetDir.startsWith(normalizedSourceDir + path$1.sep)) throw new Error("Source and target folder must be different."); } entries = await collectEntriesFromDirectory(sourceDir, statConcurrency, options.exclude); } let compressionLevel = options.compressionLevel ?? 5; if (options.compression !== void 0) switch (options.compression) { case "uncompressed": compressionLevel = 1; break; case "medium": compressionLevel = 5; break; case "high": compressionLevel = 9; break; } if (!customWS && hasTargetPath) { const parentDir = path$1.dirname(path$1.resolve(targetFilePath)); await fs$1.promises.stat(parentDir); } const outStream = customWS ?? fs$1.createWriteStream(targetFilePath); const sevenZWriter = new Native7z(compressionLevel); for (const e of entries) await sevenZWriter.addFile(e); await sevenZWriter.writeToStream(outStream); } }; /** * Convenience function: zip(...) directly. */ function zip(source, targetFilePath, options) { return ZipAFolder.zip(source, targetFilePath, options ?? {}); } /** * Convenience function: tar(...) directly. */ function tar(source, targetFilePath, options) { return ZipAFolder.tar(source, targetFilePath, options ?? {}); } /** * Convenience function: sevenZip(...) directly. * Creates a 7z archive using LZMA compression. */ function sevenZip(source, targetFilePath, options) { return ZipAFolder.sevenZip(source, targetFilePath, options ?? {}); } //#endregion //#region bin/zip-a-folder.ts const VERSION = "6.0.1"; const colors = { reset: "\x1B[0m", bold: "\x1B[1m", dim: "\x1B[2m", green: "\x1B[32m", yellow: "\x1B[33m", blue: "\x1B[34m", cyan: "\x1B[36m", red: "\x1B[31m", magenta: "\x1B[35m" }; const spinnerFrames = [ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" ]; function printHelp() { console.log(` ${colors.bold}${colors.cyan}zip-a-folder${colors.reset} - Compress folders into ZIP/TAR/TGZ/BR/7z archives ${colors.bold}USAGE:${colors.reset} zip-a-folder <source> <target> [options] ${colors.bold}ARGUMENTS:${colors.reset} ${colors.green}<source>${colors.reset} Source folder path or glob pattern ${colors.green}<target>${colors.reset} Target archive path (extension determines format) ${colors.bold}SUPPORTED FORMATS:${colors.reset} ${colors.yellow}.zip${colors.reset} ZIP archive (deflate compression) ${colors.yellow}.tar${colors.reset} TAR archive (uncompressed) ${colors.yellow}.tgz${colors.reset} TAR archive with gzip compression ${colors.yellow}.tar.gz${colors.reset} TAR archive with gzip compression ${colors.yellow}.tar.br${colors.reset} TAR archive with brotli compression ${colors.yellow}.7z${colors.reset} 7z archive with LZMA compression ${colors.bold}OPTIONS:${colors.reset} ${colors.cyan}-h, --help${colors.reset} Show this help message ${colors.cyan}-V, --version${colors.reset} Show version number ${colors.cyan}-v, --verbose${colors.reset} Show detailed progress (files being processed) ${colors.cyan}-q, --quiet${colors.reset} Suppress all output except errors ${colors.cyan}-c, --compression${colors.reset} <level> Compression preset: high, medium, uncompressed (default: high) ${colors.cyan}-l, --level${colors.reset} <number> Compression level (1-9, format-specific) ${colors.cyan}-e, --exclude${colors.reset} <pattern> Glob pattern to exclude (can be used multiple times) ${colors.cyan}-d, --dest-path${colors.reset} <path> Destination path prefix inside archive ${colors.bold}EXAMPLES:${colors.reset} ${colors.dim}# Create a ZIP archive${colors.reset} zip-a-folder ./my-folder ./archive.zip ${colors.dim}# Create a gzipped TAR with verbose output${colors.reset} zip-a-folder ./src ./backup.tgz -v ${colors.dim}# Create a 7z archive with maximum compression${colors.reset} zip-a-folder ./project ./project.7z -c high ${colors.dim}# Create archive excluding node_modules${colors.reset} zip-a-folder ./app ./app.zip -e "node_modules/**" -e "**/*.log" ${colors.dim}# Create brotli-compressed TAR${colors.reset} zip-a-folder ./data ./data.tar.br -v ${colors.dim}# Use glob patterns${colors.reset} zip-a-folder "src/**/*.ts" ./source.zip ${colors.bold}OUTPUT:${colors.reset} By default, shows a summary with compression ratio. Use ${colors.cyan}-v${colors.reset} for detailed file-by-file progress. Use ${colors.cyan}-q${colors.reset} to suppress all output. `); } function printVersion() { console.log(`zip-a-folder v${VERSION}`); } function parseArgs(args) { const options = { verbose: false, quiet: false, compression: "high", exclude: [], help: false, version: false }; let source; let target; const positional = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "-h" || arg === "--help") options.help = true; else if (arg === "-V" || arg === "--version") options.version = true; else if (arg === "-v" || arg === "--verbose") options.verbose = true; else if (arg === "-q" || arg === "--quiet") options.quiet = true; else if (arg === "-c" || arg === "--compression") { const level = args[++i]; if (level === "high" || level === "medium" || level === "uncompressed") options.compression = level; else { console.error(`${colors.red}Error:${colors.reset} Invalid compression level: ${level}`); console.error("Valid options: high, medium, uncompressed"); process.exit(1); } } else if (arg === "-l" || arg === "--level") { const level = Number.parseInt(args[++i], 10); if (Number.isNaN(level) || level < 1 || level > 9) { console.error(`${colors.red}Error:${colors.reset} Invalid compression level: must be 1-9`); process.exit(1); } options.compressionLevel = level; } else if (arg === "-e" || arg === "--exclude") options.exclude.push(args[++i]); else if (arg === "-d" || arg === "--dest-path") options.destPath = args[++i]; else if (!arg.startsWith("-")) positional.push(arg); else { console.error(`${colors.red}Error:${colors.reset} Unknown option: ${arg}`); console.error("Use --help to see available options"); process.exit(1); } } if (positional.length >= 1) source = positional[0]; if (positional.length >= 2) target = positional[1]; return { source, target, options }; } function formatBytes(bytes) { if (bytes === 0) return "0 B"; const k = 1024; const sizes = [ "B", "KB", "MB", "GB" ]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; } function getArchiveType(targetPath) { const lowerPath = targetPath.toLowerCase(); if (lowerPath.endsWith(".zip")) return "zip"; if (lowerPath.endsWith(".tar.br")) return "tar.br"; if (lowerPath.endsWith(".tar.gz") || lowerPath.endsWith(".tgz")) return "tgz"; if (lowerPath.endsWith(".tar")) return "tar"; if (lowerPath.endsWith(".7z")) return "7z"; return null; } async function getSourceSize(sourcePath) { let totalSize = 0; async function walkDir(dir) { try { const stat = await fs.promises.stat(dir); if (stat.isFile()) { totalSize += stat.size; return; } const entries = await fs.promises.readdir(dir); for (const entry of entries) await walkDir(path.join(dir, entry)); } catch {} } if (sourcePath.includes("*") || sourcePath.includes("?")) return 0; await walkDir(sourcePath); return totalSize; } async function countFiles(sourcePath) { let count = 0; async function walkDir(dir) { try { if ((await fs.promises.stat(dir)).isFile()) { count++; return; } const entries = await fs.promises.readdir(dir); for (const entry of entries) await walkDir(path.join(dir, entry)); } catch {} } if (!sourcePath.includes("*") && !sourcePath.includes("?")) await walkDir(sourcePath); return count; } var ProgressReporter = class { constructor(verbose, quiet, totalFiles) { this.spinnerIndex = 0; this.filesProcessed = 0; this.totalFiles = 0; this.verbose = verbose; this.quiet = quiet; this.totalFiles = totalFiles; this.startTime = Date.now(); } start(archiveType, source, target) { if (this.quiet) return; console.log(`\n${colors.bold}${colors.cyan}📦 zip-a-folder${colors.reset}`); console.log(`${colors.dim}───────────────────────────────────────${colors.reset}`); console.log(`${colors.green}Source:${colors.reset} ${source}`); console.log(`${colors.green}Target:${colors.reset} ${target}`); console.log(`${colors.green}Format:${colors.reset} ${archiveType.toUpperCase()}`); if (this.totalFiles > 0) console.log(`${colors.green}Files:${colors.reset} ${this.totalFiles}`); console.log(`${colors.dim}───────────────────────────────────────${colors.reset}`); if (!this.verbose) { process.stdout.write(`\n${colors.cyan}${spinnerFrames[0]}${colors.reset} Compressing...`); this.interval = setInterval(() => { this.spinnerIndex = (this.spinnerIndex + 1) % spinnerFrames.length; process.stdout.write(`\r${colors.cyan}${spinnerFrames[this.spinnerIndex]}${colors.reset} Compressing...`); }, 80); } else console.log(`\n${colors.bold}Processing files:${colors.reset}`); } file(relativePath, size) { this.filesProcessed++; if (this.verbose && !this.quiet) { const sizeStr = formatBytes(size).padStart(10); console.log(` ${colors.dim}${sizeStr}${colors.reset} ${relativePath}`); } } stop() { if (this.interval) { clearInterval(this.interval); process.stdout.write("\r" + " ".repeat(30) + "\r"); } } summary(sourceSize, targetSize) { if (this.quiet) return; const duration = ((Date.now() - this.startTime) / 1e3).toFixed(2); const ratio = sourceSize > 0 ? ((1 - targetSize / sourceSize) * 100).toFixed(1) : "N/A"; console.log(`\n${colors.dim}───────────────────────────────────────${colors.reset}`); console.log(`${colors.bold}${colors.green}✓ Compression complete!${colors.reset}`); console.log(`${colors.dim}───────────────────────────────────────${colors.reset}`); if (sourceSize > 0) { console.log(`${colors.yellow}Original size:${colors.reset} ${formatBytes(sourceSize)}`); console.log(`${colors.yellow}Compressed size:${colors.reset} ${formatBytes(targetSize)}`); console.log(`${colors.yellow}Compression:${colors.reset} ${colors.green}${ratio}%${colors.reset} reduction`); } else console.log(`${colors.yellow}Archive size:${colors.reset} ${formatBytes(targetSize)}`); console.log(`${colors.yellow}Time:${colors.reset} ${duration}s`); if (this.filesProcessed > 0) console.log(`${colors.yellow}Files:${colors.reset} ${this.filesProcessed}`); console.log(); } error(message) { this.stop(); console.error(`\n${colors.red}✗ Error:${colors.reset} ${message}\n`); } }; async function main() { const { source, target, options } = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); process.exit(0); } if (options.version) { printVersion(); process.exit(0); } if (!source || !target) { console.error(`${colors.red}Error:${colors.reset} Missing required arguments`); console.error("Usage: zip-a-folder <source> <target> [options]"); console.error("Use --help for more information"); process.exit(1); } const archiveType = getArchiveType(target); if (!archiveType) { console.error(`${colors.red}Error:${colors.reset} Unsupported archive format`); console.error("Supported formats: .zip, .tar, .tgz, .tar.gz, .tar.br, .7z"); process.exit(1); } const sourceSize = await getSourceSize(source); const fileCount = await countFiles(source); const progress = new ProgressReporter(options.verbose, options.quiet, fileCount); try { progress.start(archiveType, source, target); const baseOptions = { compression: options.compression, exclude: options.exclude.length > 0 ? options.exclude : void 0, destPath: options.destPath }; switch (archiveType) { case "zip": await zip(source, target, { ...baseOptions, zlib: options.compressionLevel ? { level: options.compressionLevel } : void 0 }); break; case "tar": await tar(source, target, { ...baseOptions, compressionType: "none" }); break; case "tgz": await tar(source, target, { ...baseOptions, compressionType: "gzip", gzipOptions: options.compressionLevel ? { level: options.compressionLevel } : void 0 }); break; case "tar.br": await tar(source, target, { ...baseOptions, compressionType: "brotli", brotliOptions: options.compressionLevel ? { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: options.compressionLevel } } : void 0 }); break; case "7z": await sevenZip(source, target, { ...baseOptions, compressionLevel: options.compressionLevel }); break; } progress.stop(); const targetStat = await fs.promises.stat(target); progress.summary(sourceSize, targetStat.size); } catch (error) { progress.error(error instanceof Error ? error.message : String(error)); process.exit(1); } } main(); //#endregion export {};