Skip to content

Commit 13db77e

Browse files
committed
feat(commands): register commands lazily against a checked definition type
registerCommand takes one shape and registers in one call, off the global binding and into the loading context's injector; built-in commands load on first use through a shared helper; getInjector becomes getRootInjector. Package-manager commands register from their real path, dev-post-install is reachable again, and a mistyped subcommand shows help in the terminal.
1 parent e4bf7da commit 13db77e

96 files changed

Lines changed: 1980 additions & 1009 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

defining-commands.md

Lines changed: 129 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -485,48 +485,159 @@ leaves the CLI's defaults in place.
485485
Registering a definition
486486
------------------------
487487

488-
Inside the CLI, a definition is registered with `registerCommandDefinition`:
488+
Inside the CLI, a definition is registered with `registerCommand`:
489489

490490
```ts
491-
import { registerCommandDefinition } from "../common/services/command-definition-adapter";
492-
import addWidgetCommand from "./add-widget";
491+
import { registerCommand } from "../common/services/command-definition-adapter";
493492

494-
registerCommandDefinition(addWidgetCommand);
493+
registerCommand({
494+
name: "widget|add",
495+
options: { force: booleanOption({ default: false }) },
496+
run: async (ctx) => { … },
497+
});
498+
```
499+
500+
It takes either a `DefinedCommand` — the result of `defineCommand`, marker and
501+
all — or the definition itself, which it defines on your behalf, so registering
502+
a command is one call. Either way the definition is validated before it reaches
503+
the registry. It claims every name the definition declares, through the
504+
`CommandRegistry` the target injector provides, and returns a
505+
`DeferredCommandResult` — see *The owner is ambient* below. The command instance
506+
is built by a factory on first resolution and cached.
507+
508+
Pass providers as the second argument to scope the command to a child injector
509+
of the one it registers against — how a definition is parameterized per
510+
registration:
511+
512+
```ts
513+
for (const [name, platform] of buildCommandPlatforms) {
514+
registerCommand({ ...buildCommandDefinition, name }, [
515+
{ provide: BUILD_PLATFORM, useValue: platform },
516+
]);
517+
}
518+
```
519+
520+
That is how one definition serves several commands that differ only in data —
521+
the platform each one targets — instead of one command subclassing another.
522+
523+
**Which injector it registers against is not a parameter.** It is the injector
524+
of the current injection context — see *The owner is ambient* below — and the
525+
CLI's own injector outside one. To register against some other injector, run
526+
the call in its context:
527+
528+
```ts
529+
runInInjectionContext(someInjector, () => registerCommand(definition));
495530
```
496531

497-
It takes a `DefinedCommand` — the result of `defineCommand`, marker and all —
498-
and rejects a bare object of the right shape, so a definition can never reach
499-
the registry without having been validated. It registers under every name the
500-
definition declares, through the `CommandRegistry` the target injector provides;
501-
pass a second argument to target a different injector (tests do this). The
502-
command instance is built by a factory on first resolution and cached.
532+
A test registering into its own container does that too, which is the same
533+
path the CLI itself takes.
503534

504-
`registerCommandDefinition` lives in
535+
`registerCommand` lives in
505536
`lib/common/services/command-definition-adapter` rather than in
506537
`nativescript/contracts`, because it reaches into the CLI runtime — the
507538
side-effect-free contracts entry point deliberately does not pull it in.
508539
`defineCommand`, the option helpers and all the types are exported from both
509540
`nativescript/contracts` and `lib/common/define-command`.
510541

511-
Extensions do not need `registerCommandDefinition` at all: a
542+
Extensions do not need `registerCommand` at all: a
512543
`nativescript.commands` manifest entry may point straight at a module that
513544
exports a definition, and the CLI adapts and registers it lazily under the
514545
manifest key (see [extensions.md](extensions.md)).
515546

