Skip to content

Commit d712a4e

Browse files
committed
feat(commands): add ctx.fail and warn on colliding option aliases
`ctx.fail(message)` is the failure verb on the command context, in both `run` and `canExecute`. It maps to the errors service's `failWithHelp`, so a command failure carries the usage suggestion, and returns `never` so it can end a branch without a return. The message is validated like the define-time errors are, naming the command. Throwing keeps working unchanged — fail() is sugar over it, not a replacement. Commands get no `skip()`: warn-and-continue has no meaning inside run(). The CLI-wide option collision warning now covers aliases on both sides, so an `alias: "p"` that shadows `--path`'s shorthand is reported the same way a `verbose` option shadowing `--verbose` is, naming both sides.
1 parent 8160822 commit d712a4e

5 files changed

Lines changed: 209 additions & 33 deletions

File tree

defining-commands.md

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,11 @@ const buildOptions = {
155155
the CLI itself. Declaring one of those names in a command's schema makes the
156156
command's declaration win for the duration of that command, which means the
157157
same flag means different things depending on which command is running. The CLI
158-
warns at registration naming the collision; pick another name.
158+
warns at registration naming both sides of the collision; pick another name.
159+
160+
Aliases count too, in both directions: an `alias: "p"` collides with `--path`'s
161+
shorthand just as `output: stringOption()` would collide with a CLI-wide
162+
`--output`.
159163

160164
### How validation behaves
161165

@@ -211,10 +215,10 @@ arguments even when it supplies a `canExecute`, and a `canExecute` that only
211215
inspects options cannot accidentally widen what the command accepts.
212216

213217
`canExecute` receives a context of the same shape as `run`'s — the same
214-
`args` and the same declared options, built freshly for the call — and returns
215-
a boolean (or a promise of one). Returning `false` aborts the command and
216-
prints a help suggestion; throwing surfaces your own error message, which is
217-
usually the friendlier choice.
218+
`args`, the same declared options and the same `fail` — built freshly for the
219+
call, and returns a boolean (or a promise of one). Returning `false` aborts the
220+
command and prints a bare help suggestion; `ctx.fail(message)` aborts it with
221+
your own message, which is usually the friendlier choice.
218222

219223
`canExecute` runs inside a dependency-injection context, on the same terms as
220224
`run`: `inject()` is valid up to the first `await`.
@@ -228,11 +232,38 @@ The run context
228232
(including any subcommand segments) has been consumed.
229233
- `ctx.options` — the current value of each declared option, read at the moment
230234
the command executes.
235+
- `ctx.fail(message)` — fails the command with `message` and a usage help
236+
suggestion.
231237

232238
`run` may be synchronous or `async`; the CLI awaits the result and treats a
233-
rejection as a command failure. Throwing is how a definition fails a command;
234-
`$errors.failWithHelp` from the injected `errors` service adds the help
235-
suggestion.
239+
rejection as a command failure.
240+
241+
### Failing a command
242+
243+
`ctx.fail(message)` is the idiomatic way to stop a command:
244+
245+
```ts
246+
defineCommand({
247+
name: "widget|add",
248+
arguments: "any",
249+
options: { output: stringOption() },
250+
async run(ctx) {
251+
if (!ctx.options.output) {
252+
ctx.fail("--output is required.");
253+
}
254+
255+
/* ... */
256+
},
257+
});
258+
```
259+
260+
It is available on the `canExecute` context as well, and it returns `never`, so
261+
it can end a branch without a `return`. The message must be a non-empty string.
262+
263+
Throwing is equivalent and keeps working — `ctx.fail` is sugar over the
264+
`errors` service's `failWithHelp`, which is what adds the "Run `ns widget add
265+
--help`" line. Throw when you already have an `Error` to propagate; call
266+
`ctx.fail` when you are writing the message.
236267

237268
`run` starts inside a dependency-injection context, so `inject()` works
238269
directly:

lib/common/define-command.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export interface CommandContext<TSchema extends CommandOptionsSchema = {}> {
7070
args: string[];
7171
/** Current value of every option declared in the schema, and nothing else. */
7272
options: CommandOptionValues<TSchema>;
73+
/** Fails the command with `message` and the usage help suggestion. */
74+
fail(message: string): never;
7375
}
7476

7577
export interface CommandDefinition<TSchema extends CommandOptionsSchema = {}> {

lib/common/services/command-definition-adapter.ts

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { OptionType } from "../enums";
22
import { injector } from "../yok";
33
import { runInInjectionContext } from "../di/inject";
4-
import { IDictionary, IDashedOption } from "../declarations";
4+
import { IDictionary, IDashedOption, IErrors } from "../declarations";
55
import { IInjector } from "../definitions/yok";
66
import { ICommand } from "../definitions/commands";
77
import { CommandRegistry } from "../contracts/command-registry";
@@ -51,23 +51,51 @@ const compileOptions = (
5151
return dashedOptions;
5252
};
5353

54+
const aliasList = (alias: string | string[]): string[] =>
55+
alias === undefined ? [] : Array.isArray(alias) ? alias : [alias];
56+
5457
/**
5558
* A command option that shadows a CLI-wide one wins the re-parse for this
56-
* command only, so the two spellings mean different things depending on what
57-
* the user typed first. Warned rather than rejected while the policy is open.
59+
* command only, so the same spelling means different things depending on which
60+
* command is running. Warned rather than rejected while the policy is open.
5861
*/
5962
const warnOnCliOptionCollisions = (
6063
targetInjector: IInjector,
6164
definition: CommandDefinition<any>,
62-
optionNames: string[],
65+
schema: CommandOptionsSchema,
6366
optionsService: any,
6467
): void => {
6568
const cliOptions = optionsService && optionsService.options;
6669
if (!cliOptions) {
6770
return;
6871
}
6972

70-
const collisions = optionNames.filter((optionName) => cliOptions[optionName]);
73+
// Every spelling the CLI already answers to, mapped to the option owning it.
74+
const cliSpellings: IDictionary<string> = {};
75+
for (const cliName of Object.keys(cliOptions)) {
76+
cliSpellings[cliName] = cliName;
77+
for (const alias of aliasList(cliOptions[cliName].alias)) {
78+
cliSpellings[alias] = cliName;
79+
}
80+
}
81+
82+
const collisions: string[] = [];
83+
for (const optionName of Object.keys(schema)) {
84+
if (cliSpellings[optionName]) {
85+
collisions.push(
86+
`'--${optionName}' with the CLI option '--${cliSpellings[optionName]}'`,
87+
);
88+
}
89+
90+
for (const alias of aliasList(schema[optionName].alias)) {
91+
if (cliSpellings[alias]) {
92+
collisions.push(
93+
`alias '-${alias}' of '--${optionName}' with the CLI option '--${cliSpellings[alias]}'`,
94+
);
95+
}
96+
}
97+
}
98+
7199
if (!collisions.length) {
72100
return;
73101
}
@@ -81,10 +109,9 @@ const warnOnCliOptionCollisions = (
81109
? definition.name[0]
82110
: definition.name;
83111
logger.warn(
84-
`Command '${commandName}' declares option(s) ${collisions
85-
.map((name) => `'--${name}'`)
86-
.join(", ")} that the CLI already defines globally. The command's ` +
87-
`declaration wins while the command runs; rename them to avoid it.`,
112+
`Command '${commandName}' declares options that collide with CLI-wide ` +
113+
`ones: ${collisions.join("; ")}. The command's declaration wins while ` +
114+
`the command runs; rename them to avoid it.`,
88115
);
89116
};
90117

