Bundling

Validates that cli-forge works correctly when bundled into a single file by popular bundlers, in both ESM and CJS output formats.

21 FILES

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.

typescript
1#!/usr/bin/env node
json
1{
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.ts
1import esbuild from 'esbuild';
2
3esbuild.buildSync({
4  entryPoints: ['cli-esbuild-cjs.ts'],
5  bundle: true,
6  platform: 'node',
7  format: 'cjs',
8  outfile: 'dist/cjs/esbuild.cjs',
9  tsconfig: '../tsconfig.json',
10  logLevel: 'warning',
11});

ESM

ESM bundles work out of the box — no workarounds needed:

1npx tsx esm/esbuild.ts
1import esbuild from 'esbuild';
2
3esbuild.buildSync({
4  entryPoints: ['cli.ts'],
5  bundle: true,
6  platform: 'node',
7  format: 'esm',
8  outfile: 'dist/esm/esbuild.mjs',
9  tsconfig: '../tsconfig.json',
10  logLevel: 'warning',
11});

Rollup

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 --silent
1import nodeResolve from '@rollup/plugin-node-resolve';
2
3export default {
4  input: 'cli.ts',
5  plugins: [nodeResolve()],
6  output: {
7    format: 'cjs',
8    file: 'dist/cjs/rollup.cjs',
9    inlineDynamicImports: true,
10  },
11};

ESM

1npx rollup -c esm/rollup.config.mjs --silent
1import nodeResolve from '@rollup/plugin-node-resolve';
2
3export default {
4  input: 'cli.ts',
5  plugins: [nodeResolve()],
6  output: {
7    format: 'esm',
8    file: 'dist/esm/rollup.mjs',
9    inlineDynamicImports: true,
10  },
11};

Rolldown

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.ts
1import { 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.ts
1import { 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.ts
1const result = await Bun.build({
2  entrypoints: ['cli.ts'],
3  outdir: 'dist/cjs',
4  target: 'node',
5  format: 'cjs',
6  naming: 'bun.cjs',
7});
8
9if (!result.success) {
10  console.error(result.logs);
11  process.exit(1);
12}

ESM

1bun run esm/bun.ts
1const result = await Bun.build({
2  entrypoints: ['cli.ts'],
3  outdir: 'dist/esm',
4  target: 'node',
5  format: 'esm',
6  naming: 'bun.mjs',
7});
8
9if (!result.success) {
10  console.error(result.logs);
11  process.exit(1);
12}

tsdown

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-dts

ESM

1npx tsdown cli.ts --format esm --outDir dist/esm --platform node --no-dts

All Example Files

FILE EXPLORER
cjs/bun.ts
1const result = await Bun.build({
2  entrypoints: ['cli.ts'],
3  outdir: 'dist/cjs',
4  target: 'node',
5  format: 'cjs',
6  naming: 'bun.cjs',
7});
8
9if (!result.success) {
10  console.error(result.logs);
11  process.exit(1);
12}
13