547+
### Registering lazily
548+
549+
`registerCommand` needs the definition in hand, which means loading the module
550+
that holds it. `registerLazyCommand` claims the name instead, and loads the
551+
module the first time that one command is resolved:
552+
553+
```ts
554+
import { registerLazyCommand } from "../common/services/command-definition-adapter";
555+
556+
registerLazyCommand<typeof import("./commands/run").iosRunCommand>(
557+
"run|ios",
558+
() => require("./commands/run").iosRunCommand,
559+
);
560+
```
561+
562+
The name routes immediately — including through the `run` dispatcher the CLI
563+
synthesizes for it — so listing commands, resolving a sibling, or printing help
564+
for the parent never loads `run.js`. The loader runs on the resolution of
565+
`run|ios` alone, and what it returns is registered under the name that was
566+
claimed.
567+
568+
**The type argument is mandatory.** `require()` is typed `any`, so nothing can
569+
be inferred from the loader: without the type argument the name would be
570+
checked against nothing at all. Leave it off and the `name` parameter says so:
571+
572+
```
573+
error TS2345: Argument of type '"run|ios"' is not assignable to parameter of type
574+
'"Pass the definition type: registerLazyCommand<typeof import('./commands/x').cmd>(...)"'
575+
```
576+
577+
With the type argument, the name is checked against the one the definition
578+
declares — every one of them, for a definition that declares aliases:
579+
580+
```
581+
error TS2345: Argument of type '"run|iosss"' is not assignable to parameter of
582+
type '"run|ios"'
583+
```
584+
585+
and a type argument that is not a definition is rejected against the
586+
constraint. The loader is re-checked at runtime as well, because the guarantee
587+
is only as good as the type the call site passed.
588+
589+
**The loader must be synchronous.** `CommandsService` reads the resolved
590+
command's `dashedOptions` before it validates the command line, so a command
591+
that is still being imported has no options to validate against — a dynamic
592+
`import()` here would report every flag as unknown. `require` is the tool for
593+
this job.
594+
595+
**Providers are optional and cost nothing until the command runs.** The child
596+
injector is built inside the loader, so a name that is never resolved never
597+
creates one:
598+
599+
```ts
600+
registerLazyCommand<typeof import("./commands/x").cmd>(
601+
"x",
602+
() => require("./commands/x").cmd,
603+
[{ provide: SOME_TOKEN, useValue: "value" }],
604+
);
605+
```
606+
607+
**The owner is ambient.** Every registration has an owner, which attributes
608+
conflicts and load failures and makes re-registering the same name under the
609+
same owner a no-op instead of a conflict. It is not a parameter: the helper
610+
targets the injector of the current injection context when there is one, and
611+
reads `COMMAND_OWNER` off it. Outside a context it targets the CLI's own
612+
injector, and the CLI is the owner. An extension's module is loaded inside a
613+
context whose injector provides `COMMAND_OWNER`, so a command the module
614+
registers on its own is attributed to the extension without the module naming
615+
itself — through `registerCommand` just as much as through this helper.
616+
617+
`registerCommand` therefore returns a `DeferredCommandResult` too: every
618+
registration is arbitrated against the names already claimed, rather than
619+
overwriting one.
620+
621+
**Conflicts are returned, not thrown.** The result is the same
622+
`DeferredCommandResult` the extension manifest path gets — `{ registered:
623+
true }`, or `registered: false` with a `rejection` to branch on. The CLI's own
624+
bootstrap wraps the call and throws, because a name it cannot claim is a
625+
mistake in `bootstrap.ts`; a host loading someone else's command usually wants
626+
to warn and carry on. `describeRejection(rejection)` renders one for a human.
627+
516628
### One definition, several registrations
517629

518630
A family of commands that differ only in a value — `run|android` and `run|ios`,
519-
say — is one definition registered several times, each against a child injector
520-
that provides the value:
631+
say — is one definition registered several times, each with providers that
632+
carry the value:
521633

522634
```ts
523635
const PLATFORM = new InjectionToken<string>("commandPlatform");
524636

525637
for (const platform of ["android", "ios"]) {
526-
registerCommandDefinition(
527-
{ ...definition, name: `run|${platform}` },
528-
injector.createChild([{ provide: PLATFORM, useValue: platform }]),
529-
);
638+
registerCommand({ ...definition, name: `run|${platform}` }, [
639+
{ provide: PLATFORM, useValue: platform },
640+
]);
530641
}
531642
```
532643

extensions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ rejected with a warning.
218218
**The manifest key decides how a command is invoked.** It has to: the CLI routes
219219
`ns hello world` to your module before that module has been loaded, so the key
220220
is the only name it can know. A `name` inside the definition is metadata — it is
221-
what `registerCommandDefinition` uses when a module registers itself, and it is
221+
what `registerCommand` uses when a module registers itself, and it is
222222
useful documentation, but a manifest entry overrides it. If the two disagree the
223223
CLI warns, naming both, and runs the command under the manifest key.
224224

0 commit comments

Comments
 (0)