[![NPM](https://nodei.co/npm/zip-a-folder.png)](https://nodei.co/npm/zip-a-folder/)

[![CircleCI](https://circleci.com/gh/maugenst/zip-a-folder.svg?style=shield&downloads=true&downloadRank=true&stars=true)](https://circleci.com/gh/maugenst/zip-a-folder)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/df3f742eabe741029c221fd602407d0f)](https://app.codacy.com/gh/maugenst/zip-a-folder/dashboard)
[![Codacy Coverage](https://app.codacy.com/project/badge/Coverage/df3f742eabe741029c221fd602407d0f)](https://app.codacy.com/gh/maugenst/zip-a-folder/dashboard)
[![Known Vulnerabilities](https://snyk.io/test/github/maugenst/zip-a-folder/badge.svg)](https://snyk.io/test/github/maugenst/zip-a-folder)

# zip-a-folder

Compress a folder into ZIP/TAR/TGZ/BR/7z. This library is trying to with as less dependencies as possible to 
other libraries. For ZIP, TAR and Brotli archives it uses the **native Node.js compression (zlib)**. 
7z uses the pure JavaScript `lzma-js` library for LZMA compression.

Feature overview:

- ZIP archives (with optional ZIP64)
- TAR archives (optionally gzipped or brotli-compressed)
- **7z archives** with LZMA compression (via lzma-js)
- Fine-grained zlib/gzip/brotli/LZMA control
- Globs (single or comma-separated)
- Parallel directory scanning (`statConcurrency`)
- Custom write streams
- Compression presets (`high`, `medium`, `uncompressed`)

---

## Table of Contents

- [Incompatible Changes](#incompatible-changes)
- [Installation](#installation)
- [Usage](#usage)
  - [Create a ZIP file](#create-a-zip-file)
  - [Create a GZIP-compressed TAR file](#create-a-gzip-compressed-tar-file-tgz)
- [Compression Handling](#compression-handling)
- [ZIP Options](#zip-options)
- [TAR / TGZ Options](#tar--tgz-options)
- [7z Options](#7z-options)
- [Custom Write Streams](#custom-write-streams)
- [Glob Handling](#glob-handling)
- [Destination Path Handling (destPath)](#destination-path-handling-destpath)
- [Excluding Files and Directories (exclude)](#excluding-files-and-directories-exclude)
- [Module Import Structure](#module-import-structure)
- [Native Implementation Notes](#native-implementation-notes)
- [Unix Permissions Preservation in ZIP](#unix-permissions-preservation-in-zip)
- [Command Line Interface (CLI)](#command-line-interface-cli)
- [Running Tests](#running-tests)
- [Thanks](#thanks)

---

## Incompatible Changes

### Version 2

Added support for comma-separated glob lists.
This may change the behavior for cases previously interpreted as "folder only".

### Version 3

Dual-module support (CJS + ESM).

### Version 3.1

Added support for `destPath` to control the internal path layout of created archives.

### Version 4

A major rewrite using:

- **Fully native ZIP writer** (no dependencies)
- **Native TAR + gzip writer**
- **ZIP64 support** for large archives
- **Parallel statting** (`statConcurrency`)
- **Strict internal path normalization** mirroring classic zip-a-folder behavior
- **Native glob handling** via `tinyglobby`

### Version 5

- `COMPRESSION_LEVEL` is now a plain `const` object whose values are **string literals** (`'uncompressed'`, `'medium'`, `'high'`) instead of a numeric enum.
  The public API (`COMPRESSION_LEVEL.high`, etc.) is unchanged — only the underlying type changed from a TypeScript `enum` to a `const satisfies` object. See [PR #70](https://github.com/maugenst/zip-a-folder/pull/70) by [@schplitt](https://github.com/schplitt).
- Added `exclude` option — an array of glob patterns for files/directories to omit from the archive. See [issue #65](https://github.com/maugenst/zip-a-folder/issues/65) by [@Pomax](https://github.com/Pomax).

### Version 6 (current)

- **Native Brotli compression support** for TAR archives using Node.js built-in `zlib.brotliCompressSync()`. Create `.tar.br` files with the new `compressionType: 'brotli'` option. Brotli typically provides better compression ratios than gzip, especially for text-based content.
- **New 7z archive format support** via the `sevenZip()` function. Uses pure JavaScript LZMA compression (via `lzma-js` library) — no external binaries required. Provides excellent compression ratios with the industry-standard 7z format.
- New `brotliOptions` for fine-grained control over brotli compression parameters.
- New `compressionLevel` option (1-9) for 7z archives to control LZMA compression level.

---

## Installation

```bash
npm install zip-a-folder
```

---

## Usage

### Create a ZIP file

```js
import { zip } from 'zip-a-folder';

await zip('/path/to/folder', '/path/to/archive.zip');
```

### Create a GZIP-compressed TAR file (`.tgz`)

```js
import { tar } from 'zip-a-folder';

await tar('/path/to/folder', '/path/to/archive.tgz');
```

---

## Compression Handling

Supported compression levels:

```ts
COMPRESSION_LEVEL.high          // highest compression (default)
COMPRESSION_LEVEL.medium        // balanced
COMPRESSION_LEVEL.uncompressed  // STORE for zip, no-gzip for tar
```

> **v5:** `COMPRESSION_LEVEL` is now a `const` object with **string** values (`'high'`, `'medium'`, `'uncompressed'`).
> The `compression` option accepts either the constant (`COMPRESSION_LEVEL.high`) or the plain string (`'high'`) directly.

Example:

```js
import { zip, COMPRESSION_LEVEL } from 'zip-a-folder';

await zip('/path/to/folder', '/path/to/archive.zip', {
    compression: COMPRESSION_LEVEL.medium  // or just 'medium'
});
```

---

## ZIP Options

| Option              | Type          | Description                          |
| ------------------- | ------------- | ------------------------------------ |
| `comment`           | `string`      | ZIP file comment                     |
| `forceLocalTime`    | `boolean`     | Use local timestamps instead of UTC  |
| `forceZip64`        | `boolean`     | Always include ZIP64 headers         |
| `namePrependSlash`  | `boolean`     | Prefix all ZIP entry names with `/`  |
| `store`             | `boolean`     | Force STORE method (no compression)  |
| `zlib`              | `ZlibOptions` | Passed directly to `zlib.deflateRaw` |
| `statConcurrency`   | `number`      | Parallel `stat` workers (default: 4) |
| `destPath`          | `string`      | Prefix inside the archive (>=3.1)    |
| `exclude`           | `string[]`    | Glob patterns for paths to omit      |
| `customWriteStream` | `WriteStream` | Manually handle output               |

Example:

```js
await zip('/dir', '/archive.zip', {
    comment: "Created by zip-a-folder",
    forceZip64: true,
    namePrependSlash: true,
    store: false,
    statConcurrency: 16,
    zlib: { level: 9 }
});
```

---

## TAR / TGZ / TAR.BR Options

| Option              | Type                       | Description                                                      |
| ------------------- | -------------------------- | ---------------------------------------------------------------- |
| `compressionType`   | `'none' \| 'gzip' \| 'brotli'` | Compression algorithm (default: `'gzip'`)                    |
| `gzip`              | `boolean`                  | Enable gzip compression (deprecated, use `compressionType`)      |
| `gzipOptions`       | `ZlibOptions`              | Passed to `zlib.createGzip`                                      |
| `brotliOptions`     | `BrotliOptions`            | Passed to `zlib.createBrotliCompress`                            |
| `statConcurrency`   | `number`                   | Parallel `stat` workers                                          |
| `exclude`           | `string[]`                 | Glob patterns to omit                                            |
| `compression`       | `COMPRESSION_LEVEL`        | Convenience preset (`high`, `medium`, `uncompressed`)            |

### Gzip Example

```js
await tar('/dir', '/archive.tgz', {
    compressionType: 'gzip',
    gzipOptions: { level: 6 }
});
```

### Brotli Example

Brotli compression is supported natively via Node.js `zlib` module (available since Node.js v10.16.0).
Brotli typically provides better compression ratios than gzip, especially for text-based content.

```js
import { tar, COMPRESSION_LEVEL } from 'zip-a-folder';
import * as zlib from 'zlib';

// Simple brotli compression
await tar('/dir', '/archive.tar.br', {
    compressionType: 'brotli'
});

// With compression level preset
await tar('/dir', '/archive.tar.br', {
    compressionType: 'brotli',
    compression: COMPRESSION_LEVEL.high
});

// With custom brotli options
await tar('/dir', '/archive.tar.br', {
    compressionType: 'brotli',
    brotliOptions: {
        params: {
            [zlib.constants.BROTLI_PARAM_QUALITY]: 11,  // 0-11, higher = better compression
            [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT
        }
    }
});
```

### Uncompressed TAR

```js
await tar('/dir', '/archive.tar', {
    compressionType: 'none'
});
```

---

## 7z Options

Create 7z archives with LZMA compression using the `sevenZip()` function.

| Option              | Type               | Description                                           |
| ------------------- | ------------------ | ----------------------------------------------------- |
| `compression`       | `COMPRESSION_LEVEL`| Convenience preset (`high`, `medium`, `uncompressed`) |
| `compressionLevel`  | `number` (1-9)     | LZMA compression level (default: 5)                   |
| `statConcurrency`   | `number`           | Parallel `stat` workers (default: 4)                  |
| `exclude`           | `string[]`         | Glob patterns to omit                                 |
| `customWriteStream` | `WriteStream`      | Manually handle output                                |

### Basic Example

```js
import { sevenZip } from 'zip-a-folder';

await sevenZip('/path/to/folder', '/path/to/archive.7z');
```

### With Compression Level

```js
import { sevenZip, COMPRESSION_LEVEL } from 'zip-a-folder';

// Using preset
await sevenZip('/dir', '/archive.7z', {
    compression: COMPRESSION_LEVEL.high
});

// Using explicit level (1-9)
await sevenZip('/dir', '/archive.7z', {
    compressionLevel: 9
});
```

### With Glob Patterns

```js
await sevenZip('src/**/*.ts', '/archive.7z');
```

### With Exclusions

```js
await sevenZip('/project', '/archive.7z', {
    exclude: ['node_modules/**', '**/*.log']
});
```

> **Note:** 7z archives use LZMA compression via the `lzma-js` library. This provides excellent compression ratios, especially for text-based content. The 7z format stores files as a solid archive, meaning all file data is concatenated and compressed together for better compression efficiency.

---

## Custom Write Streams

ZIP and TAR can be written into **any stream**.
If `customWriteStream` is used, the `targetFilePath` can be empty or undefined.

```js
import fs from 'fs';
import { zip } from 'zip-a-folder';

const ws = fs.createWriteStream('/tmp/output.zip');
await zip('/path/to/folder', undefined, { customWriteStream: ws });
```

**Important:**
zip-a-folder does *not* validate custom streams. You must ensure:

- parent directory exists
- you're not writing into the source directory (to avoid recursion)

---

## Glob Handling

The first parameter may be:

- A path to a directory
- A single glob
- A comma-separated list of globs

Example:

```js
await zip('**/*.json', '/archive.zip');
await zip('**/*.json, **/*.txt', '/archive2.zip');
```

If no files match, zip-a-folder throws:

```
Error: No glob match found
```

---

## Destination Path Handling (`destPath`)

Adds a prefix *inside* the archive:

```js
await zip('data/', '/archive.zip', { destPath: 'data/' });
```

Resulting ZIP layout:

```
data/file1.txt
data/subdir/file2.txt
```

### Directory Root Inclusion Semantics

When passing a directory path as the first argument (e.g. `zip('/path/to/folder', '/archive.zip')`), the archive by default contains the *contents* of that directory at the archive root (i.e. you will see the files inside `folder/`, not a top-level `folder/` directory itself).

#### Include the directory itself

If you want the archive to unpack into the named folder (so the top level of the archive contains `folder/`), set `destPath` to that folder name plus a trailing slash:

```ts
await zip('/path/to/folder', '/archive.zip', {
  destPath: 'folder/'
});
```

Result layout:

```
folder/file1.txt
folder/sub/file2.txt
```

### Summary

- Default: directory contents only (no enclosing folder)
- To include the folder: use `destPath: '<dirname>/'`

This applies equally to `tar()`.

---

## Excluding Files and Directories (`exclude`)

The `exclude` option accepts an array of **picomatch-style glob patterns**.
Matching entries (files and directories) are omitted from the archive entirely —
when a directory is excluded its entire subtree is skipped.

```js
import { zip } from 'zip-a-folder';

const packed = await zip('/path/to/project', '/path/to/archive.zip', {
  exclude: [
    // dot-files (.env, .DS_Store, …)
    '**/.*',
    // dot-directories (.git, .vscode, …) and their contents
    '**/.*/**',
    // build output and dependencies
    'dist/**',
    'dist/',
    'node_modules/**',
    'node_modules/',
  ]
});
```

> **Notes:**
>
> - `exclude` is applied to **directory sources** and **glob sources** alike.
> - For glob sources, the patterns are passed as additional `ignore` entries to tinyglobby.
> - Patterns are matched against **relative paths** inside the source directory (POSIX-style).
> - To exclude a whole directory tree, add both `dirname/` and `dirname/**` (or just `dirname/**`).

Inspired by [issue #65](https://github.com/maugenst/zip-a-folder/issues/65) reported by [@Pomax](https://github.com/Pomax).

---

## Module Import Structure

> **Note:** As of v4 and the modernized build, all code is bundled into a single entry file. Do not import submodules (e.g. `dist/lib/mjs/core/types`). Always import from the main entry point:

```js
import { zip, tar, COMPRESSION_LEVEL } from 'zip-a-folder';
```

If you need types, import from the main entry point as well:

```js
import type { ZipArchiveOptions, TarArchiveOptions } from 'zip-a-folder';
```

---

## Native Implementation Notes

- ZIP and TAR are written using **pure Node.js** (`zlib`, raw buffering)
- ZIP64 support included
- File system scanning performed with a **parallel stat queue**
- Globs handled via the **tinyglobby** package
- Archive layout matches the original zip-a-folder for compatibility
- ZIP writer supports dependency-free deflate and manual header construction
- TAR writer produces POSIX ustar format with proper 512-byte block alignment

---

## Unix Permissions Preservation in ZIP

A recent contribution restored correct handling of file modes for Unix systems inside ZIP archives:

- The "Version Made By" field is now set to Unix (upper byte = 3) in the Central Directory.
- File modes from `fs.stat().mode` are passed through to the ZIP entries and preserved.
- Modes are mapped into the upper 16 bits of the "External File Attributes" field per the ZIP spec.

This brings parity back with v3 behavior and ensures executables and other POSIX permissions are preserved
when packaging for Linux/Debian and similar environments.

See PR #66: <https://github.com/maugenst/zip-a-folder/pull/66>

Automated tests were added to validate:

- Central Directory "Version Made By" upper byte is 3 (Unix).
- External File Attributes store the POSIX mode (both files and directories), including directory flag.
- FileCollector behavior and glob handling are fully covered, pushing coverage to 100% lines/functions.

---

## Command Line Interface (CLI)

zip-a-folder includes a CLI for quick archive creation from the terminal.

### Installation

```bash
# Global installation
npm install -g zip-a-folder

# Or use via npx
npx zip-a-folder ./folder ./archive.zip
```

### Usage

```bash
zip-a-folder <source> <target> [options]
```

### Supported Formats

| Extension   | Description                        |
| ----------- | ---------------------------------- |
| `.zip`      | ZIP archive (deflate compression)  |
| `.tar`      | TAR archive (uncompressed)         |
| `.tgz`      | TAR archive with gzip compression  |
| `.tar.gz`   | TAR archive with gzip compression  |
| `.tar.br`   | TAR archive with brotli compression|
| `.7z`       | 7z archive with LZMA compression   |

### Options

| Option                    | Description                                      |
| ------------------------- | ------------------------------------------------ |
| `-h, --help`              | Show help message                                |
| `-V, --version`           | Show version number                              |
| `-v, --verbose`           | Show detailed progress (files being processed)   |
| `-q, --quiet`             | Suppress all output except errors                |
| `-c, --compression <level>`| Compression preset: `high`, `medium`, `uncompressed` |
| `-l, --level <number>`    | Compression level (1-9, format-specific)         |
| `-e, --exclude <pattern>` | Glob pattern to exclude (can be used multiple times) |
| `-d, --dest-path <path>`  | Destination path prefix inside archive           |

### Examples

```bash
# Create a ZIP archive
zip-a-folder ./my-folder ./archive.zip

# Create a gzipped TAR with verbose output
zip-a-folder ./src ./backup.tgz -v

# Create a 7z archive with maximum compression
zip-a-folder ./project ./project.7z -c high

# Create archive excluding node_modules
zip-a-folder ./app ./app.zip -e "node_modules/**" -e "**/*.log"

# Create brotli-compressed TAR
zip-a-folder ./data ./data.tar.br -v

# Use glob patterns
zip-a-folder "src/**/*.ts" ./source.zip

# Quiet mode (only errors)
zip-a-folder ./folder ./archive.zip -q
```

### Output

By default, the CLI shows a summary with:
- Source and target paths
- Archive format
- File count
- Original and compressed sizes
- Compression ratio
- Processing time

Use `-v` for detailed file-by-file progress, or `-q` to suppress all output.

---

## Running Tests

Tests are written in **Vitest** with full coverage (100% statements/branches/functions/lines):

```bash
npm test
```

Coverage is reported automatically via `@vitest/coverage-v8`.

---

## Thanks

Special thanks to contributors:

- @sole – initial work
- @YOONBYEONGIN
- @Wunschik
- @ratbeard
- @Xotabu4
- @dallenbaldwin
- @wiralegawa
- @karan-gaur
- @malthe
- @nesvet
- @schplitt – string-literal compression levels refactor ([PR #70](https://github.com/maugenst/zip-a-folder/pull/70))
- @Pomax – `exclude` option feature request ([issue #65](https://github.com/maugenst/zip-a-folder/issues/65))

Additional thanks to everyone helping shape the native rewrite.
