Skip to content

Commit d9f9735

Browse files
committed
docs(commands): option groups, process-level options, scopes and the invocation record
Option groups and the list form of `options`, the collision rules, `CliOptions` and the spellings it protects, contributions and their timing, `providedIn` with its disposal, and `currentInvocationInjector()` with its lookup order. extensions.md states what an extension can use today and that a manifest-level contribution does not exist yet.
1 parent 911d5dc commit d9f9735

2 files changed

Lines changed: 275 additions & 17 deletions

File tree

‎defining-commands.md‎

Lines changed: 258 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,9 @@ Options
102102
-------
103103

104104
`options` is a schema keyed by the long option name — `output` is passed as
105-
`--output`. Declare each entry with one of the five helpers, which fix the
106-
value type:
105+
`--output`. It may also be a list of option groups and such schemas; see
106+
[Option groups](#option-groups). Declare each entry with one of the five
107+
helpers, which fix the value type:
107108

108109
| Helper | Declared with `default` | Declared without |
109110
| --------------- | ----------------------- | ----------------------- |
@@ -149,9 +150,12 @@ options: {
149150
The schema types `ctx.options` and nothing else: `ctx.options` carries exactly
150151
the declared keys, and a typo is a compile error. There is deliberately no
151152
"give me everything" escape hatch — a command declares every option it reads,
152-
CLI-wide ones (`--release`, `--path`, `--bundle`, …) included. Declaring one
153+
CLI-wide ones (`--release`, `--watch`, `--bundle`, …) included. Declaring one
153154
that the CLI already knows is supported and carries its value through to
154-
`ctx.options` exactly as a command-specific one does.
155+
`ctx.options` exactly as a command-specific one does. The process-level
156+
options (`--path`, `--log`, `--verbose` and the rest of `CliOptions`) are the
157+
exception: a command lists their group instead of redeclaring them. See
158+
[Process-level options](#process-level-options-clioptions).
155159

156160
### Sharing a schema between commands
157161

@@ -166,9 +170,84 @@ const buildOptions = {
166170
} satisfies CommandOptionsSchema;
167171
```
168172

173+
### Option groups
174+
175+
`defineOptions(name, schema)` declares an option group: a named schema that is
176+
also an injection token. A command lists groups under `options`, next to
177+
inline schemas:
178+
179+
```ts
180+
import {
181+
booleanOption,
182+
defineCommand,
183+
defineOptions,
184+
stringOption,
185+
} from "nativescript/contracts";
186+
187+
export const WidgetOptions = defineOptions("widget", {
188+
theme: stringOption(),
189+
compact: booleanOption({ default: false }),
190+
});
191+
192+
export default defineCommand({
193+
name: "widget|add",
194+
options: [WidgetOptions, { output: stringOption({ alias: "o" }) }],
195+
run(ctx) {
196+
// ctx.options -> { theme: string | undefined; compact: boolean;
197+
// output: string | undefined }
198+
},
199+
});
200+
```
201+
202+
`ctx.options` is typed as the merged values of every part. The class form
203+
takes the same list, and types `this.options` the same way.
204+
205+
Each invocation provides the values of every group it parsed in its own
206+
injector, under the group. So a per-command provider, or a service scoped to
207+
the invocation, injects the group and gets the same typed values:
208+
209+
```ts
210+
const widget = inject(WidgetOptions); // { theme: string | undefined; compact: boolean }
211+
```
212+
213+
Nothing outside an invocation can resolve a group; the root injector does not
214+
provide it. A service that needs one is scoped to the invocation; see
215+
[Services scoped to the invocation](#services-scoped-to-the-invocation-providedin).
216+
217+
`defineOptions` validates the schema where it is written, as `defineCommand`
218+
does, and reports a problem as `Invalid option group '<name>': <problem>.`
219+
followed by the accepted form.
220+
221+
The group's registry name is `options:<name>`. Group names share one
222+
namespace with contract and token names, so minting a second group with a
223+
name already taken throws an error that starts with `Token name
224+
'options:widget' is already used by an injection token.` Pick a name that is
225+
unique to your package.
226+
227+
### Collisions between parts
228+
229+
A spelling is an option's long name or one of its aliases. One spelling may
230+
appear in several parts of `options` only when every part declares it with an
231+
identical spec: the same type, `default`, `alias` and `hasSensitiveValue`.
232+
`description` is not compared. An alias may not equal another option's name
233+
or alias.
234+
235+
`defineCommand` and `Command()` check this when they are called, and
236+
`defineOptions` checks it within its own schema. The message names both
237+
declarations:
238+
239+
```
240+
Invalid command definition for 'widget|add': option '--compact' is declared
241+
by option group 'widget' and by the command's own options with different specs.
242+
```
243+
244+
followed by the accepted form. A group contributed from outside the command
245+
collides under the same rules; see
246+
[Option groups contributed from outside the command](#option-groups-contributed-from-outside-the-command).
247+
169248
### Redeclaring a CLI-wide option, and shadowing one
170249

171-
`--verbose`, `--path`, `--log`, `--release`, `--env` and friends are declared by
250+
`--release`, `--env`, `--watch`, `--device` and friends are declared by
172251
the CLI itself. A command's declaration is merged over the CLI-wide dictionary
173252
for the duration of that command, and that merge is the sanctioned way to give
174253
a global option a per-command default — `watch`, `hmr` and `skipNative` all
@@ -186,14 +265,96 @@ still warns about at registration is a redeclaration that changes what the
186265
spelling _means_:
187266

188267
- a declared option whose name matches a CLI-wide one but whose type differs —
189-
`verbose: stringOption()` against the CLI's boolean `--verbose`;
268+
`release: stringOption()` against the CLI's boolean `--release`;
190269
- an alias that belongs to a _different_ CLI-wide option — `output:
191-
stringOption({ alias: "p" })` steals `--path`'s shorthand. Restating an
192-
option's own shorthand (`path: stringOption({ alias: "p" })`) is fine.
270+
stringOption({ alias: "f" })` steals `--force`'s shorthand. Restating an
271+
option's own shorthand (`force: booleanOption({ alias: "f" })`) is fine.
193272

194273
A redeclaration that leaves `alias`, `default` or `hasSensitiveValue` unset
195-
keeps what the CLI-wide declaration carries for them, so `path: stringOption()`
196-
still answers to `-p` and stays out of the logs; set one only to change it.
274+
keeps what the CLI-wide declaration carries for them, so `release:
275+
booleanOption()` still answers to `-r`, and `device: stringOption()` stays out
276+
of the logs; set one only to change it.
277+
278+
None of this applies to the process-level options. Redeclaring one of those is
279+
an error, not a warning.
280+
281+
### Process-level options: `CliOptions`
282+
283+
`CliOptions` is the group of options the CLI parses once at startup, before a
284+
command is chosen: `--log`, `--verbose`, `--version` (`-v`), `--help` (`-h`),
285+
`--profileDir`, `--analyticsClient`, `--path` (`-p`) and `--config` (`-c`).
286+
The services that run for every command read them: the logger, analytics,
287+
project resolution. The group is provided at the root, so any service injects
288+
it, inside an invocation or not:
289+
290+
```ts
291+
import { CliOptions, inject } from "nativescript/contracts";
292+
293+
const { path, verbose } = inject(CliOptions);
294+
```
295+
296+
A command may not redeclare any of these spellings, as a name or as an alias.
297+
The CLI refuses the command when it compiles it, on the first resolution of a
298+
registered command or when `runCommand` is given the definition:
299+
300+
```
301+
Command 'widget|add': '--path' is a process-level option; list CliOptions under 'options', or inject it, instead of redeclaring it
302+
```
303+
304+
To have the values on `ctx.options`, list the group itself. That is not a
305+
redeclaration: it reads the same declaration. `install` does this:
306+
307+
```ts
308+
const installCommandOptions = [
309+
CliOptions,
310+
{
311+
frameworkPath: stringOption(),
312+
disableNpmInstall: booleanOption(),
313+
ignoreScripts: booleanOption(),
314+
} satisfies CommandOptionsSchema,
315+
] satisfies CommandOptionsInput;
316+
```
317+
318+
`ctx.options.path` is then `string | undefined`. A listed `CliOptions` is not
319+
provided again per invocation; injecting it still resolves the root's values.
320+
321+
### Option groups contributed from outside the command
322+
323+
The `OptionContributions` contract adds a group to a command the caller does
324+
not own, or to the process-level options:
325+
326+
```ts
327+
import { inject, OptionContributions } from "nativescript/contracts";
328+
329+
const contributions = inject(OptionContributions);
330+
contributions.contributeToCommand("run|ios", WidgetOptions);
331+
contributions.contributeToRoot(TelemetryOptions);
332+
```
333+
334+
`forCommand(name)` and `forRoot()` return what has been contributed so far.
335+
336+
A group contributed to a command is parsed with that command's options and
337+
provided per invocation, like the command's own groups. It is not on
338+
`ctx.options`, since the command's type does not know it; a handler or service
339+
reads it by injecting the group. It collides under the rules above, with the
340+
command's own parts and with other contributions, and it may not redeclare a
341+
process-level spelling. A collision is reported when the command's options are
342+
parsed, as `Command '<name>': <problem>`. The name is one the definition
343+
declares or one it was registered under, such as an extension's manifest key,
344+
and only commands defined with `defineCommand` or `Command()` read
345+
contributions.
346+
347+
A group contributed to the root joins the process-level table. It is parsed
348+
with `CliOptions` on the next parse after it is registered, its values are
349+
provided at the root, and its spellings are process-level: no command may
350+
redeclare them. `contributeToRoot` throws `Option group '<name>': <problem>`
351+
when the group collides with `CliOptions` or with an earlier root group.
352+
353+
A contribution is read when the parse it targets happens, so it must be
354+
registered before that parse. One registered later does not change a parse
355+
that already happened. There is no manifest-level way to declare a
356+
contribution yet, so only code that has already run can contribute; see
357+
[extensions.md](extensions.md#options-and-option-groups).
197358

198359
### How validation behaves
199360

@@ -354,8 +515,9 @@ The run context
354515
declare, `{}` when there are none. It is spelled `params` because
355516
`arguments` is a reserved binding name in strict mode, so a destructuring
356517
`const { args, arguments } = ctx` would not even parse.
357-
- `ctx.options` — the current value of each declared option, read at the moment
358-
the command executes.
518+
- `ctx.options` — the value of each option the command declares, its groups and
519+
inline schemas merged, read when the invocation opens. A group contributed
520+
from outside the command is not on it; inject the group instead.
359521
- `ctx.injector` — this invocation's injector, a child of the one the command
360522
was registered against; see
361523
[Injection, and the first `await`](#injection-and-the-first-await).
@@ -440,8 +602,8 @@ async run(ctx) {
440602
`ctx.inject(...)`: it is a visibly different mechanism because it obeys
441603
different rules, and mistaking one for the other is exactly the bug this shape
442604
prevents. It is the **invocation's own injector**: a child of the one the
443-
command was registered against, holding the context under `COMMAND_CONTEXT`
444-
and any per-command providers — see
605+
command was registered against, holding the context under `COMMAND_CONTEXT`,
606+
the values of the option groups it parsed, and any per-command providers — see
445607
[Registering a definition](#registering-a-definition). `inject()` before the
446608
first `await` and `ctx.injector.get()` after it are therefore the same lookup
447609
against the same injector. The same guidance, and the reasoning behind it, is
@@ -566,6 +728,57 @@ Sharing is either of two things, and neither of them is a bag:
566728
which asks that command itself; see [Asking another
567729
command](#asking-another-command).
568730

731+
### Services scoped to the invocation: `providedIn`
732+
733+
A service that reads the invocation, through its option groups or
734+
`COMMAND_CONTEXT`, cannot live at the root: the root provides neither. Scope
735+
it to the invocation instead, in one of three places:
736+
737+
```ts
738+
import {
739+
COMMAND_CONTEXT,
740+
Contract,
741+
inject,
742+
ProvidedIn,
743+
} from "nativescript/contracts";
744+
745+
// on the implementation class
746+
@ProvidedIn("invocation")
747+
export class WidgetRenderer {
748+
private widget = inject(WidgetOptions);
749+
private context = inject(COMMAND_CONTEXT);
750+
}
751+
752+
// on a contract token, for every implementation of it
753+
@Contract({ name: "widgetRenderer", providedIn: "invocation" })
754+
export abstract class WidgetRendererContract {}
755+
756+
// on one provider
757+
{ provide: WidgetRenderer, useClass: WidgetRenderer, providedIn: "invocation" }
758+
```
759+
760+
The provider's `providedIn` wins over the class's marker, and the class's over
761+
the token's. The registration may stay where it is, the root included; the
762+
instance is built and cached on the nearest invocation injector above the
763+
lookup, and its own dependencies resolve there. That is why it can inject
764+
option groups and `COMMAND_CONTEXT`. Each invocation gets its own instance,
765+
an in-process dispatch included.
766+
767+
Resolving such a service from outside an invocation is an error, even with
768+
`optional: true`. That covers the root, and a root singleton that injects it
769+
in its constructor or a field, because a root singleton resolves against the
770+
root:
771+
772+
```
773+
<token> is provided in the 'invocation' scope; it cannot be resolved from outside one
774+
```
775+
776+
A scoped instance is recorded on the invocation injector that holds it and is
777+
disposed with that injector when the invocation ends: after `postRun` when
778+
the command has one, else after `run`, or when `canExecute` refuses. A scoped
779+
service with a `dispose()` method gets per-invocation cleanup for free; the
780+
root singletons the invocation reached are the root's and stay.
781+
569782
### `setup`, when a command has one
570783

571784
`setup(ctx)` runs once per invocation, after the preconditions and before the
@@ -999,9 +1212,11 @@ name still identifies it for hooks and reporting.
9991212

10001213
A definition run as given is compiled against an injector chosen at the call,
10011214
the way Angular's `createComponent` takes one: the `injector` in the options
1002-
bag when one is passed, otherwise the injection context the call is made from,
1003-
otherwise the CLI's root. So a definition dispatched from inside an
1004-
extension's command lands under the extension's scope, where `registerCommand`
1215+
bag when one is passed, otherwise the invocation running now, which starts with
1216+
the injection context the call is made from (see [The invocation running
1217+
now](#the-invocation-running-now)), otherwise the CLI's root. So a definition
1218+
dispatched from inside an extension's command lands under the extension's
1219+
scope, where `registerCommand`
10051220
would have placed it, and a caller holding a scope of its own names it:
10061221

10071222
```ts
@@ -1048,6 +1263,32 @@ argument list to a child that declares fewer is a rejection, not a wider check.
10481263
`canExecuteCommand` follows `runCommand` in everything else: the same option
10491264
priming and restoration, the same routing of a parent name to its subcommand.
10501265

1266+
### The invocation running now
1267+
1268+
Code that resolves by name outside an injection context, such as a hook or a
1269+
plugin's callback, reaches the running invocation through
1270+
`currentInvocationInjector()`, exported from `"nativescript/contracts"`. It
1271+
tries, in order:
1272+
1273+
1. the synchronous injection context, when there is one;
1274+
2. the invocation whose asynchronous flow the caller is in;
1275+
3. the most recently opened invocation still open, for a callback that lost
1276+
its asynchronous context, such as an emitter another invocation registered
1277+
or a library timer;
1278+
4. `null`.
1279+
1280+
The CLI uses it in two places. A hook runs against it, so its by-name
1281+
dependencies can come from the invocation's providers and scoped services; it
1282+
falls back to the root when there is no invocation. `runCommand(definition)`
1283+
with no `injector` option compiles the definition against it, with the same
1284+
fallback.
1285+
1286+
The first invocation of the process, the command line's own, stays open for
1287+
the life of the process, so a long-lived command's callbacks keep resolving
1288+
through it after its `run` has returned. Every later invocation closes when it
1289+
finishes, and an in-process dispatch closes any invocation it opened,
1290+
including one opened only to answer `canExecuteCommand`.
1291+
10511292
### Key shortcuts
10521293

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

‎extensions.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,23 @@ the command itself, executing that command fails with an error naming the
205205
extension, the command and the module — the entry points at the wrong file, or
206206
the file is not doing what the entry promises.
207207

208+
Options and option groups
209+
-------------------------
210+
211+
An extension's own commands declare options exactly as built-in commands do,
212+
option groups included. `defineOptions`, `CliOptions` and the option helpers
213+
are exported from `"nativescript/contracts"`, and a group a command lists under
214+
`options` is parsed with it and can be injected by services in its invocation
215+
(see [defining-commands.md](defining-commands.md#option-groups)).
216+
217+
Adding options to a command the extension does not own, or to the
218+
process-level options, is not supported for a manifest-declared extension yet.
219+
The `OptionContributions` contract exists as a programmatic seam, and code
220+
whose module is loaded before the target parse can call it. A command module
221+
named in the manifest is loaded only when its own command is resolved, so it
222+
cannot add options to a built-in command the user invoked. A manifest-level
223+
way to declare a contribution does not exist yet.
224+
208225
Command names
209226
-------------
210227

0 commit comments

Comments
 (0)