@@ -59,10 +59,11 @@ accepted form:
5959
6060```
6161Invalid 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
6869Names and the command hierarchy
@@ -132,9 +133,11 @@ options: {
132133 nothing renders it yet.
133134
134135The 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
181201one 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+
184227Positional 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
272383import { 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
289475Other flags
290476-----------
@@ -327,26 +513,56 @@ Extensions do not need `registerCommandDefinition` at all: a
327513exports a definition, and the CLI adapts and registers it lazily under the
328514manifest 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+
330540Relationship to ` ICommand `
331541--------------------------
332542
333543A definition is compiled into an ordinary ` ICommand ` , so nothing downstream —
334544the registry, the router, hooks, help, analytics — knows the difference. The
335545mapping 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
345558The compiled command always exposes ` canExecute ` , because ` CommandsService `
346559stops 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
349564Existing command classes need no migration. Reach for a definition when a
350565command is mostly "parse these flags and do this"; a class still makes sense
351566when 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.
0 commit comments