Skip to content

Commit 3927d99

Browse files
committed
fix(commands): route parent names in process and fire the full hook name
A parent name handed to runCommand resolved to the synthesized hierarchical dispatcher, whose execute re-enters through the command line's entry point: it exits the process on failure, tracks analytics and consent, and fires hooks twice. The two shipped call sites, "autocomplete" from post-install and "device" from the device log stream, went that way. The dispatcher now routes a parent name to its subcommand with the same rules the command line uses, and dispatches that subcommand in process with the remaining arguments. An in-process dispatch of a subcommand also only fired the truncated hook name (before-open for open|ios), while the command line fires the full name around it. runCommand now fires before-/after-<full name> around the subcommand's own pair, in the command line's order, so a project's before-open-ios hook runs however the command was reached.
1 parent b5cc572 commit 3927d99

6 files changed

Lines changed: 442 additions & 39 deletions

File tree

‎defining-commands.md‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -866,8 +866,9 @@ running afterwards:
866866
the caller decides what happens next.
867867
- **Analytics do not fire.** An in-process dispatch is not a new invocation of
868868
the CLI, and the consent check can prompt on a terminal the caller has put
869-
into raw mode. Hooks do fire: a project's `before-open-ios` hook is part of
870-
what `open|ios` means, however the command was reached.
869+
into raw mode. Hooks do fire, under the same names the command line fires:
870+
`open|ios` fires `before-open-ios` and then `before-open` (and `after-open`,
871+
`after-open-ios` on the way out), however the command was reached.
871872

872873
The options service is put back the way it was found. Merging a command's
873874
declarations into it rewrites the values the host process is still running on
@@ -882,7 +883,10 @@ that holds an injector can call `CommandsService.runCommand` directly.
882883
### Asking another command
883884

884885
Both methods take a registered name, or — the typed way — a definition or
885-
`Command()` class. A name is looked up in the registry; a definition runs as
886+
`Command()` class. A name is looked up in the registry, and a parent name is
887+
routed to its subcommand the way the command line routes it —
888+
`runCommand("device")` runs `device|*list`, `runCommand("device", ["log"])`
889+
runs `device|log`; a definition runs as
886890
given, whether or not it is registered, so `runCommand(prepareCommandDefinition)`
887891
runs exactly what you hold and cannot go stale the way a string can. Its first
888892
name still identifies it for hooks and reporting.
@@ -907,17 +911,17 @@ This is how one command builds on another's precondition. `embed` prepares the
907911
project, so "could `embed` run" starts with "could `prepare` run" — and the way
908912
to ask that is to ask `prepare`, not to import its `canExecute` and hand it
909913
services. The named command is resolved and its options primed exactly as
910-
`runCommand` does, then its own `canExecute` returns the verdict. It builds its
911-
own setup from its own services; nothing crosses between the two commands but
912-
the name and the arguments.
914+
`runCommand` does, then its own `canExecute` returns its verdict or throws. It
915+
builds its own setup from its own services; nothing crosses between the two
916+
commands but the name and the arguments.
913917

914918
Pass only the arguments the child's own `arguments` policy accepts. The child
915919
enforces that policy before its `canExecute`, so forwarding a caller's whole
916920
argument list to a child that declares fewer is a rejection, not a wider check.
917921

918-
`canExecuteCommand` is a thin call onto
919-
`CommandsService.canExecuteCommandInProcess`, and follows `runCommand` in
920-
everything else: the same injector rule, the same option priming and
922+
`canExecuteCommand` is a thin call onto `CommandsService.canExecuteCommand`
923+
(which the deprecated `canExecuteCommandInProcess` also calls), and follows
924+
`runCommand` in everything else: the same injector rule, the same option priming and
921925
restoration.
922926

923927
### Key shortcuts

