Skip to content

Commit 75009b0

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 72de270 commit 75009b0

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
| --------------- | ----------------------- | ----------------------- |
@@ -153,9 +154,12 @@ options: {
153154
The schema types `ctx.options` and nothing else: `ctx.options` carries exactly
154155
the declared keys, and a typo is a compile error. There is deliberately no
155156
"give me everything" escape hatch — a command declares every option it reads,
156-
CLI-wide ones (`--release`, `--path`, `--bundle`, …) included. Declaring one
157+
CLI-wide ones (`--release`, `--watch`, `--bundle`, …) included. Declaring one
157158
that the CLI already knows is supported and carries its value through to
158-
`ctx.options` exactly as a command-specific one does.
159+
`ctx.options` exactly as a command-specific one does. The process-level
160+
options (`--path`, `--log`, `--verbose` and the rest of `CliOptions`) are the
161+
exception: a command lists their group instead of redeclaring them. See
162+
[Process-level options](#process-level-options-clioptions).
159163

160164
### Sharing a schema between commands
161165

@@ -170,9 +174,84 @@ const buildOptions = {
170174
} satisfies CommandOptionsSchema;
171175
```
172176

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

175-
`--verbose`, `--path`, `--log`, `--release`, `--env` and friends are declared by
254+
`--release`, `--env`, `--watch`, `--device` and friends are declared by
176255
the CLI itself. A command's declaration is merged over the CLI-wide dictionary
177256
for the duration of that command, and that merge is the sanctioned way to give
178257
a global option a per-command default — `watch`, `hmr` and `skipNative` all
@@ -190,14 +269,96 @@ still warns about at registration is a redeclaration that changes what the
190269
spelling _means_:
191270

192271
- a declared option whose name matches a CLI-wide one but whose type differs —
193-
`verbose: stringOption()` against the CLI's boolean `--verbose`;
272+
`release: stringOption()` against the CLI's boolean `--release`;
194273
- an alias that belongs to a _different_ CLI-wide option — `output:
195-
stringOption({ alias: "p" })` steals `--path`'s shorthand. Restating an
196-
option's own shorthand (`path: stringOption({ alias: "p" })`) is fine.
274+
stringOption({ alias: "f" })` steals `--force`'s shorthand. Restating an
275+
option's own shorthand (`force: booleanOption({ alias: "f" })`) is fine.
197276

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

202363
### How validation behaves
203364

@@ -358,8 +519,9 @@ The run context
358519
declare, `{}` when there are none. It is spelled `params` because
359520
`params` is a reserved binding name in strict mode, so a destructuring
360521
`const { args, arguments } = ctx` would not even parse.
361-
- `ctx.options` — the current value of each declared option, read at the moment
362-
the command executes.
522+
- `ctx.options` — the value of each option the command declares, its groups and
523+
inline schemas merged, read when the invocation opens. A group contributed
524+
from outside the command is not on it; inject the group instead.
363525
- `ctx.injector` — this invocation's injector, a child of the one the command
364526
was registered against; see
365527
[Injection, and the first `await`](#injection-and-the-first-await).
@@ -444,8 +606,8 @@ async run(ctx) {
444606
`ctx.inject(...)`: it is a visibly different mechanism because it obeys
445607
different rules, and mistaking one for the other is exactly the bug this shape
446608
prevents. It is the **invocation's own injector**: a child of the one the
447-
command was registered against, holding the context under `COMMAND_CONTEXT`
448-
and any per-command providers — see
609+
command was registered against, holding the context under `COMMAND_CONTEXT`,
610+
the values of the option groups it parsed, and any per-command providers — see
449611
[Registering a definition](#registering-a-definition). `inject()` before the
450612
first `await` and `ctx.injector.get()` after it are therefore the same lookup
451613
against the same injector. The same guidance, and the reasoning behind it, is
@@ -570,6 +732,57 @@ Sharing is either of two things, and neither of them is a bag:
570732
which asks that command itself; see [Asking another
571733
command](#asking-another-command).
572734

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

575788
`setup(ctx)` runs once per invocation, after the preconditions and before the
@@ -1003,9 +1216,11 @@ name still identifies it for hooks and reporting.
10031216

10041217
A definition run as given is compiled against an injector chosen at the call,
10051218
the way Angular's `createComponent` takes one: the `injector` in the options
1006-
bag when one is passed, otherwise the injection context the call is made from,
1007-
otherwise the CLI's root. So a definition dispatched from inside an
1008-
extension's command lands under the extension's scope, where `registerCommand`
1219+
bag when one is passed, otherwise the invocation running now, which starts with
1220+
the injection context the call is made from (see [The invocation running
1221+
now](#the-invocation-running-now)), otherwise the CLI's root. So a definition
1222+
dispatched from inside an extension's command lands under the extension's
1223+
scope, where `registerCommand`
10091224
would have placed it, and a caller holding a scope of its own names it:
10101225

10111226
```ts
@@ -1052,6 +1267,32 @@ argument list to a child that declares fewer is a rejection, not a wider check.
10521267
`canExecuteCommand` follows `runCommand` in everything else: the same option
10531268
priming and restoration, the same routing of a parent name to its subcommand.
10541269

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

10571298
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)