Skip to content

Commit 916d504

Browse files
committed
feat(commands): add defineCommand with typed options via an ICommand adapter
Commands can now be declared as plain objects: a name, an option schema built from booleanOption/stringOption/numberOption/arrayOption, and a run function whose context carries the positional args plus the declared options, typed by inference from the schema. lib/common/define-command holds the types and the pure factories only, so it stays side-effect-free and can be re-exported from nativescript/contracts. The runtime bridge lives in lib/common/services/command-definition-adapter, which compiles a definition into the ICommand the legacy registry expects and runs it inside an injection context. canExecute is emitted only when the definition supplies one or opts into arguments: "any"; CommandsService skips all parameter validation as soon as canExecute exists, so omitting it is what lets the framework reject stray positional arguments for arguments: "none". Fully additive — existing ICommand classes are untouched.
1 parent ec5de90 commit 916d504

5 files changed

Lines changed: 954 additions & 0 deletions

File tree

defining-commands.md

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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+
verbose: 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 -> { verbose: boolean; output: string }
36+
if (ctx.options.verbose) {
37+
console.log(`adding ${ctx.args.join(", ")} to ${ctx.options.output}`);
38+
}
39+
},
40+
});
41+
```
42+
43+
`defineCommand` returns the definition, tagged with a marker symbol so that any
44+
copy of the CLI can recognise it (`isCommandDefinition(value)` is the exported
45+
predicate). It does not register anything by itself — see
46+
[Registering a definition](#registering-a-definition).
47+
48+
Names and the command hierarchy
49+
-------------------------------
50+
51+
`name` is either a single string or an array of strings, in which case every
52+
entry becomes an alias for the same command.
53+
54+
The CLI's command registry is flat; the hierarchy the user types on the command
55+
line is encoded in the name with a `|` separator. `"widget|add"` is the command
56+
invoked as `ns widget add`, and `"widget|template|list"` is `ns widget template
57+
list`. Registering a hierarchical name automatically synthesises the parent
58+
dispatcher (`widget`), which routes to the right subcommand or prints help.
59+
60+
A leading `*` on the last segment marks a **default subcommand**: `"widget|*add"`
61+
runs both for `ns widget add` and for a bare `ns widget`. This is the convention
62+
the CLI's own commands use (`run|*all`, `debug|*all`); the encoding is
63+
user-visible because it feeds shell autocompletion and generated help.
64+
65+
Options
66+
-------
67+
68+
`options` is a schema keyed by the long option name — `verbose` is passed as
69+
`--verbose`. Declare each entry with one of the four helpers, which fix the
70+
value type:
71+
72+
| Helper | Value type on `ctx.options` |
73+
| --------------- | --------------------------- |
74+
| `booleanOption` | `boolean` |
75+
| `stringOption` | `string` |
76+
| `numberOption` | `number` |
77+
| `arrayOption` | `string[]` |
78+
79+
Each helper takes an optional spec:
80+
81+
```ts
82+
options: {
83+
// --release, absent means false
84+
release: booleanOption({ default: false }),
85+
// --output <dir>, also accepted as -o <dir>
86+
output: stringOption({ alias: "o", description: "Output directory" }),
87+
// --retries <n>
88+
retries: numberOption({ default: 3 }),
89+
// --file a.ts --file b.ts
90+
file: arrayOption(),
91+
// kept out of analytics and logs
92+
token: stringOption({ hasSensitiveValue: true }),
93+
}
94+
```
95+
96+
- `default` — value used when the flag is absent.
97+
- `alias` — single-dash shorthand, or an array of them.
98+
- `hasSensitiveValue` — defaults to `false`; set it for anything that must not
99+
be recorded. There is no reason not to be explicit about credentials, paths
100+
containing user directories, and tokens.
101+
- `description` — shown in generated help.
102+
103+
The schema types `ctx.options` and nothing else: `ctx.options` carries exactly
104+
the declared keys, with the types the helpers imply. Values that the CLI parses
105+
globally (`--path`, `--log`, …) are not exposed there; resolve the `options`
106+
service if you need them.
107+
108+
### How validation behaves
109+
110+
Option validation is the CLI's existing behaviour, not something the definition
111+
opts into. Before a command runs, the parser is re-primed with that command's
112+
declared options and the command line is re-parsed:
113+
114+
- Declared options are accepted and appear on `ctx.options`.
115+
- An option the CLI does not know — neither global nor declared by this command
116+
— is a **hard error**: the command does not run, and the CLI prints
117+
`The option '<name>' is not supported.` followed by a help suggestion.
118+
119+
So adding an option is exactly a matter of adding a schema entry; forgetting to
120+
declare one that users pass is a failure, not a silent `undefined`.
121+
122+
Positional arguments
123+
--------------------
124+
125+
`arguments` declares whether the command takes positional arguments at all:
126+
127+
- `"none"` (the default) — the command accepts no positional arguments. Passing
128+
any is rejected with `This command doesn't accept parameters.`
129+
- `"any"` — positional arguments are accepted and handed to `run` as
130+
`ctx.args`.
131+
132+
Anything finer than that belongs in `canExecute`.
133+
134+
### `canExecute` owns validation
135+
136+
```ts
137+
defineCommand({
138+
name: "widget|add",
139+
arguments: "any",
140+
async canExecute(ctx) {
141+
return ctx.args.length === 1;
142+
},
143+
async run(ctx) {
144+
/* ... */
145+
},
146+
});
147+
```
148+
149+
`canExecute` receives the same context as `run` and returns a boolean (or a
150+
promise of one). Returning `false` aborts the command and prints a help
151+
suggestion; throwing surfaces your own error message, which is usually the
152+
friendlier choice.
153+
154+
There is one rule to internalise: **the moment a command supplies
155+
`canExecute`, it owns argument validation completely.** The framework returns
156+
that verdict and skips every built-in parameter check, including the
157+
"no parameters" rule implied by `arguments: "none"`. A definition with a
158+
`canExecute` that only inspects options will therefore accept stray positional
159+
arguments unless it checks `ctx.args` itself. If you do not need custom
160+
validation, omit `canExecute` and let `arguments` do the work.
161+
162+
The run context
163+
---------------
164+
165+
`run(ctx)` receives:
166+
167+
- `ctx.args``string[]`, the positional arguments left after the command name
168+
(including any subcommand segments) has been consumed.
169+
- `ctx.options` — the current value of each declared option, read at the moment
170+
the command executes.
171+
172+
`run` may be synchronous or `async`; the CLI awaits the result and treats a
173+
rejection as a command failure.
174+
175+
`run` starts inside a dependency-injection context, so `inject()` works
176+
directly:
177+
178+
```ts
179+
import { defineCommand, inject } from "nativescript/contracts";
180+
import { DoctorService } from "nativescript/contracts";
181+
182+
export default defineCommand({
183+
name: "widget|check",
184+
async run() {
185+
const doctorService = inject(DoctorService);
186+
await doctorService.printWarnings();
187+
},
188+
});
189+
```
190+
191+
The injection context is synchronous: `inject()` is valid up to the first
192+
`await` in `run`, and not after it. Capture what you need at the top of `run`,
193+
or inject the `Injector` itself and use `injector.get()` for late lookups. See
194+
`dependency-injection.md`.
195+
196+
Other flags
197+
-----------
198+
199+
- `disableAnalytics: true` — skips analytics tracking for this command.
200+
- `enableHooks: false` — skips the before/after hooks that normally run around
201+
the command. Hooks are enabled by default.
202+
203+
Both are simply passed through to the command the CLI executes; omitting them
204+
leaves the CLI's defaults in place.
205+
206+
Registering a definition
207+
------------------------
208+
209+
Inside the CLI, a definition is registered with `registerCommandDefinition`:
210+
211+
```ts
212+
import { registerCommandDefinition } from "../common/services/command-definition-adapter";
213+
import addWidgetCommand from "./add-widget";
214+
215+
registerCommandDefinition(addWidgetCommand);
216+
```
217+
218+
It registers the definition under every name it declares, on the CLI's injector
219+
by default; pass a second argument to target a different injector (tests do
220+
this). The command instance is created on first resolution and cached.
221+
222+
`registerCommandDefinition` lives in
223+
`lib/common/services/command-definition-adapter` rather than in
224+
`nativescript/contracts`, because it reaches into the CLI runtime — the
225+
side-effect-free contracts entry point deliberately does not pull it in.
226+
`defineCommand`, the option helpers and all the types are exported from both
227+
`nativescript/contracts` and `lib/common/define-command`.
228+
229+
Declaring commands from an extension manifest, so that an extension does not
230+
have to call a registration function at load time, is being added separately.
231+
Until then, extensions register definitions the same way the CLI does.
232+
233+
Relationship to `ICommand`
234+
--------------------------
235+
236+
A definition is compiled into an ordinary `ICommand`, so nothing downstream —
237+
the registry, the router, hooks, help, analytics — knows the difference. The
238+
mapping is:
239+
240+
| Definition | `ICommand` |
241+
| --------------------------------- | ------------------------------------------ |
242+
| `options` | `dashedOptions` |
243+
| `run` | `execute`, wrapped in an injection context |
244+
| `arguments`, `canExecute` | `canExecute` (see the rule above) |
245+
|| `allowedParameters`, always `[]` |
246+
| `disableAnalytics`, `enableHooks` | passed through unchanged |
247+
248+
Existing command classes need no migration. Reach for a definition when a
249+
command is mostly "parse these flags and do this"; a class still makes sense
250+
when a command needs constructor-injected collaborators shared across several
251+
methods, custom `ICommandParameter` validators, or a `postCommandAction`.

