Skip to content

Commit 3361798

Browse files
committed
refactor(commands): one context per invocation and the class-form sweep
Each invocation builds one context object that every stage shares. The structured commands move to the class form, whose result type is inferred from `run` and whose meta is validated eagerly; the simple ones are inlined as object definitions; cross-command checks go through canExecuteCommand. The guide describes where a handler gets its services.
1 parent 217a38b commit 3361798

84 files changed

Lines changed: 3640 additions & 4057 deletions

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: 125 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ A definition is checked at the moment `defineCommand` is called, not when the
5959
command eventually runs. A misspelled field, a missing `run`, an option
6060
declared with something other than the four helpers, an `arguments` value
6161
outside `"none" | "any"` — each throws immediately, naming the command and the
62-
accepted form:
62+
accepted form. The class form's meta is checked the same way at the
63+
`Command({ ... })` call, which also rejects handlers passed there; only a
64+
missing `run` method waits until the definition is first read:
6365

6466
```
6567
Invalid command definition for 'widget|add': unknown field(s) 'handler'; a
@@ -419,73 +421,69 @@ resolves providers a child scope supplied — see
419421
[Registering a definition](#registering-a-definition). The same guidance, and
420422
the reasoning behind it, is in `dependency-injection.md`.
421423

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

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:
427+
A handler resolves what it needs itself, at the top of its own body:
427428

428429
```ts
429430
export default defineCommand({
430431
name: "widget|add",
431432
arguments: "any",
432-
setup() {
433+
async run(ctx) {
434+
const widgets = inject(WidgetService);
433435
const projectData = inject(ProjectData);
436+
434437
projectData.initializeProjectData();
435-
return { projectData, widgets: inject(WidgetService) };
436-
},
437-
canExecute(ctx, { projectData }) {
438-
return !!projectData.projectDir;
439-
},
440-
async run(ctx, { widgets }) {
441438
await widgets.add(ctx.args);
442439
},
443440
});
444441
```
445442

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-
}
443+
The injection context is synchronous, so the `inject()` calls belong **above
444+
the first `await`** — see [Injection, and the first
445+
`await`](#injection-and-the-first-await). Resolve everything the handler needs
446+
there and the rule never bites; for anything that genuinely has to wait —
447+
resolved after an `await`, or inside a helper called later — use
448+
`ctx.injector.get(token)`, which works at any point.
449+
450+
**Services are never bundled.** There is no `setupXCommand()` returning an
451+
object of injected services for another command to spread, and no
452+
`IXCommandServices` type travelling between commands. A dependency is named
453+
where it is used, so reading a handler tells you exactly what it touches.
454+
Sharing is either of two things, and neither of them is a bag:
455+
456+
- **Shared logic** — a plain function taking the typed `ctx` and plain values,
457+
resolving its own services through `ctx.injector.get(...)`:
458+
459+
```ts
460+
export async function canBuildFor(
461+
ctx: CommandContext<any>,
462+
platform: string,
463+
): Promise<boolean> {
464+
const validation = ctx.injector.get(PlatformValidationService);
465+
return validation.canBuild(platform);
466+
}
467+
```
468+
469+
- **A whole command's precondition** — `canExecuteCommand(name, args)`, which
470+
asks that command itself; see [Asking another
471+
command](#asking-another-command).
472+
473+
### `setup`, when a command has one
473474

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>>`.
475+
`setup(ctx)` runs once per invocation, before `canExecute`, and its return
476+
value is handed to `canExecute`, `run` and `postRun` as their second argument.
477+
"Once per invocation" means once across the three together — whichever the CLI
478+
reaches first triggers it, and the rest reuse the value.
479+
480+
It is optional sugar for **one** command's own handlers, for the case where
481+
`canExecute` and `run` would otherwise repeat the same per-invocation
482+
derivation. It is never a place to assemble services for anything but the
483+
command it belongs to, and a command with a single handler does not need it at
484+
all. When a command has enough structure to want one, the
485+
[class form](#class-form) usually says the same thing better: the instance *is*
486+
the setup, and each dependency is a field.
489487

490488
`run`'s return value, and `postRun`
491489
-----------------------------------
@@ -563,18 +561,26 @@ export class PlatformCleanCommand extends Command({
563561
`arguments`, `allowUnknownOptions`, `disableAnalytics` and `enableHooks`. The
564562
handlers are methods instead — `run` is required, and `canExecute`, `postRun`
565563
and `shortcuts` are optional, each with the same meaning and the same ordering
566-
as the fields of the same name. `postRun(result)` receives what `run` returned;
564+
as the fields of the same name. The result type is inferred from `run`, and
565+
`postRun(result)` receives it, awaited, with no type argument to restate;
567566
`shortcuts()` returns the same table `shortcuts(ctx, setup)` does. A method the
568567
class does not declare is left out of the definition entirely, so a class
569568
without `postRun` gets no `postCommandAction`, exactly as an object without one
570569
does.
571570

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

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

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

603609
```ts
604-
export function injectPlatformCommandServices() {
605-
const projectData = inject(ProjectData);
606-
projectData.initializeProjectData();
607-
return { projectData, platformHelper: inject(PlatformCommandHelper) };
608-
}
609-
610610
export class PlatformAddCommand extends Command({ name: "platform|add" }) {
611-
private services = injectPlatformCommandServices();
611+
private $projectData = inject<IProjectData>("projectData");
612+
private $platformHelper = inject<IPlatformCommandHelper>(
613+
"platformCommandHelper",
614+
);
615+
616+
constructor() {
617+
super();
618+
this.$projectData.initializeProjectData();
619+
}
612620
// ...
613621
}
614622
```
615623

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.
624+
Never a `private services = injectSomething()` holding a bag — the fields are
625+
the point, and a bag puts the dependency list back behind one more hop. Two
626+
commands needing the same four services restate those four lines; that
627+
duplication is cheaper than a shared shape neither of them owns.
628+
629+
**Share logic, not base classes and not services.** What two commands genuinely
630+
have in common is a check or a step, so share a function that takes
631+
`this.context` and plain values and resolves its own services — see [Where a
632+
handler gets its services](#where-a-handler-gets-its-services). To reuse
633+
another command's precondition whole, ask that command: [Asking another
634+
command](#asking-another-command). A base class between `Command()` and the
635+
command is the pattern the legacy `ICommand` hierarchy used, and untangling it
636+
is most of why this API exists.
620637

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

868+
### Asking another command
869+
870+
`canExecuteCommand(name, args)` asks a registered command whether it *could*
871+
run, without running it:
872+
873+
```ts
874+
import { canExecuteCommand } from "../common/services/command-definition-adapter";
875+
876+
async canExecute(): Promise<boolean> {
877+
if (!(await canExecuteCommand("prepare", [this.args[0]]))) {
878+
return false;
879+
}
880+
881+
return !!this.hostProjectPath;
882+
}
883+
```
884+
885+
This is how one command builds on another's precondition. `embed` prepares the
886+
project, so "could `embed` run" starts with "could `prepare` run" — and the way
887+
to ask that is to ask `prepare`, not to import its `canExecute` and hand it
888+
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.
892+
893+
Pass only the arguments the child's own `arguments` policy accepts. The child
894+
enforces that policy before its `canExecute`, so forwarding a caller's whole
895+
argument list to a child that declares fewer is a rejection, not a wider check.
896+
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.
901+
851902
### Key shortcuts
852903

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

0 commit comments

Comments
 (0)