‎lib/common/contracts/commands-service.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ export abstract class CommandsService {
2424
*
2525
* `command` is a registered name, looked up in the registry, or a
2626
* definition or `Command()` class, which runs as given whether or not it is
27-
* registered — the typed way to refer to a command.
27+
* registered — the typed way to refer to a command. A parent name is routed
28+
* to its subcommand as the command line routes it, and a subcommand fires
29+
* its full hook name (`before-open-ios`) as well as its parent's.
2830
*/
2931
abstract runCommand(
3032
command: CommandReference,
@@ -34,7 +36,8 @@ export abstract class CommandsService {
3436
/**
3537
* Asks a registered command whether it could run on `args`, without running
3638
* it. The command is resolved and its options primed exactly as for
37-
* `runCommand`, and its own `canExecute` returns the verdict. The child
39+
* `runCommand`, and its own `canExecute` returns its verdict or throws — an
40+
* arguments-policy violation, a setup failure or `ctx.fail`. The child
3841
* builds its own setup from its own services, so nothing crosses between
3942
* the two but the name and the arguments; pass only the arguments the
4043
* child's own `arguments` policy accepts.

‎lib/common/definitions/commands-service.d.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ interface ICommandsService {
22
currentCommandData: ICommandData;
33
/**
44
* Whether the command running right now was dispatched by
5-
* executeCommandInProcess rather than by the command line — what tells a
5+
* runCommand rather than by the command line — what tells a
66
* command that it is borrowing a host process instead of owning one.
77
*/
88
readonly isExecutingInProcess: boolean;

‎lib/common/services/commands-service.ts‎

Lines changed: 106 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
const jaroWinklerDistance = require("../vendor/jaro-winkler_distance");
22
import * as helpers from "../helpers";
3-
import { CommandsDelimiters } from "../constants";
3+
import {
4+
CommandsDelimiters,
5+
ERROR_NO_VALID_SUBCOMMAND_FORMAT,
6+
} from "../constants";
47
import { EOL } from "os";
58
import * as _ from "lodash";
69
import { IOptions, IOptionsTracker } from "../../declarations";
@@ -121,9 +124,7 @@ export class CommandsService
121124
await this.$optionsTracker.trackOptions(this.$options);
122125
}
123126

124-
const shouldExecuteHooks =
125-
!this.$staticConfig.disableCommandHooks &&
126-
(command.enableHooks === undefined || command.enableHooks === true);
127+
const shouldExecuteHooks = this.shouldExecuteHooks(command);
127128
let hookCommandName = commandName;
128129
if (shouldExecuteHooks) {
129130
// Handle correctly hierarchical commands
@@ -132,15 +133,8 @@ export class CommandsService
132133
commandArguments,
133134
);
134135
if (hierarchicalCommandName) {
135-
hookCommandName = helpers.stringReplaceAll(
136+
hookCommandName = this.toHookCommandName(
136137
hierarchicalCommandName.commandName,
137-
CommandsDelimiters.DefaultHierarchicalCommand,
138-
CommandsDelimiters.HooksCommand,
139-
);
140-
hookCommandName = helpers.stringReplaceAll(
141-
hookCommandName,
142-
CommandsDelimiters.HierarchicalCommand,
143-
CommandsDelimiters.HooksCommand,
144138
);
145139
}
146140

@@ -157,6 +151,57 @@ export class CommandsService
157151
}
158152
}
159153

154+
private toHookCommandName(commandName: string): string {
155+
const hookCommandName = helpers.stringReplaceAll(
156+
commandName,
157+
CommandsDelimiters.DefaultHierarchicalCommand,
158+
CommandsDelimiters.HooksCommand,
159+
);
160+
return helpers.stringReplaceAll(
161+
hookCommandName,
162+
CommandsDelimiters.HierarchicalCommand,
163+
CommandsDelimiters.HooksCommand,
164+
);
165+
}
166+
167+
/**
168+
* The command line reaches a subcommand through its parent's dispatcher,
169+
* which fires the subcommand's full hook name (`before-open-ios`) around
170+
* the subcommand's own dispatch, whose name the hooks service truncates at
171+
* the `|` (`before-open`). An in-process dispatch goes straight to the
172+
* subcommand, so it fires the outer pair itself.
173+
*/
174+
private async runResolvedCommandInProcess(
175+
command: ICommand,
176+
commandName: string,
177+
commandArguments: string[],
178+
): Promise<void> {
179+
const subcommandHookName =
180+
commandName.includes(CommandsDelimiters.HierarchicalCommand) &&
181+
this.shouldExecuteHooks(command)
182+
? this.toHookCommandName(commandName)
183+
: undefined;
184+
185+
if (subcommandHookName) {
186+
await this.$hooksService.executeBeforeHooks(subcommandHookName);
187+
}
188+
189+
await this.runResolvedCommand(command, commandName, commandArguments, {
190+
trackAnalytics: false,
191+
});
192+
193+
if (subcommandHookName) {
194+
await this.$hooksService.executeAfterHooks(subcommandHookName);
195+
}
196+
}
197+
198+
private shouldExecuteHooks(command: ICommand): boolean {
199+
return (
200+
!this.$staticConfig.disableCommandHooks &&
201+
(command.enableHooks === undefined || command.enableHooks === true)
202+
);
203+
}
204+
160205
private printHelpSuggestion(commandName?: string): Promise<void> {
161206
const command = commandName
162207
? helpers.stringReplaceAll(
@@ -256,9 +301,10 @@ export class CommandsService
256301
let commandName = typeof reference === "string" ? reference : undefined;
257302
this.inProcessDepth++;
258303
try {
259-
const resolved = this.resolveReference(reference);
304+
const resolved = this.resolveReference(reference, commandArguments);
260305
const command = resolved.command;
261306
commandName = resolved.commandName;
307+
commandArguments = resolved.commandArguments;
262308

263309
this.commands.push({ commandName, commandArguments });
264310
const restoreOptions = this.primeOptions(command);
@@ -280,9 +326,11 @@ export class CommandsService
280326
);
281327
}
282328

283-
await this.runResolvedCommand(command, commandName, commandArguments, {
284-
trackAnalytics: false,
285-
});
329+
await this.runResolvedCommandInProcess(
330+
command,
331+
commandName,
332+
commandArguments,
333+
);
286334
} finally {
287335
restoreOptions();
288336
this.commands.pop();
@@ -301,7 +349,7 @@ export class CommandsService
301349
/**
302350
* The `canExecute` half of {@link runCommand}: the named command is resolved
303351
* and its options are primed the same way, and its own `canExecute` returns
304-
* the verdict. The child builds its own setup from its own services —
352+
* its verdict or throws. The child builds its own setup from its own services —
305353
* nothing is threaded in from the caller — which is what lets one command
306354
* reuse another's precondition without importing its handlers.
307355
*/
@@ -311,7 +359,9 @@ export class CommandsService
311359
): Promise<boolean> {
312360
this.inProcessDepth++;
313361
try {
314-
const { commandName, command } = this.resolveReference(reference);
362+
const resolved = this.resolveReference(reference, commandArguments);
363+
const { commandName, command } = resolved;
364+
commandArguments = resolved.commandArguments;
315365

316366
this.commands.push({ commandName, commandArguments });
317367
const restoreOptions = this.primeOptions(command);
@@ -347,20 +397,22 @@ export class CommandsService
347397
return this.canExecuteCommand(commandName, commandArguments);
348398
}
349399

350-
/**
351-
* Merging a command's options into the parser rewrites the values the host
352-
* process is still running on: a declared default replaces the CLI-wide one
353-
* and the host keeps reading the replacement long after the command is
354-
* done. An in-process dispatch has to put the parser back where it found it.
355-
*/
356400
/**
357401
* A name is looked up in the registry; a definition or class is run as the
358402
* caller holds it, registered or not, so what runs is what was referenced.
359403
* Its first name still identifies it for hooks and reporting.
404+
*
405+
* A parent name is routed to its subcommand here rather than run: the
406+
* parent's synthesized dispatcher re-enters through `tryExecuteCommand`,
407+
* which exits the process on failure and tracks analytics.
360408
*/
361-
private resolveReference(reference: CommandReference): {
409+
private resolveReference(
410+
reference: CommandReference,
411+
commandArguments: string[],
412+
): {
362413
commandName: string;
363414
command: ICommand;
415+
commandArguments: string[];
364416
} {
365417
if (typeof reference === "string") {
366418
const command = this.$injector.resolveCommand(reference);
@@ -370,7 +422,28 @@ export class CommandsService
370422
);
371423
}
372424

373-
return { commandName: reference, command };
425+
if (command.isHierarchicalCommand) {
426+
const subcommand = this.$injector.buildHierarchicalCommand(
427+
reference,
428+
commandArguments,
429+
);
430+
const subcommandInstance =
431+
subcommand && this.$injector.resolveCommand(subcommand.commandName);
432+
if (!subcommandInstance) {
433+
this.$errors.failWithHelp(
434+
ERROR_NO_VALID_SUBCOMMAND_FORMAT,
435+
reference,
436+
);
437+
}
438+
439+
return {
440+
commandName: subcommand.commandName,
441+
command: subcommandInstance,
442+
commandArguments: subcommand.remainingArguments,
443+
};
444+
}
445+
446+
return { commandName: reference, command, commandArguments };
374447
}
375448

376449
const definition = toCommandDefinition(reference);
@@ -386,9 +459,16 @@ export class CommandsService
386459
? definition.name[0]
387460
: definition.name,
388461
command: createCommandFromDefinition(definition, <any>this.$injector),
462+
commandArguments,
389463
};
390464
}
391465

466+
/**
467+
* Merging a command's options into the parser rewrites the values the host
468+
* process is still running on: a declared default replaces the CLI-wide one
469+
* and the host keeps reading the replacement long after the command is
470+
* done. An in-process dispatch has to put the parser back where it found it.
471+
*/
392472
private primeOptions(command: ICommand): () => void {
393473
if (command.isHierarchicalCommand) {
394474
return () => undefined;

‎test/commands-service.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,12 @@ describe("commands-service", () => {
336336

337337
await service.executeCommandInProcess("open|ios");
338338

339-
assert.deepEqual(record.hooks, ["before:open|ios", "after:open|ios"]);
339+
assert.deepEqual(record.hooks, [
340+
"before:open-ios",
341+
"before:open|ios",
342+
"after:open|ios",
343+
"after:open-ios",
344+
]);
340345
assert.deepEqual(record.analytics, []);
341346
});
342347
});

0 commit comments

Comments
 (0)