Skip to content

Commit 50853b2

Browse files
committed
fix(cli): show the -- escape for an id that opens with a dash
Short ids draw from a 64-character alphabet containing one dash, so 1 in 64 open with one and commander reads it as an unknown option. It reaches `audit-logs get` and the custom-tool commands, and the escape was documented nowhere. The hint is appended only for a lone dash followed by two or more characters carrying an uppercase letter or digit — a shape no flag on this surface has — so a misspelt flag keeps commander's own suggestion.
1 parent e8ded4b commit 50853b2

3 files changed

Lines changed: 92 additions & 7 deletions

File tree

packages/sim-cli/src/program.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ Profiles work like the AWS CLI: settings live in ~/.sim/config, keys in
2121
with -P, --profile, or SIM_PROFILE.
2222
2323
Workflow, knowledge-base and workspace IDs are bare UUIDs. Table IDs carry a
24-
tbl_ prefix and file IDs a wf_ one, so wf_ never names a workflow.
24+
tbl_ prefix and file IDs a wf_ one, so wf_ never names a workflow. An audit-log
25+
or custom-tool ID can open with a dash, which reads as a flag; put -- in front
26+
of it, as in sim audit-logs get -- -HlDcD1z76nK6R4crsUp0.
2527
2628
Examples:
2729
$ sim login Authorize the default profile

packages/sim-cli/src/runtime/build.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,52 @@ describe('commands parsed through commander', () => {
326326
expect(errorOutput).not.toContain('--skillId')
327327
})
328328

329+
it('shows the -- escape when an id argument opens with a dash', async () => {
330+
const root = program()
331+
const auditLogs = root.commands.find((command) => command.name() === 'audit-logs')
332+
const get = auditLogs?.commands.find((command) => command.name() === 'get')
333+
if (!get) throw new Error('Missing command audit-logs get')
334+
335+
let errorOutput = ''
336+
get.configureOutput({
337+
writeErr: (message) => {
338+
errorOutput += message
339+
},
340+
})
341+
342+
await expect(
343+
root.parseAsync(['node', 'sim', 'audit-logs', 'get', '-HlDcD1z76nK6R4crsUp0'])
344+
).rejects.toMatchObject({ code: 'commander.unknownOption' })
345+
expect(errorOutput).toContain("error: unknown option '-HlDcD1z76nK6R4crsUp0'")
346+
expect(errorOutput).toContain('Example: sim audit-logs get -- -HlDcD1z76nK6R4crsUp0')
347+
})
348+
349+
it("leaves a misspelt flag with commander's own suggestion", async () => {
350+
const root = program()
351+
const auditLogs = root.commands.find((command) => command.name() === 'audit-logs')
352+
const get = auditLogs?.commands.find((command) => command.name() === 'get')
353+
if (!get) throw new Error('Missing command audit-logs get')
354+
355+
let errorOutput = ''
356+
get.configureOutput({
357+
writeErr: (message) => {
358+
errorOutput += message
359+
},
360+
})
361+
362+
await expect(
363+
root.parseAsync(['node', 'sim', 'audit-logs', 'get', 'log_1', '--organisation'])
364+
).rejects.toMatchObject({ code: 'commander.unknownOption' })
365+
expect(errorOutput).toContain("error: unknown option '--organisation'")
366+
expect(errorOutput).not.toContain('Example:')
367+
368+
await expect(
369+
root.parseAsync(['node', 'sim', 'audit-logs', 'get', 'log_1', '-organisation'])
370+
).rejects.toMatchObject({ code: 'commander.unknownOption' })
371+
expect(errorOutput).toContain("error: unknown option '-organisation'")
372+
expect(errorOutput).not.toContain('Example:')
373+
})
374+
329375
it('dispatches generated commands through their singular resource alias', async () => {
330376
const [tablePath] = await run(['table', 'list'])
331377
expect(tablePath).toBe('/api/v2/tables')

packages/sim-cli/src/runtime/build.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,55 @@ function commandPath(command: Command): string {
6464
return names.join(' ')
6565
}
6666

67-
function addMissingArgumentExample(command: Command): Command {
67+
const UNKNOWN_OPTION_TOKEN = /^error: unknown option '(.+?)'/
68+
69+
/**
70+
* Whether an unknown-option token reads as a resource id rather than a flag.
71+
*
72+
* `generateShortId` draws from a 64-character alphabet holding exactly one
73+
* `-`, so one id in 64 opens with a dash and commander parses it as an option
74+
* instead of the positional it was typed as — `audit-logs get` and
75+
* `custom-tools get/update/delete` all take such an id. A flag on this surface
76+
* is either a single-character short (`-w`) or a lowercase kebab-case long
77+
* (`--dry-run`), so a lone dash followed by two or more characters of which at
78+
* least one is an uppercase letter or a digit is not a flag any caller meant
79+
* to type. `--organisation` and every other misspelt flag keeps the plain
80+
* error and commander's own suggestion.
81+
*/
82+
function looksLikeAnId(token: string): boolean {
83+
return (
84+
token.length > 2 &&
85+
!token.startsWith('--') &&
86+
/^-[A-Za-z0-9_-]*[A-Z0-9][A-Za-z0-9_-]*$/.test(token)
87+
)
88+
}
89+
90+
/**
91+
* Appends a worked example to the parse errors a positional argument causes.
92+
*
93+
* Covers the argument being absent and the argument being swallowed as an
94+
* option because its id opens with a dash; the second needs the `--` escape,
95+
* which commander never mentions.
96+
*/
97+
function addArgumentExamples(command: Command): Command {
6898
const outputError = command.configureOutput().outputError
6999
if (!outputError) throw new Error('Commander output formatter is not configured')
70100

71101
command.configureOutput({
72102
outputError: (message, write) => {
73103
outputError(message, write)
74-
if (!message.startsWith('error: missing required argument ')) return
75104

76-
const syntax = argumentSyntax(command)
77-
const example = syntax ? `${commandPath(command)} ${syntax}` : commandPath(command)
78-
write(`Example: ${example}\n`)
105+
if (message.startsWith('error: missing required argument ')) {
106+
const syntax = argumentSyntax(command)
107+
const example = syntax ? `${commandPath(command)} ${syntax}` : commandPath(command)
108+
write(`Example: ${example}\n`)
109+
return
110+
}
111+
112+
if (command.registeredArguments.length === 0) return
113+
const token = UNKNOWN_OPTION_TOKEN.exec(message)?.[1]
114+
if (!token || !looksLikeAnId(token)) return
115+
write(`Example: ${commandPath(command)} -- ${token}\n`)
79116
},
80117
})
81118
return command
@@ -298,7 +335,7 @@ function configureOperation(
298335
}
299336

300337
function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command {
301-
return addMissingArgumentExample(configureOperation(new Command(leafName), operation, spec))
338+
return addArgumentExamples(configureOperation(new Command(leafName), operation, spec))
302339
}
303340

304341
/**

0 commit comments

Comments
 (0)