Skip to content

Commit 25f15ec

Browse files
committed
feat(commands): make the in-process dispatcher a contract
CommandsService is the API a command or plugin runs or consults another command through: runCommand and canExecuteCommand take the registered name or the definition or class it was registered from. The free helpers stay as convenience over it; the *InProcess methods are deprecated.
1 parent 0158722 commit 25f15ec

15 files changed

Lines changed: 277 additions & 70 deletions

File tree

defining-commands.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -824,12 +824,20 @@ per-platform command subclasses a shared base to override one field.
824824
Running a command in process
825825
----------------------------
826826

827-
`runCommand` dispatches a registered command from inside the process that is
828-
already running:
827+
The `CommandsService` contract dispatches a registered command from inside the
828+
process that is already running. A class command injects it like any other
829+
service; an inline handler or a key shortcut may use the `runCommand`
830+
convenience, which only resolves the contract from the current context:
829831

830832
```ts
833+
import { CommandsService } from "../common/contracts/commands-service";
831834
import { runCommand } from "../common/services/command-definition-adapter";
832835

836+
// in a class command
837+
private $commandsService = inject(CommandsService);
838+
await this.$commandsService.runCommand("autocomplete");
839+
840+
// in an inline handler or a shortcut action
833841
await runCommand("open|ios");
834842
await runCommand("install", ["lodash"]);
835843
```
@@ -857,14 +865,20 @@ declarations into it rewrites the values the host process is still running on
857865
`open|ios` declares `watch: false`, which would otherwise leave an `ns start`
858866
out of watch mode for the rest of its life.
859867

860-
Which injector it dispatches through follows the rule `registerCommand` does:
861-
the injector of the current injection context, and the CLI's own outside one.
862-
`runCommand` is a thin call onto `CommandsService.executeCommandInProcess`,
863-
where the pipeline itself lives.
868+
Which injector `runCommand` dispatches through follows the rule
869+
`registerCommand` does: the injector of the current injection context, and the
870+
CLI's own outside one. The pipeline itself lives on the contract, so a plugin
871+
that holds an injector can call `CommandsService.runCommand` directly.
864872

865873
### Asking another command
866874

867-
`canExecuteCommand(name, args)` asks a registered command whether it *could*
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.
879+
880+
`CommandsService.canExecuteCommand(command, args)` — or the
881+
`canExecuteCommand` convenience — asks a registered command whether it *could*
868882
run, without running it:
869883

