xzipit 0.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ---
9
+
10
+ ## [0.1.0] – Initial development
11
+
12
+ ### Added
13
+
14
+ - CLI tool `xzipit` for creating ZIP archives
15
+ - Support for packing:
16
+ - current directory (default)
17
+ - custom source directory via `--src`
18
+ - Output archive naming:
19
+ - automatic name from source directory
20
+ - custom name via `--out`
21
+ - optional version suffix via `--version`
22
+ - timestamp suffix when file already exists
23
+ - `.zipignore` support with `.gitignore`-compatible syntax
24
+ - Interactive conflict resolution when output ZIP already exists:
25
+ - overwrite
26
+ - cancel
27
+ - add timestamp suffix
28
+ - CI-friendly mode with `--yes` (no interactive prompts)
29
+ - Compression options:
30
+ - compression level (`--level`, 0–9)
31
+ - compression algorithm (`deflate`, `store`)
32
+ - Bounded concurrency for file streaming via `--concurrency`
33
+ - prevents `EMFILE: too many open files`
34
+ - Live progress UI:
35
+ - list of archived files
36
+ - per-file progress
37
+ - total archive progress
38
+ - ETA and compression speed
39
+ - Cross-platform support (Linux, macOS, Windows)
40
+
41
+ ### Internal
42
+
43
+ - Modular architecture (`lib/` + thin CLI)
44
+ - Sequential / bounded-parallel streaming to avoid file descriptor exhaustion
45
+ - Unit tests for core logic:
46
+ - archive naming
47
+ - `.zipignore` handling
48
+ - ZIP creation correctness
49
+
50
+ ---
51
+
52
+ [0.1.0]: https://github.com/yourname/xzipit
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roman Ivaskiv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # xzipit 🎤📦
2
+
3
+ ![npm](https://img.shields.io/npm/v/xzipit)
4
+ ![node](https://img.shields.io/node/v/xzipit)
5
+ ![license](https://img.shields.io/npm/l/xzipit)
6
+ ![tests](https://github.com/romaniv1024/xzipit/actions/workflows/test.yml/badge.svg)
7
+
8
+ **xzipit** is a fast and flexible Node.js CLI tool for archiving projects into ZIP files.
9
+ It supports `.zipignore` (gitignore-style rules), live progress display, CI-friendly mode,
10
+ and safe concurrent file streaming.
11
+
12
+ > The name is partially inspired by a famous American rapper 😄
13
+
14
+ ---
15
+
16
+ ## ✨ Features
17
+
18
+ - 📦 Zip folders into ZIP archives
19
+ - 🧾 `.zipignore` support (same syntax as `.gitignore`)
20
+ - 📊 Live progress:
21
+ - overall archive progress
22
+ - current file progress
23
+ - ETA and speed
24
+ - 🧠 Smart defaults if options are omitted
25
+ - 🧩 Interactive handling when output ZIP already exists
26
+ - ⚙️ Control over:
27
+ - compression level
28
+ - compression algorithm (`deflate` / `store`)
29
+ - concurrency (prevents `EMFILE`)
30
+
31
+ ---
32
+
33
+ ## 📥 Installation
34
+
35
+ ```bash
36
+ npm install -g xzipit
37
+ ```
38
+
39
+ or locally:
40
+
41
+ ```bash
42
+ npm install xzipit
43
+ ```
44
+
45
+ ---
46
+
47
+ ## 🚀 Usage
48
+
49
+ ```bash
50
+ xzipit [options]
51
+ ```
52
+
53
+ ### Examples
54
+
55
+ ```bash
56
+ # Pack current directory
57
+ xzipit
58
+
59
+ # Pack a specific folder
60
+ xzipit --src ./project
61
+
62
+ # Add version suffix
63
+ xzipit --src ./project --version 1.2.3
64
+ # => project-1.2.3.zip
65
+
66
+ # Custom output name
67
+ xzipit --src ./project --out my-archive
68
+
69
+ # No compression
70
+ xzipit --algo store
71
+
72
+ # Faster packing (bounded concurrency)
73
+ xzipit --concurrency 8
74
+
75
+ # CI mode: overwrite without prompt
76
+ xzipit --yes
77
+ ```
78
+
79
+ ---
80
+
81
+ ## ⚙️ Options
82
+
83
+ | Option | Description | Default |
84
+ |------|------------|---------|
85
+ | `-s, --src <dir>` | Source directory | current directory |
86
+ | `-o, --out <name>` | Output ZIP name (without `.zip`) | source folder name |
87
+ | `-v, --version <ver>` | Version suffix (`-1.2.3`) | — |
88
+ | `-l, --level <0-9>` | Compression level | `9` |
89
+ | `-a, --algo <type>` | `deflate` or `store` | `deflate` |
90
+ | `-c, --concurrency <n>` | Number of files processed in parallel | `1` |
91
+ | `-y, --yes` | CI mode: overwrite existing zip without prompt | — |
92
+ | `-h, --help` | Show help | — |
93
+
94
+ ### Concurrency recommendations
95
+
96
+ `--concurrency` controls how many files are streamed in parallel.
97
+
98
+ - `1` — safest (lowest RAM/FD usage, avoids `EMFILE`)
99
+ - `4–8` — usually optimal on most machines
100
+ - `16+` — may hit OS file descriptor limits (`EMFILE`) on some systems and can reduce I/O efficiency
101
+
102
+ ---
103
+
104
+ ## 📄 .zipignore
105
+
106
+ If a `.zipignore` file exists in the source directory, it will be used to exclude files
107
+ using the same syntax as `.gitignore`.
108
+
109
+ ### Example
110
+
111
+ ```gitignore
112
+ node_modules/
113
+ dist/
114
+ .env
115
+ *.log
116
+ **/*.tmp
117
+ ```
118
+
119
+ ---
120
+
121
+ ## 📜 License
122
+
123
+ MIT
package/README.uk.md ADDED
@@ -0,0 +1,87 @@
1
+ # xzipit 🎤📦 (Українська версія)
2
+
3
+ ![npm](https://img.shields.io/npm/v/xzipit)
4
+ ![node](https://img.shields.io/node/v/xzipit)
5
+ ![license](https://img.shields.io/npm/l/xzipit)
6
+ ![tests](https://github.com/romaniv1024/xzipit/actions/workflows/test.yml/badge.svg)
7
+
8
+ Це українська версія документації.
9
+ Основна (англійська) версія знаходиться у `README.md`.
10
+
11
+ **xzipit** — це швидкий і гнучкий інструмент Node.js CLI для архівування проектів у ZIP-файли.
12
+ Він підтримує `.zipignore` (правила у стилі gitignore), відображення прогресу в режимі реального часу, режим, сумісний із CI,
13
+ та безпечну одночасну передачу файлів.
14
+
15
+ ---
16
+
17
+ ## ✨ Можливості
18
+
19
+ - 📦 Архівування папок у ZIP
20
+ - 🧾 Підтримка `.zipignore` (синтаксис як `.gitignore`)
21
+ - 📊 Live-прогрес:
22
+ - загальний прогрес архіву
23
+ - прогрес поточного файлу
24
+ - ETA та швидкість
25
+ - 🧠 Розумні дефолти
26
+ - 🧩 Інтерактивна обробка конфліктів імен
27
+ - ⚙️ Контроль рівня стиснення, алгоритму та паралельності
28
+
29
+ ---
30
+
31
+ ## 📥 Встановлення
32
+
33
+ ```bash
34
+ npm install -g xzipit
35
+ ```
36
+
37
+ або локально:
38
+
39
+ ```bash
40
+ npm install xzipit
41
+ ```
42
+
43
+ ---
44
+
45
+ ## 🚀 Використання
46
+
47
+ ```bash
48
+ xzipit [options]
49
+ ```
50
+
51
+ ### Приклади
52
+
53
+ ```bash
54
+ xzipit
55
+ xzipit --src ./project
56
+ xzipit --version 1.2.3
57
+ xzipit --algo store
58
+ xzipit --concurrency 8
59
+ xzipit --yes
60
+ ```
61
+
62
+ ---
63
+
64
+ ## ⚙️ Опції
65
+
66
+ | Опція | Опис | За замовчуванням |
67
+ |-----|------|------------------|
68
+ | `-s, --src <dir>` | Папка для архівування | поточна |
69
+ | `-o, --out <name>` | Назва ZIP | назва папки |
70
+ | `-v, --version <ver>` | Версія | — |
71
+ | `-l, --level <0-9>` | Рівень стиснення | `9` |
72
+ | `-a, --algo <type>` | `deflate` або `store` | `deflate` |
73
+ | `-c, --concurrency <n>` | Паралельність | `1` |
74
+ | `-y, --yes` | CI-режим (без запитань) | — |
75
+ | `-h, --help` | Довідка | — |
76
+
77
+ ---
78
+
79
+ ## 📄 .zipignore
80
+
81
+ Файл `.zipignore` дозволяє виключати файли за синтаксисом `.gitignore`.
82
+
83
+ ---
84
+
85
+ ## 📜 Ліцензія
86
+
87
+ MIT
package/bin/xzipit.js ADDED
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ import fs from "fs";
3
+ import fsp from "fs/promises";
4
+ import path from "path";
5
+ import { Command } from "commander";
6
+ import inquirer from "inquirer";
7
+ import logUpdate from "log-update";
8
+ import chalk from "chalk";
9
+
10
+ import {
11
+ buildZipName,
12
+ createIgnoreMatcher,
13
+ listFiles,
14
+ createZip,
15
+ formatBytes,
16
+ formatTime
17
+ } from "../lib/index.js";
18
+
19
+ function bar(pct, width = 36) {
20
+ const p = Math.max(0, Math.min(1, pct));
21
+ const filled = Math.round(p * width);
22
+ return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled));
23
+ }
24
+
25
+ async function fileExists(p) {
26
+ try {
27
+ await fsp.access(p, fs.constants.F_OK);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ const program = new Command();
35
+
36
+ program
37
+ .name("xzipit")
38
+ .usage("[options]")
39
+ .description("Pack a folder into a ZIP archive with .zipignore support and live progress UI")
40
+ .option("-s, --src <dir>", "Source folder to pack (default: current folder)")
41
+ .option("-o, --out <name>", "Output zip base name without .zip (default: source folder name)")
42
+ .option("-v, --version <ver>", "Version to append to archive name (e.g. -1.2.3)")
43
+ .option("-l, --level <0-9>", "Compression level (0..9, default: 9)", "9")
44
+ .option("-a, --algo <deflate|store>", "Compression algorithm: deflate (default) or store", "deflate")
45
+ .option(
46
+ "-c, --concurrency <n>",
47
+ "Number of files processed in parallel (default: 1). Recommended: 1 safest; 4-8 usually optimal; 16+ may hit OS file descriptor limits (EMFILE).",
48
+ "1"
49
+ )
50
+ .option("-y, --yes", "CI mode: overwrite existing zip without prompt", false)
51
+ .helpOption("-h, --help", "Display help for command")
52
+ .addHelpText(
53
+ "after",
54
+ `
55
+ Examples:
56
+ xzipit
57
+ xzipit --src ./project
58
+ xzipit --src ./project --version 1.2.3
59
+ xzipit --out my-archive --algo store
60
+ xzipit --concurrency 8
61
+ xzipit --yes
62
+ `
63
+ );
64
+
65
+ program.parse(process.argv);
66
+ const opts = program.opts();
67
+
68
+ async function main() {
69
+ const srcDir = path.resolve(opts.src ?? ".");
70
+ const srcBase = path.basename(srcDir);
71
+ const baseName = (opts.out ?? srcBase).replace(/\.zip$/i, "");
72
+ const version = opts.version ? String(opts.version) : "";
73
+
74
+ const algo = String(opts.algo || "deflate").toLowerCase() === "store" ? "store" : "deflate";
75
+ let level = Number(opts.level ?? 9);
76
+ if (!Number.isFinite(level) || level < 0 || level > 9) level = 9;
77
+
78
+ let concurrency = Number(opts.concurrency ?? 1);
79
+ if (!Number.isFinite(concurrency) || concurrency < 1) concurrency = 1;
80
+
81
+ const yes = Boolean(opts.yes);
82
+
83
+ const ig = await createIgnoreMatcher(srcDir);
84
+ const relFiles = await listFiles({ srcDir, ig });
85
+
86
+ if (relFiles.length === 0) {
87
+ console.error("No files to archive (after applying .zipignore).");
88
+ process.exit(1);
89
+ }
90
+
91
+ // output name
92
+ let outZip = path.resolve(buildZipName(baseName, version, ""));
93
+
94
+ if (await fileExists(outZip)) {
95
+ if (!yes) {
96
+ const { action } = await inquirer.prompt([
97
+ {
98
+ type: "list",
99
+ name: "action",
100
+ message: `ZIP file already exists:\n${outZip}\nChoose an action:`,
101
+ choices: [
102
+ { name: "Overwrite (Y)", value: "Y" },
103
+ { name: "Cancel (N)", value: "N" },
104
+ { name: "Add timestamp suffix (M)", value: "M" }
105
+ ],
106
+ default: 1
107
+ }
108
+ ]);
109
+
110
+ if (action === "N") process.exit(0);
111
+ if (action === "M") {
112
+ outZip = path.resolve(buildZipName(baseName, version, Date.now()));
113
+ }
114
+ }
115
+ // yes=true => overwrite silently
116
+ }
117
+
118
+ // progress UI state
119
+ const doneList = [];
120
+ const doneListMax = 12;
121
+
122
+ let totalBytes = 0;
123
+ let processedBytes = 0;
124
+
125
+ let currentRel = null;
126
+ let currentRead = 0;
127
+ let currentSize = 0;
128
+
129
+ let speedBytesPerSec = 0;
130
+ let etaMs = Infinity;
131
+
132
+ const render = () => {
133
+ const overallPct = totalBytes > 0 ? processedBytes / totalBytes : 0;
134
+ const filePct = currentSize > 0 ? currentRead / currentSize : 0;
135
+
136
+ const lines = [];
137
+ lines.push(chalk.dim("Archived files:"));
138
+ if (doneList.length === 0) lines.push(chalk.dim(" (none yet)"));
139
+ for (const f of doneList.slice(-doneListMax)) lines.push(chalk.dim(" ✓ ") + f);
140
+
141
+ lines.push("");
142
+ lines.push(
143
+ chalk.bold("Current file: ") +
144
+ (currentRel
145
+ ? `${currentRel} (${formatBytes(currentRead)} / ${formatBytes(currentSize)})`
146
+ : "—")
147
+ );
148
+ lines.push(`${bar(filePct)} ${(filePct * 100).toFixed(1)}%`);
149
+
150
+ lines.push("");
151
+ lines.push(chalk.bold("Total progress: ") + `${formatBytes(processedBytes)} / ${formatBytes(totalBytes)}`);
152
+ lines.push(
153
+ `${bar(overallPct)} ${(overallPct * 100).toFixed(1)}% ` +
154
+ chalk.dim(`ETA: ${formatTime(etaMs)} | Speed: ${formatBytes(speedBytesPerSec)}/s`)
155
+ );
156
+
157
+ logUpdate(lines.join("\n"));
158
+ };
159
+
160
+ const timer = setInterval(render, 80);
161
+
162
+ const result = await createZip({
163
+ srcDir,
164
+ outZip,
165
+ algo,
166
+ level,
167
+ concurrency,
168
+ relFiles,
169
+ onOverallProgress: (e) => {
170
+ totalBytes = e.totalBytes;
171
+ processedBytes = e.processedBytes;
172
+ speedBytesPerSec = e.speedBytesPerSec;
173
+ etaMs = e.etaMs;
174
+ },
175
+ onFileProgress: (e) => {
176
+ currentRel = e.rel;
177
+ currentRead = e.currentRead;
178
+ currentSize = e.size;
179
+ },
180
+ onFileDone: (e) => {
181
+ doneList.push(e.rel);
182
+ if (doneList.length > doneListMax * 3) doneList.splice(0, doneList.length - doneListMax * 3);
183
+ }
184
+ });
185
+
186
+ clearInterval(timer);
187
+ render();
188
+ logUpdate.done();
189
+
190
+ console.log(`\nDone: ${outZip}`);
191
+ console.log(`Files: ${result.fileCount}, Total: ${formatBytes(result.totalBytes)}`);
192
+ }
193
+
194
+ main().catch((err) => {
195
+ console.error(err);
196
+ process.exit(1);
197
+ });
package/lib/files.js ADDED
@@ -0,0 +1,19 @@
1
+ import fg from "fast-glob";
2
+ import path from "path";
3
+
4
+ export function normalizeToPosix(p) {
5
+ return p.split(path.sep).join("/");
6
+ }
7
+
8
+ export async function listFiles({ srcDir, ig }) {
9
+ const all = await fg(["**/*"], {
10
+ cwd: srcDir,
11
+ dot: true,
12
+ onlyFiles: true,
13
+ followSymbolicLinks: false
14
+ });
15
+
16
+ if (!ig) return all;
17
+
18
+ return all.filter((rel) => !ig.ignores(normalizeToPosix(rel)));
19
+ }
package/lib/ignore.js ADDED
@@ -0,0 +1,19 @@
1
+ import fsp from "fs/promises";
2
+ import path from "path";
3
+ import ignore from "ignore";
4
+
5
+ export async function readZipIgnoreFile(srcDir) {
6
+ const p = path.join(srcDir, ".zipignore");
7
+ try {
8
+ return await fsp.readFile(p, "utf8");
9
+ } catch {
10
+ return "";
11
+ }
12
+ }
13
+
14
+ export async function createIgnoreMatcher(srcDir) {
15
+ const igText = await readZipIgnoreFile(srcDir);
16
+ const ig = ignore();
17
+ if (igText.trim()) ig.add(igText);
18
+ return ig;
19
+ }
package/lib/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { buildZipName } from "./naming.js";
2
+ export { createIgnoreMatcher, readZipIgnoreFile } from "./ignore.js";
3
+ export { listFiles, normalizeToPosix } from "./files.js";
4
+ export { createZip } from "./zip.js";
5
+ export { formatBytes, formatTime } from "./progress.js";
package/lib/naming.js ADDED
@@ -0,0 +1,6 @@
1
+ export function buildZipName(baseName, version = "", msSuffix = "") {
2
+ let name = baseName;
3
+ if (version) name += `-${version}`;
4
+ if (msSuffix) name += `_${msSuffix}`;
5
+ return `${name}.zip`;
6
+ }
@@ -0,0 +1,18 @@
1
+ export function formatBytes(bytes) {
2
+ const units = ["B", "KB", "MB", "GB", "TB"];
3
+ let i = 0;
4
+ let n = bytes;
5
+ while (n >= 1024 && i < units.length - 1) {
6
+ n /= 1024;
7
+ i++;
8
+ }
9
+ return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
10
+ }
11
+
12
+ export function formatTime(ms) {
13
+ if (!isFinite(ms) || ms < 0) return "—";
14
+ const s = Math.ceil(ms / 1000);
15
+ const m = Math.floor(s / 60);
16
+ const r = s % 60;
17
+ return m > 0 ? `${m}m ${r}s` : `${r}s`;
18
+ }
package/lib/zip.js ADDED
@@ -0,0 +1,153 @@
1
+ import fs from "fs";
2
+ import fsp from "fs/promises";
3
+ import path from "path";
4
+ import archiver from "archiver";
5
+ import { normalizeToPosix } from "./files.js";
6
+
7
+ /**
8
+ * @typedef {Object} ZipFileMeta
9
+ * @property {string} rel
10
+ * @property {string} abs
11
+ * @property {number} size
12
+ * @property {Date} mtime
13
+ */
14
+
15
+ async function statFiles(srcDir, relFiles) {
16
+ /** @type {ZipFileMeta[]} */
17
+ const metas = [];
18
+ let total = 0;
19
+
20
+ for (const rel of relFiles) {
21
+ const abs = path.join(srcDir, rel);
22
+ const st = await fsp.stat(abs);
23
+ if (!st.isFile()) continue;
24
+ total += st.size;
25
+ metas.push({ rel, abs, size: st.size, mtime: st.mtime });
26
+ }
27
+
28
+ return { metas, totalBytes: total };
29
+ }
30
+
31
+ function clampInt(n, min, max, fallback) {
32
+ const v = Number(n);
33
+ if (!Number.isFinite(v)) return fallback;
34
+ const i = Math.trunc(v);
35
+ return Math.max(min, Math.min(max, i));
36
+ }
37
+
38
+ /**
39
+ * Create ZIP archive with bounded concurrency (prevents EMFILE).
40
+ *
41
+ * @param {Object} params
42
+ * @param {string} params.srcDir
43
+ * @param {string} params.outZip
44
+ * @param {"deflate"|"store"} params.algo
45
+ * @param {number} params.level
46
+ * @param {number} [params.concurrency] number of files streamed in parallel (default: 1)
47
+ * @param {string[]} params.relFiles
48
+ * @param {(e: {type:"overall", processedBytes:number, totalBytes:number, speedBytesPerSec:number, etaMs:number})=>void} [params.onOverallProgress]
49
+ * @param {(e: {type:"file", rel:string, currentRead:number, size:number})=>void} [params.onFileProgress]
50
+ * @param {(e: {type:"fileDone", rel:string})=>void} [params.onFileDone]
51
+ */
52
+ export async function createZip({
53
+ srcDir,
54
+ outZip,
55
+ algo = "deflate",
56
+ level = 9,
57
+ concurrency = 1,
58
+ relFiles,
59
+ onOverallProgress,
60
+ onFileProgress,
61
+ onFileDone
62
+ }) {
63
+ const { metas, totalBytes } = await statFiles(srcDir, relFiles);
64
+
65
+ if (metas.length === 0) {
66
+ throw new Error("No files to archive.");
67
+ }
68
+
69
+ // ✅ bounded concurrency (1..64)
70
+ const conc = clampInt(concurrency, 1, 64, 1);
71
+
72
+ const zipOptions = algo === "store" ? { store: true } : { zlib: { level } };
73
+
74
+ await fsp.mkdir(path.dirname(outZip), { recursive: true });
75
+
76
+ const output = fs.createWriteStream(outZip);
77
+ const archive = archiver("zip", zipOptions);
78
+
79
+ archive.on("warning", () => { });
80
+ archive.on("error", (err) => {
81
+ throw err;
82
+ });
83
+
84
+ archive.pipe(output);
85
+
86
+ const start = Date.now();
87
+ let processedBytes = 0;
88
+
89
+ const timer = setInterval(() => {
90
+ const elapsed = Date.now() - start;
91
+ const speedBytesPerSec = (processedBytes / Math.max(1, elapsed)) * 1000;
92
+ const remaining = totalBytes - processedBytes;
93
+ const etaMs = speedBytesPerSec > 0 ? (remaining / speedBytesPerSec) * 1000 : Infinity;
94
+
95
+ onOverallProgress?.({
96
+ type: "overall",
97
+ processedBytes,
98
+ totalBytes,
99
+ speedBytesPerSec,
100
+ etaMs
101
+ });
102
+ }, 120);
103
+
104
+ let idx = 0;
105
+
106
+ // Один "таск" архівує один файл: відкрив stream, підписався на data, append, дочекався end
107
+ async function archiveOne(f) {
108
+ let currentRead = 0;
109
+ const stream = fs.createReadStream(f.abs);
110
+
111
+ stream.on("data", (chunk) => {
112
+ currentRead += chunk.length;
113
+ processedBytes += chunk.length;
114
+ onFileProgress?.({
115
+ type: "file",
116
+ rel: f.rel,
117
+ currentRead,
118
+ size: f.size
119
+ });
120
+ });
121
+
122
+ archive.append(stream, { name: normalizeToPosix(f.rel), date: f.mtime });
123
+
124
+ await new Promise((resolve, reject) => {
125
+ stream.on("end", resolve);
126
+ stream.on("error", reject);
127
+ });
128
+
129
+ onFileDone?.({ type: "fileDone", rel: f.rel });
130
+ }
131
+
132
+ // ✅ Worker pool
133
+ const workers = Array.from({ length: conc }, async () => {
134
+ while (true) {
135
+ const my = idx++;
136
+ if (my >= metas.length) return;
137
+ await archiveOne(metas[my]);
138
+ }
139
+ });
140
+
141
+ await Promise.all(workers);
142
+
143
+ await archive.finalize();
144
+
145
+ await new Promise((resolve, reject) => {
146
+ output.on("close", resolve);
147
+ output.on("error", reject);
148
+ });
149
+
150
+ clearInterval(timer);
151
+
152
+ return { totalBytes, fileCount: metas.length };
153
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "xzipit",
3
+ "version": "0.1.0",
4
+ "description": "CLI utility to zip projects with .zipignore support and live progress",
5
+ "keywords": [
6
+ "zip",
7
+ "archiver",
8
+ "cli",
9
+ "compression",
10
+ "zipignore",
11
+ "nodejs",
12
+ "npm",
13
+ "project-archiver",
14
+ "ci",
15
+ "build-tools"
16
+ ],
17
+ "author": "Roman Ivaskiv",
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "bin": {
21
+ "xzipit": "bin/xzipit.js"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "lib",
26
+ "README.md",
27
+ "README.uk.md",
28
+ "CHANGELOG.md"
29
+ ],
30
+ "scripts": {
31
+ "test": "node --test",
32
+ "prepublishOnly": "npm test"
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "dependencies": {
38
+ "adm-zip": "^0.5.16",
39
+ "archiver": "^6.0.2",
40
+ "chalk": "^5.3.0",
41
+ "commander": "^11.1.0",
42
+ "fast-glob": "^3.3.2",
43
+ "ignore": "^5.3.1",
44
+ "inquirer": "^9.2.12",
45
+ "log-update": "^6.0.0"
46
+ }
47
+ }