Skip to content

Commit 0158722

Browse files
committed
docs(commands): describe where a handler gets its services
Says that a handler resolves its own dependencies at its top, that services are never bundled or shared between commands, and that setup is optional sugar for one command. Documents canExecuteCommand as the way to reuse another command's precondition, and sharpens which authoring form fits which command.
1 parent 0b8d65b commit 0158722

1 file changed

Lines changed: 120 additions & 72 deletions

File tree

defining-commands.md

Lines changed: 120 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -419,73 +419,69 @@ resolves providers a child scope supplied — see
419419
[Registering a definition](#registering-a-definition). The same guidance, and
420420
the reasoning behind it, is in `dependency-injection.md`.
421421

422-
`setup` — hoisting work out of `run`
423-
------------------------------------
422+
Where a handler gets its services
423+
---------------------------------
424424

425-
`setup(ctx)` runs once per invocation, before `canExecute`, and its return
426-
value is handed to `canExecute`, `run` and `postRun` as their second argument:
425+
A handler resolves what it needs itself, at the top of its own body:
427426

428427
```ts
429428
export default defineCommand({
430429
name: "widget|add",
431430
arguments: "any",
432-
setup() {
431+
async run(ctx) {
432+
const widgets = inject(WidgetService);
433433
const projectData = inject(ProjectData);
434+
434435
projectData.initializeProjectData();
435-
return { projectData, widgets: inject(WidgetService) };
436-
},
437-
canExecute(ctx, { projectData }) {
438-
return !!projectData.projectDir;
439-
},
440-
async run(ctx, { widgets }) {
441436
await widgets.add(ctx.args);
442437
},
443438
});
444439
```
445440

446-
It exists for two reasons. It is the place to inject services before the first
447-
`await` when several handlers need them, and it is where the work a command
448-
class used to do in its constructor goes — most often
449-
`$projectData.initializeProjectData()`.
450-
451-
`setup` is sugar. A command may ignore it entirely and call `inject()` at the
452-
top of `run`; nothing else changes. "Once per invocation" means once across
453-
`canExecute`, `run` and `postRun` together — whichever of them the CLI reaches
454-
first triggers it, and the rest reuse the value.
455-
456-
When several commands share a setup, or a helper outside the definition takes
457-
the services as a parameter, lift it into a named function and derive the type
458-
from it instead of writing the shape out by hand:
459-
460-
```ts
461-
export function setupWidgetAddCommand() {
462-
const projectData = inject(ProjectData);
463-
projectData.initializeProjectData();
464-
return { projectData, widgets: inject(WidgetService) };
465-
}
466-
export type IWidgetAddCommandServices = ReturnType<
467-
typeof setupWidgetAddCommand
468-
>;
469-
470-
export function canAddWidget(services: IWidgetAddCommandServices): boolean {
471-
return !!services.projectData.projectDir;
472-
}
441+
The injection context is synchronous, so the `inject()` calls belong **above
442+
the first `await`** — see [Injection, and the first
443+
`await`](#injection-and-the-first-await). Resolve everything the handler needs
444+
there and the rule never bites; for anything that genuinely has to wait —
445+
resolved after an `await`, or inside a helper called later — use
446+
`ctx.injector.get(token)`, which works at any point.
447+
448+
**Services are never bundled.** There is no `setupXCommand()` returning an
449+
object of injected services for another command to spread, and no
450+
`IXCommandServices` type travelling between commands. A dependency is named
451+
where it is used, so reading a handler tells you exactly what it touches.
452+
Sharing is either of two things, and neither of them is a bag:
453+
454+
- **Shared logic** — a plain function taking the typed `ctx` and plain values,
455+
resolving its own services through `ctx.injector.get(...)`:
456+
457+
```ts
458+
export async function canBuildFor(
459+
ctx: CommandContext<any>,
460+
platform: string,
461+
): Promise<boolean> {
462+
const validation = ctx.injector.get(PlatformValidationService);
463+
return validation.canBuild(platform);
464+
}
465+
```
466+
467+
- **A whole command's precondition**`canExecuteCommand(name, args)`, which
468+
asks that command itself; see [Asking another
469+
command](#asking-another-command).
470+
471+
### `setup`, when a command has one
473472

474-
export default defineCommand({
475-
name: "widget|add",
476-
arguments: "any",
477-
setup: setupWidgetAddCommand,
478-
canExecute: (ctx, services) => canAddWidget(services),
479-
async run(ctx, { widgets }) {
480-
await widgets.add(ctx.args);
481-
},
482-
});
483-
```
484-
485-
Leave the setup function's return type off: the alias reads what the body
486-
infers, so annotating the function with the alias makes the pair circular. Read
487-
a setup curried over a parameter — `setupX(platform)` returning the setup
488-
itself — through its inner function, `ReturnType<ReturnType<typeof setupX>>`.
473+
`setup(ctx)` runs once per invocation, before `canExecute`, and its return
474+
value is handed to `canExecute`, `run` and `postRun` as their second argument.
475+
"Once per invocation" means once across the three together — whichever the CLI
476+
reaches first triggers it, and the rest reuse the value.
477+
478+
It is optional sugar for **one** command's own handlers, for the case where
479+
`canExecute` and `run` would otherwise repeat the same per-invocation
480+
derivation. It is never a place to assemble services for anything but the
481+
command it belongs to, and a command with a single handler does not need it at
482+
all. When a command has enough structure to want one, the
483+
[class form](#class-form) usually says the same thing better: the instance *is*
484+
the setup, and each dependency is a field.
489485

490486
`run`'s return value, and `postRun`
491487
-----------------------------------
@@ -569,12 +565,19 @@ class does not declare is left out of the definition entirely, so a class
569565
without `postRun` gets no `postCommandAction`, exactly as an object without one
570566
does.
571567

572-
**Which form to use.** The class form is for a single named command. When a
573-
function generates variants of one command — the `run|ios` / `run|vision`
574-
family, one definition per platform — the object form is what fits, because
575-
the thing being parameterized is a value and definitions are values.
576-
Registering the same class twice under two names is not the equivalent: the
577-
class is one definition.
568+
**Which form to use.** The class form is for a single named command with
569+
internal structure: state shared between `canExecute` and `run`, values derived
570+
once per invocation, several private steps, or enough collaborators that
571+
`this.$service` reads better than a local in every handler. Everything simpler
572+
— a handful of services and a short handler — is an object definition with its
573+
handlers written inline, where `ctx` is typed by inference and there is nothing
574+
to name.
575+
576+
When a function generates variants of one command — the `run|ios` /
577+
`run|vision` family, one definition per platform — the object form is what
578+
fits, because the thing being parameterized is a value and definitions are
579+
values. Registering the same class twice under two names is not the
580+
equivalent: the class is one definition.
578581

579582
**The class is the setup.** One instance is constructed per invocation, as that
580583
invocation's `setup`, before `canExecute` runs. So field initializers and the
@@ -597,26 +600,37 @@ the base class reads it. A provider registered for one command — through the
597600
`providers` argument of `registerCommand` or `registerLazyCommand` — can inject
598601
it too, and resolves nothing outside a running invocation.
599602

600-
**Share through functions, not base classes.** Two commands that need the same
601-
services share an `inject()`-based helper, not a common ancestor:
603+
**One field per dependency.** Each service the class uses is its own field,
604+
read as `this.$x`:
602605

603606
```ts
604-
export function injectPlatformCommandServices() {
605-
const projectData = inject(ProjectData);
606-
projectData.initializeProjectData();
607-
return { projectData, platformHelper: inject(PlatformCommandHelper) };
608-
}
609-
610607
export class PlatformAddCommand extends Command({ name: "platform|add" }) {
611-
private services = injectPlatformCommandServices();
608+
private $projectData = inject<IProjectData>("projectData");
609+
private $platformHelper = inject<IPlatformCommandHelper>(
610+
"platformCommandHelper",
611+
);
612+
613+
constructor() {
614+
super();
615+
this.$projectData.initializeProjectData();
616+
}
612617
// ...
613618
}
614619
```
615620

616-
A helper composes — a command can call two of them — and it stays readable
617-
without the reader walking a chain of files. A base class between `Command()`
618-
and the command does not: it is the pattern the legacy `ICommand` hierarchy
619-
used, and untangling it is most of why this API exists.
621+
Never a `private services = injectSomething()` holding a bag — the fields are
622+
the point, and a bag puts the dependency list back behind one more hop. Two
623+
commands needing the same four services restate those four lines; that
624+
duplication is cheaper than a shared shape neither of them owns.
625+
626+
**Share logic, not base classes and not services.** What two commands genuinely
627+
have in common is a check or a step, so share a function that takes
628+
`this.context` and plain values and resolves its own services — see [Where a
629+
handler gets its services](#where-a-handler-gets-its-services). To reuse
630+
another command's precondition whole, ask that command: [Asking another
631+
command](#asking-another-command). A base class between `Command()` and the
632+
command is the pattern the legacy `ICommand` hierarchy used, and untangling it
633+
is most of why this API exists.
620634

621635
Registration takes the class itself; see
622636
[Registering a definition](#registering-a-definition):
@@ -848,6 +862,40 @@ the injector of the current injection context, and the CLI's own outside one.
848862
`runCommand` is a thin call onto `CommandsService.executeCommandInProcess`,
849863
where the pipeline itself lives.
850864

865+
### Asking another command
866+
867+
`canExecuteCommand(name, args)` asks a registered command whether it *could*
868+
run, without running it:
869+
870+
```ts
871+
import { canExecuteCommand } from "../common/services/command-definition-adapter";
872+
873+
async canExecute(): Promise<boolean> {
874+
if (!(await canExecuteCommand("prepare", [this.args[0]]))) {
875+
return false;
876+
}
877+
878+
return !!this.hostProjectPath;
879+
}
880+
```
881+
882+
This is how one command builds on another's precondition. `embed` prepares the
883+
project, so "could `embed` run" starts with "could `prepare` run" — and the way
884+
to ask that is to ask `prepare`, not to import its `canExecute` and hand it
885+
services. The named command is resolved and its options primed exactly as
886+
`runCommand` does, then its own `canExecute` returns the verdict. It builds its
887+
own setup from its own services; nothing crosses between the two commands but
888+
the name and the arguments.
889+
890+
Pass only the arguments the child's own `arguments` policy accepts. The child
891+
enforces that policy before its `canExecute`, so forwarding a caller's whole
892+
argument list to a child that declares fewer is a rejection, not a wider check.
893+
894+
`canExecuteCommand` is a thin call onto
895+
`CommandsService.canExecuteCommandInProcess`, and follows `runCommand` in
896+
everything else: the same injector rule, the same option priming and
897+
restoration.
898+
851899
### Key shortcuts
852900

853901
The interactive keys `ns start` and `ns run` offer are the CLI's own caller. A

0 commit comments

Comments
 (0)