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 +52 -0
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/README.uk.md +87 -0
- package/bin/xzipit.js +197 -0
- package/lib/files.js +19 -0
- package/lib/ignore.js +19 -0
- package/lib/index.js +5 -0
- package/lib/naming.js +6 -0
- package/lib/progress.js +18 -0
- package/lib/zip.js +153 -0
- package/package.json +47 -0
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
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
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
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
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
package/lib/progress.js
ADDED
|
@@ -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
|
+
}
|