diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f3d256c..bea2100 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,6 +35,14 @@ jobs:
- name: Check server syntax
run: node --check server/index.mjs
+ - name: Smoke-test CLI
+ run: |
+ node --check bin/opencompress.mjs
+ npm run cli -- --help
+ node --input-type=module -e "import sharp from 'sharp'; await sharp({ create: { width: 32, height: 32, channels: 4, background: '#ff0000' } }).png().toFile('/tmp/opencompress-ci.png');"
+ npm run cli -- /tmp/opencompress-ci.png --format webp --output /tmp/opencompress-cli-output
+ test -s /tmp/opencompress-cli-output/opencompress-ci.webp
+
- name: Typecheck strict
run: npm run typecheck:strict
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..08a5ba5
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,78 @@
+name: Release
+
+on:
+ push:
+ branches:
+ - 'release/v*'
+
+permissions:
+ contents: write
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20.19.0
+ cache: npm
+
+ - name: Validate release version
+ shell: bash
+ run: |
+ set -euo pipefail
+ TAG="${GITHUB_REF_NAME#release/}"
+ if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Invalid release branch tag: $TAG" >&2
+ exit 1
+ fi
+ PACKAGE_VERSION="$(node -p "require('./package.json').version")"
+ if [[ "v${PACKAGE_VERSION}" != "$TAG" ]]; then
+ echo "package.json version v${PACKAGE_VERSION} does not match $TAG" >&2
+ exit 1
+ fi
+ echo "TAG=$TAG" >> "$GITHUB_ENV"
+ echo "ARCHIVE=opencompress-${PACKAGE_VERSION}-source.zip" >> "$GITHUB_ENV"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Validate source
+ run: |
+ node --check server/index.mjs
+ node --check bin/opencompress.mjs
+ npm run typecheck:strict
+ npm run build
+
+ - name: Build source archive
+ shell: bash
+ run: |
+ set -euo pipefail
+ git archive --format=zip --output="$ARCHIVE" HEAD
+ sha256sum "$ARCHIVE" > "$ARCHIVE.sha256"
+
+ - name: Create GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ if gh release view "$TAG" >/dev/null 2>&1; then
+ echo "Release $TAG already exists; uploading assets only."
+ else
+ gh release create "$TAG" \
+ --target "$GITHUB_SHA" \
+ --title "OpenCompress Studio $TAG" \
+ --notes-file RELEASE_NOTES.md
+ fi
+ gh release upload "$TAG" "$ARCHIVE" "$ARCHIVE.sha256" --clobber
diff --git a/README.md b/README.md
index 3a2cdb8..3bc8ead 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,18 @@ Most image compressors are either single-file tools, cloud services or too gener
- **Safe defaults:** metadata removal and keep-original-if-larger are enabled by design.
- **Optional external comparison:** reSmush.it can be enabled explicitly and automatically falls back to local compression when possible.
+## Where it fits
+
+This is a workflow comparison, not a claim that one encoder always produces smaller files. Results depend on image content, codec and settings.
+
+| Tool | Processing model | Strong fit | OpenCompress difference |
+| --- | --- | --- | --- |
+| **OpenCompress Studio** | Local Node.js + Sharp by default; optional reSmush.it | Repeatable shop and creator batches | Presets, SEO rename, target-size mode, before/after review and ZIP batch export in one workflow |
+| **[Squoosh](https://squoosh.app/)** | Local in the browser | Hands-on visual codec tuning | OpenCompress focuses on repeatable multi-image shop workflows and can also be automated from the CLI |
+| **[TinyPNG / TinyJPG](https://tinypng.com/)** | Hosted service/API; images are sent to the service for compression | Managed web/API compression | OpenCompress can keep the complete image workflow local with no account or API key |
+
+Squoosh documents that image processing stays on-device. TinyPNG's API documentation describes uploading image data to its service for compression. OpenCompress is aimed at users who want the local model plus batch-oriented e-commerce tooling.
+
## Quick start
### Windows
@@ -49,12 +61,47 @@ npm run dev
Open `http://127.0.0.1:5174`.
+## CLI preview
+
+OpenCompress 2.1 also includes a local-only CLI foundation for scripts, CI jobs and large folders. It intentionally does not call reSmush.it or another external image service.
+
+```bash
+# Convert a folder to WebP at quality 82
+npm run cli -- ./images --format webp --quality 82
+
+# Resize a complete catalog recursively
+npm run cli -- ./catalog --recursive --format webp --quality 82 --max-width 1600 --max-height 1600 -o ./optimized
+
+# JPEG output with a white background for transparent inputs
+npm run cli -- hero.png --format jpg --quality 90 --background '#ffffff'
+
+# Machine-readable result summary
+npm run cli -- ./images --format webp --json
+```
+
+CLI options:
+
+```text
+-o, --output
Output directory
+-f, --format webp, jpg/jpeg or png
+-q, --quality <1-100> Lossy output quality
+ --max-width Maximum output width
+ --max-height Maximum output height
+-r, --recursive Scan nested directories
+ --allow-larger Keep optimized output even when larger
+ --background JPG alpha background
+ --json Machine-readable JSON summary
+```
+
+Run `npm run cli -- --help` for the complete usage text. The CLI currently supports JPG, PNG, WebP, TIFF and BMP inputs. Auto Best, target-size search and reSmush.it remain GUI-only for now.
+
## Feature highlights
- Batch upload for up to 250 images
-- JPG, PNG, WebP, GIF, TIF and BMP input
+- JPG, PNG, WebP, GIF, TIF and BMP input in the GUI
- JPG, PNG and WebP output
- Local compression with `sharp`
+- Local-only CLI preview for scripts and folder processing
- Auto Best local mode that tests multiple output candidates and keeps the smallest
- Optional reSmush.it API compression
- Auto Compare local vs reSmush.it
@@ -80,8 +127,9 @@ Open `http://127.0.0.1:5174`.
| **Auto Best local** | Yes | No | Smallest local result without cloud uploads |
| **reSmush.it API** | No | Yes | Explicit external compression |
| **Auto Compare** | No | Yes | Comparing local output with reSmush.it |
+| **CLI preview** | Yes | No | Scripts, CI jobs and local folders |
-Local-only processing stores temporary job files in `.opencompress/` and removes old jobs automatically after the configured TTL.
+Local-only processing stores temporary GUI job files in `.opencompress/` and removes old jobs automatically after the configured TTL. CLI output is written directly to the selected output directory.
> If you bind `OPENCOMPRESS_HOST` to a non-loopback address, the local API becomes reachable from your network. Keep the default `127.0.0.1` unless you intentionally want remote access.
@@ -134,7 +182,7 @@ If the target cannot be reached, the result is still returned with a warning ins
## reSmush.it support
-reSmush.it is optional and is never used by Local only or Auto Best local mode.
+reSmush.it is optional and is never used by Local only, Auto Best local or CLI mode.
Supported external inputs:
@@ -166,6 +214,7 @@ Open `http://127.0.0.1:5174`.
```bash
npm run dev # Local development server with Vite middleware
npm start # Production server using dist/
+npm run cli -- --help # Local CLI usage
npm run build # Type-check and build production assets
npm run typecheck # TypeScript checks
npm run typecheck:strict # TypeScript checks including unused-code detection
@@ -190,6 +239,7 @@ OPENCOMPRESS_USER_AGENT=OpenCompress-Studio/2.1.0
```text
OpenCompress/
+├─ bin/ Local CLI preview
├─ src/ React UI
├─ server/ Local Express + Sharp processing API
├─ docs/ Repository media and documentation
@@ -203,6 +253,7 @@ OpenCompress/
Good next contributions include:
+- CLI parity for Auto Best and target-size mode
- Real per-file streaming progress
- Drag-and-drop file sorting
- AVIF export
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index fc3ac34..43b5803 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,17 @@
# OpenCompress Studio 2.1.0
+OpenCompress Studio 2.1.0 turns the project into a more complete local-first batch image workflow for shops, creators and automation.
+
+## Highlights
+
+- Private-by-default local compression with Sharp.
+- Batch workflow for up to 250 images in the GUI.
+- Auto Best local mode for comparing multiple local output candidates.
+- Target file-size mode for JPG/WebP output.
+- Before/after preview, candidate comparison and detailed savings.
+- Shop-focused presets, SEO batch rename and ZIP export.
+- New local-only CLI preview for scripts, CI jobs and large folders.
+
## Added
- Auto Best local mode that tests multiple local output candidates and keeps the smallest result.
@@ -13,16 +25,57 @@
- Candidate comparison panel for Auto Best and Auto Compare results.
- Expanded results table with format, dimensions, quality, method, status and warnings.
- More specific shop/image presets.
+- Local CLI preview with file/folder input, recursive scanning, WebP/JPG/PNG output, quality, resize, JSON summaries and keep-original-if-larger behavior.
+- GitHub contribution, security, issue and pull-request templates.
+- Dependabot configuration and stronger CI checks.
+
+## Fixed and hardened
+
+- Fresh Windows clones now build before starting production mode.
+- Invalid job IDs can no longer collapse to the jobs root during deletion.
+- Target-size compression now works when preferred quality is below 35.
+- Duplicate filenames map to the correct active preview item.
+- Upload count and malformed settings errors return clearer API responses.
+- Result downloads use streaming file responses instead of unnecessary whole-file buffering.
+- reSmush.it requests now have bounded timeouts and HTTPS result validation.
+- Numeric environment settings are validated and bounded.
+- CLI image processing is smoke-tested in GitHub Actions with a generated image fixture.
## Changed
-- Local compression now reports original and output dimensions/formats.
+- Local compression reports original and output dimensions/formats.
- Auto Compare can run in a fair mode with no resize, original format and matching quality.
-- ZIP report now includes settings and detailed result metadata.
-- README updated for the V2.1 workflow.
+- ZIP report includes settings and detailed result metadata.
+- README now explains privacy modes, CLI usage and workflow positioning versus Squoosh and TinyPNG/TinyJPG without synthetic benchmark claims.
+- Supported Node.js versions are aligned across documentation, launcher and CI.
+
+## CLI preview
+
+```bash
+npm ci
+npm run cli -- ./images --format webp --quality 82 --max-width 1600 -o ./optimized
+```
+
+Run `npm run cli -- --help` for all options. The CLI is local-only in 2.1.0; Auto Best, target-size search and reSmush.it remain GUI-only.
+
+## Privacy notes
+
+- Local only, Auto Best local and CLI modes do not upload images externally.
+- reSmush.it remains optional and is used only when explicitly selected in the GUI.
+- Temporary GUI jobs live under `.opencompress/` and expire automatically.
+
+## Requirements
+
+- Node.js 20.19+ or 22.12+
+- npm 10+
+
+## Install
-## Notes
+```bash
+git clone https://github.com/SLP-DEV1/OpenCompress.git
+cd OpenCompress
+npm ci
+npm run dev
+```
-- reSmush.it remains optional and uploads selected files to an external API only when explicitly selected.
-- Local only and Auto Best local modes do not upload images externally.
-- `node_modules/`, `dist/` and `.opencompress/` are excluded from the release package.
+Windows users can also run `start.bat` after installing a supported Node.js version.
diff --git a/bin/opencompress.mjs b/bin/opencompress.mjs
new file mode 100644
index 0000000..b871941
--- /dev/null
+++ b/bin/opencompress.mjs
@@ -0,0 +1,344 @@
+#!/usr/bin/env node
+
+import { copyFile, mkdir, readdir, stat, writeFile } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import sharp from 'sharp';
+
+const VERSION = '2.1.0';
+const SUPPORTED_INPUTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.tif', '.tiff', '.bmp']);
+const FORMAT_ALIASES = new Map([
+ ['jpg', 'jpeg'],
+ ['jpeg', 'jpeg'],
+ ['png', 'png'],
+ ['webp', 'webp']
+]);
+const FORMAT_EXTENSION = {
+ jpeg: '.jpg',
+ png: '.png',
+ webp: '.webp'
+};
+
+main().catch((error) => {
+ console.error(`[opencompress-cli] ${error instanceof Error ? error.message : String(error)}`);
+ process.exitCode = 1;
+});
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ if (options.version) {
+ console.log(VERSION);
+ return;
+ }
+
+ if (!options.inputs.length) {
+ throw new Error('No input files or directories supplied. Run with --help for usage.');
+ }
+
+ const outputDir = path.resolve(options.output);
+ const files = await collectInputFiles(options.inputs, options.recursive, outputDir);
+ if (!files.length) {
+ throw new Error('No supported images found. Supported inputs: JPG, PNG, WebP, TIFF and BMP.');
+ }
+
+ await mkdir(outputDir, { recursive: true });
+
+ const results = [];
+ for (const filePath of files) {
+ try {
+ const result = await processImage(filePath, outputDir, options);
+ results.push(result);
+ if (!options.json) printResult(result);
+ } catch (error) {
+ const failure = {
+ input: filePath,
+ status: 'failed',
+ error: error instanceof Error ? error.message : String(error)
+ };
+ results.push(failure);
+ if (!options.json) console.error(`FAILED ${filePath}: ${failure.error}`);
+ }
+ }
+
+ const successful = results.filter((item) => item.status !== 'failed');
+ const failed = results.length - successful.length;
+ const totalInputBytes = successful.reduce((sum, item) => sum + item.inputBytes, 0);
+ const totalOutputBytes = successful.reduce((sum, item) => sum + item.outputBytes, 0);
+ const savedBytes = totalInputBytes - totalOutputBytes;
+ const savedPercent = totalInputBytes > 0 ? roundOne((savedBytes / totalInputBytes) * 100) : 0;
+
+ const summary = {
+ version: VERSION,
+ outputDir,
+ processed: successful.length,
+ failed,
+ totalInputBytes,
+ totalOutputBytes,
+ savedBytes,
+ savedPercent,
+ results
+ };
+
+ if (options.json) {
+ console.log(JSON.stringify(summary, null, 2));
+ } else {
+ console.log('');
+ console.log(`Processed ${successful.length}/${results.length} image(s).`);
+ console.log(`Total: ${formatBytes(totalInputBytes)} -> ${formatBytes(totalOutputBytes)} (${formatSavings(savedPercent)}).`);
+ console.log(`Output: ${outputDir}`);
+ }
+
+ if (failed > 0) process.exitCode = 1;
+}
+
+function parseArgs(argv) {
+ const options = {
+ inputs: [],
+ output: 'opencompress-output',
+ format: 'webp',
+ quality: 82,
+ maxWidth: null,
+ maxHeight: null,
+ recursive: false,
+ keepOriginalIfLarger: true,
+ background: '#ffffff',
+ json: false,
+ help: false,
+ version: false
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (!arg.startsWith('-')) {
+ options.inputs.push(arg);
+ continue;
+ }
+
+ switch (arg) {
+ case '-h':
+ case '--help':
+ options.help = true;
+ break;
+ case '-v':
+ case '--version':
+ options.version = true;
+ break;
+ case '-o':
+ case '--output':
+ options.output = readValue(argv, ++index, arg);
+ break;
+ case '-f':
+ case '--format': {
+ const raw = readValue(argv, ++index, arg).toLowerCase();
+ const format = FORMAT_ALIASES.get(raw);
+ if (!format) throw new Error(`Unsupported format "${raw}". Use webp, jpg/jpeg or png.`);
+ options.format = format;
+ break;
+ }
+ case '-q':
+ case '--quality':
+ options.quality = readInteger(argv, ++index, arg, 1, 100);
+ break;
+ case '--max-width':
+ options.maxWidth = readInteger(argv, ++index, arg, 1, 20000);
+ break;
+ case '--max-height':
+ options.maxHeight = readInteger(argv, ++index, arg, 1, 20000);
+ break;
+ case '-r':
+ case '--recursive':
+ options.recursive = true;
+ break;
+ case '--allow-larger':
+ options.keepOriginalIfLarger = false;
+ break;
+ case '--background': {
+ const value = readValue(argv, ++index, arg);
+ if (!/^#[0-9a-fA-F]{6}$/.test(value)) {
+ throw new Error('--background must be a six-digit hex color such as #ffffff.');
+ }
+ options.background = value;
+ break;
+ }
+ case '--json':
+ options.json = true;
+ break;
+ default:
+ throw new Error(`Unknown option "${arg}". Run with --help for usage.`);
+ }
+ }
+
+ return options;
+}
+
+function readValue(argv, index, flag) {
+ const value = argv[index];
+ if (!value || value.startsWith('-')) throw new Error(`${flag} requires a value.`);
+ return value;
+}
+
+function readInteger(argv, index, flag, min, max) {
+ const raw = readValue(argv, index, flag);
+ const value = Number(raw);
+ if (!Number.isInteger(value) || value < min || value > max) {
+ throw new Error(`${flag} must be an integer between ${min} and ${max}.`);
+ }
+ return value;
+}
+
+async function collectInputFiles(inputs, recursive, outputDir) {
+ const collected = new Set();
+
+ for (const input of inputs) {
+ const resolved = path.resolve(input);
+ let info;
+ try {
+ info = await stat(resolved);
+ } catch {
+ throw new Error(`Input does not exist: ${input}`);
+ }
+
+ if (info.isFile()) {
+ if (!isSupportedInput(resolved)) throw new Error(`Unsupported image type: ${input}`);
+ collected.add(resolved);
+ continue;
+ }
+
+ if (!info.isDirectory()) throw new Error(`Input is not a file or directory: ${input}`);
+ await walkDirectory(resolved, recursive, outputDir, collected);
+ }
+
+ return [...collected].sort((a, b) => a.localeCompare(b));
+}
+
+async function walkDirectory(directory, recursive, outputDir, collected) {
+ if (samePath(directory, outputDir)) return;
+ const entries = await readdir(directory, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = path.join(directory, entry.name);
+ if (entry.isFile() && isSupportedInput(fullPath)) {
+ collected.add(fullPath);
+ } else if (recursive && entry.isDirectory() && !samePath(fullPath, outputDir)) {
+ await walkDirectory(fullPath, true, outputDir, collected);
+ }
+ }
+}
+
+function samePath(a, b) {
+ return path.resolve(a) === path.resolve(b);
+}
+
+function isSupportedInput(filePath) {
+ return SUPPORTED_INPUTS.has(path.extname(filePath).toLowerCase());
+}
+
+async function processImage(inputPath, outputDir, options) {
+ const inputInfo = await stat(inputPath);
+ const metadata = await sharp(inputPath).metadata();
+
+ let pipeline = sharp(inputPath).rotate();
+ if (options.maxWidth || options.maxHeight) {
+ pipeline = pipeline.resize({
+ width: options.maxWidth ?? undefined,
+ height: options.maxHeight ?? undefined,
+ fit: 'inside',
+ withoutEnlargement: true
+ });
+ }
+
+ if (options.format === 'jpeg') {
+ if (metadata.hasAlpha) pipeline = pipeline.flatten({ background: options.background });
+ pipeline = pipeline.jpeg({ quality: options.quality, mozjpeg: true });
+ } else if (options.format === 'png') {
+ pipeline = pipeline.png({ compressionLevel: 9, adaptiveFiltering: true });
+ } else {
+ pipeline = pipeline.webp({ quality: options.quality, effort: 4 });
+ }
+
+ const outputBuffer = await pipeline.toBuffer();
+ const inputBytes = inputInfo.size;
+ const stem = path.parse(inputPath).name;
+
+ if (options.keepOriginalIfLarger && outputBuffer.length > inputBytes) {
+ const outputPath = uniqueOutputPath(outputDir, stem, path.extname(inputPath).toLowerCase());
+ await copyFile(inputPath, outputPath);
+ return {
+ input: inputPath,
+ output: outputPath,
+ status: 'original-kept',
+ format: normalizeFormat(metadata.format),
+ inputBytes,
+ outputBytes: inputBytes,
+ savedBytes: 0,
+ savedPercent: 0
+ };
+ }
+
+ const extension = FORMAT_EXTENSION[options.format];
+ const outputPath = uniqueOutputPath(outputDir, stem, extension);
+ await writeFile(outputPath, outputBuffer);
+
+ const savedBytes = inputBytes - outputBuffer.length;
+ const savedPercent = inputBytes > 0 ? roundOne((savedBytes / inputBytes) * 100) : 0;
+ return {
+ input: inputPath,
+ output: outputPath,
+ status: 'optimized',
+ format: options.format,
+ quality: options.format === 'png' ? null : options.quality,
+ inputBytes,
+ outputBytes: outputBuffer.length,
+ savedBytes,
+ savedPercent
+ };
+}
+
+function uniqueOutputPath(outputDir, stem, extension) {
+ let counter = 1;
+ let candidate = path.join(outputDir, `${stem}${extension}`);
+ while (existsSync(candidate)) {
+ counter += 1;
+ candidate = path.join(outputDir, `${stem}-${counter}${extension}`);
+ }
+ return candidate;
+}
+
+function normalizeFormat(value) {
+ if (value === 'jpg') return 'jpeg';
+ return value || 'original';
+}
+
+function printResult(result) {
+ const label = result.status === 'original-kept' ? 'KEPT' : 'OK';
+ console.log(`${label} ${path.basename(result.input)} -> ${path.basename(result.output)} | ${formatBytes(result.inputBytes)} -> ${formatBytes(result.outputBytes)} | ${formatSavings(result.savedPercent)}`);
+}
+
+function formatSavings(percent) {
+ if (percent > 0) return `${percent}% smaller`;
+ if (percent < 0) return `${Math.abs(percent)}% larger`;
+ return 'same size';
+}
+
+function formatBytes(bytes) {
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB'];
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
+ const value = bytes / 1024 ** exponent;
+ return `${value >= 10 || exponent === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[exponent]}`;
+}
+
+function roundOne(value) {
+ return Math.round(value * 10) / 10;
+}
+
+function printHelp() {
+ console.log(`OpenCompress CLI ${VERSION} (preview)\n\nUsage:\n npm run cli -- [options]\n node bin/opencompress.mjs [options]\n\nOptions:\n -o, --output Output directory (default: opencompress-output)\n -f, --format webp, jpg/jpeg or png (default: webp)\n -q, --quality <1-100> Lossy output quality (default: 82)\n --max-width Resize to fit within this width\n --max-height Resize to fit within this height\n -r, --recursive Scan nested directories\n --allow-larger Keep optimized output even when it is larger\n --background JPG alpha background (default: #ffffff)\n --json Print machine-readable JSON summary\n -v, --version Print version\n -h, --help Show this help\n\nSupported inputs:\n JPG, PNG, WebP, TIFF and BMP\n\nExamples:\n npm run cli -- ./images --format webp --quality 82 --max-width 1600\n npm run cli -- ./catalog --recursive --format jpg --quality 88 -o ./optimized\n npm run cli -- hero.png --format webp --json\n\nThe CLI preview is local-only. It does not call reSmush.it or any other external image service.`);
+}
diff --git a/package.json b/package.json
index e12fbbf..12b77d3 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"dev": "node server/index.mjs",
"start": "node server/index.mjs --production",
"preview": "node server/index.mjs --production",
+ "cli": "node bin/opencompress.mjs",
"build": "npm run typecheck && vite build",
"typecheck": "tsc --noEmit",
"typecheck:strict": "tsc --noEmit --noUnusedLocals --noUnusedParameters",