Skip to content

Commit 77df2b8

Browse files
Merge pull request #383 from paritytech/feat/help-hint-unknown-command
feat(cli): point to --help when a command/option typo has no suggestion
2 parents 4fa4089 + fcf2269 commit 77df2b8

4 files changed

Lines changed: 182 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"playground-cli": patch
3+
---
4+
5+
When a mistyped command or option is too far off for commander's built-in "Did you mean …?" suggestion, the error now tails with a pointer to `playground --help` (matching git's behavior). Covers the root program and every subcommand, including option typos on `login`, `deploy`, etc.

src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
onProcessShutdown,
3737
setProcessGuardWarningHandler,
3838
} from "./utils/process-guard.js";
39+
import { installHelpHint } from "./utils/help-hint.js";
3940
import { clearWindowTitle } from "./utils/ui/theme/window-title.js";
4041
import { startVersionCheck } from "./utils/version-check.js";
4142

@@ -140,6 +141,13 @@ program.addCommand(decentralizeCommand);
140141
program.addCommand(logoutCommand);
141142
program.addCommand(updateCommand);
142143

144+
// Commander already suggests "Did you mean …?" for near-miss typos. When the
145+
// typo is too far for a suggestion it emits a bare "unknown command/option"
146+
// error; tail it with a pointer to `--help` (git does the same). Must run AFTER
147+
// the addCommand calls above so the walk reaches every subcommand — commander
148+
// does not propagate the root output config to addCommand'd children.
149+
installHelpHint(program);
150+
143151
// Kick off the "is there a newer dot release?" check immediately so the
144152
// jsDelivr fetch races the command rather than tacking onto its tail. The
145153
// banner (if any) is printed in the `finally` once the user-visible work

src/utils/help-hint.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (C) Parity Technologies (UK) Ltd.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
import { Command } from "commander";
17+
import { afterEach, describe, expect, it, vi } from "vitest";
18+
import { appendHelpHint, installHelpHint } from "./help-hint.js";
19+
20+
describe("appendHelpHint", () => {
21+
it("appends a commands hint when an unknown command has no suggestion", () => {
22+
const out = appendHelpHint("error: unknown command 'xyzzy'\n", "playground");
23+
expect(out).toBe(
24+
"error: unknown command 'xyzzy'\nRun 'playground --help' to see available commands.\n",
25+
);
26+
});
27+
28+
it("appends an options hint when an unknown option has no suggestion", () => {
29+
const out = appendHelpHint("error: unknown option '--zzz'\n", "playground");
30+
expect(out).toBe(
31+
"error: unknown option '--zzz'\nRun 'playground --help' to see available options.\n",
32+
);
33+
});
34+
35+
it("leaves an unknown command untouched when commander already suggested one", () => {
36+
const input = "error: unknown command 'loign'\n(Did you mean login?)\n";
37+
expect(appendHelpHint(input, "playground")).toBe(input);
38+
});
39+
40+
it("leaves an unknown option untouched when commander already suggested one", () => {
41+
const input = "error: unknown option '--yse'\n(Did you mean --yes?)\n";
42+
expect(appendHelpHint(input, "playground")).toBe(input);
43+
});
44+
45+
it("leaves unrelated errors untouched", () => {
46+
const input = "error: required option '--name <value>' not specified\n";
47+
expect(appendHelpHint(input, "playground")).toBe(input);
48+
});
49+
50+
it("uses the supplied program name in the hint", () => {
51+
const out = appendHelpHint("error: unknown command 'nope'\n", "pg");
52+
expect(out).toBe(
53+
"error: unknown command 'nope'\nRun 'pg --help' to see available commands.\n",
54+
);
55+
});
56+
57+
it("appends a trailing newline before the hint when the error text lacks one", () => {
58+
const out = appendHelpHint("error: unknown command 'nope'", "playground");
59+
expect(out).toBe(
60+
"error: unknown command 'nope'\nRun 'playground --help' to see available commands.\n",
61+
);
62+
});
63+
});
64+
65+
describe("installHelpHint", () => {
66+
afterEach(() => vi.restoreAllMocks());
67+
68+
function buildProgram(): Command {
69+
const program = new Command().name("playground").exitOverride();
70+
const login = new Command("login").option("--yes").exitOverride();
71+
// addCommand (not .command()) mirrors src/index.ts; commander does NOT
72+
// propagate the root output config to addCommand'd subcommands, which is
73+
// exactly why installHelpHint must walk the tree.
74+
program.addCommand(login);
75+
installHelpHint(program);
76+
return program;
77+
}
78+
79+
function captureStderr(): { read: () => string } {
80+
let buf = "";
81+
vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
82+
buf += chunk.toString();
83+
return true;
84+
});
85+
return { read: () => buf };
86+
}
87+
88+
it("adds the hint to a root-level unknown command with no suggestion", () => {
89+
const program = buildProgram();
90+
const stderr = captureStderr();
91+
expect(() => program.parse(["xyzzy"], { from: "user" })).toThrow();
92+
expect(stderr.read()).toContain("Run 'playground --help' to see available commands.");
93+
});
94+
95+
it("adds the hint to a SUBCOMMAND unknown option with no suggestion", () => {
96+
const program = buildProgram();
97+
const stderr = captureStderr();
98+
expect(() => program.parse(["login", "--zzzzzz"], { from: "user" })).toThrow();
99+
expect(stderr.read()).toContain("Run 'playground --help' to see available options.");
100+
});
101+
102+
it("does not add the hint when the subcommand option has a suggestion", () => {
103+
const program = buildProgram();
104+
const stderr = captureStderr();
105+
expect(() => program.parse(["login", "--yse"], { from: "user" })).toThrow();
106+
expect(stderr.read()).toContain("Did you mean --yes?");
107+
expect(stderr.read()).not.toContain("--help");
108+
});
109+
});