@@ -113,12 +140,22 @@ export function createCommandFromDefinition<
113140
? targetInjector.resolve("options")
114141
: null;
115142

116-
warnOnCliOptionCollisions(
117-
targetInjector,
118-
definition,
119-
optionNames,
120-
optionsService,
121-
);
143+
warnOnCliOptionCollisions(targetInjector, definition, schema, optionsService);
144+
145+
const commandName = Array.isArray(definition.name)
146+
? definition.name[0]
147+
: definition.name;
148+
149+
const fail = (message: string): never => {
150+
if (typeof message !== "string" || !message.trim()) {
151+
throw new Error(
152+
`ctx.fail() for command '${commandName}' requires a non-empty message.`,
153+
);
154+
}
155+
156+
const errors: IErrors = targetInjector.resolve("errors");
157+
return errors.failWithHelp(message);
158+
};
122159

123160
// Read per call rather than snapshotted here: the options service only holds
124161
// this command's parsed values once validateOptions has run for it.
@@ -128,7 +165,7 @@ export function createCommandFromDefinition<
128165
options[optionName] = optionsService[optionName];
129166
}
130167

131-
return { args, options };
168+
return { args, options, fail };
132169
};
133170

134171
const acceptsArguments = definition.arguments === "any";
@@ -144,10 +181,7 @@ export function createCommandFromDefinition<
144181
: { enableHooks: definition.enableHooks }),
145182
canExecute: async (args: string[]): Promise<boolean> => {
146183
if (!acceptsArguments && args.length) {
147-
targetInjector
148-
.resolve("errors")
149-
.failWithHelp("This command doesn't accept parameters.");
150-
return false;
184+
fail("This command doesn't accept parameters.");
151185
}
152186

153187
const refine = definition.canExecute;

test/define-command.ts

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -496,26 +496,58 @@ describe("defineCommand", () => {
496496
assert.deepEqual(command.dashedOptions, {});
497497
});
498498

499-
it("warns when a declared option shadows a CLI-wide one", () => {
499+
it("warns when a declared option or alias shadows a CLI-wide one", () => {
500500
const testInjector = createTestInjector({
501-
options: { verbose: { type: "boolean" } },
501+
options: {
502+
verbose: { type: "boolean" },
503+
path: { type: "string", alias: "p" },
504+
},
502505
});
503506

504507
createCommandFromDefinition(
505508
defineCommand({
506509
name: "dctestshadow",
507-
options: { verbose: booleanOption(), fresh: booleanOption() },
510+
options: {
511+
verbose: booleanOption(),
512+
output: stringOption({ alias: ["p", "o"] }),
513+
fresh: booleanOption({ alias: "f" }),
514+
},
508515
run: (): void => undefined,
509516
}),
510517
testInjector,
511518
);
512519

513520
const logger: LoggerStub = testInjector.resolve("logger");
514-
assert.match(
521+
assert.include(
522+
logger.warnOutput,
523+
"'--verbose' with the CLI option '--verbose'",
524+
);
525+
assert.include(
515526
logger.warnOutput,
516-
/Command 'dctestshadow' declares option\(s\) '--verbose' that the CLI already defines globally/,
527+
"alias '-p' of '--output' with the CLI option '--path'",
517528
);
518529
assert.notInclude(logger.warnOutput, "--fresh");
530+
assert.notInclude(logger.warnOutput, "'-o'");
531+
});
532+
533+
it("stays quiet when nothing collides", () => {
534+
const testInjector = createTestInjector({
535+
options: { path: { type: "string", alias: "p" } },
536+
});
537+
538+
createCommandFromDefinition(
539+
defineCommand({
540+
name: "dctestnoshadow",
541+
options: { output: stringOption({ alias: ["o", "out"] }) },
542+
run: (): void => undefined,
543+
}),
544+
testInjector,
545+
);
546+
547+
assert.strictEqual(
548+
(<LoggerStub>testInjector.resolve("logger")).warnOutput,
549+
"",
550+
);
519551
});
520552
});
521553

@@ -617,6 +649,81 @@ describe("defineCommand", () => {
617649
});
618650
});
619651

