Bundling
Validates that cli-forge works correctly when bundled into a single file by popular bundlers, in both ESM and CJS output formats.
Do I need to bundle?
Bundling is not required to distribute a cli-forge CLI. You can publish your TypeScript source and let consumers run it with tsx, or compile with tsc and ship the JavaScript directly. Node.js handles cli-forge's dual-format exports automatically at runtime.
That said, bundling can be useful when you want a single portable file, faster cold starts, or when embedding a CLI inside a larger tool.
Which tool should I pick?
If you want the simplest setup, Bun is a strong option. It has a built-in bundler, and bun build --compile can produce a standalone executable that embeds the Bun runtime, so Bun does not become a runtime dependency for your users.
If you want to stay Node-only, esbuild and Rolldown are both good default choices. Rollup gives you a similar model with a more established plugin ecosystem, while tsdown is a good fit when you want bundler-style transforms and tree-shaking without necessarily producing a fully self-contained single-file bundle.
Shipping a bundled CLI
The examples below focus on module-format compatibility and bundling behavior. If you want to publish the built output as an executable CLI, there is one extra packaging step: make sure the final file has a Node shebang and is exposed through your package's bin field.
1#!/usr/bin/env node1{
2 "bin": {
3 "my-cli": "./dist/esm/my-cli.mjs"
4 }
5}Most bundlers can prepend the shebang with a banner option, or you can add it in a small postbuild step and mark the file as executable with chmod +x.
Shared CLI source
All builds (except esbuild CJS) use this shared entry point:
1import cliForge from 'cli-forge';
2
3const cli = cliForge('bundled-cli')
4 .command('greet', {
5 builder: (args) =>
6 args.option('name', {
7 type: 'string',
8 default: 'World',
9 description: 'Who to greet',
10 }),
11 handler: (args) => {
12 console.log(`Hello, ${args.name}!`);
13 },
14 })
15 .command('add', {
16 builder: (args) =>
17 args
18 .option('a', { type: 'number', required: true })
19 .option('b', { type: 'number', required: true }),
20 handler: (args) => {
21 console.log(`${args.a} + ${args.b} = ${args.a + args.b}`);
22 },
23 });
24
25export default cli;
26
27cli.forge();esbuild
CJS
esbuild activates the "import" export condition whenever source code uses import ... from syntax — regardless of the output format. This means even with format: 'cjs' and platform: 'node', esbuild resolves the ESM entry from dual-format packages. If that entry contains import.meta (valid in ESM, undefined in CJS), the bundle crashes at runtime.
The fix is simple: use TypeScript's import = require(...) syntax for the esbuild CJS entry point. This tells esbuild to resolve the "require" export condition instead:
1// For esbuild CJS bundles, use `import = require(...)` instead of
2// `import ... from`. This tells esbuild to resolve the "require"
3// export condition, picking up the CJS entry from dual-format packages.
4//
5// `import ... from` would activate the "import" condition regardless
6// of the output format, pulling in ESM files that may use import.meta.
7import cliForge = require('cli-forge');With that change, no plugins or configuration tweaks are needed:
1npx tsx cjs/esbuild.tsESM
ESM bundles work out of the box — no workarounds needed:
1npx tsx esm/esbuild.tsRollup
Rollup requires @rollup/plugin-node-resolve to bundle node_modules packages, so it uses a config file.
CJS
1npx rollup -c cjs/rollup.config.mjs --silentESM
1npx rollup -c esm/rollup.config.mjs --silentRolldown
Rolldown is a Rust-based bundler designed as a drop-in replacement for Rollup with better performance. It uses a JavaScript API similar to Rollup's.
CJS
1npx tsx cjs/rolldown.ts1import { rolldown } from 'rolldown';
2
3(async () => {
4 const bundle = await rolldown({
5 input: 'cli.ts',
6 platform: 'node',
7 resolve: { tsconfigFilename: '../tsconfig.json' },
8 });
9 await bundle.write({
10 format: 'cjs',
11 file: 'dist/cjs/rolldown.cjs',
12 codeSplitting: false,
13 });
14 await bundle.close();
15})();ESM
1npx tsx esm/rolldown.ts1import { rolldown } from 'rolldown';
2
3(async () => {
4 const bundle = await rolldown({
5 input: 'cli.ts',
6 platform: 'node',
7 resolve: { tsconfigFilename: '../tsconfig.json' },
8 });
9 await bundle.write({
10 format: 'esm',
11 file: 'dist/esm/rolldown.mjs',
12 codeSplitting: false,
13 });
14 await bundle.close();
15})();Bun
Bun includes a built-in bundler accessible via the Bun.build() API. It is the simplest setup in this example set, and if you later switch to bun build --compile, Bun can also produce a standalone executable that embeds the runtime. These build scripts are run with bun run rather than tsx.
CJS
1bun run cjs/bun.tsESM
1bun run esm/bun.tstsdown
tsdown sits somewhere between bundling and plain transpilation. It still uses a bundler-style pipeline, so you get tree-shaking and related optimizations, but it keeps dependencies external by default instead of inlining node_modules into a single file. This is often the right choice for CLIs that will be installed via npm, since Node.js resolves dependencies at runtime.
CJS
1npx tsdown cli.ts --format cjs --outDir dist/cjs --platform node --no-dtsESM
1npx tsdown cli.ts --format esm --outDir dist/esm --platform node --no-dts