Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/helpful-command-suggestions.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions src/utils/help-hint.test.ts
Original file line number Diff line number Diff line change
@@ -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 <value>' 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");
});
});
60 changes: 60 additions & 0 deletions src/utils/help-hint.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading