From fcf22698c02f1998828487ec47545593742bf65c Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Sat, 13 Jun 2026 16:55:04 +0200 Subject: [PATCH] feat(cli): point to --help when a typo has no "did you mean" suggestion Commander already suggests near-miss commands/options, but when a typo is too far for a suggestion it emits a bare "unknown command/option" error with no next step. Tail those with a pointer to `playground --help`, matching git's behavior. Walks the command tree because commander does not propagate the root output config to addCommand'd subcommands. --- .changeset/helpful-command-suggestions.md | 5 + src/index.ts | 8 ++ src/utils/help-hint.test.ts | 109 ++++++++++++++++++++++ src/utils/help-hint.ts | 60 ++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 .changeset/helpful-command-suggestions.md create mode 100644 src/utils/help-hint.test.ts create mode 100644 src/utils/help-hint.ts diff --git a/.changeset/helpful-command-suggestions.md b/.changeset/helpful-command-suggestions.md new file mode 100644 index 00000000..245cb530 --- /dev/null +++ b/.changeset/helpful-command-suggestions.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +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. diff --git a/src/index.ts b/src/index.ts index 6c695b02..27811b31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ import { onProcessShutdown, setProcessGuardWarningHandler, } from "./utils/process-guard.js"; +import { installHelpHint } from "./utils/help-hint.js"; import { clearWindowTitle } from "./utils/ui/theme/window-title.js"; import { startVersionCheck } from "./utils/version-check.js"; @@ -140,6 +141,13 @@ program.addCommand(decentralizeCommand); program.addCommand(logoutCommand); program.addCommand(updateCommand); +// Commander already suggests "Did you mean …?" for near-miss typos. When the +// typo is too far for a suggestion it emits a bare "unknown command/option" +// error; tail it with a pointer to `--help` (git does the same). Must run AFTER +// the addCommand calls above so the walk reaches every subcommand — commander +// does not propagate the root output config to addCommand'd children. +installHelpHint(program); + // Kick off the "is there a newer dot release?" check immediately so the // jsDelivr fetch races the command rather than tacking onto its tail. The // banner (if any) is printed in the `finally` once the user-visible work diff --git a/src/utils/help-hint.test.ts b/src/utils/help-hint.test.ts new file mode 100644 index 00000000..957c9f45 --- /dev/null +++ b/src/utils/help-hint.test.ts @@ -0,0 +1,109 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Command } from "commander"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { appendHelpHint, installHelpHint } from "./help-hint.js"; + +describe("appendHelpHint", () => { + it("appends a commands hint when an unknown command has no suggestion", () => { + const out = appendHelpHint("error: unknown command 'xyzzy'\n", "playground"); + expect(out).toBe( + "error: unknown command 'xyzzy'\nRun 'playground --help' to see available commands.\n", + ); + }); + + it("appends an options hint when an unknown option has no suggestion", () => { + const out = appendHelpHint("error: unknown option '--zzz'\n", "playground"); + expect(out).toBe( + "error: unknown option '--zzz'\nRun 'playground --help' to see available options.\n", + ); + }); + + it("leaves an unknown command untouched when commander already suggested one", () => { + const input = "error: unknown command 'loign'\n(Did you mean login?)\n"; + expect(appendHelpHint(input, "playground")).toBe(input); + }); + + it("leaves an unknown option untouched when commander already suggested one", () => { + const input = "error: unknown option '--yse'\n(Did you mean --yes?)\n"; + expect(appendHelpHint(input, "playground")).toBe(input); + }); + + it("leaves unrelated errors untouched", () => { + const input = "error: required option '--name ' not specified\n"; + expect(appendHelpHint(input, "playground")).toBe(input); + }); + + it("uses the supplied program name in the hint", () => { + const out = appendHelpHint("error: unknown command 'nope'\n", "pg"); + expect(out).toBe( + "error: unknown command 'nope'\nRun 'pg --help' to see available commands.\n", + ); + }); + + it("appends a trailing newline before the hint when the error text lacks one", () => { + const out = appendHelpHint("error: unknown command 'nope'", "playground"); + expect(out).toBe( + "error: unknown command 'nope'\nRun 'playground --help' to see available commands.\n", + ); + }); +}); + +describe("installHelpHint", () => { + afterEach(() => vi.restoreAllMocks()); + + function buildProgram(): Command { + const program = new Command().name("playground").exitOverride(); + const login = new Command("login").option("--yes").exitOverride(); + // addCommand (not .command()) mirrors src/index.ts; commander does NOT + // propagate the root output config to addCommand'd subcommands, which is + // exactly why installHelpHint must walk the tree. + program.addCommand(login); + installHelpHint(program); + return program; + } + + function captureStderr(): { read: () => string } { + let buf = ""; + vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { + buf += chunk.toString(); + return true; + }); + return { read: () => buf }; + } + + it("adds the hint to a root-level unknown command with no suggestion", () => { + const program = buildProgram(); + const stderr = captureStderr(); + expect(() => program.parse(["xyzzy"], { from: "user" })).toThrow(); + expect(stderr.read()).toContain("Run 'playground --help' to see available commands."); + }); + + it("adds the hint to a SUBCOMMAND unknown option with no suggestion", () => { + const program = buildProgram(); + const stderr = captureStderr(); + expect(() => program.parse(["login", "--zzzzzz"], { from: "user" })).toThrow(); + expect(stderr.read()).toContain("Run 'playground --help' to see available options."); + }); + + it("does not add the hint when the subcommand option has a suggestion", () => { + const program = buildProgram(); + const stderr = captureStderr(); + expect(() => program.parse(["login", "--yse"], { from: "user" })).toThrow(); + expect(stderr.read()).toContain("Did you mean --yes?"); + expect(stderr.read()).not.toContain("--help"); + }); +}); diff --git a/src/utils/help-hint.ts b/src/utils/help-hint.ts new file mode 100644 index 00000000..be282cd4 --- /dev/null +++ b/src/utils/help-hint.ts @@ -0,0 +1,60 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Command } from "commander"; + +/** + * Commander v12 already prints a Levenshtein "Did you mean …?" suggestion for an + * unknown command or option (`showSuggestionAfterError`, on by default). But when + * the typo is too far from any real name it can't suggest anything and emits a + * bare `error: unknown command 'xyzzy'` with no next step. `git` always tails its + * equivalent error with `See 'git --help'`; this mirrors that. + * + * Wired via `installHelpHint(program)` in `src/index.ts`. We append a help + * pointer ONLY for unknown command/option errors that commander left without a + * suggestion; every other error (already-suggested, missing-required-option, + * invalid-argument, …) passes through untouched. + */ +export function appendHelpHint(errorText: string, programName: string): string { + if (errorText.includes("Did you mean")) return errorText; + + const noun = errorText.includes("unknown command") + ? "commands" + : errorText.includes("unknown option") + ? "options" + : null; + if (noun === null) return errorText; + + const base = errorText.endsWith("\n") ? errorText : `${errorText}\n`; + return `${base}Run '${programName} --help' to see available ${noun}.\n`; +} + +/** + * Install the help-hint output wrapper on `program` AND every descendant + * command. Commander does NOT propagate a parent's `configureOutput` to + * subcommands attached via `addCommand` (the form `src/index.ts` uses), so a + * root-only hook would miss option typos on `login`, `deploy`, etc. — we walk + * the tree so every command's error output is wrapped. The hint always points at + * the root program name (`playground`), matching git's single generic pointer. + */ +export function installHelpHint(program: Command): void { + const wrap = (cmd: Command): void => { + cmd.configureOutput({ + outputError: (str, write) => write(appendHelpHint(str, program.name())), + }); + for (const child of cmd.commands) wrap(child); + }; + wrap(program); +}