diff --git a/defining-commands.md b/defining-commands.md
new file mode 100644
index 0000000000..fa0143123b
--- /dev/null
+++ b/defining-commands.md
@@ -0,0 +1,251 @@
+Defining Commands
+=================
+
+`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.
+
+At a glance
+-----------
+
+```ts
+import {
+ defineCommand,
+ booleanOption,
+ stringOption,
+} from "nativescript/contracts";
+
+export default defineCommand({
+ name: "widget|add",
+ description: "Adds a widget to the project",
+ options: {
+ verbose: booleanOption({ default: false }),
+ output: stringOption({ alias: "o" }),
+ },
+ arguments: "any",
+ async run(ctx) {
+ // ctx.args -> string[] of positional arguments
+ // ctx.options -> { verbose: boolean; output: string }
+ if (ctx.options.verbose) {
+ console.log(`adding ${ctx.args.join(", ")} to ${ctx.options.output}`);
+ }
+ },
+});
+```
+
+`defineCommand` returns the definition, tagged with a marker symbol so that any
+copy of the CLI can recognise it (`isCommandDefinition(value)` is the exported
+predicate). It does not register anything by itself — see
+[Registering a definition](#registering-a-definition).
+
+Names and the command hierarchy
+-------------------------------
+
+`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.
+
+Options
+-------
+
+`options` is a schema keyed by the long option name — `verbose` is passed as
+`--verbose`. Declare each entry with one of the four helpers, which fix the
+value type:
+
+| Helper | Value type on `ctx.options` |
+| --------------- | --------------------------- |
+| `booleanOption` | `boolean` |
+| `stringOption` | `string` |
+| `numberOption` | `number` |
+| `arrayOption` | `string[]` |
+
+Each helper takes an optional spec:
+
+```ts
+options: {
+ // --release, absent means false
+ release: booleanOption({ default: false }),
+ // --output
, also accepted as -o
+ output: stringOption({ alias: "o", description: "Output directory" }),
+ // --retries
+ 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.
+- `hasSensitiveValue` — defaults to `false`; 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` — shown in generated help.
+
+The schema types `ctx.options` and nothing else: `ctx.options` carries exactly
+the declared keys, with the types the helpers imply. Values that the CLI parses
+globally (`--path`, `--log`, …) are not exposed there; resolve the `options`
+service if you need them.
+
+### How validation behaves
+
+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
+ — is a **hard error**: the command does not run, and the CLI prints
+ `The option '' is not supported.` followed by a help suggestion.
+
+So adding an option is exactly a matter of adding a schema entry; forgetting to
+declare one that users pass is a failure, not a silent `undefined`.
+
+Positional arguments
+--------------------
+
+`arguments` declares whether the command takes positional arguments at all:
+
+- `"none"` (the default) — the command accepts no positional arguments. Passing
+ any is rejected with `This command doesn't accept parameters.`
+- `"any"` — positional arguments are accepted and handed to `run` as
+ `ctx.args`.
+
+Anything finer than that belongs in `canExecute`.
+
+### `canExecute` owns validation
+
+```ts
+defineCommand({
+ name: "widget|add",
+ arguments: "any",
+ async canExecute(ctx) {
+ return ctx.args.length === 1;
+ },
+ async run(ctx) {
+ /* ... */
+ },
+});
+```
+
+`canExecute` receives the same context as `run` and returns a boolean (or a
+promise of one). Returning `false` aborts the command and prints a help
+suggestion; throwing surfaces your own error message, which is usually the
+friendlier choice.
+
+There is one rule to internalise: **the moment a command supplies
+`canExecute`, it owns argument validation completely.** The framework returns
+that verdict and skips every built-in parameter check, including the
+"no parameters" rule implied by `arguments: "none"`. A definition with a
+`canExecute` that only inspects options will therefore accept stray positional
+arguments unless it checks `ctx.args` itself. If you do not need custom
+validation, omit `canExecute` and let `arguments` do the work.
+
+The run context
+---------------
+
+`run(ctx)` receives:
+
+- `ctx.args` — `string[]`, the positional arguments left after the command name
+ (including any subcommand segments) has been consumed.
+- `ctx.options` — the current value of each declared option, read at the moment
+ the command executes.
+
+`run` may be synchronous or `async`; the CLI awaits the result and treats a
+rejection as a command failure.
+
+`run` starts inside a dependency-injection context, so `inject()` works
+directly:
+
+```ts
+import { defineCommand, inject } from "nativescript/contracts";
+import { DoctorService } from "nativescript/contracts";
+
+export default defineCommand({
+ name: "widget|check",
+ async run() {
+ const doctorService = inject(DoctorService);
+ await doctorService.printWarnings();
+ },
+});
+```
+
+The injection context is synchronous: `inject()` is valid up to the first
+`await` in `run`, and not after it. Capture what you need at the top of `run`,
+or inject the `Injector` itself and use `injector.get()` for late lookups. See
+`dependency-injection.md`.
+
+Other flags
+-----------
+
+- `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.
+
+Registering a definition
+------------------------
+
+Inside the CLI, a definition is registered with `registerCommandDefinition`:
+
+```ts
+import { registerCommandDefinition } from "../common/services/command-definition-adapter";
+import addWidgetCommand from "./add-widget";
+
+registerCommandDefinition(addWidgetCommand);
+```
+
+It registers the definition under every name it declares, on the CLI's injector
+by default; pass a second argument to target a different injector (tests do
+this). The command instance is created on first resolution and cached.
+
+`registerCommandDefinition` 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`.
+
+Declaring commands from an extension manifest, so that an extension does not
+have to call a registration function at load time, is being added separately.
+Until then, extensions register definitions the same way the CLI does.
+
+Relationship to `ICommand`
+--------------------------
+
+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 |
+| `arguments`, `canExecute` | `canExecute` (see the rule above) |
+| — | `allowedParameters`, always `[]` |
+| `disableAnalytics`, `enableHooks` | passed through unchanged |
+
+Existing command classes need no migration. Reach for a definition when a
+command is mostly "parse these flags and do this"; a class still makes sense
+when a command needs constructor-injected collaborators shared across several
+methods, custom `ICommandParameter` validators, or a `postCommandAction`.
diff --git a/lib/common/define-command.ts b/lib/common/define-command.ts
new file mode 100644
index 0000000000..b197709ca9
--- /dev/null
+++ b/lib/common/define-command.ts
@@ -0,0 +1,104 @@
+/**
+ * The declarative command API. Types and pure factories only — this module is
+ * re-exported from `nativescript/contracts` and must stay side-effect-free, so
+ * it may not import lib/common/yok (whose import creates global.$injector).
+ * The runtime bridge onto the legacy registry lives in
+ * lib/common/services/command-definition-adapter.
+ */
+
+/**
+ * Symbol.for so that a definition produced by one copy of the CLI is still
+ * recognised by another — extensions bundle their own node_modules.
+ */
+export const COMMAND_DEFINITION_MARKER = Symbol.for(
+ "nativescript:cli:commandDefinition",
+);
+
+export type CommandOptionType = "boolean" | "string" | "number" | "array";
+
+export interface ICommandOptionSpec {
+ type: CommandOptionType;
+ /** Value used when the flag is absent from the command line. */
+ default?: TValue;
+ /** Single-dash shorthand, e.g. `-o` for `--output`. */
+ alias?: string | string[];
+ /** Keeps the value out of analytics and logs. Defaults to false. */
+ hasSensitiveValue?: boolean;
+ description?: string;
+}
+
+/** The parts of an option spec a caller supplies; `type` comes from the helper. */
+export type CommandOptionSpecInit = Omit<
+ ICommandOptionSpec,
+ "type"
+>;
+
+export interface ICommandOptionsSchema {
+ [optionName: string]: ICommandOptionSpec;
+}
+
+export type CommandOptionValues = {
+ [K in keyof TSchema]: TSchema[K] extends ICommandOptionSpec
+ ? TValue
+ : any;
+};
+
+export interface ICommandContext<
+ TSchema extends ICommandOptionsSchema = ICommandOptionsSchema,
+> {
+ /** Positional arguments, after the command name has been consumed. */
+ args: string[];
+ /** Current value of every option declared in the schema, and nothing else. */
+ options: CommandOptionValues;
+}
+
+export interface ICommandDefinition<
+ TSchema extends ICommandOptionsSchema = ICommandOptionsSchema,
+> {
+ /** `"widget|add"`; `|` separates hierarchy levels. Several names alias one command. */
+ name: string | string[];
+ description?: string;
+ options?: TSchema;
+ /**
+ * `"none"` (the default) rejects positional arguments; `"any"` accepts them.
+ * Anything finer belongs in `canExecute`.
+ */
+ arguments?: "none" | "any";
+ canExecute?(context: ICommandContext): Promise | boolean;
+ disableAnalytics?: boolean;
+ enableHooks?: boolean;
+ run(context: ICommandContext): Promise | void;
+}
+
+const optionSpec = (
+ type: CommandOptionType,
+ init: CommandOptionSpecInit,
+): ICommandOptionSpec => ({ ...init, type });
+
+export const booleanOption = (
+ init: CommandOptionSpecInit = {},
+): ICommandOptionSpec => optionSpec("boolean", init);
+
+export const stringOption = (
+ init: CommandOptionSpecInit = {},
+): ICommandOptionSpec => optionSpec("string", init);
+
+export const numberOption = (
+ init: CommandOptionSpecInit = {},
+): ICommandOptionSpec => optionSpec("number", init);
+
+export const arrayOption = (
+ init: CommandOptionSpecInit = {},
+): ICommandOptionSpec => optionSpec("array", init);
+
+export function defineCommand(
+ definition: ICommandDefinition,
+): ICommandDefinition {
+ const marked: ICommandDefinition = { ...definition };
+ (marked)[COMMAND_DEFINITION_MARKER] = true;
+ return marked;
+}
+
+export function isCommandDefinition(value: any): value is ICommandDefinition {
+ return !!value && (value)[COMMAND_DEFINITION_MARKER] === true;
+}
diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts
new file mode 100644
index 0000000000..e92e5a88c8
--- /dev/null
+++ b/lib/common/services/command-definition-adapter.ts
@@ -0,0 +1,124 @@
+import { OptionType } from "../enums";
+import { injector } from "../yok";
+import { runInInjectionContext } from "../di/inject";
+import { IDictionary, IDashedOption } from "../declarations";
+import { IInjector } from "../definitions/yok";
+import { ICommand } from "../definitions/commands";
+import {
+ CommandOptionType,
+ ICommandContext,
+ ICommandDefinition,
+ ICommandOptionsSchema,
+} from "../define-command";
+
+const OPTION_TYPES: IDictionary = {
+ boolean: OptionType.Boolean,
+ string: OptionType.String,
+ number: OptionType.Number,
+ array: OptionType.Array,
+};
+
+/**
+ * Wraps a declarative definition in the ICommand shape the legacy registry and
+ * CommandsService expect.
+ *
+ * Constraint worth knowing before changing the canExecute mapping: the moment
+ * an ICommand exposes `canExecute`, CommandsService returns its verdict and
+ * skips `allowedParameters` validation entirely. So `canExecute` is emitted
+ * only when the definition supplies one or opts into `arguments: "any"` — the
+ * adapter then owns argument validation wholesale. With `arguments: "none"`
+ * and no user `canExecute` the property is omitted, which lets the framework's
+ * own empty-`allowedParameters` check reject stray positional arguments.
+ */
+export function createCommandFromDefinition<
+ TSchema extends ICommandOptionsSchema,
+>(
+ definition: ICommandDefinition,
+ targetInjector: IInjector = injector,
+): ICommand {
+ const schema = definition.options || {};
+ const optionNames = Object.keys(schema);
+
+ const dashedOptions: IDictionary = {};
+ for (const optionName of optionNames) {
+ const spec = schema[optionName];
+ const dashedOption: IDashedOption = {
+ type: OPTION_TYPES[spec.type],
+ hasSensitiveValue: spec.hasSensitiveValue === true,
+ };
+
+ if (spec.default !== undefined) {
+ dashedOption.default = spec.default;
+ }
+
+ if (spec.alias !== undefined) {
+ dashedOption.alias = spec.alias;
+ }
+
+ if (spec.description !== undefined) {
+ dashedOption.describe = spec.description;
+ }
+
+ dashedOptions[optionName] = dashedOption;
+ }
+
+ // Resolved per call: the options service only holds parsed values once
+ // validateOptions has run for this command.
+ const buildContext = (args: string[]): ICommandContext => {
+ const options: any = {};
+ // Only a definition that declares options may depend on the options
+ // service being registered - a bare command must work without one.
+ if (optionNames.length) {
+ const optionsService: any = targetInjector.resolve("options");
+ for (const optionName of optionNames) {
+ options[optionName] = optionsService
+ ? optionsService[optionName]
+ : undefined;
+ }
+ }
+
+ return { args, options };
+ };
+
+ const command: ICommand = {
+ allowedParameters: [],
+ dashedOptions,
+ execute: async (args: string[]): Promise => {
+ // runInInjectionContext is synchronous, so inject() is available while
+ // run() executes up to its first await, and not after it.
+ await runInInjectionContext(targetInjector, () =>
+ definition.run(buildContext(args)),
+ );
+ },
+ };
+
+ if (definition.canExecute || definition.arguments === "any") {
+ command.canExecute = async (args: string[]): Promise =>
+ definition.canExecute
+ ? await definition.canExecute(buildContext(args))
+ : true;
+ }
+
+ if (definition.disableAnalytics !== undefined) {
+ command.disableAnalytics = definition.disableAnalytics;
+ }
+
+ if (definition.enableHooks !== undefined) {
+ command.enableHooks = definition.enableHooks;
+ }
+
+ return command;
+}
+
+export function registerCommandDefinition<
+ TSchema extends ICommandOptionsSchema,
+>(
+ definition: ICommandDefinition,
+ targetInjector: IInjector = injector,
+): void {
+ // An arrow function resolver is invoked as a factory rather than new-ed, and
+ // its result is cached as the command instance.
+ targetInjector.registerCommand(definition.name, () =>
+ createCommandFromDefinition(definition, targetInjector),
+ );
+}
diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts
index c13a9b9996..efbdc504f6 100644
--- a/lib/contracts/index.ts
+++ b/lib/contracts/index.ts
@@ -25,3 +25,21 @@ export type {
export { DoctorService } from "./doctor-service";
export { ProjectNameService } from "./project-name-service";
+
+export {
+ defineCommand,
+ isCommandDefinition,
+ booleanOption,
+ stringOption,
+ numberOption,
+ arrayOption,
+} from "../common/define-command";
+export type {
+ ICommandDefinition,
+ ICommandContext,
+ ICommandOptionSpec,
+ ICommandOptionsSchema,
+ CommandOptionSpecInit,
+ CommandOptionType,
+ CommandOptionValues,
+} from "../common/define-command";
diff --git a/test/define-command.ts b/test/define-command.ts
new file mode 100644
index 0000000000..da866802d2
--- /dev/null
+++ b/test/define-command.ts
@@ -0,0 +1,460 @@
+import { assert } from "chai";
+import { Yok } from "../lib/common/yok";
+import { IInjector } from "../lib/common/definitions/yok";
+import { inject } from "../lib/common/di";
+import { CommandsService } from "../lib/common/services/commands-service";
+import { LoggerStub, HooksServiceStub } from "./stubs";
+import {
+ arrayOption,
+ booleanOption,
+ defineCommand,
+ isCommandDefinition,
+ numberOption,
+ stringOption,
+} from "../lib/common/define-command";
+import {
+ createCommandFromDefinition,
+ registerCommandDefinition,
+} from "../lib/common/services/command-definition-adapter";
+
+type IsExact =
+ (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2
+ ? true
+ : false;
+
+/** Fails to compile unless the schema inferred exactly the expected type. */
+const expectExactType = (): void => undefined;
+
+const createTestInjector = (options: any = {}): IInjector => {
+ const testInjector = new Yok();
+ testInjector.register("options", options);
+ return testInjector;
+};
+
+describe("defineCommand", () => {
+ it("marks definitions so a duplicated CLI copy still recognises them", () => {
+ const definition = defineCommand({
+ name: "dctest-marker",
+ run: () => undefined,
+ });
+
+ assert.isTrue(isCommandDefinition(definition));
+ assert.isTrue(
+ (definition)[Symbol.for("nativescript:cli:commandDefinition")],
+ );
+ assert.isFalse(isCommandDefinition({ name: "dctest-marker" }));
+ assert.isFalse(isCommandDefinition(null));
+ });
+
+ describe("registration", () => {
+ it("round-trips through the legacy command registry", () => {
+ const definition = defineCommand({
+ name: "dctestwidget|add",
+ description: "Adds a widget",
+ run: () => undefined,
+ });
+
+ const testInjector = createTestInjector();
+ registerCommandDefinition(definition, testInjector);
+
+ const command = testInjector.resolveCommand("dctestwidget|add");
+ assert.isFunction(command.execute);
+ assert.deepEqual(command.allowedParameters, []);
+
+ const parent = testInjector.resolveCommand("dctestwidget");
+ assert.isTrue(parent.isHierarchicalCommand);
+
+ assert.include(
+ testInjector.getRegisteredCommandsNames(false),
+ "dctestwidget|add",
+ );
+ });
+
+ it("caches one command instance per registered name", () => {
+ const testInjector = createTestInjector();
+ registerCommandDefinition(
+ defineCommand({ name: "dctestflat", run: () => undefined }),
+ testInjector,
+ );
+
+ assert.strictEqual(
+ testInjector.resolveCommand("dctestflat"),
+ testInjector.resolveCommand("dctestflat"),
+ );
+ });
+
+ it("registers every alias of a multi-name definition", () => {
+ const testInjector = createTestInjector();
+ registerCommandDefinition(
+ defineCommand({
+ name: ["dctestalias", "dctestalias2"],
+ run: () => undefined,
+ }),
+ testInjector,
+ );
+
+ assert.isFunction(testInjector.resolveCommand("dctestalias").execute);
+ assert.isFunction(testInjector.resolveCommand("dctestalias2").execute);
+ });
+ });
+
+ describe("execute", () => {
+ it("passes args and the declared options through, inside an injection context", async () => {
+ const testInjector = createTestInjector({
+ verbose: true,
+ output: "dist",
+ undeclared: "ignored",
+ });
+ testInjector.register("dcTestGreeter", { greet: () => "hello" });
+
+ let capturedArgs: string[];
+ let capturedOptions: any;
+ let greeting: string;
+
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestexec",
+ options: {
+ verbose: booleanOption(),
+ output: stringOption(),
+ },
+ run(context) {
+ greeting = inject("dcTestGreeter").greet();
+ capturedArgs = context.args;
+ capturedOptions = context.options;
+ },
+ }),
+ testInjector,
+ );
+
+ await command.execute(["one", "two"]);
+
+ assert.deepEqual(capturedArgs, ["one", "two"]);
+ assert.deepEqual(capturedOptions, { verbose: true, output: "dist" });
+ assert.strictEqual(greeting, "hello");
+ });
+
+ it("reads option values at execution time", async () => {
+ const optionsService: any = { verbose: false };
+ const testInjector = createTestInjector(optionsService);
+
+ let seen: boolean;
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestlate",
+ options: { verbose: booleanOption() },
+ run: (context) => {
+ seen = context.options.verbose;
+ },
+ }),
+ testInjector,
+ );
+
+ optionsService.verbose = true;
+ await command.execute([]);
+
+ assert.isTrue(seen);
+ });
+
+ it("awaits an asynchronous run", async () => {
+ const testInjector = createTestInjector();
+ let finished = false;
+
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestasync",
+ run: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ finished = true;
+ },
+ }),
+ testInjector,
+ );
+
+ await command.execute([]);
+
+ assert.isTrue(finished);
+ });
+
+ it("infers the option value types on the run context", async () => {
+ const testInjector = createTestInjector({
+ verbose: true,
+ output: "dist",
+ retries: 3,
+ files: ["a.ts"],
+ });
+
+ let verbose: boolean;
+ let output: string;
+ let retries: number;
+ let files: string[];
+
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctesttypes",
+ options: {
+ verbose: booleanOption(),
+ output: stringOption(),
+ retries: numberOption(),
+ files: arrayOption(),
+ },
+ run: (context) => {
+ expectExactType>();
+ expectExactType>();
+ expectExactType>();
+ expectExactType>();
+
+ verbose = context.options.verbose;
+ output = context.options.output;
+ retries = context.options.retries;
+ files = context.options.files;
+ },
+ }),
+ testInjector,
+ );
+
+ await command.execute([]);
+
+ assert.isTrue(verbose);
+ assert.strictEqual(output, "dist");
+ assert.strictEqual(retries, 3);
+ assert.deepEqual(files, ["a.ts"]);
+ });
+ });
+
+ describe("dashedOptions", () => {
+ it("compiles the schema into the shape the option parser expects", () => {
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestdashed",
+ options: {
+ verbose: booleanOption({ default: false }),
+ output: stringOption({ alias: "o" }),
+ retries: numberOption({ default: 3 }),
+ files: arrayOption(),
+ token: stringOption({
+ hasSensitiveValue: true,
+ description: "Auth token",
+ }),
+ },
+ run: () => undefined,
+ }),
+ createTestInjector(),
+ );
+
+ assert.deepEqual(command.dashedOptions, {
+ verbose: { type: "boolean", hasSensitiveValue: false, default: false },
+ output: { type: "string", hasSensitiveValue: false, alias: "o" },
+ retries: { type: "number", hasSensitiveValue: false, default: 3 },
+ files: { type: "array", hasSensitiveValue: false },
+ token: {
+ type: "string",
+ hasSensitiveValue: true,
+ describe: "Auth token",
+ },
+ });
+ });
+
+ it("is empty when no options are declared", () => {
+ const command = createCommandFromDefinition(
+ defineCommand({ name: "dctestnoopts", run: () => undefined }),
+ createTestInjector(),
+ );
+
+ assert.deepEqual(command.dashedOptions, {});
+ });
+ });
+
+ describe("canExecute", () => {
+ it("is omitted for argument-less commands so the framework rejects parameters", () => {
+ const testInjector = createTestInjector();
+
+ const implicit = createCommandFromDefinition(
+ defineCommand({ name: "dctestnone", run: () => undefined }),
+ testInjector,
+ );
+ const explicit = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestnone2",
+ arguments: "none",
+ run: () => undefined,
+ }),
+ testInjector,
+ );
+
+ assert.isUndefined(implicit.canExecute);
+ assert.isUndefined(explicit.canExecute);
+ assert.deepEqual(implicit.allowedParameters, []);
+ assert.deepEqual(explicit.allowedParameters, []);
+ });
+
+ it("accepts anything when arguments are 'any'", async () => {
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestany",
+ arguments: "any",
+ run: () => undefined,
+ }),
+ createTestInjector(),
+ );
+
+ assert.isFunction(command.canExecute);
+ assert.isTrue(await command.canExecute(["whatever", "else"]));
+ });
+
+ it("hands the context to a user canExecute and honours its verdict", async () => {
+ const testInjector = createTestInjector({ force: true });
+ let capturedContext: any;
+
+ const build = (verdict: boolean) =>
+ createCommandFromDefinition(
+ defineCommand({
+ name: "dctestverdict",
+ arguments: "any",
+ options: { force: booleanOption() },
+ canExecute: (context) => {
+ capturedContext = context;
+ return verdict;
+ },
+ run: () => undefined,
+ }),
+ testInjector,
+ );
+
+ assert.isTrue(await build(true).canExecute(["android"]));
+ assert.deepEqual(capturedContext.args, ["android"]);
+ assert.deepEqual(capturedContext.options, { force: true });
+
+ assert.isFalse(await build(false).canExecute(["android"]));
+ });
+
+ it("is emitted for a user canExecute even when arguments are 'none'", async () => {
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestnonecan",
+ arguments: "none",
+ canExecute: async () => true,
+ run: () => undefined,
+ }),
+ createTestInjector(),
+ );
+
+ assert.isFunction(command.canExecute);
+ assert.isTrue(await command.canExecute([]));
+ });
+ });
+
+ describe("command flags", () => {
+ it("passes disableAnalytics and enableHooks through", () => {
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestflags",
+ disableAnalytics: true,
+ enableHooks: false,
+ run: () => undefined,
+ }),
+ createTestInjector(),
+ );
+
+ assert.isTrue(command.disableAnalytics);
+ assert.isFalse(command.enableHooks);
+ });
+
+ it("leaves both absent when the definition omits them", () => {
+ const command = createCommandFromDefinition(
+ defineCommand({ name: "dctestnoflags", run: () => undefined }),
+ createTestInjector(),
+ );
+
+ assert.isFalse("disableAnalytics" in command);
+ assert.isFalse("enableHooks" in command);
+ });
+ });
+
+ describe("end to end through CommandsService", () => {
+ let validatedOptions: any;
+
+ const createCommandsServiceInjector = (options: any): IInjector => {
+ const testInjector = new Yok();
+ testInjector.register("errors", {
+ beginCommand: async (action: () => Promise) => action(),
+ failWithHelp: (message: string) => {
+ throw new Error(message);
+ },
+ fail: (message: string) => {
+ throw new Error(message);
+ },
+ });
+ testInjector.register("hooksService", HooksServiceStub);
+ testInjector.register("logger", LoggerStub);
+ testInjector.register("staticConfig", {
+ disableAnalytics: true,
+ disableCommandHooks: true,
+ });
+ testInjector.register("extensibilityService", {});
+ testInjector.register("optionsTracker", {});
+ testInjector.register("options", {
+ ...options,
+ validateOptions: (dashedOptions: any) => {
+ validatedOptions = dashedOptions;
+ },
+ });
+ testInjector.register("commandsService", CommandsService);
+ return testInjector;
+ };
+
+ beforeEach(() => {
+ validatedOptions = undefined;
+ });
+
+ it("validates the declared options and runs the command", async () => {
+ const testInjector = createCommandsServiceInjector({ verbose: true });
+ let ran: any;
+
+ registerCommandDefinition(
+ defineCommand({
+ name: "dctest-e2e",
+ options: { verbose: booleanOption({ default: false }) },
+ arguments: "any",
+ run: (context) => {
+ ran = context;
+ },
+ }),
+ testInjector,
+ );
+
+ const commandsService: ICommandsService =
+ testInjector.resolve("commandsService");
+ await commandsService.tryExecuteCommand("dctest-e2e", ["alpha"]);
+
+ assert.deepEqual(validatedOptions, {
+ verbose: { type: "boolean", hasSensitiveValue: false, default: false },
+ });
+ assert.deepEqual(ran.args, ["alpha"]);
+ assert.deepEqual(ran.options, { verbose: true });
+ });
+
+ it("lets the framework reject parameters when arguments are 'none'", async () => {
+ const testInjector = createCommandsServiceInjector({});
+ let ran = false;
+
+ registerCommandDefinition(
+ defineCommand({
+ name: "dctest-e2e-none",
+ run: () => {
+ ran = true;
+ },
+ }),
+ testInjector,
+ );
+
+ const commandsService: ICommandsService =
+ testInjector.resolve("commandsService");
+ await assert.isRejected(
+ commandsService.tryExecuteCommand("dctest-e2e-none", ["stray"]),
+ /doesn't accept parameters/,
+ );
+ assert.isFalse(ran);
+ });
+ });
+});