From dbebf4093e61a7d2e9755a473714d21df2085596 Mon Sep 17 00:00:00 2001 From: vian Date: Thu, 6 Aug 2026 16:56:48 +0530 Subject: [PATCH 1/3] fix(cli): report mistyped subcommand before options --- src/factory-core.ts | 11 +++++++-- test/cli.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/factory-core.ts b/src/factory-core.ts index 19740910..ce167f12 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,8 +366,12 @@ 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]}`) + const firstArg = this.args[0] + if (firstArg != null) { + if (firstArg.startsWith('-')) { + group.error(`unknown option '${firstArg}'`) + } + group.error(`unknown command: ${firstArg}`) } else { group.help() } diff --git a/test/cli.test.ts b/test/cli.test.ts index cc1afeef..a6eeff76 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -334,6 +334,64 @@ 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 { code, stderr } = await runCli(['sanitize', '--not-a-real-option']) + + assert.equal(code, 1) + assert.match(stderr, /unknown option '--not-a-real-option'/) + }) +}) + describe('elastic CLI -- --help --json', () => { it('`elastic --help --json` emits structured JSON help', async () => { const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-help-json-')) From ccb38bbf54866db2cd0bd4bf8140b670442c14d1 Mon Sep 17 00:00:00 2001 From: vian Date: Thu, 6 Aug 2026 22:14:43 +0530 Subject: [PATCH 2/3] fix(cli): handle option separator for groups --- src/factory-core.ts | 12 +++++++----- test/cli.test.ts | 48 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/factory-core.ts b/src/factory-core.ts index ce167f12..88f09cc0 100644 --- a/src/factory-core.ts +++ b/src/factory-core.ts @@ -367,11 +367,13 @@ export function defineGroup (config: GroupConfig, ...commands: OpaqueCommandHand // Default action: error on unknown sub-command, show help otherwise group.action(function (this: OpaqueCommandHandle) { const firstArg = this.args[0] - if (firstArg != null) { - if (firstArg.startsWith('-')) { - group.error(`unknown option '${firstArg}'`) - } - group.error(`unknown command: ${firstArg}`) + const unknownCommand = firstArg === '--' ? this.args[1] : firstArg + if (firstArg == null) { + group.help() + } else if (firstArg !== '--' && firstArg.startsWith('-')) { + group.error(`unknown option '${firstArg}'`) + } else if (unknownCommand != null) { + group.error(`unknown command: ${unknownCommand}`) } else { group.help() } diff --git a/test/cli.test.ts b/test/cli.test.ts index a6eeff76..dc0d6b13 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -385,10 +385,52 @@ describe('elastic CLI -- command and option error ordering', () => { }) it('reports an unknown option when no subcommand is provided', async () => { - const { code, stderr } = await runCli(['sanitize', '--not-a-real-option']) + 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 }) + } + }) - assert.equal(code, 1) - assert.match(stderr, /unknown option '--not-a-real-option'/) + 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 }) + } }) }) From 8461a325018d5dee2460b51b88bc05069e1992d6 Mon Sep 17 00:00:00 2001 From: vian Date: Sat, 8 Aug 2026 01:19:51 +0530 Subject: [PATCH 3/3] refactor(cli): simplify group error handling --- src/factory-core.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/factory-core.ts b/src/factory-core.ts index 88f09cc0..f010a3ff 100644 --- a/src/factory-core.ts +++ b/src/factory-core.ts @@ -367,15 +367,17 @@ export function defineGroup (config: GroupConfig, ...commands: OpaqueCommandHand // Default action: error on unknown sub-command, show help otherwise group.action(function (this: OpaqueCommandHandle) { const firstArg = this.args[0] - const unknownCommand = firstArg === '--' ? this.args[1] : firstArg if (firstArg == null) { group.help() } else if (firstArg !== '--' && firstArg.startsWith('-')) { group.error(`unknown option '${firstArg}'`) - } else if (unknownCommand != null) { - group.error(`unknown command: ${unknownCommand}`) } else { - group.help() + const command = firstArg === '--' ? this.args[1] : firstArg + if (command != null) { + group.error(`unknown command: ${command}`) + } else { + group.help() // Bare '--' with no command following it. + } } })