652+
describe("ctx.fail", () => {
653+
const createFailInjector = (): IInjector => {
654+
const testInjector = createTestInjector();
655+
testInjector.register("errors", {
656+
failWithHelp: (message: string) => {
657+
throw new Error(`with help: ${message}`);
658+
},
659+
});
660+
return testInjector;
661+
};
662+
663+
it("fails the command from run, through failWithHelp", async () => {
664+
const command = createCommandFromDefinition(
665+
defineCommand({
666+
name: "dctestfailrun",
667+
run: (ctx) => ctx.fail("no project found"),
668+
}),
669+
createFailInjector(),
670+
);
671+
672+
await assert.isRejected(
673+
command.execute([]),
674+
/with help: no project found/,
675+
);
676+
});
677+
678+
it("fails the command from canExecute, through failWithHelp", async () => {
679+
const command = createCommandFromDefinition(
680+
defineCommand({
681+
name: "dctestfailcan",
682+
arguments: "any",
683+
canExecute: (ctx) =>
684+
ctx.args.length === 1 || ctx.fail("expected one argument"),
685+
run: (): void => undefined,
686+
}),
687+
createFailInjector(),
688+
);
689+
690+
assert.isTrue(await command.canExecute(["one"]));
691+
await assert.isRejected(
692+
command.canExecute([]),
693+
/with help: expected one argument/,
694+
);
695+
});
696+
697+
it("rejects a message that carries nothing", async () => {
698+
const command = createCommandFromDefinition(
699+
defineCommand({
700+
name: "dctestfailempty",
701+
run: (ctx) => ctx.fail(" "),
702+
}),
703+
createFailInjector(),
704+
);
705+
706+
await assert.isRejected(
707+
command.execute([]),
708+
/ctx.fail\(\) for command 'dctestfailempty' requires a non-empty message/,
709+
);
710+
});
711+
712+
it("still lets a thrown error through unchanged", async () => {
713+
const command = createCommandFromDefinition(
714+
defineCommand({
715+
name: "dctestthrow",
716+
run: () => {
717+
throw new Error("raw failure");
718+
},
719+
}),
720+
createFailInjector(),
721+
);
722+
723+
await assert.isRejected(command.execute([]), /^raw failure$/);
724+
});
725+
});
726+
620727
describe("command flags", () => {
621728
it("passes disableAnalytics and enableHooks through", () => {
622729
const command = createCommandFromDefinition(

test/type-fixtures/define-command-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ defineCommand({
5555
name: "typefixture|no-options",
5656
run(ctx) {
5757
expectExactType<IsExact<typeof ctx.args, string[]>>();
58+
// `never` is what lets fail() end a branch without a return.
59+
expectExactType<IsExact<ReturnType<typeof ctx.fail>, never>>();
5860

5961
// @ts-expect-error - nothing is declared, so any access is a typo
6062
ctx.options.anything;

0 commit comments

Comments
 (0)