defineCommand is the declarative way to add a command to the NativeScript
CLI. A definition is a plain object: a name, an option schema, and a run
function. The CLI compiles it into the command shape its registry expects, so a
definition gets the same option parsing, hooks, analytics and help wiring as a
hand-written command class — without a class, a constructor, or an
allowedParameters array.
This is purely additive. The legacy ICommand classes registered through
$injector.registerCommand keep working exactly as before, and the two styles
coexist in the same registry.
import {
defineCommand,
booleanOption,
stringOption,
} from "nativescript/contracts";
export default defineCommand({
name: "widget|add",
description: "Adds a widget to the project",
options: {
overwrite: booleanOption({ default: false }),
output: stringOption({ alias: "o" }),
},
params: "any",
async run(ctx) {
// ctx.args -> string[] of positional arguments
// ctx.options -> { overwrite: boolean; output: string | undefined }
if (ctx.options.output) {
console.log(`adding ${ctx.args.join(", ")} to ${ctx.options.output}`);
}
},
});defineCommand validates the definition and returns it, tagged with a marker
symbol so that any copy of the CLI can recognise it. isCommandDefinition(value)
is the exported check, and it narrows to DefinedCommand. The tag survives a
spread, so { ...baseDefinition, name: "widget|add2" } is still recognised.
defineCommand does not register anything by itself — see
Registering a definition.
A command may also be written as a class, with the handlers as methods — see
Class form. It is sugar over defineCommand: everything below
describes both.
A definition is checked at the moment defineCommand is called, not when the
command eventually runs. A misspelled field, a missing run, an option
declared with something other than the five helpers, an params value that
is neither "none", "any" nor a list of argument specs — each throws
immediately, naming the command and the accepted form. The class form's meta is checked the same way at the
Command({ ... }) call, which also rejects handlers passed there; only a
missing run method waits until the definition is first read:
Invalid command definition for 'widget|add': unknown field(s) 'handler'; a
definition accepts name, description, options, params, allowUnknownOptions,
canExecute, disableAnalytics, enableHooks, providers, setup, run, shortcuts,
postRun. Accepted form: defineCommand({ name: "widget|add", run(ctx) { ... } })
— with the optional fields description, options, params,
allowUnknownOptions, providers, setup, canExecute, shortcuts, postRun,
disableAnalytics and enableHooks. Or the class form, class WidgetAdd extends
Command({ name: "widget|add" }) { run() { ... } }, which declares the same
fields except the handlers and implements run, and optionally canExecute,
postRun and shortcuts, as methods.
name is either a single string or an array of strings, in which case every
entry becomes an alias for the same command.
The CLI's command registry is flat; the hierarchy the user types on the command
line is encoded in the name with a | separator. "widget|add" is the command
invoked as ns widget add, and "widget|template|list" is ns widget template list. Registering a hierarchical name automatically synthesises the parent
dispatcher (widget), which routes to the right subcommand or prints help.
A leading * on the last segment marks a default subcommand: "widget|*add"
runs both for ns widget add and for a bare ns widget. This is the convention
the CLI's own commands use (run|*all, debug|*all); the encoding is
user-visible because it feeds shell autocompletion and generated help.
A parent name cannot also be a command of its own. If widget is already
registered as a flat command, registering widget|add leaves that command in
place, warns naming both, and creates no dispatcher — so ns widget add will
not route until one of the two is renamed.
options is a schema keyed by the long option name — output is passed as
--output. Declare each entry with one of the five helpers, which fix the
value type:
| Helper | Declared with default |
Declared without |
|---|---|---|
booleanOption |
boolean |
boolean | undefined |
stringOption |
string |
string | undefined |
numberOption |
number |
number | undefined |
arrayOption |
string[] |
string[] | undefined |
objectOption |
any |
any |
objectOption is for flags the parser nests, such as --env.production, and
its value is untyped.
The two columns are the whole story of the option types: a flag the user did
not pass is absent at runtime, so only a default makes the value on
ctx.options always present. Declare a default whenever there is a sensible
one and the | undefined disappears from the type.
Each helper takes an optional spec:
options: {
// --release, absent means false
release: booleanOption({ default: false }),
// --output <dir>, also accepted as -o <dir>
output: stringOption({ alias: "o", description: "Output directory" }),
// --retries <n>
retries: numberOption({ default: 3 }),
// --file a.ts --file b.ts
file: arrayOption(),
// kept out of analytics and logs
token: stringOption({ hasSensitiveValue: true }),
}default— value used when the flag is absent.alias— single-dash shorthand, or an array of them (alias: ["o", "out"]).required— the command line must pass the flag. Its absence fails the invocation withThe option '--output' is required.after the params policy and beforecanExecute, and the handler's value type drops| undefined, as it does for a spec with adefault. The two do not combine.hasSensitiveValue— defaults tofalse; set it for anything that must not be recorded. There is no reason not to be explicit about credentials, paths containing user directories, and tokens.description— reserved for generated help. It reaches the option parser but nothing renders it yet.
The schema types ctx.options and nothing else: ctx.options carries exactly
the declared keys, and a typo is a compile error. There is deliberately no
"give me everything" escape hatch — a command declares every option it reads,
CLI-wide ones (--release, --path, --bundle, …) included. Declaring one
that the CLI already knows is supported and carries its value through to
ctx.options exactly as a command-specific one does.
Extract the schema with satisfies rather than a type annotation. An
annotation widens every entry back to the general spec type and the default
information — and with it the non-optional value types — is lost:
const buildOptions = {
release: booleanOption({ default: false }),
output: stringOption({ alias: "o" }),
} satisfies CommandOptionsSchema;--verbose, --path, --log, --release, --env and friends are declared by
the CLI itself. A command's declaration is merged over the CLI-wide dictionary
for the duration of that command, and that merge is the sanctioned way to give
a global option a per-command default — watch, hmr and skipNative all
carry different defaults on build, prepare, deploy and test:
options: {
// CLI-wide --watch, but this command defaults it off
watch: booleanOption({ default: false }),
}So a redeclaration of the same name with the same type is silent. What the CLI still warns about at registration is a redeclaration that changes what the spelling means:
- a declared option whose name matches a CLI-wide one but whose type differs —
verbose: stringOption()against the CLI's boolean--verbose; - an alias that belongs to a different CLI-wide option —
output: stringOption({ alias: "p" })steals--path's shorthand. Restating an option's own shorthand (path: stringOption({ alias: "p" })) is fine.
A redeclaration that leaves alias, default or hasSensitiveValue unset
keeps what the CLI-wide declaration carries for them, so path: stringOption()
still answers to -p and stays out of the logs; set one only to change it.
Option validation is the CLI's existing behaviour, not something the definition opts into. Before a command runs, the parser is re-primed with that command's declared options and the command line is re-parsed:
- Declared options are accepted and appear on
ctx.options. - An option the CLI does not know — neither global nor declared by this command
— produces a warning:
The option '<name>' is not supported. This will become an error in a future release.The command still runs. SetNS_STRICT_OPTIONS=errorto preview the hard failure, which is what a future release will do by default. - The same staging applies to value-shape violations: a string option passed with no value, an array option passed nothing, a single-valued option passed twice.
So adding an option is a matter of adding a schema entry; forgetting to declare
one that users pass is a warning today and a failure later, never a silent
undefined.
A command that forwards its command line to a separately installed CLI cannot
know which flags are legitimate, so validating them here would reject the other
CLI's own options. allowUnknownOptions: true lets unknown flags through for
that command:
defineCommand({
name: "preview",
allowUnknownOptions: true,
options: { disableNpmInstall: booleanOption({ default: false }) },
async run(ctx) {
/* spawn the other CLI with process.argv */
},
});An unknown flag is neither warned about nor rejected. Everything else about
validation still applies: the parser is re-primed with the command's declared
options, so ctx.options.disableNpmInstall above carries its value, and a
known option passed with the wrong shape is still reported. Reach for it only
when forwarding.
params declares what the command takes after its name:
"none"(the default) — the command accepts no positional arguments. Passing any is rejected withThis command doesn't accept parameters."any"— any number of positional arguments is accepted and handed torunasctx.args.- an array of specs — each argument is declared, named, and validated.
defineCommand({
name: "widget|add",
params: [
{
name: "platform",
required: true,
errorMessage: "Specify the platform to add the widget for.",
validate: (value) =>
["android", "ios"].includes(value) ||
`'${value}' is not a supported platform.`,
},
{ name: "template" },
{ name: "files", variadic: true },
],
async run(ctx) {
ctx.params.platform; // "android"
ctx.params.template; // "blank", or absent
ctx.params.files; // string[], possibly empty
},
});A spec accepts:
name— the key the value appears under onctx.params, and the name messages use.required— defaults to false. A required argument may not follow an optional one; positional matching would never be able to satisfy it.variadic— collects every remaining argument as astring[]. Must be the last spec. A required variadic wants at least one value.description— reserved for generated help, like an option's.errorMessage— replacesMissing required argument '<name>'.when the argument is required and absent.validate(value, ctx)— run per value,ctxbeing the same contextrunreceives, inside the invocation's injection context. Returntrueto accept; returnfalsefor a default message, or return the message itself as a string. It may beasync.
Enforcement happens after setup and before canExecute, in this order:
missing required arguments (every missing one is named at once), then too many
arguments, then each validate.
The first spec takes the first argument, the second spec the second, and so on.
This is a deliberate divergence from the ICommandParameter machinery a
hand-written command class uses, where CommandsService scans the validators
and lets a mandatory parameter claim whichever argument happens to satisfy it —
so ns command b a could satisfy [a, b]. Nothing in the CLI depends on that
behaviour, and positional is what the declaration reads like.
The practical consequence: ctx.params.template is args[1] whether or not
args[1] looks like a template. An argument that could be several things is a
job for validate or for canExecute, not for the matcher.
ctx.params is always present, even with params: "none" or "any" —
it is simply {} when no specs are declared. An optional non-variadic argument
the command line did not reach is absent from it; a variadic one is always
there, as an array.
defineCommand({
name: "widget|add",
params: "any",
async canExecute(ctx) {
return ctx.args.length === 1;
},
async run(ctx) {
/* ... */
},
});The two fields compose. The declared params policy is enforced first, and
canExecute is consulted only for command lines that already satisfy it — so a
definition that leaves params at "none" still rejects stray positional
arguments even when it supplies a canExecute, and a canExecute that only
inspects options cannot accidentally widen what the command accepts.
canExecute receives the same context object run does: one context is built
when the invocation opens and every stage of it shares that object. It
returns a boolean (or a promise of one). Returning false aborts the
command and prints a bare help suggestion; ctx.fail(message) aborts it with
your own message, which is usually the friendlier choice.
canExecute runs inside a dependency-injection context, on the same terms as
run: inject() is valid up to the first await.
run(ctx) receives:
ctx.args—string[], the positional arguments left after the command name (including any subcommand segments) has been consumed.ctx.params— the same arguments keyed by the names theparamsspecs declare,{}when there are none. It is spelledparamsbecauseargumentsis a reserved binding name in strict mode, so a destructuringconst { args, arguments } = ctxwould not even parse.ctx.options— the current value of each declared option, read at the moment the command executes.ctx.injector— this invocation's injector, a child of the one the command was registered against; see Injection, and the firstawait.ctx.fail(message, options?)— fails the command withmessage, followed by a usage help suggestion unlessoptions.helpisfalse.
run may be synchronous or async; the CLI awaits the result and treats a
rejection as a command failure.
ctx.fail(message) is the idiomatic way to stop a command. By default it
follows the message with the usage help suggestion — the "Run ns widget add --help" line — which is what the user needs when they got the command line
wrong: a missing argument, an unknown value, an invalid combination of options.
When the command line was fine and something else is not — the environment,
the project, a file on disk — the help suggestion only gets in the way. Pass
{ help: false } to print the message alone:
defineCommand({
name: "widget|add",
params: "any",
options: { output: stringOption() },
async run(ctx) {
if (!ctx.options.output) {
ctx.fail("--output is required.");
}
if (fs.existsSync(ctx.options.output)) {
ctx.fail(`${ctx.options.output} already exists.`, { help: false });
}
/* ... */
},
});It is available on the setup and canExecute contexts as well, and it
returns never, so it can end a branch without a return. The message must be
a non-empty string, and options, when given, a plain object. The two forms
map onto the errors service's failWithHelp and fail.
Throwing keeps working too: an error thrown from a handler propagates
unchanged. Throw when you already have an Error to propagate; call
ctx.fail when you are writing the message.
setup, canExecute, run, postRun and shortcuts, an argument's
validate and each precondition start inside a dependency-injection context,
so inject() works directly:
import { defineCommand, inject, DoctorService } from "nativescript/contracts";
export default defineCommand({
name: "widget|check",
async run() {
const doctorService = inject(DoctorService);
await doctorService.printWarnings();
},
});The injection context is synchronous, so inject() is valid up to the first
await in a handler, and not after it. After that first await, use
ctx.injector.get(token):
async run(ctx) {
const packageManager = inject(PackageManager); // fine, no await yet
await packageManager.install(name);
// inject() would throw here
const platform = ctx.injector.get(PlatformService);
}ctx.injector is deliberately the injector itself rather than a bound
ctx.inject(...): it is a visibly different mechanism because it obeys
different rules, and mistaking one for the other is exactly the bug this shape
prevents. It is the invocation's own injector: a child of the one the
command was registered against, holding the context under COMMAND_CONTEXT
and any per-command providers — see
Registering a definition. inject() before the
first await and ctx.injector.get() after it are therefore the same lookup
against the same injector. The same guidance, and the reasoning behind it, is
in dependency-injection.md.
A handler resolves what it needs itself, at the top of its own body:
export default defineCommand({
name: "widget|add",
params: "any",
providers: [provideProject()],
async run(ctx) {
const widgets = inject(WidgetService);
const projectData = inject(ProjectData);
await widgets.add(ctx.args, projectData);
},
});A definition may carry providers, added to each invocation's own injector
next to the context, so a factory or class among them can inject the
invocation and is built once per invocation. An entry is an object with a
provide token or a bare class, which stands for
{ provide: Cls, useClass: Cls } as in Angular; anything else is rejected
when the definition is defined. One token in that list is
special: COMMAND_PRECONDITIONS is a multi token, and every
{ provide: COMMAND_PRECONDITIONS, multi: true, useValue: check } contributes
a precondition — a check on the environment the command runs in, as
opposed to canExecute, which judges the arguments. Preconditions run when
the invocation opens, in declaration order, before setup and before the
arguments policy, inside the injection context, and a throw fails the
invocation. That fixed order is the point: being outside a project is what a
bad invocation reports first.
Each entry is a function, or a list of functions. Anything else fails the
first invocation with Command '<name>': COMMAND_PRECONDITIONS entry #n is not a function or a list of functions.
Multi providers are per injector level, as in Angular. A command that declares
any precondition of its own, provideProject() included, replaces the
preconditions provided by the scope it was registered in; the two lists do not
merge. To keep the scope's preconditions, contribute the parent's list as one
more entry:
import { COMMAND_PRECONDITIONS, inject } from "nativescript/contracts";
providers: [
{
provide: COMMAND_PRECONDITIONS,
multi: true,
useFactory: () =>
inject(COMMAND_PRECONDITIONS, { skipSelf: true, optional: true }) || [],
},
provideProject(),
],A built-in imports inject from "../common/di" instead.
The precondition every project command declares comes from a helper. The
built-in sample below imports it relatively; a plugin imports provideProject
from "nativescript/contracts".
import { provideProject } from "../command-base";
export default defineCommand({
name: "platform|clean",
providers: [provideProject()],
run(ctx) {
const projectData = inject(ProjectData); // the project the command line names
},
});provideProject() resolves the project from --path or the working
directory and fails the invocation with the usual "no project found" error
when there is none. A command that does not declare it — doctor, create,
the device family — pays nothing. In such a command inject(ProjectData)
returns the process-wide object uninitialised, without any error. A command
that wants the project only when there is one, like clean or
device put-file, resolves ProjectData behind its own check and calls
initializeProjectData() there. A command that always needs the project
declares the provider. A plugin adds its own preconditions the same way, with
its own helper returning a multi provider for the token.
The injection context is synchronous, so the inject() calls belong above
the first await — see Injection, and the first
await. Resolve everything the handler needs
there and the rule never bites; for anything that genuinely has to wait —
resolved after an await, or inside a helper called later — use
ctx.injector.get(token), which works at any point.
Services are never bundled. There is no setupXCommand() returning an
object of injected services for another command to spread, and no
IXCommandServices type travelling between commands. A dependency is named
where it is used, so reading a handler tells you exactly what it touches.
Sharing is either of two things, and neither of them is a bag:
-
Shared logic — a plain function taking the typed
ctxand plain values, resolving its own services throughctx.injector.get(...):export async function canBuildFor( ctx: CommandContext<any>, platform: string, ): Promise<boolean> { const validation = ctx.injector.get(PlatformValidationService); return validation.canBuild(platform); }
-
A whole command's precondition —
CommandsService.canExecuteCommand, which asks that command itself; see Asking another command.
setup(ctx) runs once per invocation, after the preconditions and before the
arguments policy and canExecute, and its return value is handed to canExecute, run and postRun as their second argument.
"Once per invocation" means once across the three together — whichever the CLI
reaches first triggers it, and the rest reuse the value.
It is optional sugar for one command's own handlers, for the case where
canExecute and run would otherwise repeat the same per-invocation
derivation. It is never a place to assemble services for anything but the
command it belongs to, and a command with a single handler does not need it at
all. When a command has enough structure to want one, the
class form usually says the same thing better: the instance is
the setup, and each dependency is a field.
run may return a value. When the definition declares postRun, that value is
passed to it after run succeeds:
export default defineCommand({
name: "create",
params: [{ name: "appName", required: true }],
async run(ctx) {
const projectDir = await createProject(ctx.params.appName as string);
return { projectDir };
},
postRun(ctx, { projectDir }) {
printSuccessMessage(projectDir);
},
});postRun maps onto the legacy postCommandAction: the CLI runs it after the
command itself, outside the command's own error handling. The value travels
through run's return rather than through a mutable field on the definition,
because a definition object is shared by every registration of it.
disableAnalytics: true— skips analytics tracking for this command.enableHooks: false— skips the before/after hooks that normally run around the command. Hooks are enabled by default.
Both are simply passed through to the command the CLI executes; omitting them leaves the CLI's defaults in place.
Command(meta) returns a base class to extend. It is sugar over
defineCommand and nothing more: the class carries a static definition built
by defineCommand, and that definition is the only thing the CLI ever
executes.
import {
Command,
inject,
ProjectData,
provideProject,
stringOption,
} from "nativescript/contracts";
export class PlatformCleanCommand extends Command({
name: "platform|clean",
description: "Removes and adds again the selected platform.",
options: { frameworkPath: stringOption() },
params: "any",
providers: [provideProject()],
}) {
private $platformCommandHelper = inject<IPlatformCommandHelper>(
"platformCommandHelper",
);
private $projectData = inject(ProjectData);
public async run(): Promise<void> {
await this.$platformCommandHelper.cleanPlatforms(
this.args,
this.$projectData,
this.options.frameworkPath,
);
}
}meta is the definition minus its handlers: name, description, options,
params, allowUnknownOptions, disableAnalytics, enableHooks and
providers. The
handlers are methods instead — run is required, and canExecute, postRun
and shortcuts are optional, each with the same meaning and the same ordering
as the fields of the same name. The result type is inferred from run, and
postRun(result) receives it, awaited, with no type argument to restate;
shortcuts() returns the same table shortcuts(ctx, setup) does. A method the
class does not declare is left out of the definition entirely, so a class
without postRun gets no postCommandAction, exactly as an object without one
does.
Which form to use. The class form is for a single named command with
internal structure: state shared between canExecute and run, values derived
once per invocation, several private steps, or enough collaborators that
this.$service reads better than a local in every handler. Everything simpler
— a handful of services and a short handler — is an object definition with its
handlers written inline, where ctx is typed by inference and there is nothing
to name.
When a function generates variants of one command — the run|ios /
run|vision family, one definition per platform — the object form is what
fits, because the thing being parameterized is a value and definitions are
values. Registering the same class twice under two names is not the
equivalent: the class is one definition.
The class is the setup. One instance is constructed per invocation, as that
invocation's setup, before canExecute runs. So field initializers and the
constructor run inside the injection context: inject() in a field initializer
resolves, and a constructor — optional, and if written it must call a bare
super() — is where the work a legacy command did in its own constructor goes.
Because construction is the setup, inject() is valid throughout it; after the
first await inside a method, use this.context.injector.get(token) as
Injection, and the first await describes.
this.context, this.options and this.args are the same context the
object form's handlers receive, typed from the options the meta declares:
this.options.frameworkPath is string | undefined above, and a name the
schema does not declare is a compile error. this.context also carries
params, injector and fail.
Per-command providers see the invocation. The context is provided to the
invocation's own child injector under the COMMAND_CONTEXT token, which is how
the base class reads it. A provider registered for one command — through the
providers argument of registerCommand or registerLazyCommand — lives in
that same child, so a factory or class among them can inject the context too.
The cost is that such a provider is built once per invocation, never shared
across invocations, and resolves nothing outside a running one.
One field per dependency. Each service the class uses is its own field,
read as this.$x:
export class PlatformAddCommand extends Command({
name: "platform|add",
providers: [provideProject()],
}) {
private $projectData = inject(ProjectData);
private $platformHelper = inject<IPlatformCommandHelper>(
"platformCommandHelper",
);
// ...
}Never a private services = injectSomething() holding a bag — the fields are
the point, and a bag puts the dependency list back behind one more hop. Two
commands needing the same four services restate those four lines; that
duplication is cheaper than a shared shape neither of them owns.
Share logic, not base classes and not services. What two commands genuinely
have in common is a check or a step, so share a function that takes
this.context and plain values and resolves its own services — see Where a
handler gets its services. To reuse
another command's precondition whole, ask that command: Asking another
command. A base class between Command() and the
command is the pattern the legacy ICommand hierarchy used, and untangling it
is most of why this API exists.
Registration takes the class itself; see Registering a definition:
registerBuiltInCommand<
typeof import("./commands/platform-clean").PlatformCleanCommand
>(
"platform|clean",
() => require("./commands/platform-clean").PlatformCleanCommand,
);isCommandClass(value) is the exported check, and Ctor.definition is the
definition the class stands for — derived once per class, and derived for the
subclass rather than for the base Command() returned. A class that implements
no run, or a class that did not come from Command(), is refused with the
same message shape a bad object gets.
Inside the CLI, a definition is registered with registerCommand:
import { registerCommand } from "../common/services/command-definition-adapter";
registerCommand({
name: "widget|add",
options: { force: booleanOption({ default: false }) },
run: async (ctx) => { … },
});Every registration helper — registerCommand, registerLazyCommand and
registerBuiltInCommand — takes a class form command wherever
it takes a definition, and reads the name it declares through its
static definition.
It takes either a DefinedCommand — the result of defineCommand, marker and
all — or the definition itself, which it defines on your behalf, so registering
a command is one call. Either way the definition is validated before it reaches
the registry. It claims every name the definition declares, through the
CommandRegistry the target injector provides, and returns a
DeferredCommandResult — see The owner is ambient below. The command instance
is built by a factory on first resolution and cached.
Pass providers as the second argument to add them to each invocation's child
injector, the one ctx.injector names, next to the definition's own
providers — how a definition is parameterized per registration:
for (const [name, platform] of buildCommandPlatforms) {
registerCommand({ ...buildCommandDefinition, name }, [
{ provide: BUILD_PLATFORM, useValue: platform },
]);
}That is how one definition serves several commands that differ only in data — the platform each one targets — instead of one command subclassing another.
Which injector it registers against is not a parameter. It is the injector of the current injection context — see The owner is ambient below — and the CLI's own injector outside one. To register against some other injector, run the call in its context:
runInInjectionContext(someInjector, () => registerCommand(definition));A test registering into its own container does that too, which is the same path the CLI itself takes.
registerCommand lives in
lib/common/services/command-definition-adapter rather than in
nativescript/contracts, because it reaches into the CLI runtime — the
side-effect-free contracts entry point deliberately does not pull it in.
defineCommand, the option helpers and all the types are exported from both
nativescript/contracts and lib/common/define-command.
Extensions do not need registerCommand at all: a
nativescript.commands manifest entry may point straight at a module that
exports a definition, and the CLI adapts and registers it lazily under the
manifest key (see extensions.md).
registerCommand needs the definition in hand, which means loading the module
that holds it. registerLazyCommand claims the name instead, and loads the
module the first time that one command is resolved:
import { registerLazyCommand } from "../common/services/command-definition-adapter";
registerLazyCommand<typeof import("./commands/run").iosRunCommand>(
"run|ios",
() => require("./commands/run").iosRunCommand,
);The name routes immediately — including through the run dispatcher the CLI
synthesizes for it — so listing commands, resolving a sibling, or printing help
for the parent never loads run.js. The loader runs on the resolution of
run|ios alone, and what it returns is registered under the name that was
claimed.
The type argument is mandatory. require() is typed any, so nothing can
be inferred from the loader: without the type argument the name would be
checked against nothing at all. Leave it off and the name parameter says so:
error TS2345: Argument of type '"run|ios"' is not assignable to parameter of type
'"Pass the definition type: registerLazyCommand<typeof import('./commands/x').cmd>(...)"'
With the type argument, the name is checked against the one the definition declares — every one of them, for a definition that declares aliases:
error TS2345: Argument of type '"run|iosss"' is not assignable to parameter of
type '"run|ios"'
and a type argument that is not a definition is rejected against the constraint. The loader is re-checked at runtime as well, because the guarantee is only as good as the type the call site passed.
The loader must be synchronous. CommandsService reads the resolved
command's dashedOptions before it validates the command line, so a command
that is still being imported has no options to validate against — a dynamic
import() here would report every flag as unknown. require is the tool for
this job.
Providers are optional and cost nothing until the command runs. The child injector is built inside the loader, so a name that is never resolved never creates one:
registerLazyCommand<typeof import("./commands/x").cmd>(
"x",
() => require("./commands/x").cmd,
[{ provide: SOME_TOKEN, useValue: "value" }],
);The owner is ambient. Every registration has an owner, which attributes
conflicts and load failures and makes re-registering the same name under the
same owner a no-op instead of a conflict. It is not a parameter: the helper
targets the injector of the current injection context when there is one, and
reads COMMAND_OWNER off it. Outside a context it targets the CLI's own
injector, and the CLI is the owner. An extension's module is loaded inside a
context whose injector provides COMMAND_OWNER, so a command the module
registers on its own is attributed to the extension without the module naming
itself — through registerCommand just as much as through this helper.
registerCommand therefore returns a DeferredCommandResult too: every
registration is arbitrated against the names already claimed, rather than
overwriting one.
Conflicts are returned, not thrown. The result is the same
DeferredCommandResult the extension manifest path gets — { registered: true }, or registered: false with a rejection to branch on. The CLI's own
bootstrap wraps the call and throws, because a name it cannot claim is a
mistake in bootstrap.ts; a host loading someone else's command usually wants
to warn and carry on. describeRejection(rejection) renders one for a human.
A family of commands that differ only in a value — run|android and run|ios,
say — is one definition registered several times, each with providers that
carry the value:
const PLATFORM = new InjectionToken<string>("commandPlatform");
for (const platform of ["android", "ios"]) {
registerCommand({ ...definition, name: `run|${platform}` }, [
{ provide: PLATFORM, useValue: platform },
]);
}The definition then reads inject(PLATFORM) — or ctx.injector.get(PLATFORM)
after the first await — and needs to know nothing else. The spread keeps the
defineCommand marker, so the copy is still a DefinedCommand.
This replaces the class-inheritance pattern the legacy commands use, where a per-platform command subclasses a shared base to override one field.
The CommandsService contract dispatches a registered command from inside the
process that is already running. It is a service like any other, so it follows
the rule every service does: inject() before the first await, the
injector after it, and a key shortcut's action reaches it through the
injector its context carries:
import { CommandsService } from "nativescript/contracts";
// in a class command
private $commandsService = inject(CommandsService);
await this.$commandsService.runCommand("autocomplete");
// in an inline handler, after the first await
await ctx.injector.get(CommandsService).runCommand("install", ["lodash"]);
// in a shortcut action
action: (ctx) => ctx.injector.get(CommandsService).runCommand("open|ios"),The command gets what a typed command line gives it, in the same order: its
declared options are primed into the parser — so ctx.options holds this
command's values and its declared defaults rather than the outer command
line's — then its preconditions, as the invocation opens, then setup, then
the params policy, then canExecute, then run, postRun, and the
command's hooks.
Two things differ, both because the caller is a process that has to keep running afterwards:
- A failure throws instead of exiting. A failed command line ends in
process.exit.runCommandreports the failure the same way — the same message formatting, the samens … --helpsuggestion — and then throws, so the caller decides what happens next. - Analytics do not fire. An in-process dispatch is not a new invocation of
the CLI, and the consent check can prompt on a terminal the caller has put
into raw mode. Hooks do fire, under the same names the command line fires:
open|iosfiresbefore-open-iosand thenbefore-open(andafter-open,after-open-ioson the way out), however the command was reached.
The options service is put back the way it was found. Merging a command's
declarations into it rewrites the values the host process is still running on
— open|ios declares watch: false, which would otherwise leave an ns start
out of watch mode for the rest of its life.
In-process dispatches nest; they never overlap. The options are put back in
the order the dispatches were entered, which only restores the right values
when each one finishes before the dispatch it was started from. A dispatch
started while another is in flight, and not from inside it — two
runCommand calls under one Promise.all, say — is rejected with
Cannot dispatch '…' in process while '…' is still running: in-process dispatches must nest, not overlap; await the running one first. Await one
before starting the next.
There is deliberately no free runCommand() function: one that silently fell
back to the CLI's root injector outside an injection context would dispatch
through the wrong scope from exactly the places — after an await, inside a
stdin handler — where the mistake is hardest to notice. The injector you hold
is the one to dispatch through.
Both methods take a registered name, or — the typed way — a definition or
Command() class. A name is looked up in the registry, and a parent name is
routed to its subcommand the way the command line routes it —
runCommand("device") runs device|*list, runCommand("device", ["log"])
runs device|log; a definition runs as
given, whether or not it is registered, so runCommand(prepareCommandDefinition)
runs exactly what you hold and cannot go stale the way a string can. Its first
name still identifies it for hooks and reporting.
A definition run as given is compiled against an injector chosen at the call,
the way Angular's createComponent takes one: the injector in the options
bag when one is passed, otherwise the injection context the call is made from,
otherwise the CLI's root. So a definition dispatched from inside an
extension's command lands under the extension's scope, where registerCommand
would have placed it, and a caller holding a scope of its own names it:
await commandsService.runCommand(definition, args, { injector });A registered command keeps the scope it was registered under, and passing
injector together with a name throws.
CommandsService.canExecuteCommand(command, args) asks a registered command
whether it could run, without running it:
import { CommandsService } from "nativescript/contracts";
private $commandsService = inject(CommandsService);
async canExecute(): Promise<boolean> {
if (
!(await this.$commandsService.canExecuteCommand(
prepareCommandDefinition,
[this.args[0]],
))
) {
return false;
}
return !!this.hostProjectPath;
}This is how one command builds on another's precondition. embed prepares the
project, so "could embed run" starts with "could prepare run" — and the way
to ask that is to ask prepare, not to import its canExecute and hand it
services. The named command is resolved and its options primed exactly as
runCommand does, then its own canExecute returns its verdict or throws. It
builds its own setup from its own services; nothing crosses between the two
commands but the name and the arguments.
Pass only the arguments the child's own params policy accepts. The child
enforces that policy before its canExecute, so forwarding a caller's whole
argument list to a child that declares fewer is a rejection, not a wider check.
canExecuteCommand follows runCommand in everything else: the same option
priming and restoration, the same routing of a parent name to its subcommand.
The interactive keys ns start and ns run offer are the CLI's own caller. A
shortcut is a table entry with a when deciding whether the key is live, and
an action that runs it:
{
key: "I",
description: "Open project in Xcode",
when: onPlatform("iOS"),
action: (ctx) => ctx.injector.get(CommandsService).runCommand("open|ios"),
}The context an action receives carries state and nothing else — the platform
being watched, whether this is ns start or an ns run child it spawned, and
the injector. Capabilities are resolved from that injector rather than handed
over as context methods:
action: (ctx) => ctx.injector.get<IStartService>("startService").runIOS(),A definition is compiled into an ordinary ICommand, so nothing downstream —
the registry, the router, hooks, help, analytics — knows the difference. The
mapping is:
| Definition | ICommand |
|---|---|
options |
dashedOptions |
run |
execute, wrapped in an injection context |
params, canExecute |
canExecute: policy enforced, then the refinement |
setup |
— run inside canExecute/execute, memoised |
postRun |
postCommandAction, with run's return value |
allowUnknownOptions |
allowUnknownOptions |
| — | allowedParameters, always [] |
disableAnalytics, enableHooks |
passed through unchanged |
The compiled command always exposes canExecute, because CommandsService
stops consulting allowedParameters as soon as a command has one — the adapter
therefore enforces the params policy itself. allowedParameters stays
empty, which is why declared params are matched positionally rather than
by the ICommandParameter scan.
Existing command classes need no migration. Reach for a definition when a
command is mostly "parse these flags and do this"; a legacy ICommand class
still makes sense when a command needs constructor-injected collaborators shared across several
methods, or ICommandParameter validators whose claim-any-argument matching it
actually depends on.