Skip to content

Commit 71c4324

Browse files
committed
fix(commands): run a definition passed to the dispatcher as given
A name is looked up in the registry; a definition or Command() class is built and run as the caller holds it, registered or not, its first name serving only hooks and reporting.
1 parent 25f15ec commit 71c4324

5 files changed

Lines changed: 82 additions & 49 deletions

File tree

defining-commands.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -872,10 +872,11 @@ that holds an injector can call `CommandsService.runCommand` directly.
872872

873873
### Asking another command
874874

875-
Both methods take the command's registered name, or — the typed way — the
876-
definition or `Command()` class it was registered from, whose first name is
877-
used: `runCommand(prepareCommandDefinition)` cannot go stale the way a string
878-
can.
875+
Both methods take a registered name, or — the typed way — a definition or
876+
`Command()` class. A name is looked up in the registry; a definition runs as
877+
given, whether or not it is registered, so `runCommand(prepareCommandDefinition)`
878+
runs exactly what you hold and cannot go stale the way a string can. Its first
879+
name still identifies it for hooks and reporting.
879880

880881
`CommandsService.canExecuteCommand(command, args)` — or the
881882
`canExecuteCommand` convenience — asks a registered command whether it *could*

lib/common/contracts/commands-service.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ export abstract class CommandsService {
2222
* failure throws instead of exiting, so a process that has to keep running
2323
* can catch it. Analytics do not fire: this is not a new CLI invocation.
2424
*
25-
* `command` is the registered name, or the definition or `Command()` class
26-
* it was registered from — the typed way to refer to a command.
25+
* `command` is a registered name, looked up in the registry, or a
26+
* definition or `Command()` class, which runs as given whether or not it is
27+
* registered — the typed way to refer to a command.
2728
*/
2829
abstract runCommand(
2930
command: CommandReference,

lib/common/define-command.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -763,27 +763,11 @@ export function toCommandDefinition(
763763
}
764764

765765
/**
766-
* What a dispatcher accepts in place of a command name: the name itself, or
767-
* the definition or class it was registered from, whose first name is used.
766+
* What a dispatcher accepts: a registered command's name, or a definition or
767+
* class to run as given.
768768
*/
769769
export type CommandReference = string | RegisterableCommand;
770770

771-
export function commandNameOf(command: CommandReference): string {
772-
if (typeof command === "string") {
773-
return command;
774-
}
775-
776-
const definition = toCommandDefinition(command);
777-
if (!definition) {
778-
throw new Error(
779-
`${describeDefinition(command)} is neither a command name, a ` +
780-
`defineCommand() definition nor a Command() class.`,
781-
);
782-
}
783-
784-
return Array.isArray(definition.name) ? definition.name[0] : definition.name;
785-
}
786-
787771
/**
788772
* The class authoring form: sugar over defineCommand, not a second execution
789773
* path. The returned base carries a `definition` that reads the class it is

lib/common/services/commands-service.ts

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import { injector } from "../yok";
1111
import { IExtensibilityService } from "../definitions/extensibility";
1212
import { IGoogleAnalyticsPageviewData } from "../definitions/google-analytics";
1313
import { CommandsService as CommandsServiceContract } from "../contracts/commands-service";
14-
import { CommandReference, commandNameOf } from "../define-command";
14+
import { CommandReference, toCommandDefinition } from "../define-command";
15+
import { createCommandFromDefinition } from "./command-definition-adapter";
1516
import {
1617
ICommandParameter,
1718
ICommand,
@@ -247,24 +248,28 @@ export class CommandsService
247248
* `checkConsent` may prompt on a terminal the caller has put in raw mode.
248249
*/
249250
public async runCommand(
250-
command: CommandReference,
251+
reference: CommandReference,
251252
commandArguments: string[] = [],
252253
): Promise<void> {
253-
const commandName = commandNameOf(command);
254+
// Known before the lookup, so a failure to resolve reports under the name
255+
// the caller used.
256+
let commandName = typeof reference === "string" ? reference : undefined;
254257
this.inProcessDepth++;
255258
try {
256-
const command = this.$injector.resolveCommand(commandName);
257-
if (!command) {
258-
this.$errors.failWithHelp(
259-
`Unknown command '${helpers.stringReplaceAll(commandName, "|", " ")}'.`,
260-
);
261-
}
259+
const resolved = this.resolveReference(reference);
260+
const command = resolved.command;
261+
commandName = resolved.commandName;
262262

263263
this.commands.push({ commandName, commandArguments });
264264
const restoreOptions = this.primeOptions(command);
265265
try {
266266
if (
267-
!(await this.canExecuteResolvedCommand(commandName, commandArguments))
267+
!(await this.canExecuteResolvedCommand(
268+
commandName,
269+
commandArguments,
270+
undefined,
271+
command,
272+
))
268273
) {
269274
let commandWithArgs = commandName;
270275
if (commandArguments && commandArguments.length) {
@@ -301,25 +306,21 @@ export class CommandsService
301306
* reuse another's precondition without importing its handlers.
302307
*/
303308
public async canExecuteCommand(
304-
command: CommandReference,
309+
reference: CommandReference,
305310
commandArguments: string[] = [],
306311
): Promise<boolean> {
307-
const commandName = commandNameOf(command);
308312
this.inProcessDepth++;
309313
try {
310-
const command = this.$injector.resolveCommand(commandName);
311-
if (!command) {
312-
this.$errors.failWithHelp(
313-
`Unknown command '${helpers.stringReplaceAll(commandName, "|", " ")}'.`,
314-
);
315-
}
314+
const { commandName, command } = this.resolveReference(reference);
316315

317316
this.commands.push({ commandName, commandArguments });
318317
const restoreOptions = this.primeOptions(command);
319318
try {
320319
return await this.canExecuteResolvedCommand(
321320
commandName,
322321
commandArguments,
322+
undefined,
323+
command,
323324
);
324325
} finally {
325326
restoreOptions();
@@ -352,6 +353,42 @@ export class CommandsService
352353
* and the host keeps reading the replacement long after the command is
353354
* done. An in-process dispatch has to put the parser back where it found it.
354355
*/
356+
/**
357+
* A name is looked up in the registry; a definition or class is run as the
358+
* caller holds it, registered or not, so what runs is what was referenced.
359+
* Its first name still identifies it for hooks and reporting.
360+
*/
361+
private resolveReference(reference: CommandReference): {
362+
commandName: string;
363+
command: ICommand;
364+
} {
365+
if (typeof reference === "string") {
366+
const command = this.$injector.resolveCommand(reference);
367+
if (!command) {
368+
this.$errors.failWithHelp(
369+
`Unknown command '${helpers.stringReplaceAll(reference, "|", " ")}'.`,
370+
);
371+
}
372+
373+
return { commandName: reference, command };
374+
}
375+
376+
const definition = toCommandDefinition(reference);
377+
if (!definition) {
378+
throw new Error(
379+
"Expected a command name, a defineCommand() definition or a " +
380+
"Command() class to run.",
381+
);
382+
}
383+
384+
return {
385+
commandName: Array.isArray(definition.name)
386+
? definition.name[0]
387+
: definition.name,
388+
command: createCommandFromDefinition(definition, <any>this.$injector),
389+
};
390+
}
391+
355392
private primeOptions(command: ICommand): () => void {
356393
if (command.isHierarchicalCommand) {
357394
return () => undefined;
@@ -375,8 +412,9 @@ export class CommandsService
375412
commandName: string,
376413
commandArguments: string[],
377414
isDynamicCommand?: boolean,
415+
resolved?: ICommand,
378416
): Promise<boolean> {
379-
const command = this.$injector.resolveCommand(commandName);
417+
const command = resolved || this.$injector.resolveCommand(commandName);
380418
const beautifiedName = helpers.stringReplaceAll(commandName, "|", " ");
381419
if (command) {
382420
// Verify command is enabled

test/define-command.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1434,7 +1434,7 @@ describe("defineCommand", () => {
14341434
assert.isTrue(ran);
14351435
});
14361436

1437-
it("takes the definition or class in place of the name", async () => {
1437+
it("runs a definition or class as given, registered or not", async () => {
14381438
const testInjector = createInProcessInjector();
14391439
const runs: string[] = [];
14401440
const definition = defineCommand({
@@ -1453,22 +1453,31 @@ describe("defineCommand", () => {
14531453
runs.push("class");
14541454
}
14551455
}
1456-
1456+
// Registered under the same name as the definition, to show the
1457+
// definition wins over the lookup.
14571458
runInInjectionContext(testInjector, () => {
1458-
registerCommand(definition);
1459-
registerCommand(RefCommand);
1459+
registerCommand(
1460+
defineCommand({
1461+
name: "dctest-ref-primary",
1462+
arguments: "any",
1463+
run: () => {
1464+
runs.push("registered");
1465+
},
1466+
}),
1467+
);
14601468
});
14611469
const service = testInjector.get(CommandsServiceContract);
14621470

14631471
assert.isTrue(await service.canExecuteCommand(definition, ["ok"]));
14641472
assert.isFalse(await service.canExecuteCommand(definition, ["no"]));
14651473
await service.runCommand(definition, ["ok"]);
14661474
await service.runCommand(RefCommand);
1467-
assert.deepEqual(runs, ["definition", "class"]);
1475+
await service.runCommand("dctest-ref-primary");
1476+
assert.deepEqual(runs, ["definition", "class", "registered"]);
14681477

14691478
await assert.isRejected(
14701479
service.runCommand(<any>{ name: "not-a-definition" }),
1471-
/neither a command name/,
1480+
/Expected a command name/,
14721481
);
14731482
});
14741483

0 commit comments

Comments
 (0)