lib/common/define-command.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* The declarative command API. Types and pure factories only — this module is
3+
* re-exported from `nativescript/contracts` and must stay side-effect-free, so
4+
* it may not import lib/common/yok (whose import creates global.$injector).
5+
* The runtime bridge onto the legacy registry lives in
6+
* lib/common/services/command-definition-adapter.
7+
*/
8+
9+
/**
10+
* Symbol.for so that a definition produced by one copy of the CLI is still
11+
* recognised by another — extensions bundle their own node_modules.
12+
*/
13+
export const COMMAND_DEFINITION_MARKER = Symbol.for(
14+
"nativescript:cli:commandDefinition",
15+
);
16+
17+
export type CommandOptionType = "boolean" | "string" | "number" | "array";
18+
19+
export interface ICommandOptionSpec<TValue = any> {
20+
type: CommandOptionType;
21+
/** Value used when the flag is absent from the command line. */
22+
default?: TValue;
23+
/** Single-dash shorthand, e.g. `-o` for `--output`. */
24+
alias?: string | string[];
25+
/** Keeps the value out of analytics and logs. Defaults to false. */
26+
hasSensitiveValue?: boolean;
27+
description?: string;
28+
}
29+
30+
/** The parts of an option spec a caller supplies; `type` comes from the helper. */
31+
export type CommandOptionSpecInit<TValue = any> = Omit<
32+
ICommandOptionSpec<TValue>,
33+
"type"
34+
>;
35+
36+
export interface ICommandOptionsSchema {
37+
[optionName: string]: ICommandOptionSpec;
38+
}
39+
40+
export type CommandOptionValues<TSchema extends ICommandOptionsSchema> = {
41+
[K in keyof TSchema]: TSchema[K] extends ICommandOptionSpec<infer TValue>
42+
? TValue
43+
: any;
44+
};
45+
46+
export interface ICommandContext<
47+
TSchema extends ICommandOptionsSchema = ICommandOptionsSchema,
48+
> {
49+
/** Positional arguments, after the command name has been consumed. */
50+
args: string[];
51+
/** Current value of every option declared in the schema, and nothing else. */
52+
options: CommandOptionValues<TSchema>;
53+
}
54+
55+
export interface ICommandDefinition<
56+
TSchema extends ICommandOptionsSchema = ICommandOptionsSchema,
57+
> {
58+
/** `"widget|add"`; `|` separates hierarchy levels. Several names alias one command. */
59+
name: string | string[];
60+
description?: string;
61+
options?: TSchema;
62+
/**
63+
* `"none"` (the default) rejects positional arguments; `"any"` accepts them.
64+
* Anything finer belongs in `canExecute`.
65+
*/
66+
arguments?: "none" | "any";
67+
canExecute?(context: ICommandContext<TSchema>): Promise<boolean> | boolean;
68+
disableAnalytics?: boolean;
69+
enableHooks?: boolean;
70+
run(context: ICommandContext<TSchema>): Promise<void> | void;
71+
}
72+
73+
const optionSpec = <TValue>(
74+
type: CommandOptionType,
75+
init: CommandOptionSpecInit<TValue>,
76+
): ICommandOptionSpec<TValue> => ({ ...init, type });
77+
78+
export const booleanOption = (
79+
init: CommandOptionSpecInit<boolean> = {},
80+
): ICommandOptionSpec<boolean> => optionSpec("boolean", init);
81+
82+
export const stringOption = (
83+
init: CommandOptionSpecInit<string> = {},
84+
): ICommandOptionSpec<string> => optionSpec("string", init);
85+
86+
export const numberOption = (
87+
init: CommandOptionSpecInit<number> = {},
88+
): ICommandOptionSpec<number> => optionSpec("number", init);
89+
90+
export const arrayOption = (
91+
init: CommandOptionSpecInit<string[]> = {},
92+
): ICommandOptionSpec<string[]> => optionSpec("array", init);
93+
94+
export function defineCommand<TSchema extends ICommandOptionsSchema>(
95+
definition: ICommandDefinition<TSchema>,
96+
): ICommandDefinition<TSchema> {
97+
const marked: ICommandDefinition<TSchema> = { ...definition };
98+
(<any>marked)[COMMAND_DEFINITION_MARKER] = true;
99+
return marked;
100+
}
101+
102+
export function isCommandDefinition(value: any): value is ICommandDefinition {
103+
return !!value && (<any>value)[COMMAND_DEFINITION_MARKER] === true;
104+
}

0 commit comments

Comments
 (0)