Skip to content

Commit 693e34d

Browse files
committed
feat(commands): the in-process dispatcher as a contract
CommandsService is the contract a command, a key shortcut or a plugin runs or consults another command through. It takes a registered name or a definition run as given, routes a parent name to its subcommand and fires the full hook name, rejects a dispatch that overlaps a running one while letting them nest, and replaces the free runCommand and canExecuteCommand helpers. `ctx.injector` is the invocation's own injector.
1 parent 3361798 commit 693e34d

18 files changed

Lines changed: 1143 additions & 268 deletions

‎defining-commands.md‎

Lines changed: 77 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,8 @@ The run context
346346
`const { args, arguments } = ctx` would not even parse.
347347
- `ctx.options` — the current value of each declared option, read at the moment
348348
the command executes.
349-
- `ctx.injector` — the injector this command was registered against; see
349+
- `ctx.injector` — this invocation's injector, a child of the one the command
350+
was registered against; see
350351
[Injection, and the first `await`](#injection-and-the-first-await).
351352
- `ctx.fail(message)` — fails the command with `message` and a usage help
352353
suggestion.
@@ -416,10 +417,13 @@ async run(ctx) {
416417
`ctx.injector` is deliberately the injector itself rather than a bound
417418
`ctx.inject(...)`: it is a visibly different mechanism because it obeys
418419
different rules, and mistaking one for the other is exactly the bug this shape
419-
prevents. It is the injector the command was **registered against**, so it also
420-
resolves providers a child scope supplied — see
421-
[Registering a definition](#registering-a-definition). The same guidance, and
422-
the reasoning behind it, is in `dependency-injection.md`.
420+
prevents. It is the **invocation's own injector**: a child of the one the
421+
command was registered against, holding the context under `COMMAND_CONTEXT`
422+
and any per-command providers — see
423+
[Registering a definition](#registering-a-definition). `inject()` before the
424+
first `await` and `ctx.injector.get()` after it are therefore the same lookup
425+
against the same injector. The same guidance, and the reasoning behind it, is
426+
in `dependency-injection.md`.
423427

424428
Where a handler gets its services
425429
---------------------------------
@@ -466,8 +470,8 @@ Sharing is either of two things, and neither of them is a bag:
466470
}
467471
```
468472

469-
- **A whole command's precondition** — `canExecuteCommand(name, args)`, which
470-
asks that command itself; see [Asking another
473+
- **A whole command's precondition** — `CommandsService.canExecuteCommand`,
474+
which asks that command itself; see [Asking another
471475
command](#asking-another-command).
472476

473477
### `setup`, when a command has one
@@ -600,8 +604,10 @@ schema does not declare is a compile error. `this.context` also carries
600604
**Per-command providers see the invocation.** The context is provided to the
601605
invocation's own child injector under the `COMMAND_CONTEXT` token, which is how
602606
the base class reads it. A provider registered for one command — through the
603-
`providers` argument of `registerCommand` or `registerLazyCommand` — can inject
604-
it too, and resolves nothing outside a running invocation.
607+
`providers` argument of `registerCommand` or `registerLazyCommand` — lives in
608+
that same child, so a factory or class among them can inject the context too.
609+
The cost is that such a provider is built once per invocation, never shared
610+
across invocations, and resolves nothing outside a running one.
605611

606612
**One field per dependency.** Each service the class uses is its own field,
607613
read as `this.$x`:
@@ -681,8 +687,8 @@ the registry. It claims every name the definition declares, through the
681687
`DeferredCommandResult` — see _The owner is ambient_ below. The command instance
682688
is built by a factory on first resolution and cached.
683689

684-
Pass providers as the second argument to scope the command to a child injector
685-
of the one it registers against — how a definition is parameterized per
690+
Pass providers as the second argument to add them to each invocation's child
691+
injector, the one `ctx.injector` names — how a definition is parameterized per
686692
registration:
687693

688694
```ts
@@ -827,14 +833,24 @@ per-platform command subclasses a shared base to override one field.
827833
Running a command in process
828834
----------------------------
829835

830-
`runCommand` dispatches a registered command from inside the process that is
831-
already running:
836+
The `CommandsService` contract dispatches a registered command from inside the
837+
process that is already running. It is a service like any other, so it follows
838+
the rule every service does: `inject()` before the first `await`, the
839+
injector after it, and a key shortcut's action reaches it through the
840+
injector its context carries:
832841

833842
```ts
834-
import { runCommand } from "../common/services/command-definition-adapter";
843+
import { CommandsService } from "nativescript/contracts";
835844

836-
await runCommand("open|ios");
837-
await runCommand("install", ["lodash"]);
845+
// in a class command
846+
private $commandsService = inject(CommandsService);
847+
await this.$commandsService.runCommand("autocomplete");
848+
849+
// in an inline handler, after the first await
850+
await ctx.injector.get(CommandsService).runCommand("install", ["lodash"]);
851+
852+
// in a shortcut action
853+
action: (ctx) => ctx.injector.get(CommandsService).runCommand("open|ios"),
838854
```
839855

840856
The command gets what a typed command line gives it, in the same order: its
@@ -852,29 +868,56 @@ running afterwards:
852868
the caller decides what happens next.
853869
- **Analytics do not fire.** An in-process dispatch is not a new invocation of
854870
the CLI, and the consent check can prompt on a terminal the caller has put
855-
into raw mode. Hooks do fire: a project's `before-open-ios` hook is part of
856-
what `open|ios` means, however the command was reached.
871+
into raw mode. Hooks do fire, under the same names the command line fires:
872+
`open|ios` fires `before-open-ios` and then `before-open` (and `after-open`,
873+
`after-open-ios` on the way out), however the command was reached.
857874

858875
The options service is put back the way it was found. Merging a command's
859876
declarations into it rewrites the values the host process is still running on
860877
— `open|ios` declares `watch: false`, which would otherwise leave an `ns start`
861878
out of watch mode for the rest of its life.
862879

863-
Which injector it dispatches through follows the rule `registerCommand` does:
864-
the injector of the current injection context, and the CLI's own outside one.
865-
`runCommand` is a thin call onto `CommandsService.executeCommandInProcess`,
866-
where the pipeline itself lives.
880+
In-process dispatches nest; they never overlap. The options are put back in
881+
the order the dispatches were entered, which only restores the right values
882+
when each one finishes before the dispatch it was started from. A dispatch
883+
started while another is in flight, and not from inside it — two
884+
`runCommand` calls under one `Promise.all`, say — is rejected with
885+
`Cannot dispatch '…' in process while '…' is still running: in-process
886+
dispatches must nest, not overlap; await the running one first.` Await one
887+
before starting the next.
888+
889+
There is deliberately no free `runCommand()` function: one that silently fell
890+
back to the CLI's root injector outside an injection context would dispatch
891+
through the wrong scope from exactly the places — after an `await`, inside a
892+
stdin handler — where the mistake is hardest to notice. The injector you hold
893+
is the one to dispatch through.
867894

868895
### Asking another command
869896

870-
`canExecuteCommand(name, args)` asks a registered command whether it *could*
871-
run, without running it:
897+
Both methods take a registered name, or — the typed way — a definition or
898+
`Command()` class. A name is looked up in the registry, and a parent name is
899+
routed to its subcommand the way the command line routes it —
900+
`runCommand("device")` runs `device|*list`, `runCommand("device", ["log"])`
901+
runs `device|log`; a definition runs as
902+
given, whether or not it is registered, so `runCommand(prepareCommandDefinition)`
903+
runs exactly what you hold and cannot go stale the way a string can. Its first
904+
name still identifies it for hooks and reporting.
905+
906+
`CommandsService.canExecuteCommand(command, args)` asks a registered command
907+
whether it *could* run, without running it:
872908

873909
```ts
874-
import { canExecuteCommand } from "../common/services/command-definition-adapter";
910+
import { CommandsService } from "nativescript/contracts";
911+
912+
private $commandsService = inject(CommandsService);
875913

876914
async canExecute(): Promise<boolean> {
877-
if (!(await canExecuteCommand("prepare", [this.args[0]]))) {
915+
if (
916+
!(await this.$commandsService.canExecuteCommand(
917+
prepareCommandDefinition,
918+
[this.args[0]],
919+
))
920+
) {
878921
return false;
879922
}
880923

@@ -886,18 +929,18 @@ This is how one command builds on another's precondition. `embed` prepares the
886929
project, so "could `embed` run" starts with "could `prepare` run" — and the way
887930
to ask that is to ask `prepare`, not to import its `canExecute` and hand it
888931
services. The named command is resolved and its options primed exactly as
889-
`runCommand` does, then its own `canExecute` returns the verdict. It builds its
890-
own setup from its own services; nothing crosses between the two commands but
891-
the name and the arguments.
932+
`runCommand` does, then its own `canExecute` returns its verdict or throws. It
933+
builds its own setup from its own services; nothing crosses between the two
934+
commands but the name and the arguments.
892935

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

897-
`canExecuteCommand` is a thin call onto
898-
`CommandsService.canExecuteCommandInProcess`, and follows `runCommand` in
899-
everything else: the same injector rule, the same option priming and
900-
restoration.
940+
`canExecuteCommand` follows `runCommand` in everything else: the same option
941+
priming and restoration, the same routing of a parent name to its subcommand.
942+
The deprecated `canExecuteCommandInProcess` and `executeCommandInProcess`
943+
call the two methods with a name.
901944

902945
### Key shortcuts
903946

@@ -910,7 +953,7 @@ an `action` that runs it:
910953
key: "I",
911954
description: "Open project in Xcode",
912955
when: onPlatform("iOS"),
913-
action: () => runCommand("open|ios"),
956+
action: (ctx) => ctx.injector.get(CommandsService).runCommand("open|ios"),
914957
}
915958
```
916959

‎lib/commands/embedding/embed.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import { IProjectConfigService, IProjectData } from "../../definitions/project";
55
import { Command } from "../../common/define-command";
66
import { IFileSystem } from "../../common/declarations";
77
import { inject } from "../../common/di";
8-
import { canExecuteCommand } from "../../common/services/command-definition-adapter";
8+
import { CommandsService } from "../../common/contracts/commands-service";
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,
@@ -31,6 +35,7 @@ export class EmbedCommand extends Command({
3135
{ name: "hostProjectModuleName" },
3236
],
3337
}) {
38+
private $commandsService = inject(CommandsService);
3439
private $fs = inject<IFileSystem>("fs");
3540
private $logger = inject<ILogger>("logger");
3641
private $options = inject<IOptions>("options");
@@ -52,7 +57,12 @@ export class EmbedCommand extends Command({
5257
public async canExecute(): Promise<boolean> {
5358
// `prepare` takes the platform alone; the host project arguments are this
5459
// command's own and it would reject them.
55-
if (!(await canExecuteCommand("prepare", this.args.slice(0, 1)))) {
60+
if (
61+
!(await this.$commandsService.canExecuteCommand(
62+
prepareCommandDefinition,
63+
this.args.slice(0, 1),
64+
))
65+
) {
5666
return false;
5767
}
5868

‎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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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 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. 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.
30+
*
31+
* Dispatches nest: one may start from inside a running dispatch, but one
32+
* started while another is in flight and not from inside it is rejected.
33+
*/
34+
abstract runCommand(
35+
command: CommandReference,
36+
args?: string[],
37+
): Promise<void>;
38+
39+
/**
40+
* Asks a registered command whether it could run on `args`, without running
41+
* it. The command is resolved and its options primed exactly as for
42+
* `runCommand`, and its own `canExecute` returns its verdict or throws — an
43+
* arguments-policy violation, a setup failure or `ctx.fail`. The child
44+
* builds its own setup from its own services, so nothing crosses between
45+
* the two but the name and the arguments; pass only the arguments the
46+
* child's own `arguments` policy accepts.
47+
*/
48+
abstract canExecuteCommand(
49+
command: CommandReference,
50+
args?: string[],
51+
): Promise<boolean>;
52+
}

‎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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,12 @@ export function toCommandDefinition(
805805
return isCommandDefinition(value) ? value : null;
806806
}
807807

808+
/**
809+
* What a dispatcher accepts: a registered command's name, or a definition or
810+
* class to run as given.
811+
*/
812+
export type CommandReference = string | RegisterableCommand;
813+
808814
/**
809815
* The class authoring form: sugar over defineCommand, not a second execution
810816
* path. The returned base carries a `definition` that reads the class it is

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

Lines changed: 13 additions & 3 deletions
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;
@@ -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[],

0 commit comments

Comments
 (0)