Skip to content

Commit ae2f54e

Browse files
committed
feat(commands): extend defineCommand for the CLI's own commands
Options and arguments are declared on the definition and validated before run. Setup runs ahead of argument enforcement so a definition can derive its arguments; a redeclared CLI option keeps whatever it leaves unspecified; unknown options are tolerated instead of skipping validation; objectOption covers --env.* style values; a missing required argument keeps the command's preamble in the error.
1 parent a52b7dd commit ae2f54e

12 files changed

Lines changed: 1696 additions & 124 deletions

File tree

defining-commands.md

Lines changed: 250 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,11 @@ accepted form:
5959

6060
```
6161
Invalid command definition for 'widget|add': unknown field(s) 'handler'; a
62-
definition accepts name, description, options, arguments, canExecute,
63-
disableAnalytics, enableHooks, run. Accepted form: defineCommand({ name:
64-
"widget|add", run(ctx) { ... } }) — with the optional fields description,
65-
options, arguments, canExecute, disableAnalytics and enableHooks.
62+
definition accepts name, description, options, arguments, allowUnknownOptions,
63+
canExecute, disableAnalytics, enableHooks, setup, run, postRun. Accepted form:
64+
defineCommand({ name: "widget|add", run(ctx) { ... } }) — with the optional
65+
fields description, options, arguments, allowUnknownOptions, setup, canExecute,
66+
postRun, disableAnalytics and enableHooks.
6667
```
6768

