@timeax/zipper
v0.2.0
Published
Config-driven project zipper with .zipconfig / zip.json (include/exclude, presets, dry-run, gitignore support).
Readme
Zipper
A flexible project archiver and configuration‑based zipping tool. Define what goes into your archives with a simple .zipconfig file, stubs, presets, groups, and preprocess hooks. First‑class workflows for Laravel, Node, and Inertia projects.
✨ Features
- Config‑driven:
.zipconfig(YAML/JSON) controls include/exclude, presets, output, etc. - Stubs: Ready‑made config templates (e.g.
laravel.stub,node.stub,inertia.stub). - Built‑ins always available: The CLI always searches bundled stubs in the package, in addition to local and global.
- Presets: Reusable include/exclude bundles (built‑in + user presets).
- Groups: Map matched files into virtual folders in the archive (e.g.
src/,web/,docs/). - Preprocess (JS/TS): Run transform callbacks on matched files at pack time without touching source.
- Diagnostics:
preprocess doctorto validate modules and preview changes. - Interactive UX: TUI pickers for migrating presets and selecting stubs.
- CLI niceties:
pack(withbuildalias), dry‑run,--list(final zip paths), respect.gitignore, manifest emit, list files from--from. - Cross‑platform: Linux, macOS, Windows.
📦 Installation
npm install -g @timeax/zipperLocal (per‑project):
npm install --save-dev @timeax/zipper🚀 Quick Start
# 1) Create a config from a stub (auto‑discovers local, global, and built‑in stubs)
zipper init laravel
# 2) Preview what will be packed (scanner output)
zipper pack --dry-run
# 3) Create the archive
zipper pack --out dist/project.zipPrefer
pack(similar tonpm pack).buildremains as a hidden alias for convenience.
🛠 Usage
Init a config from stubs
# Non‑interactive by name
zipper init laravel
# Interactive menu (shows Local / Global / Built‑in)
zipper init --interactive
# Use a local stubs folder explicitly (if you’re inside ./stubs, use --dir .)
zipper init inertia --dir stubsStub resolution order: Local ./stubs/ → Global dir(s) → Built‑in (always checked).
Pack an archive
zipper pack [options]Common flows:
zipper pack --out ./dist/my-app.zip
zipper pack --config ./custom-config.yml
zipper pack --config laravel # resolves laravel.stub (local→global→built‑in)
zipper pack --dry-run # print pre-zip selection (scanner output)
zipper pack --list # print final zip paths (after groups + preprocess)All options:
--config <path>: Path to config file. If no extension,.stubis assumed and resolved via Local/Global/Built‑in.--out <path>: Output zip path (overrides config field).--include <globs...>: Extra include globs.--exclude <globs...>: Extra exclude globs.--order <string>: Rule order;include,exclude(default) orexclude,include.--root <path>: Project root for scanning.--dry-run: Print file list before grouping/preprocess.--list: Print final zip paths (after groups + preprocess) and exit.--group <names...>: Only include these group(s) by name.--respect-gitignore: Also exclude files from.gitignore.--from <path>: Read additional paths (one per line) from a file.--ignore-file <paths...>: Extra ignore files (defaults include.zipignore).--no-manifest: Disable manifest emission.--manifest-path <path>: Write manifest to an external path.- Preprocess flags:
--no-preprocess,--strict-preprocess,--preprocess-timeout <ms>,--preprocess-max-bytes <n>,--preprocess-binary-mode <skip|pass|buffer>,--preprocess <modules...>(adds modules in addition to config).
Alias:
zipper build [options]📑 .zipconfig format (YAML/JSON)
Minimal:
# .zipconfig
include:
- app/**
- config/**
exclude:
- node_modules/**
- vendor/**
presets:
- laravel-basic
out: dist/project.zip
respectGitignore: true
order: [exclude, include] # let includes punch holes back inGroups (virtual folders in the zip)
# Map matched files into folders inside the archive
groups:
backend:
target: src/
include: ["app/**", "config/**"]
exclude: ["app/Debug/**"]
priority: 10 # higher wins when multiple groups match
frontend:
target: web/
include: ["resources/js/**", "resources/css/**", "public/**"]
files: ['resources/views/index.blade.php'] # included as web/index.blade.php
priority: 5
docs:
target: docs/
include: ["README.md", "docs/**"]- Files matching a group are placed under its
targetpath inside the zip. - If multiple groups match, higher
prioritywins; ties are resolved by later‑defined groups. - Files that match no group are kept at their original relative path.
- Use
--group nameto include only specific groups.
Preprocess (JS/TS modules only)
JS/TS files are not full configs; they only export preprocess handlers. Reference them from YAML/JSON via
preprocess.modules.
preprocess:
modules:
- ./zip.preprocess.ts
- ./more-hooks.js
includes: ["**/*.html", "**/*.js"] # which files should be considered for preprocess
excludes: ["**/*.min.js"]
files: ["README.md"] # explicit additions
maxBytes: 5242880 # skip preprocess for files larger than this (still included)
binaryMode: pass # skip | pass | buffer
timeoutMs: 10000Module shape (zip.preprocess.ts):
import type { PreprocessHandler } from '@timeax/zipper';
export const handlers: PreprocessHandler[] = [
({ stats, content, ctx }) => {
if (stats.ext !== '.html' && stats.ext !== '.js') return;
let s = content.toString('utf8')
.replaceAll('__APP__', ctx.env.APP_NAME ?? 'ZipperApp')
.replaceAll('__BUILD__', ctx.buildId);
return Buffer.from(s, 'utf8');
},
({ stats }) => (stats.name.endsWith('.log') && stats.size > 128 * 1024 ? null : undefined),
];
export default handlers; // default or named export both supported📂 Stubs (manage templates)
Zipper ships with built‑in stubs (e.g. laravel.stub, node.stub, inertia.stub). You can also keep:
- Local:
./stubs/ - Global:
~/.config/zipper/stubs/(Windows:%USERPROFILE%\.config\zipper\stubsor%USERPROFILE%\.zipper\stubs)
Commands (grouped):
# List local / global / built‑in
zipper stub ls
# Print a stub to stdout (name optional → interactive picker)
zipper stub cat # picker
zipper stub cat laravel # by name
# Copy a stub file to a destination (creates dirs; use --force to overwrite)
zipper stub cp laravel ./.zipconfig
zipper stub cp inertia ./stubs/inertia.stub
# Add an existing file into your global stubs
zipper stub add stubs/custom.stub --to ~/.config/zipper/stubsIf you are inside the
stubs/directory, use--dir .when targeting local stubs.
🧩 Presets (reusable rule bundles)
Use presets to avoid repeating include/exclude rules across projects.
Built‑in presets:
laravel-basiclaravel-no-vendornode-moduleinertia
Commands (grouped):
# Discover & inspect
zipper preset ls
zipper preset show laravel-basic --format yaml
# Create from a config/stub or ad‑hoc
zipper preset add my-company.laravel --from .zipconfig
zipper preset add node-ci --include dist/** --exclude tests/**
# Export / import
zipper preset export laravel-basic --to laravel-basic.yml
zipper preset import inertia-prod --from stubs/inertia-prod.stub
# Rename / remove
zipper preset rename old-name new-name
zipper preset rm my-company.laravel
# Migrate in bulk (interactive picker by default)
zipper preset migrate --include-globals
zipper preset migrate --all # non‑interactive (select all)
zipper preset migrate --all --dry-runMerge order (later wins):
- Defaults → 2) Presets (listed order) → 3)
.zipconfig→ 4) CLI flags
If two rules conflict, order determines who can re‑include:
order: [include, exclude](default): excludes win lastorder: [exclude, include]: includes can re‑add specifics
🧭 Groups UX
# List groups with targets, priority, and sample matches
zipper group ls
# Restrict scan for examples
zipper group ls --glob "app/**" --glob "resources/**" --limit 10 --verbose
# Pack only certain groups
zipper pack --group backend --group docs --list📂 Groups, Includes, and Excludes
This section explains exactly how groups interact with base include, exclude, and files rules in Zipper.
1. Selection (what files enter the pipeline)
Start with files matching base
include(or**/*if none).Remove anything matching base
excludeand.gitignore(if enabled).Apply
order:include,exclude(default): excludes win last.exclude,include: includes “punch through” at the end.
Special case:
groups.*.filesare always added, even if not in baseinclude.
📌 At this stage, groups.*.include and groups.*.exclude are ignored. They never filter what exists — they only matter later for mapping.
2. Group claim (who owns a file)
For each file from selection:
A group claims it if:
- It appears in
groups.<name>.files(exact path), or - It matches the group’s
includeglobs and not the group’sexcludeglobs.
- It appears in
Conflicts:
- Higher
prioritywins. - Equal priority → later-defined group wins.
- Higher
If no group claims it → file stays ungrouped.
📌 groups.*.exclude only prevents that group from claiming the file. It does not remove the file from the archive.
3. Mapping (how paths are rewritten)
Via
files: placed astarget + basename(file)(parents dropped).Via
includeglobs: placed astarget + original/relative/path.Ungrouped files: keep their original relative path.
targetrules:""→ archive root."public/"→ inside apublicfolder in the zip.
4. Preprocess (optional, after grouping)
Runs per file (source path + zip path + buffer).
Handler may:
- return new content → replace bytes,
- return new zipPath → move it,
- return
null→ drop it, - return nothing → pass through unchanged.
Strict mode (
--strict-preprocess) fails on error; otherwise errors are logged.
5. Collisions (same zipPath twice)
If two files map to the same zipPath:
- Default: last one wins.
- Recommended: log a warning (source A → replaced by source B).
- Future policies can be: fail-fast, or auto-rename with suffix/hash.
✅ Guarantees
Base
include/excludestill apply even when groups are defined.groupsnever erase base rules; they only:- add exact
files, - decide who claims an existing file,
- and rewrite its archive path.
- add exact
Counts:
Scanner count (dry-run)=Post-group count(unless preprocess drops some).
Example
include:
- app/**
- resources/**
exclude:
- node_modules/**
- vendor/**
order: [exclude, include]
groups:
server:
target: "server/"
include: ["app/**"]
web:
target: "public/"
files:
- resources/views/index.blade.phpResult:
app/Models/User.php→server/app/Models/User.phpresources/views/index.blade.php→public/index.blade.phpresources/css/app.css→ stays asresources/css/app.css(no group claim)vendor/…→ excluded.
🧪 Preprocess diagnostics
# Validate modules + run a small test set
zipper preprocess doctor
# Add extra modules from CLI and limit to globs
zipper preprocess doctor --preprocess ./zip.preprocess.ts --glob "resources/**/*.js" --limit 8
# Fail on handler errors/timeouts
zipper preprocess doctor --strict-preprocess🎮 Interactive demos
Preset multi‑select (migrate)
$ zipper preset migrate --include-globals
Select files to migrate into user presets
Use ↑/↓, space to toggle, 'a' = toggle all, Enter to confirm
> [x] laravel.stub ./stubs
[ ] inertia.stub ./stubs
[x] node.stub ./stubs
2/3 selectedDry‑run preview
$ zipper pack --dry-run
# Config: .zipconfig Root: ./
app/Http/Controllers/UserController.php
app/Models/User.php
config/app.php
...
152 files selected.Final zip path preview (after groups + preprocess)
$ zipper pack --list
web/resources/js/app.js
src/app/Models/User.php
docs/README.md
...🌍 Global locations
- Presets:
~/.config/zipper/presets - Stubs:
~/.config/zipper/stubs
Environment overrides:
export ZIPPER_PRESETS="$HOME/dev/zipper-presets"
export ZIPPER_STUBS="$HOME/dev/zipper-stubs"Windows PowerShell:
$env:ZIPPER_PRESETS = "$HOME\.zipper\presets"
$env:ZIPPER_STUBS = "$HOME\.zipper\stubs"📘 Notes
Built‑in stubs are always checked; you can reference them by base name (e.g.
laravel).When
respectGitignoreis enabled,.gitignorerules are applied as excludes.By default, the order is
[include, exclude]→ excludes win.To allow includes to override
.gitignore, set:order: [exclude, include]This ensures you can re‑include specific files or folders even if ignored by Git.
📹 GIF workflows (optional)
Suggested tools:
- asciinema → lightweight, shareable casts: https://asciinema.org/
- terminalizer → GIFs from scripts: https://github.com/faressoft/terminalizer
Suggested script:
zipper stub lszipper init laravelzipper pack --dry-runzipper preset migrate --include-globalszipper pack --out dist/app.zip
🔄 Update Notes — CLI Additions & Behavior Changes
New: Pre/Post Hook Commands
You can now run custom commands before and/or after packaging.
.zipconfig
hooks:
pre:
- "npm ci"
- "npm run build"
- run: ["php", "artisan", "config:cache"]
timeoutMs: 120000
post:
- run: "node scripts/after-pack.js {{out}}"
continueOnError: true
env:
CHANNEL: "ci"Tokens available in hooks
{{root}},{{out}},{{config}},{{fileCount}},{{manifest}}…also exposed as env vars:ZIPPER_ROOT,ZIPPER_OUT,ZIPPER_CONFIG,ZIPPER_FILE_COUNT,ZIPPER_MANIFEST.
CLI controls
--no-hooks— disable hooks entirely--pre "<cmd>"— append an extra pre hook (repeatable)--post "<cmd>"— append an extra post hook (repeatable)--hook-timeout <ms>— default per-command timeout--hooks-dry-run— print what would run, don’t execute
Hooks are cross-platform: strings run via the shell, arrays run as raw
[cmd, ...args].
New: Smart-Merge Progress & Timing
Smart-merge now has optional progress and timings to help diagnose slow projects.
Environment toggles
ZIPPER_SMARTMERGE_PROGRESS=1— show a progress bar while resolving effective file listZIPPER_DEBUG=1— also enables smart-merge progressZIPPER_TIMING=1— end-to-end phase timings (loadConfig, buildFileList, writeZip, etc.)
Example:
ZIPPER_TIMING=1 ZIPPER_SMARTMERGE_PROGRESS=1 zipper pack --dry-runYou’ll see logs like:
[smart-merge] [cfg] sources: 3 tier(s) in 2ms
[smart-merge] [cfg] scan: 12690 candidates … in 240ms
████████████████████████████████ 100% | 12690/12690
[smart-merge] [cfg] decide: kept 4311 / 12690 in 190ms
[timing] buildFileList: 15msFaster Packing (No Globbing on Materialized Includes)
When smart-merge is enabled, cfg.include is now a final explicit list of files.
The packer skips globby() entirely and just:
- de-dupes includes +
--fromlist, - applies ignore rules,
- re-adds items if
order: [exclude, include], - sorts if
deterministic: true.
This removes large startup stalls on big repos.
Pack Enhancements (Flags)
New/clarified flags on zipper pack:
--group <name>(repeatable) — select only the named groups from.zipconfig--no-preprocess— disable preprocess pipeline--strict-preprocess— fail the build on preprocess errors--preprocess <module...>— load extra preprocess modules (ts/js)--preprocess-timeout <ms>— per-file preprocess timeout--preprocess-max-bytes <n>— cap file size fed to preprocess--preprocess-binary-mode <skip|pass>— behavior for binaries in preprocess
(These layer on top of whatever is defined in .zipconfig.)
Usability Tweaks
zipper stub cat— name is optional; omitting it opens an interactive picker (Local / Global / Built-in).zipper group ls— shows each group’s target, priority, and sample mappings; supports--globlimiter and--limitfor previews.
Backwards Compatibility
- No breaking changes to existing
.zipconfigfiles. - Hooks are additive; if not specified, nothing runs.
- The selection algorithm is unchanged in outcome; it’s just faster and more transparent.
Security Notes (Hooks)
- Treat hook commands as trusted code (they run with your user permissions).
- Prefer checked-in scripts over inline shell when sharing configs.
- Consider
continueOnError: truefor non-critical post steps (like notifications).
Quick Examples
Run with hooks disabled:
zipper pack --no-hooksAppend an extra post step (e.g., upload the artifact):
zipper pack --post "node scripts/upload.js {{out}}"Diagnose performance:
ZIPPER_TIMING=1 zipper pack --dry-runRemote Deploy & Restore (Beta)
This update adds first‑class remote deployment and restore flows to Zipper. You can deploy the zip you just built to a server via:
- SSH (shell/rsync) — runs
shell/upload.shon your server user, with preserve & backup logic. - SFTP — upload via SFTP, with preserve/merge behavior.
- FTP/FTPS — upload via FTP (explicit/implicit TLS), with preserve/merge behavior.
There are also matching restore flows to roll back from backups.
Quick start
- Add a
deployblock to your.zipconfig(JSON/YAML/JS), for one or more backends.
# .zipconfig (YAML)
out: dist/build.zip
# … your regular zipper config …
deploy:
default: sftp # optional; if omitted the first configured target is used
targets:
sftp:
host: 203.0.113.10
user: deploy
domain: example.com # used to infer webroot if not set
# webroot: /home/deploy/web/example.com/public_html
preservePaths: [uploads/, storage/, .well-known/, robots.txt]
timeoutMs: 120000
ftp:
host: ftp.example.com
user: [email protected]
password: ${FTP_PASS}
webroot: /httpdocs
secure: explicit # "explicit" | "implicit" | "none"
shell:
host: 203.0.113.20
user: app
domain: app.example.com
# webroot/backupDir can be inferred; override if custom- Build and deploy in one go:
# Build then deploy to the default/first target
zipper pack --remote
# Or pick a target explicitly (overrides deploy.default)
zipper pack --remote --target sftpYou can also deploy/restore independently:
# Upload (by target)
zipper upload:sftp
zipper upload:ftp
zipper upload:shell
# Upload (auto-select default/first)
zipper upload # uses deploy.default or first configured
zipper upload --target ftp # override
# Restore (by target)
zipper restore:sftp --remote-dir /home/deploy/backups --remote-prefix example.com-public_html
zipper restore:ftp --backup backups/example.com-public_html-20250101-000000.tar.gz
zipper restore:shell --backup-name app.example.com-public_html-20250101-000000.tar.gz
# Restore (auto-select)
zipper restore # uses deploy.default or first configured
zipper restore --target shellCommand reference
Build + deploy
zipper pack --remote [--target <shell|sftp|ftp>] [common flags…]- Uses your regular
packoptions and forwards relevant remote flags. - Selects target by
--target, elsedeploy.default, else first configured.
Upload only
Shortcuts for each backend:
zipper upload:shell [flags]
zipper upload:sftp [flags]
zipper upload:ftp [flags]Target‑agnostic:
zipper upload [--target <shell|sftp|ftp>] [flags]Restore
zipper restore:shell [--backup-name <file>]
zipper restore:sftp [--backup <local>] [--remote-dir <path>] [--remote-prefix <p>] [--remote-name <file>]
zipper restore:ftp --backup <local .zip|.tar.gz>
# Or auto-select target
zipper restore [--target <shell|sftp|ftp>] [flags]Notes on backups
- shell (SSH):
upload.shalways creates a remote tar.gz backup ofpublic_htmlbefore syncing.restore.shrestores from that remote backup directory and supports--backup-name.- sftp: Restore supports either a local archive path or pulling from a remote backup directory by prefix/name.
- ftp: Restore uses a local archive you provide.
Options (common)
--yes/--confirm=never|always|auto— control interactive confirmation (defaultauto). Non‑interactive requires--yes.--dry-run— preview without changing remote.--timeout <ms>— connection/operation timeout (default inherits sensible backend default).--concurrency <n>— parallel uploads (SFTP/FTP; default4, max16).--preserve a,b,c— override/extendpreservePathsfrom config. Paths ending with/are treated as directory prefixes; exact filenames otherwise.
Target‑specific flags (when you need to override config)
SSH (shell)
--host <ip/alias> --user <name> --domain <example.com>
--webroot <abs path> --backup-dir <abs> --backup-prefix <str> --backup-retain <N>
--ssh-key <path> --ssh-port <22> --ssh-opts "-o StrictHostKeyChecking=accept-new"SFTP
--host --user --pass <or ZIPPER_SFTP_PASS> --port <22>
--webroot <abs> --domain <example.com> # if webroot omitted, domain+user => /home/<user>/web/<domain>/public_htmlFTP/FTPS
--host --user --pass <or ZIPPER_FTP_PASS> --secure <explicit|implicit|none> --port <21|990>
--webroot <abs> # required (no domain inference for raw FTP)How “preserve” & sync work
The deployers run in two phases:
- Phase A (authoritative) — outside preserved paths, delete remote files not present in the release, then upload/update non‑preserved files.
- Phase B (merge) — inside preserved paths, only add new files (do not overwrite or delete). Useful for
uploads/,storage/, etc.
Preserve matching:
"uploads/"→ treatsuploads/as a directory prefix."robots.txt"→ exact file match.
Webroot & domain inference
- shell (SSH) and sftp can infer
webrootas:
/home/<user>/web/<domain>/public_html…when domain is provided in config/flags and webroot is omitted.
- ftp requires
webrootexplicitly (no inference).
Configuration
You can place deploy settings under deploy.targets in .zipconfig. Examples:
Minimal SFTP
{
"out": "dist/build.zip",
"deploy": {
"default": "sftp",
"targets": {
"sftp": {
"host": "203.0.113.10",
"user": "deploy",
"domain": "example.com", // webroot inferred
"preservePaths": ["uploads/", "storage/", ".well-known/", "robots.txt"],
"timeoutMs": 120000
}
}
}
}FTP with FTPS (explicit)
{
"out": "dist/build.zip",
"deploy": {
"targets": {
"ftp": {
"host": "ftp.example.com",
"user": "[email protected]",
"password": "${FTP_PASS}",
"webroot": "/httpdocs",
"secure": "explicit"
}
}
}
}SSH (shell) using shell/upload.sh
{
"out": "dist/build.zip",
"deploy": {
"targets": {
"shell": {
"host": "203.0.113.20",
"user": "app",
"domain": "app.example.com",
"preservePaths": ["uploads/", "storage/"],
"sshKeyPath": "~/.ssh/id_ed25519"
}
}
}
}The shell backend maps settings to environment variables read by
shell/upload.sh/shell/restore.sh(e.g.,HOST,USER,DOMAIN,ZIP_PATH,BACKUP_DIR,BACKUP_PREFIX,BACKUP_RETAIN,YES,DRY_RUN, etc.). You can still override values via CLI flags.
Build hooks + remote
You can keep custom steps in hooks and still use the remote deploy:
hooks:
pre:
- "pnpm build"
post:
- run: ["node", "scripts/ping.js"]zipper pack --remote --target sftpHooks run locally before upload.
Restore examples
shell (SSH) — restore the latest (server‑side backups created by upload.sh):
zipper restore:shell # pick latest by name
zipper restore:shell --backup-name app.example.com-public_html-20250101-000000.tar.gzsftp — restore from a local backup or fetch from a remote backup dir:
# local archive
zipper restore:sftp --backup backups/site-20250101-000000.tar.gz
# pick the most recent on the server by prefix
zipper restore:sftp --remote-dir /home/deploy/backups --remote-prefix example.com-public_html
# or name it explicitly
zipper restore:sftp --remote-dir /home/deploy/backups --remote-name example.com-public_html-20250101-000000.tar.gzftp — restore from a local backup archive:
zipper restore:ftp --backup backups/site-20250101-000000.zipAll restore commands accept common flags like --yes, --dry-run, --timeout, --preserve, etc.
Non‑interactive runs (CI)
Add --yes (or env YES=1) to bypass prompts. Example:
YES=1 zipper pack --remote --target sftpTroubleshooting
- “Non-interactive session. Pass --yes …” — Add
--yesor setYES=1in CI. - SSH: Permission denied (publickey) — Ensure your key is loaded (
ssh-add -l) and the server user has shell access (not SFTP‑only). The scripts useStrictHostKeyChecking=accept-newby default. - FTP TLS issues — Try
--secure implicit(port 990) or--secure nonefor plain FTP (not recommended). Some hosts require passive mode by default (handled by the client). - Wrong webroot — For shell/sftp, set
domainor overridewebroot. For FTP you must setwebrootexplicitly. - Preserve paths overwriting files — Only new files are added in preserved paths; existing files are not overwritten in Phase B.
Security
- Prefer SSH/SFTP over FTP where possible.
- Store secrets in env vars (e.g.,
ZIPPER_FTP_PASS,ZIPPER_SFTP_PASS) and reference them in.zipconfig. - Limit the deploy user’s permissions to just the docroot.
FAQ
Q: Do I have to put zipPath in the config?
A: Not for pack --remote — it uses the freshly built out file. For direct uploads (zipper upload:*) you can pass --zip to override.
Q: What’s the default target if I don’t pass --target?
A: deploy.default if set, otherwise the first configured target under deploy.targets.
Q: Can I run my own scripts?
A: Yes — use hooks locally, or the shell backend which executes our shell/*.sh scripts on the server. You can still extend those scripts as needed.
Happy shipping! 🚀
🤝 Contributing
- Fork the repo
- Create a feature branch
- Add/update stubs or presets
- Run tests (
npm test) - Submit a PR
📜 License
MIT © Timeax
