diff --git a/src/factory-core.ts b/src/factory-core.ts index 19740910..f010a3ff 100644 --- a/src/factory-core.ts +++ b/src/factory-core.ts @@ -350,6 +350,9 @@ export function defineGroup (config: GroupConfig, ...commands: OpaqueCommandHand const group = new Command(config.name) group.description(config.description) group.allowExcessArguments(true) + // Defer unknown option validation until after Commander resolves a child. + // Leaf commands still validate their own options during delegated parsing. + group.allowUnknownOption(true) configureErrorOutput(group) configureJsonHelp(group) @@ -363,10 +366,18 @@ export function defineGroup (config: GroupConfig, ...commands: OpaqueCommandHand // Default action: error on unknown sub-command, show help otherwise group.action(function (this: OpaqueCommandHandle) { - if (this.args.length > 0) { - group.error(`unknown command: ${this.args[0]}`) - } else { + const firstArg = this.args[0] + if (firstArg == null) { group.help() + } else if (firstArg !== '--' && firstArg.startsWith('-')) { + group.error(`unknown option '${firstArg}'`) + } else { + const command = firstArg === '--' ? this.args[1] : firstArg + if (command != null) { + group.error(`unknown command: ${command}`) + } else { + group.help() // Bare '--' with no command following it. + } } }) diff --git a/test/cli.test.ts b/test/cli.test.ts index cc1afeef..dc0d6b13 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -334,6 +334,106 @@ describe('elastic CLI -- stack command tree', () => { }) }) +describe('elastic CLI -- command and option error ordering', () => { + it('reports an unknown subcommand before parsing its trailing options', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-unknown-command-')) + await writeFile(join(dir, '.elasticrc.yml'), [ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://localhost:9200', + '', + ].join('\n')) + + try { + const { code, stderr } = await runCli( + ['stack', 'es', 'serch', '--index', 'my-index'], + { cwd: dir, env: { HOME: dir, USERPROFILE: dir, XDG_CONFIG_HOME: dir } } + ) + + assert.equal(code, 1) + assert.match(stderr, /unknown command: serch/) + assert.doesNotMatch(stderr, /unknown option/) + } finally { + await rm(dir, { recursive: true }) + } + }) + + it('still reports an unknown option for a valid command', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-unknown-option-')) + await writeFile(join(dir, '.elasticrc.yml'), [ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://localhost:9200', + '', + ].join('\n')) + + try { + const { code, stderr } = await runCli( + ['stack', 'es', 'search', '--not-a-real-option'], + { cwd: dir, env: { HOME: dir, USERPROFILE: dir, XDG_CONFIG_HOME: dir } } + ) + + assert.equal(code, 1) + assert.match(stderr, /unknown option '--not-a-real-option'/) + } finally { + await rm(dir, { recursive: true }) + } + }) + + it('reports an unknown option when no subcommand is provided', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-group-unknown-option-')) + await writeFile(join(dir, '.elasticrc.yml'), [ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://localhost:9200', + '', + ].join('\n')) + + try { + const { code, stderr } = await runCli( + ['stack', 'es', '--not-a-real-option'], + { cwd: dir, env: { HOME: dir, USERPROFILE: dir, XDG_CONFIG_HOME: dir } } + ) + + assert.equal(code, 1) + assert.match(stderr, /unknown option '--not-a-real-option'/) + } finally { + await rm(dir, { recursive: true }) + } + }) + + it('reports an unknown command after the option separator', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-option-separator-')) + await writeFile(join(dir, '.elasticrc.yml'), [ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://localhost:9200', + '', + ].join('\n')) + + try { + const { code, stderr } = await runCli( + ['stack', 'es', '--', 'serch'], + { cwd: dir, env: { HOME: dir, USERPROFILE: dir, XDG_CONFIG_HOME: dir } } + ) + + assert.equal(code, 1) + assert.match(stderr, /unknown command: serch/) + assert.doesNotMatch(stderr, /unknown option/) + } finally { + await rm(dir, { recursive: true }) + } + }) +}) + describe('elastic CLI -- --help --json', () => { it('`elastic --help --json` emits structured JSON help', async () => { const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-help-json-'))