From 14ca72938cc23612ff77a80f6b140db99ecfab64 Mon Sep 17 00:00:00 2001
From: opensource-elearning
<159253500+opensource-elearning@users.noreply.github.com>
Date: Sat, 5 Sep 2026 20:02:12 +0530
Subject: [PATCH 1/2] feat(release): add bun-pack script for bun-first global
installs
bun pm pack produces the same universal npm tarball npm pack does, so a
bun-packed artifact installs with both 'bun add -g' and 'npm install -g'.
Add scripts/release/bun-pack.mjs (--pm bun|npm, --skip-build,
--destination) plus the bun:release npm script, and a Bun row in the
README install table. The next build and dist assembly stay on Node;
Bun handles the build orchestration, packing, and the end-user install.
---
README.md | 1 +
package.json | 1 +
scripts/release/bun-pack.mjs | 151 +++++++++++++++++++++++++++++++++++
3 files changed, 153 insertions(+)
create mode 100644 scripts/release/bun-pack.mjs
diff --git a/README.md b/README.md
index b64f3dc543f..d5ad39c3b4b 100644
--- a/README.md
+++ b/README.md
@@ -722,6 +722,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
| Platform | Install | Highlights |
| 📦 npm (global) | npm install -g omniroute | One command, any OS |
+ | ⚡ Bun (global) | bun add -g omniroute | Same registry tarball, faster installs |
| 🐳 Docker | docker run … diegosouzapw/omniroute | Multi-arch AMD64 + ARM64 |
| 🖥️ Desktop (Electron) | npm run electron:build | Native window + system tray — Windows / macOS / Linux |
| 🎩 Menu-bar (OmniRouteTray) | brew install --cask zoispag/tap/omniroute-tray | Supervises & auto-updates the server — macOS |
diff --git a/package.json b/package.json
index f984ef4314f..9a4456331fb 100644
--- a/package.json
+++ b/package.json
@@ -112,6 +112,7 @@
"build:cli": "node --import tsx scripts/build/prepublish.ts",
"omniroute:verify": "node scripts/check/omniroute-verify.mjs",
"build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs",
+ "bun:release": "bun scripts/release/bun-pack.mjs",
"build:native:tproxy": "cd src/mitm/tproxy/native && npx --yes node-gyp rebuild",
"start": "node scripts/dev/run-next.mjs start",
"homolog": "node scripts/homolog/run.mjs",
diff --git a/scripts/release/bun-pack.mjs b/scripts/release/bun-pack.mjs
new file mode 100644
index 00000000000..70c6c4425fb
--- /dev/null
+++ b/scripts/release/bun-pack.mjs
@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+
+/**
+ * bun-pack — produce an npm-registry-compatible tarball, Bun-first.
+ *
+ * Bun is the default package manager for the whole flow (build orchestration
+ * and packing) because it is measurably faster and lighter than npm on the
+ * same machine. Node still runs the actual `next` build and the dist assembly
+ * (AGENTS.md: Bun is NOT widened into the build path, npm install,
+ * check:pack-artifact, or the published runtime) — `bun run` only executes the
+ * same Node build scripts npm would, without npm's overhead.
+ *
+ * The npm publish channel stays the release-captain flow
+ * (scripts/release/verify-published.mjs). `bun pm pack` and `npm pack` produce
+ * the SAME universal npm tarball format, so either packer's output installs
+ * with both `bun add -g ` and `npm install -g `.
+ *
+ * Usage:
+ * node scripts/release/bun-pack.mjs [--pm bun|npm] [--skip-build] [--destination ]
+ *
+ * Flags:
+ * --pm bun|npm Packer + build runner. Default: bun (npm is the fallback).
+ * --skip-build Reuse an already-staged dist/ (or run
+ * `npm run build:release` / `bun run build:release` first).
+ * Default: build first (needs the workspace node_modules).
+ * --destination Output directory. Default: /_artifacts.
+ *
+ * Output: /omniroute-.tgz (universal tarball, gzip)
+ * Verified: package.json, both bin entries, and dist/ are inside the tarball.
+ * Cross-platform: node >= 22, bun >= 1.1; no shell string interpolation
+ * (secrets/env travel as spawn options, never in the script body).
+ */
+
+import { execFileSync, spawnSync } from "node:child_process";
+import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dirname, "..", "..");
+const isWin = process.platform === "win32";
+const npmBin = isWin ? "npm.cmd" : "npm";
+const bunBin = isWin ? "bun.exe" : "bun";
+
+function log(line) {
+ console.log(`[bun-pack] ${line}`);
+}
+
+function fail(message, exitCode = 1) {
+ console.error(`[bun-pack] ❌ ${message}`);
+ process.exit(exitCode);
+}
+
+function parseArgs(argv) {
+ const opts = { pm: "bun", skipBuild: false, destination: join(ROOT, "_artifacts") };
+ for (let i = 0; i < argv.length; i += 1) {
+ if (argv[i] === "--pm") {
+ const pm = (argv[++i] || "").toLowerCase();
+ if (pm !== "bun" && pm !== "npm") fail("--pm must be bun or npm");
+ opts.pm = pm;
+ } else if (argv[i] === "--skip-build") {
+ opts.skipBuild = true;
+ } else if (argv[i] === "--destination") {
+ opts.destination = argv[++i];
+ } else {
+ fail(`unknown argument "${argv[i]}"`);
+ }
+ }
+ if (!opts.destination) fail("--destination requires a directory path");
+ return opts;
+}
+
+function readVersion() {
+ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
+ if (!/^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$/.test(pkg.version || "")) {
+ fail(`invalid version in package.json: ${pkg.version}`);
+ }
+ return pkg.version;
+}
+
+function run(bin, args, opts = {}) {
+ return spawnSync(bin, args, { cwd: ROOT, encoding: "utf8", ...opts });
+}
+
+function verifyTarball(tarball) {
+ const list = run("tar", ["-tzf", tarball]);
+ if (list.status !== 0) fail(`could not read tarball listing (${list.error?.message || "tar failed"})`);
+ const entries = new Set((list.stdout || "").split("\n").map((l) => l.replace(/\/$/, "")));
+
+ const requiredFiles = [
+ "package/package.json",
+ "package/bin/omniroute.mjs",
+ "package/bin/reset-password.mjs",
+ ];
+ const missing = requiredFiles.filter((entry) => !entries.has(entry));
+ if (missing.length > 0) fail(`tarball missing required entries: ${missing.join(", ")}`);
+
+ const distFiles = [...entries].filter((e) => e.startsWith("package/dist/"));
+ if (distFiles.length === 0) fail("tarball contains an empty dist/ — run the build first");
+ return distFiles.length;
+}
+
+log("OmniRoute release pack (npm-compatible tarball)");
+if (!existsSync(join(ROOT, "package.json"))) fail(`no package.json at ${ROOT}`);
+
+const opts = parseArgs(process.argv.slice(2));
+const version = readVersion();
+const tarball = join(opts.destination, `omniroute-${version}.tgz`);
+const isBun = opts.pm === "bun";
+
+if (isBun) {
+ const bunCheck = run(bunBin, ["--version"]);
+ if (bunCheck.status !== 0) fail("bun not found — run `npm run bun:release` or install bun (curl -fsSL https://bun.sh/install | bash)");
+ log(`packer: bun ${bunCheck.stdout.trim()}`);
+} else {
+ log("packer: npm (fallback)");
+}
+
+if (opts.skipBuild) {
+ if (!existsSync(join(ROOT, "dist", "server.js"))) {
+ fail("dist/ not staged — run `bun run build:release` first, or drop --skip-build");
+ }
+ log("reusing existing dist/ (--skip-build)");
+} else {
+ const runner = isBun ? bunBin : npmBin;
+ log(`running ${opts.pm} run build:release (Node runs the actual build)`);
+ const b = run(runner, ["run", "build:release"], { stdio: "inherit" });
+ if (b.status !== 0) fail(`build:release failed (exit ${b.status})`);
+}
+
+if (!existsSync(opts.destination)) mkdirSync(opts.destination, { recursive: true });
+if (existsSync(tarball)) {
+ log(`removing stale ${tarball}`);
+ rmSync(tarball, { force: true });
+}
+
+const packArgs =
+ opts.pm === "npm"
+ ? ["pack", "--ignore-scripts", "--pack-destination", opts.destination, "--json"]
+ : ["pm", "pack", "--destination", opts.destination, "--ignore-scripts", "--quiet"];
+const p = run(isBun ? bunBin : npmBin, packArgs, { stdio: "inherit" });
+if (p.status !== 0) fail(`${opts.pm} pack failed (exit ${p.status})`);
+
+if (!existsSync(tarball)) fail(`${opts.pm} pack completed but ${tarball} was not created`);
+
+const distFiles = verifyTarball(tarball);
+log(`✅ packed ${tarball} (${distFiles} tracked dist/ entries verified)`);
+log(`install (bun): bun add -g ${tarball}`);
+log(`install (npm): npm install -g ${tarball}`);
+log(`uninstall (bun): bun remove -g omniroute`);
+log(`run: omniroute serve`);
\ No newline at end of file
From 52b2096b4cb43cbac7786bdd7696002420dd1e9f Mon Sep 17 00:00:00 2001
From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Date: Fri, 18 Sep 2026 13:48:43 -0300
Subject: [PATCH 2/2] test(release): add unit coverage for bun-pack
parseArgs/verifyTarball
Extract the two pure pieces of logic in scripts/release/bun-pack.mjs so
they are importable and testable without running a real build: parseArgs
and verifyTarball now throw UsageError on bad input instead of calling
fail()/process.exit directly, and the top-level script body is wrapped in
main() behind a direct-run guard (import.meta.url check) that still
translates a UsageError into the same fail() CLI output as before -
verified manually that --pm bogus / --skip-build without dist/ / an
unknown flag print identical messages and exit codes to pre-change
behavior.
Adds tests/unit/bun-pack.test.ts (node:test) covering parseArgs flag
parsing, defaults and bad input, and verifyTarball against real tarballs
built in a temp dir (good tarball, missing/corrupted tarball, missing
required bin entry, empty dist/) - satisfies Hard Rule #8 for this PR's
production code.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---
scripts/release/bun-pack.mjs | 141 ++++++++++++++++----------
tests/unit/bun-pack.test.ts | 187 +++++++++++++++++++++++++++++++++++
2 files changed, 275 insertions(+), 53 deletions(-)
create mode 100644 tests/unit/bun-pack.test.ts
diff --git a/scripts/release/bun-pack.mjs b/scripts/release/bun-pack.mjs
index 70c6c4425fb..d89c6f79406 100644
--- a/scripts/release/bun-pack.mjs
+++ b/scripts/release/bun-pack.mjs
@@ -29,11 +29,16 @@
* Verified: package.json, both bin entries, and dist/ are inside the tarball.
* Cross-platform: node >= 22, bun >= 1.1; no shell string interpolation
* (secrets/env travel as spawn options, never in the script body).
+ *
+ * `parseArgs` and `verifyTarball` are pure (no `process.exit`) and exported so
+ * they are unit-testable without a real build — see tests/unit/bun-pack.test.ts.
+ * They signal a bad-input/bad-tarball condition by throwing `UsageError`; only
+ * the direct-run `main()` below translates that into the CLI's `fail()` exit.
*/
-import { execFileSync, spawnSync } from "node:child_process";
+import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
-import { dirname, join } from "node:path";
+import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -42,6 +47,8 @@ const isWin = process.platform === "win32";
const npmBin = isWin ? "npm.cmd" : "npm";
const bunBin = isWin ? "bun.exe" : "bun";
+export class UsageError extends Error {}
+
function log(line) {
console.log(`[bun-pack] ${line}`);
}
@@ -51,22 +58,22 @@ function fail(message, exitCode = 1) {
process.exit(exitCode);
}
-function parseArgs(argv) {
- const opts = { pm: "bun", skipBuild: false, destination: join(ROOT, "_artifacts") };
+export function parseArgs(argv, defaultDestination = join(ROOT, "_artifacts")) {
+ const opts = { pm: "bun", skipBuild: false, destination: defaultDestination };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--pm") {
const pm = (argv[++i] || "").toLowerCase();
- if (pm !== "bun" && pm !== "npm") fail("--pm must be bun or npm");
+ if (pm !== "bun" && pm !== "npm") throw new UsageError("--pm must be bun or npm");
opts.pm = pm;
} else if (argv[i] === "--skip-build") {
opts.skipBuild = true;
} else if (argv[i] === "--destination") {
opts.destination = argv[++i];
} else {
- fail(`unknown argument "${argv[i]}"`);
+ throw new UsageError(`unknown argument "${argv[i]}"`);
}
}
- if (!opts.destination) fail("--destination requires a directory path");
+ if (!opts.destination) throw new UsageError("--destination requires a directory path");
return opts;
}
@@ -82,9 +89,11 @@ function run(bin, args, opts = {}) {
return spawnSync(bin, args, { cwd: ROOT, encoding: "utf8", ...opts });
}
-function verifyTarball(tarball) {
+export function verifyTarball(tarball) {
const list = run("tar", ["-tzf", tarball]);
- if (list.status !== 0) fail(`could not read tarball listing (${list.error?.message || "tar failed"})`);
+ if (list.status !== 0) {
+ throw new UsageError(`could not read tarball listing (${list.error?.message || "tar failed"})`);
+ }
const entries = new Set((list.stdout || "").split("\n").map((l) => l.replace(/\/$/, "")));
const requiredFiles = [
@@ -93,59 +102,85 @@ function verifyTarball(tarball) {
"package/bin/reset-password.mjs",
];
const missing = requiredFiles.filter((entry) => !entries.has(entry));
- if (missing.length > 0) fail(`tarball missing required entries: ${missing.join(", ")}`);
+ if (missing.length > 0)
+ throw new UsageError(`tarball missing required entries: ${missing.join(", ")}`);
const distFiles = [...entries].filter((e) => e.startsWith("package/dist/"));
- if (distFiles.length === 0) fail("tarball contains an empty dist/ — run the build first");
+ if (distFiles.length === 0)
+ throw new UsageError("tarball contains an empty dist/ — run the build first");
return distFiles.length;
}
-log("OmniRoute release pack (npm-compatible tarball)");
-if (!existsSync(join(ROOT, "package.json"))) fail(`no package.json at ${ROOT}`);
+function main() {
+ log("OmniRoute release pack (npm-compatible tarball)");
+ if (!existsSync(join(ROOT, "package.json"))) fail(`no package.json at ${ROOT}`);
-const opts = parseArgs(process.argv.slice(2));
-const version = readVersion();
-const tarball = join(opts.destination, `omniroute-${version}.tgz`);
-const isBun = opts.pm === "bun";
+ let opts;
+ try {
+ opts = parseArgs(process.argv.slice(2));
+ } catch (err) {
+ if (err instanceof UsageError) fail(err.message);
+ throw err;
+ }
-if (isBun) {
- const bunCheck = run(bunBin, ["--version"]);
- if (bunCheck.status !== 0) fail("bun not found — run `npm run bun:release` or install bun (curl -fsSL https://bun.sh/install | bash)");
- log(`packer: bun ${bunCheck.stdout.trim()}`);
-} else {
- log("packer: npm (fallback)");
-}
+ const version = readVersion();
+ const tarball = join(opts.destination, `omniroute-${version}.tgz`);
+ const isBun = opts.pm === "bun";
-if (opts.skipBuild) {
- if (!existsSync(join(ROOT, "dist", "server.js"))) {
- fail("dist/ not staged — run `bun run build:release` first, or drop --skip-build");
+ if (isBun) {
+ const bunCheck = run(bunBin, ["--version"]);
+ if (bunCheck.status !== 0) {
+ fail(
+ "bun not found — run `npm run bun:release` or install bun (curl -fsSL https://bun.sh/install | bash)"
+ );
+ }
+ log(`packer: bun ${bunCheck.stdout.trim()}`);
+ } else {
+ log("packer: npm (fallback)");
}
- log("reusing existing dist/ (--skip-build)");
-} else {
- const runner = isBun ? bunBin : npmBin;
- log(`running ${opts.pm} run build:release (Node runs the actual build)`);
- const b = run(runner, ["run", "build:release"], { stdio: "inherit" });
- if (b.status !== 0) fail(`build:release failed (exit ${b.status})`);
-}
-if (!existsSync(opts.destination)) mkdirSync(opts.destination, { recursive: true });
-if (existsSync(tarball)) {
- log(`removing stale ${tarball}`);
- rmSync(tarball, { force: true });
+ if (opts.skipBuild) {
+ if (!existsSync(join(ROOT, "dist", "server.js"))) {
+ fail("dist/ not staged — run `bun run build:release` first, or drop --skip-build");
+ }
+ log("reusing existing dist/ (--skip-build)");
+ } else {
+ const runner = isBun ? bunBin : npmBin;
+ log(`running ${opts.pm} run build:release (Node runs the actual build)`);
+ const b = run(runner, ["run", "build:release"], { stdio: "inherit" });
+ if (b.status !== 0) fail(`build:release failed (exit ${b.status})`);
+ }
+
+ if (!existsSync(opts.destination)) mkdirSync(opts.destination, { recursive: true });
+ if (existsSync(tarball)) {
+ log(`removing stale ${tarball}`);
+ rmSync(tarball, { force: true });
+ }
+
+ const packArgs =
+ opts.pm === "npm"
+ ? ["pack", "--ignore-scripts", "--pack-destination", opts.destination, "--json"]
+ : ["pm", "pack", "--destination", opts.destination, "--ignore-scripts", "--quiet"];
+ const p = run(isBun ? bunBin : npmBin, packArgs, { stdio: "inherit" });
+ if (p.status !== 0) fail(`${opts.pm} pack failed (exit ${p.status})`);
+
+ if (!existsSync(tarball)) fail(`${opts.pm} pack completed but ${tarball} was not created`);
+
+ let distFiles;
+ try {
+ distFiles = verifyTarball(tarball);
+ } catch (err) {
+ if (err instanceof UsageError) fail(err.message);
+ throw err;
+ }
+ log(`✅ packed ${tarball} (${distFiles} tracked dist/ entries verified)`);
+ log(`install (bun): bun add -g ${tarball}`);
+ log(`install (npm): npm install -g ${tarball}`);
+ log(`uninstall (bun): bun remove -g omniroute`);
+ log(`run: omniroute serve`);
}
-const packArgs =
- opts.pm === "npm"
- ? ["pack", "--ignore-scripts", "--pack-destination", opts.destination, "--json"]
- : ["pm", "pack", "--destination", opts.destination, "--ignore-scripts", "--quiet"];
-const p = run(isBun ? bunBin : npmBin, packArgs, { stdio: "inherit" });
-if (p.status !== 0) fail(`${opts.pm} pack failed (exit ${p.status})`);
-
-if (!existsSync(tarball)) fail(`${opts.pm} pack completed but ${tarball} was not created`);
-
-const distFiles = verifyTarball(tarball);
-log(`✅ packed ${tarball} (${distFiles} tracked dist/ entries verified)`);
-log(`install (bun): bun add -g ${tarball}`);
-log(`install (npm): npm install -g ${tarball}`);
-log(`uninstall (bun): bun remove -g omniroute`);
-log(`run: omniroute serve`);
\ No newline at end of file
+// direct-run guard (importable for tests)
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main();
+}
diff --git a/tests/unit/bun-pack.test.ts b/tests/unit/bun-pack.test.ts
new file mode 100644
index 00000000000..e92e6f895e2
--- /dev/null
+++ b/tests/unit/bun-pack.test.ts
@@ -0,0 +1,187 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import {
+ parseArgs,
+ verifyTarball,
+ UsageError,
+ // @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
+} from "../../scripts/release/bun-pack.mjs";
+
+// Everything below runs against os.tmpdir() temp directories only — never the
+// real DATA_DIR / repo _artifacts.
+
+function mkTmpDir(prefix: string): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
+}
+
+function buildTarball(
+ dir: string,
+ {
+ withPackageJson = true,
+ withBinOmniroute = true,
+ withBinResetPassword = true,
+ withDistFile = true,
+ }: {
+ withPackageJson?: boolean;
+ withBinOmniroute?: boolean;
+ withBinResetPassword?: boolean;
+ withDistFile?: boolean;
+ } = {}
+): string {
+ const stage = path.join(dir, "stage");
+ const pkgDir = path.join(stage, "package");
+ fs.mkdirSync(path.join(pkgDir, "bin"), { recursive: true });
+ fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
+
+ if (withPackageJson) {
+ fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify({ name: "omniroute" }));
+ }
+ if (withBinOmniroute) {
+ fs.writeFileSync(path.join(pkgDir, "bin", "omniroute.mjs"), "// stub\n");
+ }
+ if (withBinResetPassword) {
+ fs.writeFileSync(path.join(pkgDir, "bin", "reset-password.mjs"), "// stub\n");
+ }
+ if (withDistFile) {
+ fs.writeFileSync(path.join(pkgDir, "dist", "server.js"), "// stub\n");
+ }
+
+ const tarball = path.join(dir, "omniroute-test.tgz");
+ const tarResult = spawnSync("tar", ["czf", tarball, "-C", stage, "package"], {
+ encoding: "utf8",
+ });
+ assert.equal(tarResult.status, 0, `failed to build fixture tarball: ${tarResult.stderr}`);
+ return tarball;
+}
+
+// ---------------------------------------------------------------------------
+// parseArgs
+// ---------------------------------------------------------------------------
+
+test("parseArgs: defaults to bun/no-skip-build/given destination when no flags are passed", () => {
+ const opts = parseArgs([], "/tmp/fake-artifacts");
+ assert.deepEqual(opts, { pm: "bun", skipBuild: false, destination: "/tmp/fake-artifacts" });
+});
+
+test("parseArgs: --pm npm is accepted and overrides the default", () => {
+ const opts = parseArgs(["--pm", "npm"], "/tmp/fake-artifacts");
+ assert.equal(opts.pm, "npm");
+});
+
+test("parseArgs: --pm bun is accepted explicitly", () => {
+ const opts = parseArgs(["--pm", "bun"], "/tmp/fake-artifacts");
+ assert.equal(opts.pm, "bun");
+});
+
+test("parseArgs: --pm is case-insensitive", () => {
+ const opts = parseArgs(["--pm", "NPM"], "/tmp/fake-artifacts");
+ assert.equal(opts.pm, "npm");
+});
+
+test("parseArgs: --skip-build flips skipBuild to true", () => {
+ const opts = parseArgs(["--skip-build"], "/tmp/fake-artifacts");
+ assert.equal(opts.skipBuild, true);
+});
+
+test("parseArgs: --destination overrides the default output directory", () => {
+ const opts = parseArgs(["--destination", "/tmp/custom-out"], "/tmp/fake-artifacts");
+ assert.equal(opts.destination, "/tmp/custom-out");
+});
+
+test("parseArgs: combines multiple flags together", () => {
+ const opts = parseArgs(
+ ["--pm", "npm", "--skip-build", "--destination", "/tmp/custom-out"],
+ "/tmp/fake-artifacts"
+ );
+ assert.deepEqual(opts, { pm: "npm", skipBuild: true, destination: "/tmp/custom-out" });
+});
+
+test("parseArgs: rejects an invalid --pm value with UsageError (bad input, does not exit)", () => {
+ assert.throws(
+ () => parseArgs(["--pm", "bogus"], "/tmp/fake-artifacts"),
+ (err: unknown) => err instanceof UsageError && err.message === "--pm must be bun or npm"
+ );
+});
+
+test("parseArgs: rejects an unrecognized flag with UsageError (bad input)", () => {
+ assert.throws(
+ () => parseArgs(["--unknown-flag"], "/tmp/fake-artifacts"),
+ (err: unknown) =>
+ err instanceof UsageError && /unknown argument "--unknown-flag"/.test(err.message)
+ );
+});
+
+test("parseArgs: --destination with no value throws UsageError (bad input)", () => {
+ assert.throws(
+ () => parseArgs(["--destination"], "/tmp/fake-artifacts"),
+ (err: unknown) =>
+ err instanceof UsageError && /--destination requires a directory path/.test(err.message)
+ );
+});
+
+// ---------------------------------------------------------------------------
+// verifyTarball
+// ---------------------------------------------------------------------------
+
+test("verifyTarball: a well-formed tarball passes and reports its dist/ entry count", (t) => {
+ const dir = mkTmpDir("bun-pack-good-");
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const tarball = buildTarball(dir);
+
+ const distFiles = verifyTarball(tarball);
+ assert.equal(distFiles, 1);
+});
+
+test("verifyTarball: a missing tarball path throws UsageError instead of crashing", (t) => {
+ const dir = mkTmpDir("bun-pack-missing-");
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const missingPath = path.join(dir, "does-not-exist.tgz");
+
+ assert.throws(
+ () => verifyTarball(missingPath),
+ (err: unknown) =>
+ err instanceof UsageError && /could not read tarball listing/.test(err.message)
+ );
+});
+
+test("verifyTarball: a corrupted (non-gzip) tarball throws UsageError", (t) => {
+ const dir = mkTmpDir("bun-pack-corrupt-");
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const corrupted = path.join(dir, "corrupted.tgz");
+ fs.writeFileSync(corrupted, "this is not a gzip tarball at all");
+
+ assert.throws(
+ () => verifyTarball(corrupted),
+ (err: unknown) =>
+ err instanceof UsageError && /could not read tarball listing/.test(err.message)
+ );
+});
+
+test("verifyTarball: a tarball missing a required bin entry throws UsageError naming it", (t) => {
+ const dir = mkTmpDir("bun-pack-missing-bin-");
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const tarball = buildTarball(dir, { withBinResetPassword: false });
+
+ assert.throws(
+ () => verifyTarball(tarball),
+ (err: unknown) =>
+ err instanceof UsageError &&
+ /tarball missing required entries: package\/bin\/reset-password\.mjs/.test(err.message)
+ );
+});
+
+test("verifyTarball: a tarball with an empty dist/ throws UsageError", (t) => {
+ const dir = mkTmpDir("bun-pack-empty-dist-");
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const tarball = buildTarball(dir, { withDistFile: false });
+
+ assert.throws(
+ () => verifyTarball(tarball),
+ (err: unknown) => err instanceof UsageError && /empty dist/.test(err.message)
+ );
+});