Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
<table>
<tr><th align="left">Platform</th><th align="left">Install</th><th align="left">Highlights</th></tr>
<tr><td align="left" nowrap>📦 <b>npm (global)</b></td><td align="left" nowrap><code>npm install -g omniroute</code></td><td align="left">One command, any OS</td></tr>
<tr><td align="left" nowrap>⚡ <b>Bun (global)</b></td><td align="left" nowrap><code>bun add -g omniroute</code></td><td align="left">Same registry tarball, faster installs</td></tr>
<tr><td align="left" nowrap>🐳 <b>Docker</b></td><td align="left" nowrap><code>docker run … diegosouzapw/omniroute</code></td><td align="left">Multi-arch <b>AMD64 + ARM64</b></td></tr>
<tr><td align="left" nowrap>🖥️ <b>Desktop (Electron)</b></td><td align="left" nowrap><code>npm run electron:build</code></td><td align="left">Native window + system tray — <b>Windows / macOS / Linux</b></td></tr>
<tr><td align="left" nowrap>🎩 <b>Menu-bar (OmniRouteTray)</b></td><td align="left" nowrap><code>brew install --cask zoispag/tap/omniroute-tray</code></td><td align="left">Supervises &amp; auto-updates the server — <b>macOS</b></td></tr>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,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",
"start:fast": "cross-env OMNIROUTE_SKIP_STANDALONE=1 node scripts/dev/run-next.mjs start",
Expand Down
186 changes: 186 additions & 0 deletions scripts/release/bun-pack.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#!/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 <tarball>` and `npm install -g <tarball>`.
*
* Usage:
* node scripts/release/bun-pack.mjs [--pm bun|npm] [--skip-build] [--destination <dir>]
*
* 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 <dir> Output directory. Default: <repo-root>/_artifacts.
*
* Output: <destination>/omniroute-<version>.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).
*
* `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 { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { dirname, join, resolve } 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";

export class UsageError extends Error {}

function log(line) {
console.log(`[bun-pack] ${line}`);
}

function fail(message, exitCode = 1) {
console.error(`[bun-pack] ❌ ${message}`);
process.exit(exitCode);
}

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") 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 {
throw new UsageError(`unknown argument "${argv[i]}"`);
}
}
if (!opts.destination) throw new UsageError("--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 });
}

export function verifyTarball(tarball) {
const list = run("tar", ["-tzf", tarball]);
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 = [
"package/package.json",
"package/bin/omniroute.mjs",
"package/bin/reset-password.mjs",
];
const missing = requiredFiles.filter((entry) => !entries.has(entry));
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)
throw new UsageError("tarball contains an empty dist/ — run the build first");
return distFiles.length;
}

function main() {
log("OmniRoute release pack (npm-compatible tarball)");
if (!existsSync(join(ROOT, "package.json"))) fail(`no package.json at ${ROOT}`);

let opts;
try {
opts = parseArgs(process.argv.slice(2));
} catch (err) {
if (err instanceof UsageError) fail(err.message);
throw err;
}

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`);

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`);
}

// direct-run guard (importable for tests)
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}
187 changes: 187 additions & 0 deletions tests/unit/bun-pack.test.ts
Original file line number Diff line number Diff line change
@@ -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)
);
});
Loading