6869
Names and the command hierarchy
@@ -132,9 +133,11 @@ options: {
132133
nothing renders it yet.
133134

134135
The schema types `ctx.options` and nothing else: `ctx.options` carries exactly
135-
the declared keys, and a typo is a compile error. Values that the CLI parses
136-
globally (`--path`, `--log`, …) are not exposed there; resolve the `options`
137-
service if you need them.
136+
the declared keys, and a typo is a compile error. There is deliberately no
137+
"give me everything" escape hatch — a command declares every option it reads,
138+
CLI-wide ones (`--release`, `--path`, `--bundle`, …) included. Declaring one
139+
that the CLI already knows is supported and carries its value through to
140+
`ctx.options` exactly as a command-specific one does.
138141

139142
### Sharing a schema between commands
140143

@@ -149,17 +152,34 @@ const buildOptions = {
149152
} satisfies CommandOptionsSchema;
150153
```
151154

152-
### Do not shadow a CLI-wide option
155+
### Redeclaring a CLI-wide option, and shadowing one
153156

154157
`--verbose`, `--path`, `--log`, `--release`, `--env` and friends are declared by
155-
the CLI itself. Declaring one of those names in a command's schema makes the
156-
command's declaration win for the duration of that command, which means the
157-
same flag means different things depending on which command is running. The CLI
158-
warns at registration naming both sides of the collision; pick another name.
158+
the CLI itself. A command's declaration is merged over the CLI-wide dictionary
159+
for the duration of that command, and that merge is the sanctioned way to give
160+
a global option a per-command default — `watch`, `hmr` and `skipNative` all
161+
carry different defaults on `build`, `prepare`, `deploy` and `test`:
159162

160-
Aliases count too, in both directions: an `alias: "p"` collides with `--path`'s
161-
shorthand just as `output: stringOption()` would collide with a CLI-wide
162-
`--output`.
163+
```ts
164+
options: {
165+
// CLI-wide --watch, but this command defaults it off
166+
watch: booleanOption({ default: false }),
167+
}
168+
```
169+
170+
So a redeclaration of the same name with the same type is silent. What the CLI
171+
still warns about at registration is a redeclaration that changes what the
172+
spelling *means*:
173+
174+
- a declared option whose name matches a CLI-wide one but whose type differs —
175+
`verbose: stringOption()` against the CLI's boolean `--verbose`;
176+
- an alias that belongs to a *different* CLI-wide option — `output:
177+
stringOption({ alias: "p" })` steals `--path`'s shorthand. Restating an
178+
option's own shorthand (`path: stringOption({ alias: "p" })`) is fine.
179+
180+
The merge replaces the CLI-wide entry rather than patching it, so a
181+
redeclaration inherits nothing: restate the `alias` and `hasSensitiveValue` the
182+
global declaration carries if the command still wants them.
163183

164184
### How validation behaves
165185

@@ -181,17 +201,101 @@ So adding an option is a matter of adding a schema entry; forgetting to declare
181201
one that users pass is a warning today and a failure later, never a silent
182202
`undefined`.
183203

204+
### `allowUnknownOptions`
205+
206+
A command that forwards its command line to a separately installed CLI cannot
207+
know which flags are legitimate, so validating them here would reject the other
208+
CLI's own options. `allowUnknownOptions: true` turns the check off for that
209+
command:
210+
211+
```ts
212+
defineCommand({
213+
name: "preview",
214+
allowUnknownOptions: true,
215+
options: { disableNpmInstall: booleanOption({ default: false }) },
216+
async run(ctx) {
217+
/* spawn the other CLI with process.argv */
218+
},
219+
});
220+
```
221+
222+
It maps onto `skipOptionsValidation` on the compiled command, which means the
223+
CLI never re-primes its parser for this command at all. A command-specific
224+
option therefore never reaches `ctx.options` under this flag — only options the
225+
CLI already knows globally carry values. Reach for it only when forwarding.
226+
184227
Positional arguments
185228
--------------------
186229

187-
`arguments` declares whether the command takes positional arguments at all:
230+
`arguments` declares what the command takes after its name:
188231

189232
- `"none"` (the default) — the command accepts no positional arguments. Passing
190233
any is rejected with `This command doesn't accept parameters.`
191-
- `"any"` — positional arguments are accepted and handed to `run` as
192-
`ctx.args`.
234+
- `"any"` — any number of positional arguments is accepted and handed to `run`
235+
as `ctx.args`.
236+
- an array of specs — each argument is declared, named, and validated.
193237

194-
Anything finer than that belongs in `canExecute`.
238+
### Declared arguments
239+
240+
```ts
241+
defineCommand({
242+
name: "widget|add",
243+
arguments: [
244+
{
245+
name: "platform",
246+
required: true,
247+
errorMessage: "Specify the platform to add the widget for.",
248+
validate: (value) =>
249+
["android", "ios"].includes(value) ||
250+
`'${value}' is not a supported platform.`,
251+
},
252+
{ name: "template" },
253+
{ name: "files", variadic: true },
254+
],
255+
async run(ctx) {
256+
ctx.arguments.platform; // "android"
257+
ctx.arguments.template; // "blank", or absent
258+
ctx.arguments.files; // string[], possibly empty
259+
},
260+
});
261+
```
262+
263+
A spec accepts:
264+
265+
- `name` — the key the value appears under on `ctx.arguments`, and the name
266+
messages use.
267+
- `required` — defaults to false. A required argument may not follow an
268+
optional one; positional matching would never be able to satisfy it.
269+
- `variadic` — collects every remaining argument as a `string[]`. Must be the
270+
last spec. A required variadic wants at least one value.
271+
- `description` — reserved for generated help, like an option's.
272+
- `errorMessage` — replaces `Missing required argument '<name>'.` when the
273+
argument is required and absent.
274+
- `validate(value, ctx)` — run per value, `ctx` being the same context `run`
275+
receives. Return `true` to accept; return `false` for a default message, or
276+
return the message itself as a string. It may be `async`.
277+
278+
Enforcement happens before `canExecute`, in this order: missing required
279+
arguments (every missing one is named at once), then too many arguments, then
280+
each `validate`.
281+
282+
### Matching is strictly positional
283+
284+
The first spec takes the first argument, the second spec the second, and so on.
285+
This is a deliberate divergence from the `ICommandParameter` machinery a
286+
hand-written command class uses, where `CommandsService` scans the validators
287+
and lets a mandatory parameter claim whichever argument happens to satisfy it —
288+
so `ns command b a` could satisfy `[a, b]`. Nothing in the CLI depends on that
289+
behaviour, and positional is what the declaration reads like.
290+
291+
The practical consequence: `ctx.arguments.template` is `args[1]` whether or not
292+
`args[1]` looks like a template. An argument that could be several things is a
293+
job for `validate` or for `canExecute`, not for the matcher.
294+
295+
`ctx.arguments` is always present, even with `arguments: "none"` or `"any"`
296+
it is simply `{}` when no specs are declared. An optional non-variadic argument
297+
the command line did not reach is absent from it; a variadic one is always
298+
there, as an array.
195299

196300
### `canExecute` refines, it does not replace
197301

@@ -230,8 +334,12 @@ The run context
230334

231335
- `ctx.args``string[]`, the positional arguments left after the command name
232336
(including any subcommand segments) has been consumed.
337+
- `ctx.arguments` — the same arguments keyed by the names the `arguments` specs
338+
declare, `{}` when there are none.
233339
- `ctx.options` — the current value of each declared option, read at the moment
234340
the command executes.
341+
- `ctx.injector` — the injector this command was registered against; see
342+
[Injection, and the first `await`](#injection-and-the-first-await).
235343
- `ctx.fail(message)` — fails the command with `message` and a usage help
236344
suggestion.
237345

@@ -265,8 +373,11 @@ Throwing is equivalent and keeps working — `ctx.fail` is sugar over the
265373
--help`" line. Throw when you already have an `Error` to propagate; call
266374
`ctx.fail` when you are writing the message.
267375

268-
`run` starts inside a dependency-injection context, so `inject()` works
269-
directly:
376+
Injection, and the first `await`
377+
--------------------------------
378+
379+
`setup`, `canExecute`, `run` and `postRun` each start inside a
380+
dependency-injection context, so `inject()` works directly:
270381

271382
```ts
272383
import { defineCommand, inject } from "nativescript/contracts";
@@ -281,10 +392,85 @@ export default defineCommand({
281392
});
282393
```
283394

284-
The injection context is synchronous: `inject()` is valid up to the first
285-
`await` in `run`, and not after it. Capture what you need at the top of `run`,
286-
or inject the `Injector` itself and use `injector.get()` for late lookups. See
287-
`dependency-injection.md`.
395+
The injection context is synchronous, so **`inject()` is valid up to the first
396+
`await` in a handler, and not after it**. After that first `await`, use
397+
`ctx.injector.get(token)`:
398+
399+
```ts
400+
async run(ctx) {
401+
const packageManager = inject(PackageManager); // fine, no await yet
402+
await packageManager.install(name);
403+
// inject() would throw here
404+
const platform = ctx.injector.get(PlatformService);
405+
}
406+
```
407+
408+
`ctx.injector` is deliberately the injector itself rather than a bound
409+
`ctx.inject(...)`: it is a visibly different mechanism because it obeys
410+
different rules, and mistaking one for the other is exactly the bug this shape
411+
prevents. It is the injector the command was **registered against**, so it also
412+
resolves providers a child scope supplied — see
413+
[Registering a definition](#registering-a-definition). The same guidance, and
414+
the reasoning behind it, is in `dependency-injection.md`.
415+
416+
`setup` — hoisting work out of `run`
417+
------------------------------------
418+
419+
`setup(ctx)` runs once per invocation, before `canExecute`, and its return
420+
value is handed to `canExecute`, `run` and `postRun` as their second argument:
421+
422+
```ts
423+
export default defineCommand({
424+
name: "widget|add",
425+
arguments: "any",
426+
setup() {
427+
const projectData = inject(ProjectData);
428+
projectData.initializeProjectData();
429+
return { projectData, widgets: inject(WidgetService) };
430+
},
431+
canExecute(ctx, { projectData }) {
432+
return !!projectData.projectDir;
433+
},
434+
async run(ctx, { widgets }) {
435+
await widgets.add(ctx.args);
436+
},
437+
});
438+
```
439+
440+
It exists for two reasons. It is the place to inject services before the first
441+
`await` when several handlers need them, and it is where the work a command
442+
class used to do in its constructor goes — most often
443+
`$projectData.initializeProjectData()`.
444+
445+
`setup` is sugar. A command may ignore it entirely and call `inject()` at the
446+
top of `run`; nothing else changes. "Once per invocation" means once across
447+
`canExecute`, `run` and `postRun` together — whichever of them the CLI reaches
448+
first triggers it, and the rest reuse the value.
449+
450+
`run`'s return value, and `postRun`
451+
-----------------------------------
452+
453+
`run` may return a value. When the definition declares `postRun`, that value is
454+
passed to it after `run` succeeds:
455+
456+
```ts
457+
export default defineCommand({
458+
name: "create",
459+
arguments: [{ name: "appName", required: true }],
460+
async run(ctx) {
461+
const projectDir = await createProject(ctx.arguments.appName as string);
462+
return { projectDir };
463+
},
464+
postRun(ctx, { projectDir }) {
465+
printSuccessMessage(projectDir);
466+
},
467+
});
468+
```
469+
470+
`postRun` maps onto the legacy `postCommandAction`: the CLI runs it after the
471+
command itself, outside the command's own error handling. The value travels
472+
through `run`'s return rather than through a mutable field on the definition,
473+
because a definition object is shared by every registration of it.
288474

289475
Other flags
290476
-----------
@@ -327,26 +513,56 @@ Extensions do not need `registerCommandDefinition` at all: a
327513
exports a definition, and the CLI adapts and registers it lazily under the
328514
manifest key (see [extensions.md](extensions.md)).
329515

516+
### One definition, several registrations
517+
518+
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:
521+
522+
```ts
523+
const PLATFORM = new InjectionToken<string>("commandPlatform");
524+
525+
for (const platform of ["android", "ios"]) {
526+
registerCommandDefinition(
527+
{ ...definition, name: `run|${platform}` },
528+
injector.createChild([{ provide: PLATFORM, useValue: platform }]),
529+
);
530+
}
531+
```
532+
533+
The definition then reads `inject(PLATFORM)` — or `ctx.injector.get(PLATFORM)`
534+
after the first `await` — and needs to know nothing else. The spread keeps the
535+
`defineCommand` marker, so the copy is still a `DefinedCommand`.
536+
537+
This replaces the class-inheritance pattern the legacy commands use, where a
538+
per-platform command subclasses a shared base to override one field.
539+
330540
Relationship to `ICommand`
331541
--------------------------
332542

333543
A definition is compiled into an ordinary `ICommand`, so nothing downstream —
334544
the registry, the router, hooks, help, analytics — knows the difference. The
335545
mapping is:
336546

337-
| Definition | `ICommand` |
338-
| --------------------------------- | -------------------------------------------------- |
339-
| `options` | `dashedOptions` |
340-
| `run` | `execute`, wrapped in an injection context |
341-
| `arguments`, `canExecute` | `canExecute`: policy enforced, then the refinement |
342-
|| `allowedParameters`, always `[]` |
343-
| `disableAnalytics`, `enableHooks` | passed through unchanged |
547+
| Definition | `ICommand` |
548+
| --------------------------------- | --------------------------------------------------- |
549+
| `options` | `dashedOptions` |
550+
| `run` | `execute`, wrapped in an injection context |
551+
| `arguments`, `canExecute` | `canExecute`: policy enforced, then the refinement |
552+
| `setup` | — run inside `canExecute`/`execute`, memoised |
553+
| `postRun` | `postCommandAction`, with `run`'s return value |
554+
| `allowUnknownOptions` | `skipOptionsValidation` |
555+
|| `allowedParameters`, always `[]` |
556+
| `disableAnalytics`, `enableHooks` | passed through unchanged |
344557

345558
The compiled command always exposes `canExecute`, because `CommandsService`
346559
stops consulting `allowedParameters` as soon as a command has one — the adapter
347-
therefore enforces the `arguments` policy itself.
560+
therefore enforces the `arguments` policy itself. `allowedParameters` stays
561+
empty, which is why declared `arguments` are matched positionally rather than
562+
by the `ICommandParameter` scan.
348563

349564
Existing command classes need no migration. Reach for a definition when a
350565
command is mostly "parse these flags and do this"; a class still makes sense
351566
when a command needs constructor-injected collaborators shared across several
352-
methods, custom `ICommandParameter` validators, or a `postCommandAction`.
567+
methods, or `ICommandParameter` validators whose claim-any-argument matching it
568+
actually depends on.

lib/commands/preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const PREVIEW_CLI_PACKAGE = "@nativescript/preview-cli";
1212

1313
export class PreviewCommand implements ICommand {
1414
allowedParameters: ICommandParameter[] = [];
15-
skipOptionsValidation = true;
15+
allowUnknownOptions = true;
1616

1717
constructor(
1818
private $logger: ILogger,

0 commit comments

Comments
 (0)