Skip to content

Commit c72b19d

Browse files
committed
fix(cli): suggest nested subcommands under their parent path
`varlock proxy strat` suggested `varlock start`, which does not exist. The lookup fails under a parent path, so a bare candidate is not runnable on its own. Use the `commandPath` the error carries for both the suggestion and the help pointer, so `varlock proxy strat` now suggests `varlock proxy start` and points at `varlock proxy --help`. Also filters gunshi's `(anonymous)` entry-command placeholder out of the candidate list, so it can never be offered as a suggestion.
1 parent 20421e1 commit c72b19d

2 files changed

Lines changed: 39 additions & 4 deletions

File tree

packages/varlock/src/cli/helpers/test/validation-errors.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ const unknownOption = (rawName: string, candidates: Array<string>) => ({
1717
class CommandNotFoundError extends Error {
1818
commandName: string;
1919
candidates: Array<string>;
20-
constructor(commandName: string, candidates: Array<string>) {
20+
commandPath: Array<string>;
21+
constructor(commandName: string, candidates: Array<string>, commandPath: Array<string> = []) {
2122
super(`Command not found: ${commandName}`);
2223
this.name = 'CommandNotFoundError';
2324
this.commandName = commandName;
2425
this.candidates = candidates;
26+
this.commandPath = commandPath;
2527
}
2628
}
2729

@@ -106,6 +108,24 @@ describe('toCliExitError', () => {
106108
expect(out).toContain('varlock load');
107109
});
108110

111+
it('suggests a nested subcommand under its parent path, so the advice is runnable', () => {
112+
// `varlock proxy strat` must not suggest `varlock start`, which does not exist
113+
const error = new AggregateError([new CommandNotFoundError('strat', ['start', 'run'], ['proxy'])]);
114+
const out = toCliExitError(error, ['proxy', 'strat']).getFormattedOutput();
115+
116+
expect(out).toContain('Invalid subcommand: strat');
117+
expect(out).toContain('varlock proxy start');
118+
expect(out).not.toMatch(/Did you mean\s+\S*varlock start/);
119+
// and point at the help for the level that actually failed
120+
expect(out).toContain('varlock proxy --help');
121+
});
122+
123+
it('never suggests the anonymous entry command', () => {
124+
const error = new AggregateError([new CommandNotFoundError('anonymou', ['(anonymous)', 'load'])]);
125+
const out = toCliExitError(error, ['anonymou']).getFormattedOutput();
126+
expect(out).not.toContain('(anonymous)');
127+
});
128+
109129
it('passes through other validation errors (bad enum, wrong type)', () => {
110130
const error = new AggregateError([new Error("Optional argument '--format' should be chosen from 'enum' [\"json\"] values")]);
111131
const out = toCliExitError(error, ['load', '--format']).getFormattedOutput();

packages/varlock/src/cli/helpers/validation-errors.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@ type UnknownOptionError = {
1919
type CommandNotFoundError = {
2020
commandName: string;
2121
candidates: Array<string>;
22+
/** parent path the lookup failed under: `['proxy']` for `varlock proxy strat`, `[]` at the top level */
23+
commandPath?: Array<string>;
2224
};
2325

26+
/** gunshi's placeholder name for the unnamed entry command; never something to suggest */
27+
const ANONYMOUS_COMMAND_NAME = '(anonymous)';
28+
2429
/**
2530
* Levenshtein distance, kept dependency-free for the "did you mean" suggestion.
2631
*
@@ -110,6 +115,7 @@ export function toCliExitError(error: AggregateError, args: Array<string>): CliE
110115
const unknownFlags: Array<string> = [];
111116
const details: Array<string> = [];
112117
let notFoundCommand: string | undefined;
118+
let notFoundParentPath: Array<string> = [];
113119

114120
for (const err of error.errors) {
115121
const unknownOption = asUnknownOptionError(err);
@@ -124,8 +130,15 @@ export function toCliExitError(error: AggregateError, args: Array<string>): CliE
124130
const commandNotFound = asCommandNotFoundError(err);
125131
if (commandNotFound) {
126132
notFoundCommand = commandNotFound.commandName;
127-
const suggestion = suggestClosest(commandNotFound.commandName, commandNotFound.candidates ?? []);
128-
if (suggestion) details.push(`Did you mean ${fmt.command(`varlock ${suggestion}`)}?`);
133+
// the lookup happens under a parent path, so a bare candidate is not runnable on its
134+
// own: `varlock proxy strat` must suggest `varlock proxy start`, not `varlock start`
135+
notFoundParentPath = commandNotFound.commandPath ?? [];
136+
const candidates = (commandNotFound.candidates ?? []).filter((c) => c !== ANONYMOUS_COMMAND_NAME);
137+
const suggestion = suggestClosest(commandNotFound.commandName, candidates);
138+
if (suggestion) {
139+
const fullCommand = ['varlock', ...notFoundParentPath, suggestion].join(' ');
140+
details.push(`Did you mean ${fmt.command(fullCommand)}?`);
141+
}
129142
continue;
130143
}
131144

@@ -142,9 +155,11 @@ export function toCliExitError(error: AggregateError, args: Array<string>): CliE
142155
}
143156

144157
if (notFoundCommand !== undefined) {
158+
// point at the help for the level that actually failed (`varlock proxy --help`)
159+
const notFoundHelp = ['varlock', ...notFoundParentPath, '--help'].join(' ');
145160
return new CliExitError(`Invalid subcommand: ${notFoundCommand}`, {
146161
details: details.length ? details : undefined,
147-
suggestion: `Run \`${fmt.command('varlock --help', { jsPackageManager: true })}\` for more info.`,
162+
suggestion: `Run \`${fmt.command(notFoundHelp, { jsPackageManager: true })}\` for more info.`,
148163
});
149164
}
150165

0 commit comments

Comments
 (0)