Skip to content

Commit 2f81d7f

Browse files
authored
feat(commands): defineCommand - typed declarative commands via an ICommand adapter (#6101)
1 parent ef68d67 commit 2f81d7f

8 files changed

Lines changed: 2032 additions & 1 deletion

File tree

defining-commands.md

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
Defining Commands
2+
=================
3+
4+
`defineCommand` is the declarative way to add a command to the NativeScript
5+
CLI. A definition is a plain object: a name, an option schema, and a `run`
6+
function. The CLI compiles it into the command shape its registry expects, so a
7+
definition gets the same option parsing, hooks, analytics and help wiring as a
8+
hand-written command class — without a class, a constructor, or an
9+
`allowedParameters` array.
10+
11+
This is purely additive. The legacy `ICommand` classes registered through
12+
`$injector.registerCommand` keep working exactly as before, and the two styles
13+
coexist in the same registry.
14+
15+
At a glance
16+
-----------
17+
18+
```ts
19+
import {
20+
defineCommand,
21+
booleanOption,
22+
stringOption,
23+
} from "nativescript/contracts";
24+
25+
export default defineCommand({
26+
name: "widget|add",
27+
description: "Adds a widget to the project",
28+
options: {
29+
overwrite: booleanOption({ default: false }),
30+
output: stringOption({ alias: "o" }),
31+
},
32+
arguments: "any",
33+
async run(ctx) {
34+
// ctx.args -> string[] of positional arguments
35+
// ctx.options -> { overwrite: boolean; output: string | undefined }
36+
if (ctx.options.output) {
37+
console.log(`adding ${ctx.args.join(", ")} to ${ctx.options.output}`);
38+
}
39+
},
40+
});
41+
```
42+
43+
`defineCommand` validates the definition and returns it, tagged with a marker
44+
symbol so that any copy of the CLI can recognise it. `isCommandDefinition(value)`
45+
is the exported check, and it narrows to `DefinedCommand`. The tag survives a
46+
spread, so `{ ...baseDefinition, name: "widget|add2" }` is still recognised.
47+
48+
`defineCommand` does not register anything by itself — see
49+
[Registering a definition](#registering-a-definition).
50+
51+
Validation happens where you can see it
52+
---------------------------------------
53+
54+
A definition is checked at the moment `defineCommand` is called, not when the
55+
command eventually runs. A misspelled field, a missing `run`, an option
56+
declared with something other than the four helpers, an `arguments` value
57+
outside `"none" | "any"` — each throws immediately, naming the command and the
58+
accepted form:
59+
60+
```
61+
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.
66+
```
67+
68+
Names and the command hierarchy
69+
-------------------------------
70+
71+
`name` is either a single string or an array of strings, in which case every
72+
entry becomes an alias for the same command.
73+
74+
The CLI's command registry is flat; the hierarchy the user types on the command
75+
line is encoded in the name with a `|` separator. `"widget|add"` is the command
76+
invoked as `ns widget add`, and `"widget|template|list"` is `ns widget template
77+
list`. Registering a hierarchical name automatically synthesises the parent
78+
dispatcher (`widget`), which routes to the right subcommand or prints help.
79+
80+
A leading `*` on the last segment marks a **default subcommand**: `"widget|*add"`
81+
runs both for `ns widget add` and for a bare `ns widget`. This is the convention
82+
the CLI's own commands use (`run|*all`, `debug|*all`); the encoding is
83+
user-visible because it feeds shell autocompletion and generated help.
84+
85+
A parent name cannot also be a command of its own. If `widget` is already
86+
registered as a flat command, registering `widget|add` leaves that command in
87+
place, warns naming both, and creates no dispatcher — so `ns widget add` will
88+
not route until one of the two is renamed.
89+
90+
Options
91+
-------
92+
93+
`options` is a schema keyed by the long option name — `output` is passed as
94+
`--output`. Declare each entry with one of the four helpers, which fix the
95+
value type:
96+
97+
| Helper | Declared with `default` | Declared without |
98+
| --------------- | ----------------------- | ----------------------- |
99+
| `booleanOption` | `boolean` | `boolean \| undefined` |
100+
| `stringOption` | `string` | `string \| undefined` |
101+
| `numberOption` | `number` | `number \| undefined` |
102+
| `arrayOption` | `string[]` | `string[] \| undefined` |
103+
104+
The two columns are the whole story of the option types: a flag the user did
105+
not pass is absent at runtime, so only a `default` makes the value on
106+
`ctx.options` always present. Declare a default whenever there is a sensible
107+
one and the `| undefined` disappears from the type.
108+
109+
Each helper takes an optional spec:
110+
111+
```ts
112+
options: {
113+
// --release, absent means false
114+
release: booleanOption({ default: false }),
115+
// --output <dir>, also accepted as -o <dir>
116+
output: stringOption({ alias: "o", description: "Output directory" }),
117+
// --retries <n>
118+
retries: numberOption({ default: 3 }),
119+
// --file a.ts --file b.ts
120+
file: arrayOption(),
121+
// kept out of analytics and logs
122+
token: stringOption({ hasSensitiveValue: true }),
123+
}
124+
```
125+
126+
- `default` — value used when the flag is absent.
127+
- `alias` — single-dash shorthand, or an array of them (`alias: ["o", "out"]`).
128+
- `hasSensitiveValue` — defaults to `false`; set it for anything that must not
129+
be recorded. There is no reason not to be explicit about credentials, paths
130+
containing user directories, and tokens.
131+
- `description` — reserved for generated help. It reaches the option parser but
132+
nothing renders it yet.
133+
134+
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.
138+
139+
### Sharing a schema between commands
140+
141+
Extract the schema with `satisfies` rather than a type annotation. An
142+
annotation widens every entry back to the general spec type and the `default`
143+
information — and with it the non-optional value types — is lost:
144+
145+
```ts
146+
const buildOptions = {
147+
release: booleanOption({ default: false }),
148+
output: stringOption({ alias: "o" }),
149+
} satisfies CommandOptionsSchema;
150+
```
151+
152+
### Do not shadow a CLI-wide option
153+
154+
`--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.
159+
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+
164+
### How validation behaves
165+
166+
Option validation is the CLI's existing behaviour, not something the definition
167+
opts into. Before a command runs, the parser is re-primed with that command's
168+
declared options and the command line is re-parsed:
169+
170+
- Declared options are accepted and appear on `ctx.options`.
171+
- An option the CLI does not know — neither global nor declared by this command
172+
— produces a warning: `The option '<name>' is not supported. This will become
173+
an error in a future release.` The command still runs. Set
174+
`NS_STRICT_OPTIONS=error` to preview the hard failure, which is what a future
175+
release will do by default.
176+
- The same staging applies to value-shape violations: a string option passed
177+
with no value, an array option passed nothing, a single-valued option passed
178+
twice.
179+
180+
So adding an option is a matter of adding a schema entry; forgetting to declare
181+
one that users pass is a warning today and a failure later, never a silent
182+
`undefined`.
183+
184+
Positional arguments
185+
--------------------
186+
187+
`arguments` declares whether the command takes positional arguments at all:
188+
189+
- `"none"` (the default) — the command accepts no positional arguments. Passing
190+
any is rejected with `This command doesn't accept parameters.`
191+
- `"any"` — positional arguments are accepted and handed to `run` as
192+
`ctx.args`.
193+
194+
Anything finer than that belongs in `canExecute`.
195+
196+
### `canExecute` refines, it does not replace
197+
198+
```ts
199+
defineCommand({
200+
name: "widget|add",
201+
arguments: "any",
202+
async canExecute(ctx) {
203+
return ctx.args.length === 1;
204+
},
205+
async run(ctx) {
206+
/* ... */
207+
},
208+
});
209+
```
210+
211+
The two fields compose. The declared `arguments` policy is enforced first, and
212+
`canExecute` is consulted only for command lines that already satisfy it — so a
213+
definition that leaves `arguments` at `"none"` still rejects stray positional
214+
arguments even when it supplies a `canExecute`, and a `canExecute` that only
215+
inspects options cannot accidentally widen what the command accepts.
216+
217+
`canExecute` receives a context of the same shape as `run`'s — the same
218+
`args`, the same declared options and the same `fail` — built freshly for the
219+
call, and returns a boolean (or a promise of one). Returning `false` aborts the
220+
command and prints a bare help suggestion; `ctx.fail(message)` aborts it with
221+
your own message, which is usually the friendlier choice.
222+
223+
`canExecute` runs inside a dependency-injection context, on the same terms as
224+
`run`: `inject()` is valid up to the first `await`.
225+
226+
The run context
227+
---------------
228+
229+
`run(ctx)` receives:
230+
231+
- `ctx.args``string[]`, the positional arguments left after the command name
232+
(including any subcommand segments) has been consumed.
233+
- `ctx.options` — the current value of each declared option, read at the moment
234+
the command executes.
235+
- `ctx.fail(message)` — fails the command with `message` and a usage help
236+
suggestion.
237+
238+
`run` may be synchronous or `async`; the CLI awaits the result and treats a
239+
rejection as a command failure.
240+
241+
### Failing a command
242+
243+
`ctx.fail(message)` is the idiomatic way to stop a command:
244+
245+
```ts
246+
defineCommand({
247+
name: "widget|add",
248+
arguments: "any",
249+
options: { output: stringOption() },
250+
async run(ctx) {
251+
if (!ctx.options.output) {
252+
ctx.fail("--output is required.");
253+
}
254+
255+
/* ... */
256+
},
257+
});
258+
```
259+
260+
It is available on the `canExecute` context as well, and it returns `never`, so
261+
it can end a branch without a `return`. The message must be a non-empty string.
262+
263+
Throwing is equivalent and keeps working — `ctx.fail` is sugar over the
264+
`errors` service's `failWithHelp`, which is what adds the "Run `ns widget add
265+
--help`" line. Throw when you already have an `Error` to propagate; call
266+
`ctx.fail` when you are writing the message.
267+
268+
`run` starts inside a dependency-injection context, so `inject()` works
269+
directly:
270+
271+
```ts
272+
import { defineCommand, inject } from "nativescript/contracts";
273+
import { DoctorService } from "nativescript/contracts";
274+
275+
export default defineCommand({
276+
name: "widget|check",
277+
async run() {
278+
const doctorService = inject(DoctorService);
279+
await doctorService.printWarnings();
280+
},
281+
});
282+
```
283+
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`.
288+
289+
Other flags
290+
-----------
291+
292+
- `disableAnalytics: true` — skips analytics tracking for this command.
293+
- `enableHooks: false` — skips the before/after hooks that normally run around
294+
the command. Hooks are enabled by default.
295+
296+
Both are simply passed through to the command the CLI executes; omitting them
297+
leaves the CLI's defaults in place.
298+
299+
Registering a definition
300+
------------------------
301+
302+
Inside the CLI, a definition is registered with `registerCommandDefinition`:
303+
304+
```ts
305+
import { registerCommandDefinition } from "../common/services/command-definition-adapter";
306+
import addWidgetCommand from "./add-widget";
307+
308+
registerCommandDefinition(addWidgetCommand);
309+
```
310+
311+
It takes a `DefinedCommand` — the result of `defineCommand`, marker and all —
312+
and rejects a bare object of the right shape, so a definition can never reach
313+
the registry without having been validated. It registers under every name the
314+
definition declares, through the `CommandRegistry` the target injector provides;
315+
pass a second argument to target a different injector (tests do this). The
316+
command instance is built by a factory on first resolution and cached.
317+
318+
`registerCommandDefinition` lives in
319+
`lib/common/services/command-definition-adapter` rather than in
320+
`nativescript/contracts`, because it reaches into the CLI runtime — the
321+
side-effect-free contracts entry point deliberately does not pull it in.
322+
`defineCommand`, the option helpers and all the types are exported from both
323+
`nativescript/contracts` and `lib/common/define-command`.
324+
325+
Declaring commands from an extension manifest, so that an extension does not
326+
have to call a registration function at load time, is being added separately.
327+
Until then, extensions register definitions the same way the CLI does.
328+
329+
Relationship to `ICommand`
330+
--------------------------
331+
332+
A definition is compiled into an ordinary `ICommand`, so nothing downstream —
333+
the registry, the router, hooks, help, analytics — knows the difference. The
334+
mapping is:
335+
336+
| Definition | `ICommand` |
337+
| --------------------------------- | -------------------------------------------------- |
338+
| `options` | `dashedOptions` |
339+
| `run` | `execute`, wrapped in an injection context |
340+
| `arguments`, `canExecute` | `canExecute`: policy enforced, then the refinement |
341+
|| `allowedParameters`, always `[]` |
342+
| `disableAnalytics`, `enableHooks` | passed through unchanged |
343+
344+
The compiled command always exposes `canExecute`, because `CommandsService`
345+
stops consulting `allowedParameters` as soon as a command has one — the adapter
346+
therefore enforces the `arguments` policy itself.
347+
348+
Existing command classes need no migration. Reach for a definition when a
349+
command is mostly "parse these flags and do this"; a class still makes sense
350+
when a command needs constructor-injected collaborators shared across several
351+
methods, custom `ICommandParameter` validators, or a `postCommandAction`.

0 commit comments

Comments
 (0)