870884
```ts

lib/commands/embedding/embed.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { IFileSystem } from "../../common/declarations";
77
import { inject } from "../../common/di";
88
import { canExecuteCommand } from "../../common/services/command-definition-adapter";
99
import { platformArgument } from "../command-base";
10-
import { prepareCommandOptions, runPrepareCommand } from "../prepare";
10+
import {
11+
prepareCommandDefinition,
12+
prepareCommandOptions,
13+
runPrepareCommand,
14+
} from "../prepare";
1115

1216
function resolveHostProjectPath(
1317
projectDir: string,
@@ -52,7 +56,12 @@ export class EmbedCommand extends Command({
5256
public async canExecute(): Promise<boolean> {
5357
// `prepare` takes the platform alone; the host project arguments are this
5458
// command's own and it would reject them.
55-
if (!(await canExecuteCommand("prepare", this.args.slice(0, 1)))) {
59+
if (
60+
!(await canExecuteCommand(
61+
prepareCommandDefinition,
62+
this.args.slice(0, 1),
63+
))
64+
) {
5665
return false;
5766
}
5867

lib/commands/post-install.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
IHostInfo,
77
ISettingsService,
88
} from "../common/declarations";
9+
import { CommandsService } from "../common/contracts/commands-service";
910
import { Command } from "../common/define-command";
1011
import { inject } from "../common/di";
1112
import { doesCurrentNpmCommandMatch } from "../common/helpers";
@@ -16,7 +17,7 @@ export class PostInstallCliCommand extends Command({
1617
disableAnalytics: true,
1718
}) {
1819
private $fs = inject<IFileSystem>("fs");
19-
private $commandsService = inject<ICommandsService>("commandsService");
20+
private $commandsService = inject(CommandsService);
2021
private $helpService = inject<IHelpService>("helpService");
2122
private $settingsService = inject<ISettingsService>("settingsService");
2223
private $analyticsService = inject<IAnalyticsService>("analyticsService");
@@ -47,7 +48,7 @@ export class PostInstallCliCommand extends Command({
4748

4849
// Explicitly ask for confirmation of usage-reporting:
4950
await this.$analyticsService.checkConsent();
50-
await this.$commandsService.tryExecuteCommand("autocomplete", []);
51+
await this.$commandsService.runCommand("autocomplete");
5152
}
5253
}
5354

lib/common/commands/device/device-log-stream.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ICleanupService } from "../../../definitions/cleanup-service";
2+
import { CommandsService } from "../../contracts/commands-service";
23
import { IErrors } from "../../declarations";
34
import {
45
CommandOptionsSchema,
@@ -29,7 +30,7 @@ export const openDeviceLogStreamCommandDefinition = defineCommand({
2930
inject<ICleanupService>("cleanupService").setShouldDispose(false);
3031
},
3132
async run(context): Promise<void> {
32-
const $commandsService = inject<ICommandsService>("commandsService");
33+
const $commandsService = inject(CommandsService);
3334
const $deviceLogProvider =
3435
inject<Mobile.IDeviceLogProvider>("deviceLogProvider");
3536
const $devicesService = inject<Mobile.IDevicesService>("devicesService");
@@ -44,7 +45,7 @@ export const openDeviceLogStreamCommandDefinition = defineCommand({
4445
});
4546

4647
if ($devicesService.deviceCount > 1) {
47-
await $commandsService.tryExecuteCommand("device", []);
48+
await $commandsService.runCommand("device");
4849
$errors.failWithHelp(NOT_SPECIFIED_DEVICE_ERROR_MESSAGE);
4950
}
5051

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Contract } from "../di/contract";
2+
import type { CommandReference } from "../define-command";
3+
4+
/**
5+
* Dispatches commands inside the running process: the surface a command, a
6+
* key shortcut or a plugin uses to run or consult another command. The command
7+
* line's own entry points into the dispatcher are not part of it.
8+
*/
9+
@Contract({ name: "commandsService" })
10+
export abstract class CommandsService {
11+
/**
12+
* Whether the command running now was dispatched in process rather than by
13+
* the command line — what tells a command it is borrowing a host process
14+
* instead of owning one.
15+
*/
16+
abstract readonly isExecutingInProcess: boolean;
17+
18+
/**
19+
* Runs a registered command in the current process. The command gets what a
20+
* typed command line gives it — its declared options primed with their
21+
* defaults, the arguments policy, `canExecute`, hooks and `postRun` — and a
22+
* failure throws instead of exiting, so a process that has to keep running
23+
* can catch it. Analytics do not fire: this is not a new CLI invocation.
24+
*
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.
27+
*/
28+
abstract runCommand(
29+
command: CommandReference,
30+
args?: string[],
31+
): Promise<void>;
32+
33+
/**
34+
* Asks a registered command whether it could run on `args`, without running
35+
* it. The command is resolved and its options primed exactly as for
36+
* `runCommand`, and its own `canExecute` returns the verdict. The child
37+
* builds its own setup from its own services, so nothing crosses between
38+
* the two but the name and the arguments; pass only the arguments the
39+
* child's own `arguments` policy accepts.
40+
*/
41+
abstract canExecuteCommand(
42+
command: CommandReference,
43+
args?: string[],
44+
): Promise<boolean>;
45+
}

lib/common/contracts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ export type {
1515
DeferredCommandResult,
1616
} from "./command-registry";
1717
export { COMMAND_CONTEXT } from "./command-context";
18+
export { CommandsService } from "./commands-service";
1819
export { ModuleRegistry } from "./module-registry";
1920
export { PublicApiBuilder } from "./public-api-builder";

lib/common/define-command.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,28 @@ export function toCommandDefinition(
762762
return isCommandDefinition(value) ? value : null;
763763
}
764764

765+
/**
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.
768+
*/
769+
export type CommandReference = string | RegisterableCommand;
770+
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+
765787
/**
766788
* The class authoring form: sugar over defineCommand, not a second execution
767789
* path. The returned base carries a `definition` that reads the class it is

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,24 @@ interface ICommandsService {
1919
* Runs a command inside the running process, throwing on failure rather
2020
* than exiting, so a long-lived host survives it.
2121
*/
22-
executeCommandInProcess(
23-
commandName: string,
22+
runCommand(
23+
command: import("../define-command").CommandReference,
2424
commandArguments?: string[],
2525
): Promise<void>;
2626
/**
2727
* Asks a command whether it could run, without running it. The command
2828
* builds its own setup from its own services.
2929
*/
30+
canExecuteCommand(
31+
command: import("../define-command").CommandReference,
32+
commandArguments?: string[],
33+
): Promise<boolean>;
34+
/** @deprecated Use `runCommand`. */
35+
executeCommandInProcess(
36+
commandName: string,
37+
commandArguments?: string[],
38+
): Promise<void>;
39+
/** @deprecated Use `canExecuteCommand`. */
3040
canExecuteCommandInProcess(
3141
commandName: string,
3242
commandArguments?: string[],

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

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Injector } from "../di/injector";
66
import { IDictionary, IDashedOption, IErrors } from "../declarations";
77
import { ICommand } from "../definitions/commands";
88
import { COMMAND_CONTEXT } from "../contracts/command-context";
9+
import { CommandsService } from "../contracts/commands-service";
910
import {
1011
COMMAND_OWNER,
1112
CommandRegistry,
@@ -29,6 +30,7 @@ import {
2930
CommandOptionSpec,
3031
CommandOptionType,
3132
CommandOptionsSchema,
33+
CommandReference,
3234
DefinedCommand,
3335
RegisterableCommand,
3436
defineCommand,
@@ -403,10 +405,9 @@ export function createCommandFromDefinition<
403405
return;
404406
}
405407

406-
const commandsService = targetInjector.get<ICommandsService>(
407-
"commandsService",
408-
{ optional: true },
409-
);
408+
const commandsService = targetInjector.get(CommandsService, {
409+
optional: true,
410+
});
410411
if (commandsService && commandsService.isExecutingInProcess) {
411412
return;
412413
}
@@ -543,40 +544,28 @@ const contextInjector = (): Injector =>
543544
getCurrentInjector() || <Injector>(<any>getRootInjector());
544545

545546
/**
546-
* Runs a registered command in the current process. The command gets what a
547-
* typed command line gives it — its declared options primed with their
548-
* defaults, the arguments policy, `canExecute`, hooks and `postRun` — and a
549-
* failure throws instead of exiting, so a process that has to keep running
550-
* (`ns start`, dispatching a key shortcut) can catch it.
547+
* Convenience over `CommandsService.runCommand` for code that has no injected
548+
* service at hand, such as a key shortcut action or an inline handler; the
549+
* contract is the API, this only resolves it from the current context.
551550
*/
552551
export async function runCommand(
553-
name: string,
552+
command: CommandReference,
554553
args: string[] = [],
555554
): Promise<void> {
556-
const commandsService =
557-
contextInjector().get<ICommandsService>("commandsService");
558-
559-
await commandsService.executeCommandInProcess(name, args);
555+
await contextInjector().get(CommandsService).runCommand(command, args);
560556
}
561557

562558
/**
563-
* Asks a registered command whether it could run on `args`, without running it.
564-
* The named command is resolved and its options primed exactly as `runCommand`
565-
* does, and its own `canExecute` returns the verdict.
566-
*
567-
* This is how one command reuses another's precondition — `embed` asking
568-
* whether `prepare` would run. The child resolves its own services, so nothing
569-
* crosses between the two but the name and the arguments; pass only the
570-
* arguments the child's own `arguments` policy accepts.
559+
* Convenience over `CommandsService.canExecuteCommand`, resolved from the
560+
* current context the way `runCommand` is.
571561
*/
572562
export async function canExecuteCommand(
573-
name: string,
563+
command: CommandReference,
574564
args: string[] = [],
575565
): Promise<boolean> {
576-
const commandsService =
577-
contextInjector().get<ICommandsService>("commandsService");
578-
579-
return commandsService.canExecuteCommandInProcess(name, args);
566+
return contextInjector()
567+
.get(CommandsService)
568+
.canExecuteCommand(command, args);
580569
}
581570

582571
/**

0 commit comments

Comments
 (0)