src/utils/help-hint.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (C) Parity Technologies (UK) Ltd.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
import type { Command } from "commander";
17+
18+
/**
19+
* Commander v12 already prints a Levenshtein "Did you mean …?" suggestion for an
20+
* unknown command or option (`showSuggestionAfterError`, on by default). But when
21+
* the typo is too far from any real name it can't suggest anything and emits a
22+
* bare `error: unknown command 'xyzzy'` with no next step. `git` always tails its
23+
* equivalent error with `See 'git --help'`; this mirrors that.
24+
*
25+
* Wired via `installHelpHint(program)` in `src/index.ts`. We append a help
26+
* pointer ONLY for unknown command/option errors that commander left without a
27+
* suggestion; every other error (already-suggested, missing-required-option,
28+
* invalid-argument, …) passes through untouched.
29+
*/
30+
export function appendHelpHint(errorText: string, programName: string): string {
31+
if (errorText.includes("Did you mean")) return errorText;
32+
33+
const noun = errorText.includes("unknown command")
34+
? "commands"
35+
: errorText.includes("unknown option")
36+
? "options"
37+
: null;
38+
if (noun === null) return errorText;
39+
40+
const base = errorText.endsWith("\n") ? errorText : `${errorText}\n`;
41+
return `${base}Run '${programName} --help' to see available ${noun}.\n`;
42+
}
43+
44+
/**
45+
* Install the help-hint output wrapper on `program` AND every descendant
46+
* command. Commander does NOT propagate a parent's `configureOutput` to
47+
* subcommands attached via `addCommand` (the form `src/index.ts` uses), so a
48+
* root-only hook would miss option typos on `login`, `deploy`, etc. — we walk
49+
* the tree so every command's error output is wrapped. The hint always points at
50+
* the root program name (`playground`), matching git's single generic pointer.
51+
*/
52+
export function installHelpHint(program: Command): void {
53+
const wrap = (cmd: Command): void => {
54+
cmd.configureOutput({
55+
outputError: (str, write) => write(appendHelpHint(str, program.name())),
56+
});
57+
for (const child of cmd.commands) wrap(child);
58+
};
59+
wrap(program);
60+
}

0 commit comments

Comments
 (0)