diff --git a/defining-commands.md b/defining-commands.md
new file mode 100644
index 0000000000..b6f589cf9a
--- /dev/null
+++ b/defining-commands.md
@@ -0,0 +1,351 @@
+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: {
+ overwrite: booleanOption({ default: false }),
+ output: stringOption({ alias: "o" }),
+ },
+ arguments: "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](#registering-a-definition).
+
+Validation happens where you can see it
+---------------------------------------
+
+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 four helpers, an `arguments` value
+outside `"none" | "any"` — each throws immediately, naming the command and the
+accepted form:
+
+```
+Invalid command definition for 'widget|add': unknown field(s) 'handler'; a
+definition accepts name, description, options, arguments, canExecute,
+disableAnalytics, enableHooks, run. Accepted form: defineCommand({ name:
+"widget|add", run(ctx) { ... } }) — with the optional fields description,
+options, arguments, canExecute, disableAnalytics and enableHooks.
+```
+
+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.
+
+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
+-------
+
+`options` is a schema keyed by the long option name — `output` is passed as
+`--output`. Declare each entry with one of the four 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` |
+
+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:
+
+```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 (`alias: ["o", "out"]`).
+- `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` — 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. Values that the CLI parses
+globally (`--path`, `--log`, …) are not exposed there; resolve the `options`
+service if you need them.
+
+### Sharing a schema between commands
+
+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:
+
+```ts
+const buildOptions = {
+ release: booleanOption({ default: false }),
+ output: stringOption({ alias: "o" }),
+} satisfies CommandOptionsSchema;
+```
+
+### Do not shadow a CLI-wide option
+
+`--verbose`, `--path`, `--log`, `--release`, `--env` and friends are declared by
+the CLI itself. Declaring one of those names in a command's schema makes the
+command's declaration win for the duration of that command, which means the
+same flag means different things depending on which command is running. The CLI
+warns at registration naming both sides of the collision; pick another name.
+
+Aliases count too, in both directions: an `alias: "p"` collides with `--path`'s
+shorthand just as `output: stringOption()` would collide with a CLI-wide
+`--output`.
+
+### 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
+ — produces a warning: `The option '' is not supported. This will become
+an error in a future release.` The command still runs. Set
+ `NS_STRICT_OPTIONS=error` to 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`.
+
+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` refines, it does not replace
+
+```ts
+defineCommand({
+ name: "widget|add",
+ arguments: "any",
+ async canExecute(ctx) {
+ return ctx.args.length === 1;
+ },
+ async run(ctx) {
+ /* ... */
+ },
+});
+```
+
+The two fields compose. The declared `arguments` policy is enforced first, and
+`canExecute` is consulted only for command lines that already satisfy it — so a
+definition that leaves `arguments` 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 a context of the same shape as `run`'s — the same
+`args`, the same declared options and the same `fail` — built freshly for the
+call, and 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`.
+
+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.
+- `ctx.fail(message)` — fails the command with `message` and a usage help
+ suggestion.
+
+`run` may be synchronous or `async`; the CLI awaits the result and treats a
+rejection as a command failure.
+
+### Failing a command
+
+`ctx.fail(message)` is the idiomatic way to stop a command:
+
+```ts
+defineCommand({
+ name: "widget|add",
+ arguments: "any",
+ options: { output: stringOption() },
+ async run(ctx) {
+ if (!ctx.options.output) {
+ ctx.fail("--output is required.");
+ }
+
+ /* ... */
+ },
+});
+```
+
+It is available on the `canExecute` context as well, and it returns `never`, so
+it can end a branch without a `return`. The message must be a non-empty string.
+
+Throwing is equivalent and keeps working — `ctx.fail` is sugar over the
+`errors` service's `failWithHelp`, which is what adds the "Run `ns widget add
+--help`" line. Throw when you already have an `Error` to propagate; call
+`ctx.fail` when you are writing the message.
+
+`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 takes a `DefinedCommand` — the result of `defineCommand`, marker and all —
+and rejects a bare object of the right shape, so a definition can never reach
+the registry without having been validated. It registers under every name the
+definition declares, through the `CommandRegistry` the target injector provides;
+pass a second argument to target a different injector (tests do this). The
+command instance is built by a factory 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`: policy enforced, then the refinement |
+| — | `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 `arguments` policy itself.
+
+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..c5cf28e5fe
--- /dev/null
+++ b/lib/common/define-command.ts
@@ -0,0 +1,344 @@
+/**
+ * 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. `unique
+ * symbol` so the marker can also be spelled in the branded return type.
+ */
+export const COMMAND_DEFINITION_MARKER: unique symbol = Symbol.for(
+ "nativescript:cli:commandDefinition",
+);
+
+export type CommandOptionType = "boolean" | "string" | "number" | "array";
+
+export interface CommandOptionSpec {
+ 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;
+ /** Reserved for generated help; nothing renders it yet. */
+ description?: string;
+}
+
+/**
+ * A spec whose `default` is required. The required property is what
+ * `CommandOptionValues` keys off to drop `| undefined` from the value type, so
+ * it may not be relaxed to an optional one.
+ */
+export interface DefaultedCommandOptionSpec<
+ TValue = any,
+> extends CommandOptionSpec {
+ default: TValue;
+}
+
+/** The parts of an option spec a caller supplies; `type` comes from the helper. */
+export type CommandOptionSpecInit = Omit<
+ CommandOptionSpec,
+ "type"
+>;
+
+export interface CommandOptionsSchema {
+ [optionName: string]: CommandOptionSpec;
+}
+
+/**
+ * An option the command line omitted is absent at runtime, so only a spec that
+ * declares a `default` yields a value that is always there.
+ */
+type CommandOptionValue =
+ TSpec extends CommandOptionSpec
+ ? TSpec extends { default: any }
+ ? TValue
+ : TValue | undefined
+ : any;
+
+export type CommandOptionValues = {
+ [K in keyof TSchema]: CommandOptionValue;
+};
+
+export interface CommandContext {
+ /** 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;
+ /** Fails the command with `message` and the usage help suggestion. */
+ fail(message: string): never;
+}
+
+export interface CommandDefinition {
+ /** `"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`, which runs after this policy.
+ */
+ arguments?: "none" | "any";
+ canExecute?(context: CommandContext): Promise | boolean;
+ disableAnalytics?: boolean;
+ enableHooks?: boolean;
+ run(context: CommandContext): Promise | void;
+}
+
+/**
+ * What `defineCommand` returns: a definition carrying the marker in its type,
+ * so `registerCommandDefinition` can require a definition that went through
+ * define-time validation rather than any object of the right shape.
+ */
+export type DefinedCommand =
+ CommandDefinition & {
+ readonly [COMMAND_DEFINITION_MARKER]: true;
+ };
+
+interface IOptionHelper {
+ (
+ init: CommandOptionSpecInit & { default: TValue },
+ ): DefaultedCommandOptionSpec;
+ (init?: CommandOptionSpecInit): CommandOptionSpec;
+}
+
+const optionHelper = (type: CommandOptionType): IOptionHelper =>
+ >((init: CommandOptionSpecInit = {}) => ({
+ ...init,
+ type,
+ }));
+
+export const booleanOption = optionHelper("boolean");
+export const stringOption = optionHelper("string");
+export const numberOption = optionHelper("number");
+export const arrayOption = optionHelper("array");
+
+const DEFINITION_FIELDS = [
+ "name",
+ "description",
+ "options",
+ "arguments",
+ "canExecute",
+ "disableAnalytics",
+ "enableHooks",
+ "run",
+];
+
+const OPTION_SPEC_FIELDS = [
+ "type",
+ "default",
+ "alias",
+ "hasSensitiveValue",
+ "description",
+];
+
+const OPTION_TYPES: CommandOptionType[] = [
+ "boolean",
+ "string",
+ "number",
+ "array",
+];
+
+const ACCEPTED_FORM =
+ 'defineCommand({ name: "widget|add", run(ctx) { ... } }) — with the ' +
+ "optional fields description, options, arguments, canExecute, " +
+ "disableAnalytics and enableHooks.";
+
+const describeDefinition = (definition: any): string => {
+ const name = definition && definition.name;
+ if (typeof name === "string" && name.length) {
+ return `'${name}'`;
+ }
+
+ if (Array.isArray(name) && typeof name[0] === "string" && name[0].length) {
+ return `'${name[0]}'`;
+ }
+
+ return "an unnamed command";
+};
+
+const invalid = (definition: any, problem: string): never => {
+ throw new Error(
+ `Invalid command definition for ${describeDefinition(definition)}: ` +
+ `${problem}. Accepted form: ${ACCEPTED_FORM}`,
+ );
+};
+
+const isPlainObject = (value: any): boolean =>
+ !!value && typeof value === "object" && !Array.isArray(value);
+
+const validateName = (definition: any): void => {
+ const name = definition.name;
+ const isUsableName = (value: any) =>
+ typeof value === "string" && value.trim().length > 0;
+
+ if (isUsableName(name)) {
+ return;
+ }
+
+ if (Array.isArray(name) && name.length && name.every(isUsableName)) {
+ return;
+ }
+
+ invalid(
+ definition,
+ "'name' must be a non-empty string, or an array of non-empty strings for a command with aliases",
+ );
+};
+
+const validateOptionSpec = (
+ definition: any,
+ optionName: string,
+ spec: any,
+): void => {
+ if (!isPlainObject(spec)) {
+ invalid(
+ definition,
+ `option '${optionName}' must be declared with one of booleanOption(), stringOption(), numberOption() or arrayOption()`,
+ );
+ }
+
+ if (OPTION_TYPES.indexOf(spec.type) === -1) {
+ invalid(
+ definition,
+ `option '${optionName}' has type '${spec.type}'; the supported types are ${OPTION_TYPES.join(
+ ", ",
+ )} — declare it with one of booleanOption(), stringOption(), numberOption() or arrayOption()`,
+ );
+ }
+
+ const unknownFields = Object.keys(spec).filter(
+ (field) => OPTION_SPEC_FIELDS.indexOf(field) === -1,
+ );
+ if (unknownFields.length) {
+ invalid(
+ definition,
+ `option '${optionName}' has unknown field(s) ${unknownFields
+ .map((field) => `'${field}'`)
+ .join(", ")}; an option spec accepts ${OPTION_SPEC_FIELDS.join(", ")}`,
+ );
+ }
+
+ const aliasIsUsable =
+ spec.alias === undefined ||
+ typeof spec.alias === "string" ||
+ (Array.isArray(spec.alias) &&
+ spec.alias.length > 0 &&
+ spec.alias.every((entry: any) => typeof entry === "string"));
+ if (!aliasIsUsable) {
+ invalid(
+ definition,
+ `option '${optionName}' declares an 'alias' that is neither a string nor a non-empty array of strings`,
+ );
+ }
+
+ if (
+ spec.hasSensitiveValue !== undefined &&
+ typeof spec.hasSensitiveValue !== "boolean"
+ ) {
+ invalid(
+ definition,
+ `option '${optionName}' declares a non-boolean 'hasSensitiveValue'`,
+ );
+ }
+
+ if (spec.description !== undefined && typeof spec.description !== "string") {
+ invalid(
+ definition,
+ `option '${optionName}' declares a non-string 'description'`,
+ );
+ }
+};
+
+const validateDefinition = (definition: any): void => {
+ if (!isPlainObject(definition)) {
+ invalid(definition, "expected an object");
+ }
+
+ const unknownFields = Object.keys(definition).filter(
+ (field) => DEFINITION_FIELDS.indexOf(field) === -1,
+ );
+ if (unknownFields.length) {
+ invalid(
+ definition,
+ `unknown field(s) ${unknownFields
+ .map((field) => `'${field}'`)
+ .join(", ")}; a definition accepts ${DEFINITION_FIELDS.join(", ")}`,
+ );
+ }
+
+ validateName(definition);
+
+ if (typeof definition.run !== "function") {
+ invalid(definition, "'run' must be a function");
+ }
+
+ if (
+ definition.arguments !== undefined &&
+ definition.arguments !== "none" &&
+ definition.arguments !== "any"
+ ) {
+ invalid(
+ definition,
+ `'arguments' is '${definition.arguments}'; it must be "none" or "any"`,
+ );
+ }
+
+ if (
+ definition.canExecute !== undefined &&
+ typeof definition.canExecute !== "function"
+ ) {
+ invalid(definition, "'canExecute' must be a function");
+ }
+
+ for (const flag of ["disableAnalytics", "enableHooks"]) {
+ if (
+ definition[flag] !== undefined &&
+ typeof definition[flag] !== "boolean"
+ ) {
+ invalid(definition, `'${flag}' must be a boolean`);
+ }
+ }
+
+ if (definition.description !== undefined) {
+ if (typeof definition.description !== "string") {
+ invalid(definition, "'description' must be a string");
+ }
+ }
+
+ if (definition.options !== undefined) {
+ if (!isPlainObject(definition.options)) {
+ invalid(
+ definition,
+ "'options' must be an object keyed by the long option name",
+ );
+ }
+
+ for (const optionName of Object.keys(definition.options)) {
+ validateOptionSpec(
+ definition,
+ optionName,
+ definition.options[optionName],
+ );
+ }
+ }
+};
+
+export function defineCommand(
+ definition: CommandDefinition,
+): DefinedCommand {
+ validateDefinition(definition);
+
+ const marked: any = { ...definition };
+ marked[COMMAND_DEFINITION_MARKER] = true;
+ return marked;
+}
+
+export function isCommandDefinition(value: any): value is DefinedCommand {
+ 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..92c85429b1
--- /dev/null
+++ b/lib/common/services/command-definition-adapter.ts
@@ -0,0 +1,231 @@
+import { OptionType } from "../enums";
+import { injector } from "../yok";
+import { runInInjectionContext } from "../di/inject";
+import { IDictionary, IDashedOption, IErrors } from "../declarations";
+import { IInjector } from "../definitions/yok";
+import { ICommand } from "../definitions/commands";
+import { CommandRegistry } from "../contracts/command-registry";
+import {
+ CommandContext,
+ CommandDefinition,
+ CommandOptionType,
+ CommandOptionsSchema,
+ DefinedCommand,
+ isCommandDefinition,
+} from "../define-command";
+
+const OPTION_TYPES: IDictionary = {
+ boolean: OptionType.Boolean,
+ string: OptionType.String,
+ number: OptionType.Number,
+ array: OptionType.Array,
+};
+
+const compileOptions = (
+ schema: CommandOptionsSchema,
+): IDictionary => {
+ const dashedOptions: IDictionary = {};
+
+ for (const optionName of Object.keys(schema)) {
+ 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;
+ }
+
+ return dashedOptions;
+};
+
+const aliasList = (alias: string | string[]): string[] =>
+ alias === undefined ? [] : Array.isArray(alias) ? alias : [alias];
+
+/**
+ * A command option that shadows a CLI-wide one wins the re-parse for this
+ * command only, so the same spelling means different things depending on which
+ * command is running. Warned rather than rejected while the policy is open.
+ */
+const warnOnCliOptionCollisions = (
+ targetInjector: IInjector,
+ definition: CommandDefinition,
+ schema: CommandOptionsSchema,
+ optionsService: any,
+): void => {
+ const cliOptions = optionsService && optionsService.options;
+ if (!cliOptions) {
+ return;
+ }
+
+ // Every spelling the CLI already answers to, mapped to the option owning it.
+ const cliSpellings: IDictionary = {};
+ for (const cliName of Object.keys(cliOptions)) {
+ cliSpellings[cliName] = cliName;
+ for (const alias of aliasList(cliOptions[cliName].alias)) {
+ cliSpellings[alias] = cliName;
+ }
+ }
+
+ const collisions: string[] = [];
+ for (const optionName of Object.keys(schema)) {
+ if (cliSpellings[optionName]) {
+ collisions.push(
+ `'--${optionName}' with the CLI option '--${cliSpellings[optionName]}'`,
+ );
+ }
+
+ for (const alias of aliasList(schema[optionName].alias)) {
+ if (cliSpellings[alias]) {
+ collisions.push(
+ `alias '-${alias}' of '--${optionName}' with the CLI option '--${cliSpellings[alias]}'`,
+ );
+ }
+ }
+ }
+
+ if (!collisions.length) {
+ return;
+ }
+
+ const logger = targetInjector.get("logger", { optional: true });
+ if (!logger) {
+ return;
+ }
+
+ const commandName = Array.isArray(definition.name)
+ ? definition.name[0]
+ : definition.name;
+ logger.warn(
+ `Command '${commandName}' declares options that collide with CLI-wide ` +
+ `ones: ${collisions.join("; ")}. The command's declaration wins while ` +
+ `the command runs; rename them to avoid it.`,
+ );
+};
+
+/**
+ * Wraps a declarative definition in the ICommand shape the legacy registry and
+ * CommandsService expect.
+ *
+ * The compiled command always exposes `canExecute`, because CommandsService
+ * skips `allowedParameters` entirely once it is present: the adapter enforces
+ * the declared `arguments` policy itself and only then consults the
+ * definition's own `canExecute`, so the two fields compose.
+ */
+export function createCommandFromDefinition<
+ TSchema extends CommandOptionsSchema,
+>(
+ definition: CommandDefinition,
+ targetInjector: IInjector = injector,
+): ICommand {
+ const schema = definition.options || {};
+ const optionNames = Object.keys(schema);
+ const dashedOptions = compileOptions(schema);
+
+ // Only a definition that declares options may depend on the options service
+ // being registered - a bare command must work without one.
+ const optionsService: any = optionNames.length
+ ? targetInjector.resolve("options")
+ : null;
+
+ warnOnCliOptionCollisions(targetInjector, definition, schema, optionsService);
+
+ const commandName = Array.isArray(definition.name)
+ ? definition.name[0]
+ : definition.name;
+
+ const fail = (message: string): never => {
+ if (typeof message !== "string" || !message.trim()) {
+ throw new Error(
+ `ctx.fail() for command '${commandName}' requires a non-empty message.`,
+ );
+ }
+
+ const errors: IErrors = targetInjector.resolve("errors");
+ return errors.failWithHelp(message);
+ };
+
+ // Read per call rather than snapshotted here: the options service only holds
+ // this command's parsed values once validateOptions has run for it.
+ const buildContext = (args: string[]): CommandContext => {
+ const options: any = {};
+ for (const optionName of optionNames) {
+ options[optionName] = optionsService[optionName];
+ }
+
+ return { args, options, fail };
+ };
+
+ const acceptsArguments = definition.arguments === "any";
+
+ return {
+ allowedParameters: [],
+ dashedOptions,
+ ...(definition.disableAnalytics === undefined
+ ? {}
+ : { disableAnalytics: definition.disableAnalytics }),
+ ...(definition.enableHooks === undefined
+ ? {}
+ : { enableHooks: definition.enableHooks }),
+ canExecute: async (args: string[]): Promise => {
+ if (!acceptsArguments && args.length) {
+ fail("This command doesn't accept parameters.");
+ }
+
+ const refine = definition.canExecute;
+ if (!refine) {
+ return true;
+ }
+
+ // Same first-await rule as execute: runInInjectionContext is
+ // synchronous, so inject() is available up to the first await.
+ return await runInInjectionContext(targetInjector, () =>
+ refine.call(definition, buildContext(args)),
+ );
+ },
+ execute: async (args: string[]): Promise => {
+ await runInInjectionContext(targetInjector, () =>
+ definition.run(buildContext(args)),
+ );
+ },
+ };
+}
+
+export function registerCommandDefinition(
+ definition: DefinedCommand,
+ targetInjector: IInjector = injector,
+): void {
+ if (!isCommandDefinition(definition)) {
+ throw new Error(
+ "registerCommandDefinition() takes the result of defineCommand(); " +
+ "the value passed carries no command-definition marker.",
+ );
+ }
+
+ // The registry facet rather than the injector itself, so a child injector
+ // that provides its own CommandRegistry receives the registration.
+ const registry = targetInjector.get(CommandRegistry);
+ const names = Array.isArray(definition.name)
+ ? definition.name
+ : [definition.name];
+
+ for (const name of names) {
+ // A prototype-less zero-parameter function registers as a useFactory
+ // provider, so the command is built on first resolution and cached.
+ registry.registerCommand(name, () =>
+ createCommandFromDefinition(definition, targetInjector),
+ );
+ }
+}
diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts
index c13a9b9996..0120e5301a 100644
--- a/lib/contracts/index.ts
+++ b/lib/contracts/index.ts
@@ -25,3 +25,23 @@ 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 {
+ CommandDefinition,
+ DefinedCommand,
+ CommandContext,
+ CommandOptionSpec,
+ DefaultedCommandOptionSpec,
+ CommandOptionsSchema,
+ 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..f249669849
--- /dev/null
+++ b/test/define-command.ts
@@ -0,0 +1,985 @@
+import { assert } from "chai";
+import { spawnSync } from "child_process";
+import * as path from "path";
+import { Yok } from "../lib/common/yok";
+import { IInjector } from "../lib/common/definitions/yok";
+import { inject } from "../lib/common/di";
+import { CommandRegistry } from "../lib/common/contracts/command-registry";
+import { CommandsService } from "../lib/common/services/commands-service";
+import { Options } from "../lib/options";
+import { Errors } from "../lib/common/errors";
+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";
+
+const createTestInjector = (options: any = {}): IInjector => {
+ const testInjector = new Yok();
+ testInjector.register("options", options);
+ testInjector.register("logger", LoggerStub);
+ testInjector.register("errors", {
+ failWithHelp: (message: string) => {
+ throw new Error(message);
+ },
+ });
+ return testInjector;
+};
+
+describe("defineCommand", () => {
+ it("marks definitions so a duplicated CLI copy still recognises them", () => {
+ const definition = defineCommand({
+ name: "dctest-marker",
+ run: (): void => undefined,
+ });
+
+ assert.isTrue(isCommandDefinition(definition));
+ assert.isTrue(
+ (definition)[Symbol.for("nativescript:cli:commandDefinition")],
+ );
+ assert.isFalse(isCommandDefinition({ name: "dctest-marker" }));
+ assert.isFalse(isCommandDefinition(null));
+ });
+
+ it("keeps the marker on a spread-derived copy", () => {
+ const derived = {
+ ...defineCommand({ name: "dctest-spread", run: (): void => undefined }),
+ name: "dctest-spread-derived",
+ };
+
+ assert.isTrue(isCommandDefinition(derived));
+ });
+
+ describe("define-time validation", () => {
+ const rejects = (definition: any, expected: RegExp) =>
+ assert.throws(() => defineCommand(definition), expected);
+
+ it("names the command and the accepted form in every message", () => {
+ rejects(
+ { name: "dctest-bad", run: 42 },
+ /Invalid command definition for 'dctest-bad'.*'run' must be a function.*Accepted form: defineCommand/s,
+ );
+ });
+
+ it("rejects a missing or unusable name", () => {
+ rejects(
+ { run: (): void => undefined },
+ /an unnamed command.*'name' must be/s,
+ );
+ rejects({ name: "", run: (): void => undefined }, /'name' must be/);
+ rejects({ name: [], run: (): void => undefined }, /'name' must be/);
+ rejects(
+ { name: ["ok", ""], run: (): void => undefined },
+ /'name' must be/,
+ );
+ rejects({ name: 7, run: (): void => undefined }, /'name' must be/);
+ });
+
+ it("rejects a missing run", () => {
+ rejects({ name: "dctest-norun" }, /'run' must be a function/);
+ });
+
+ it("rejects a typo'd definition field", () => {
+ rejects(
+ {
+ name: "dctest-typo",
+ handler: (): void => undefined,
+ run: (): void => undefined,
+ },
+ /unknown field\(s\) 'handler'/,
+ );
+ });
+
+ it("rejects an unusable arguments policy", () => {
+ rejects(
+ { name: "dctest-args", arguments: "one", run: (): void => undefined },
+ /'arguments' is 'one'; it must be "none" or "any"/,
+ );
+ });
+
+ it("rejects a non-function canExecute and non-boolean flags", () => {
+ rejects(
+ { name: "dctest-can", canExecute: true, run: (): void => undefined },
+ /'canExecute' must be a function/,
+ );
+ rejects(
+ {
+ name: "dctest-flag",
+ disableAnalytics: "yes",
+ run: (): void => undefined,
+ },
+ /'disableAnalytics' must be a boolean/,
+ );
+ rejects(
+ { name: "dctest-flag2", enableHooks: 1, run: (): void => undefined },
+ /'enableHooks' must be a boolean/,
+ );
+ });
+
+ it("rejects an option with an unsupported type", () => {
+ rejects(
+ {
+ name: "dctest-opt",
+ options: { verbose: { type: "bool" } },
+ run: (): void => undefined,
+ },
+ /option 'verbose' has type 'bool'; the supported types are boolean, string, number, array/,
+ );
+ });
+
+ it("rejects an option that is not a spec at all", () => {
+ rejects(
+ {
+ name: "dctest-opt2",
+ options: { verbose: true },
+ run: (): void => undefined,
+ },
+ /option 'verbose' must be declared with one of booleanOption/,
+ );
+ });
+
+ it("rejects a typo'd option-spec field", () => {
+ rejects(
+ {
+ name: "dctest-opt3",
+ options: { verbose: { type: "boolean", describe: "no" } },
+ run: (): void => undefined,
+ },
+ /option 'verbose' has unknown field\(s\) 'describe'/,
+ );
+ });
+
+ it("rejects unusable alias, hasSensitiveValue and description entries", () => {
+ rejects(
+ {
+ name: "dctest-opt4",
+ options: { verbose: { type: "boolean", alias: 1 } },
+ run: (): void => undefined,
+ },
+ /option 'verbose' declares an 'alias'/,
+ );
+ rejects(
+ {
+ name: "dctest-opt5",
+ options: { verbose: { type: "boolean", hasSensitiveValue: "yes" } },
+ run: (): void => undefined,
+ },
+ /non-boolean 'hasSensitiveValue'/,
+ );
+ rejects(
+ {
+ name: "dctest-opt6",
+ options: { verbose: { type: "boolean", description: 5 } },
+ run: (): void => undefined,
+ },
+ /non-string 'description'/,
+ );
+ });
+
+ it("accepts every documented field", () => {
+ assert.doesNotThrow(() =>
+ defineCommand({
+ name: ["dctest-full", "dctest-full-alias"],
+ description: "Everything at once",
+ options: {
+ verbose: booleanOption({ default: false }),
+ output: stringOption({ alias: ["o", "out"], description: "Dir" }),
+ retries: numberOption({ default: 1 }),
+ files: arrayOption({ hasSensitiveValue: true }),
+ },
+ arguments: "any",
+ canExecute: () => true,
+ disableAnalytics: true,
+ enableHooks: false,
+ run: (): void => undefined,
+ }),
+ );
+ });
+ });
+
+ describe("option value types", () => {
+ it("types default-less options as possibly undefined", () => {
+ // The repo builds without strictNullChecks, which erases the very
+ // `| undefined` under test, so the assertions live in their own
+ // strict project.
+ const project = path.join(
+ __dirname,
+ "..",
+ "..",
+ "test",
+ "type-fixtures",
+ "tsconfig.json",
+ );
+ const result = spawnSync(
+ process.execPath,
+ [require.resolve("typescript/bin/tsc"), "-p", project],
+ { encoding: "utf8" },
+ );
+
+ assert.strictEqual(
+ result.status,
+ 0,
+ `${result.stdout || ""}${result.stderr || ""}`,
+ );
+ });
+ });
+
+ describe("registration", () => {
+ it("round-trips through the legacy command registry", () => {
+ const definition = defineCommand({
+ name: "dctestwidget|add",
+ description: "Adds a widget",
+ run: (): void => 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: (): void => 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: (): void => undefined,
+ }),
+ testInjector,
+ );
+
+ assert.isFunction(testInjector.resolveCommand("dctestalias").execute);
+ assert.isFunction(testInjector.resolveCommand("dctestalias2").execute);
+ });
+
+ it("refuses a value that did not come from defineCommand", () => {
+ assert.throws(
+ () =>
+ registerCommandDefinition(
+ { name: "dctestraw", run: (): void => undefined },
+ createTestInjector(),
+ ),
+ /carries no command-definition marker/,
+ );
+ });
+
+ it("registers through the CommandRegistry the target injector provides", () => {
+ const testInjector = createTestInjector();
+ const registered: string[] = [];
+ testInjector.register({
+ provide: CommandRegistry,
+ useValue: {
+ registerCommand: (name: string) => registered.push(name),
+ },
+ });
+
+ registerCommandDefinition(
+ defineCommand({
+ name: ["dctestfacet", "dctestfacet2"],
+ run: (): void => undefined,
+ }),
+ testInjector,
+ );
+
+ assert.deepEqual(registered, ["dctestfacet", "dctestfacet2"]);
+ assert.isNull(testInjector.resolveCommand("dctestfacet"));
+ });
+
+ it("keeps a registered command when a subcommand would shadow it", () => {
+ const testInjector = createTestInjector();
+ registerCommandDefinition(
+ defineCommand({ name: "dctestowned", run: (): void => undefined }),
+ testInjector,
+ );
+
+ registerCommandDefinition(
+ defineCommand({ name: "dctestowned|sub", run: (): void => undefined }),
+ testInjector,
+ );
+
+ const owner = testInjector.resolveCommand("dctestowned");
+ assert.isUndefined(owner.isHierarchicalCommand);
+ assert.isFunction(testInjector.resolveCommand("dctestowned|sub").execute);
+
+ const logger: LoggerStub = testInjector.resolve("logger");
+ assert.match(
+ logger.warnOutput,
+ /'dctestowned' is already registered as a command of its own.*'dctestowned\|sub' cannot be reached/,
+ );
+ });
+ });
+
+ 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("carries the declared option values onto the run context", async () => {
+ const testInjector = createTestInjector({
+ verbose: true,
+ output: "dist",
+ retries: 3,
+ files: ["a.ts"],
+ });
+
+ let seen: any;
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctesttypes",
+ options: {
+ verbose: booleanOption(),
+ output: stringOption(),
+ retries: numberOption(),
+ files: arrayOption(),
+ },
+ run: (context) => {
+ seen = context.options;
+ },
+ }),
+ testInjector,
+ );
+
+ await command.execute([]);
+
+ assert.deepEqual(seen, {
+ verbose: true,
+ output: "dist",
+ retries: 3,
+ 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: (): void => 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: (): void => undefined }),
+ createTestInjector(),
+ );
+
+ assert.deepEqual(command.dashedOptions, {});
+ });
+
+ it("warns when a declared option or alias shadows a CLI-wide one", () => {
+ const testInjector = createTestInjector({
+ options: {
+ verbose: { type: "boolean" },
+ path: { type: "string", alias: "p" },
+ },
+ });
+
+ createCommandFromDefinition(
+ defineCommand({
+ name: "dctestshadow",
+ options: {
+ verbose: booleanOption(),
+ output: stringOption({ alias: ["p", "o"] }),
+ fresh: booleanOption({ alias: "f" }),
+ },
+ run: (): void => undefined,
+ }),
+ testInjector,
+ );
+
+ const logger: LoggerStub = testInjector.resolve("logger");
+ assert.include(
+ logger.warnOutput,
+ "'--verbose' with the CLI option '--verbose'",
+ );
+ assert.include(
+ logger.warnOutput,
+ "alias '-p' of '--output' with the CLI option '--path'",
+ );
+ assert.notInclude(logger.warnOutput, "--fresh");
+ assert.notInclude(logger.warnOutput, "'-o'");
+ });
+
+ it("stays quiet when nothing collides", () => {
+ const testInjector = createTestInjector({
+ options: { path: { type: "string", alias: "p" } },
+ });
+
+ createCommandFromDefinition(
+ defineCommand({
+ name: "dctestnoshadow",
+ options: { output: stringOption({ alias: ["o", "out"] }) },
+ run: (): void => undefined,
+ }),
+ testInjector,
+ );
+
+ assert.strictEqual(
+ (testInjector.resolve("logger")).warnOutput,
+ "",
+ );
+ });
+ });
+
+ describe("canExecute", () => {
+ it("rejects positional arguments before consulting the definition", async () => {
+ let refined = false;
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestnone",
+ canExecute: () => {
+ refined = true;
+ return true;
+ },
+ run: (): void => undefined,
+ }),
+ createTestInjector(),
+ );
+
+ await assert.isRejected(
+ command.canExecute(["stray"]),
+ /doesn't accept parameters/,
+ );
+ assert.isFalse(refined);
+ assert.isTrue(await command.canExecute([]));
+ assert.isTrue(refined);
+ });
+
+ it("rejects positional arguments with no definition canExecute at all", async () => {
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestnone2",
+ arguments: "none",
+ run: (): void => undefined,
+ }),
+ createTestInjector(),
+ );
+
+ await assert.isRejected(
+ command.canExecute(["stray"]),
+ /doesn't accept parameters/,
+ );
+ assert.isTrue(await command.canExecute([]));
+ });
+
+ it("accepts anything when arguments are 'any'", async () => {
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestany",
+ arguments: "any",
+ run: (): void => undefined,
+ }),
+ createTestInjector(),
+ );
+
+ assert.isTrue(await command.canExecute(["whatever", "else"]));
+ });
+
+ it("hands the context to a definition 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: (): void => 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("runs the definition canExecute inside an injection context", async () => {
+ const testInjector = createTestInjector();
+ testInjector.register("dcTestPolicy", { allowed: true });
+
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestcaninject",
+ arguments: "any",
+ canExecute: () => inject("dcTestPolicy").allowed,
+ run: (): void => undefined,
+ }),
+ testInjector,
+ );
+
+ assert.isTrue(await command.canExecute(["anything"]));
+ });
+ });
+
+ describe("ctx.fail", () => {
+ const createFailInjector = (): IInjector => {
+ const testInjector = createTestInjector();
+ testInjector.register("errors", {
+ failWithHelp: (message: string) => {
+ throw new Error(`with help: ${message}`);
+ },
+ });
+ return testInjector;
+ };
+
+ it("fails the command from run, through failWithHelp", async () => {
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestfailrun",
+ run: (ctx) => ctx.fail("no project found"),
+ }),
+ createFailInjector(),
+ );
+
+ await assert.isRejected(
+ command.execute([]),
+ /with help: no project found/,
+ );
+ });
+
+ it("fails the command from canExecute, through failWithHelp", async () => {
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestfailcan",
+ arguments: "any",
+ canExecute: (ctx) =>
+ ctx.args.length === 1 || ctx.fail("expected one argument"),
+ run: (): void => undefined,
+ }),
+ createFailInjector(),
+ );
+
+ assert.isTrue(await command.canExecute(["one"]));
+ await assert.isRejected(
+ command.canExecute([]),
+ /with help: expected one argument/,
+ );
+ });
+
+ it("rejects a message that carries nothing", async () => {
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestfailempty",
+ run: (ctx) => ctx.fail(" "),
+ }),
+ createFailInjector(),
+ );
+
+ await assert.isRejected(
+ command.execute([]),
+ /ctx.fail\(\) for command 'dctestfailempty' requires a non-empty message/,
+ );
+ });
+
+ it("still lets a thrown error through unchanged", async () => {
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestthrow",
+ run: () => {
+ throw new Error("raw failure");
+ },
+ }),
+ createFailInjector(),
+ );
+
+ await assert.isRejected(command.execute([]), /^raw failure$/);
+ });
+ });
+
+ describe("command flags", () => {
+ it("passes disableAnalytics and enableHooks through", () => {
+ const command = createCommandFromDefinition(
+ defineCommand({
+ name: "dctestflags",
+ disableAnalytics: true,
+ enableHooks: false,
+ run: (): void => 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: (): void => undefined }),
+ createTestInjector(),
+ );
+
+ assert.isFalse("disableAnalytics" in command);
+ assert.isFalse("enableHooks" in command);
+ });
+ });
+
+ describe("option validation with the real options service", () => {
+ interface IValidationRun {
+ failures: string[];
+ options: any;
+ }
+
+ // The options service parses process.argv in its constructor, so each run
+ // gets its own injector and its own instance.
+ const validate = (definition: any, argv: string[]): IValidationRun => {
+ const failures: string[] = [];
+ const testInjector = new Yok();
+ testInjector.register("staticConfig", { CLIENT_NAME: "" });
+ testInjector.register("hostInfo", {});
+ testInjector.register("settingsService", {
+ setSettings: (): any => undefined,
+ getProfileDir: () => "profileDir",
+ });
+ testInjector.register("logger", LoggerStub);
+
+ const errors = new Errors(testInjector);
+ errors.failWithHelp = ((message: string) => failures.push(message));
+ errors.fail = ((message: string) => failures.push(message));
+ testInjector.register("errors", errors);
+ testInjector.register("options", Options);
+
+ const originalArgv = process.argv;
+ process.argv = [originalArgv[0], originalArgv[1], ...argv];
+ try {
+ const command = createCommandFromDefinition(definition, testInjector);
+ const options: any = testInjector.resolve("options");
+ options.validateOptions(command.dashedOptions);
+ return { failures, options };
+ } finally {
+ process.argv = originalArgv;
+ }
+ };
+
+ beforeEach(() => {
+ process.env.NS_STRICT_OPTIONS = "error";
+ });
+
+ afterEach(() => {
+ delete process.env.NS_STRICT_OPTIONS;
+ });
+
+ it("accepts an option declared with an array of aliases, under any spelling", () => {
+ const definition = defineCommand({
+ name: "dctest-alias",
+ options: { outputDir: stringOption({ alias: ["o", "out"] }) },
+ run: (): void => undefined,
+ });
+
+ for (const spelling of ["--output-dir", "--outputDir", "-o", "--out"]) {
+ const run = validate(definition, [spelling, "dist"]);
+ assert.deepEqual(run.failures, [], `rejected ${spelling}`);
+ assert.strictEqual(run.options.outputDir, "dist");
+ }
+ });
+
+ it("still rejects an option the definition did not declare", () => {
+ const definition = defineCommand({
+ name: "dctest-alias2",
+ options: { outputDir: stringOption({ alias: ["o", "out"] }) },
+ run: (): void => undefined,
+ });
+
+ const run = validate(definition, ["--outputdirr", "dist"]);
+
+ assert.lengthOf(run.failures, 1);
+ assert.match(run.failures[0], /'outputdirr' is not supported/);
+ });
+ });
+
+ 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("rejects 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);
+ });
+
+ it("rejects parameters even when the definition supplies a canExecute", async () => {
+ const testInjector = createCommandsServiceInjector();
+ let ran = false;
+
+ registerCommandDefinition(
+ defineCommand({
+ name: "dctest-e2e-refine",
+ canExecute: () => true,
+ run: () => {
+ ran = true;
+ },
+ }),
+ testInjector,
+ );
+
+ const commandsService: ICommandsService =
+ testInjector.resolve("commandsService");
+ await assert.isRejected(
+ commandsService.tryExecuteCommand("dctest-e2e-refine", ["stray"]),
+ /doesn't accept parameters/,
+ );
+ assert.isFalse(ran);
+ });
+
+ it("dispatches a subcommand through the parent name", async () => {
+ const testInjector = createCommandsServiceInjector();
+ let ran: any;
+
+ registerCommandDefinition(
+ defineCommand({
+ name: "dctest-widget|add",
+ arguments: "any",
+ run: (context) => {
+ ran = context;
+ },
+ }),
+ testInjector,
+ );
+
+ const commandsService: ICommandsService =
+ testInjector.resolve("commandsService");
+ await commandsService.tryExecuteCommand("dctest-widget", [
+ "add",
+ "alpha",
+ ]);
+
+ assert.deepEqual(ran.args, ["alpha"]);
+ });
+
+ it("dispatches the default subcommand, named or bare", async () => {
+ const testInjector = createCommandsServiceInjector();
+ const runs: string[][] = [];
+
+ registerCommandDefinition(
+ defineCommand({
+ name: "dctest-gadget|*all",
+ arguments: "any",
+ run: (context) => {
+ runs.push(context.args);
+ },
+ }),
+ testInjector,
+ );
+
+ const commandsService: ICommandsService =
+ testInjector.resolve("commandsService");
+ await commandsService.tryExecuteCommand("dctest-gadget", ["all", "beta"]);
+ await commandsService.tryExecuteCommand("dctest-gadget", []);
+
+ assert.deepEqual(runs, [["beta"], []]);
+ });
+ });
+});
diff --git a/test/type-fixtures/define-command-types.ts b/test/type-fixtures/define-command-types.ts
new file mode 100644
index 0000000000..e8d1f80d49
--- /dev/null
+++ b/test/type-fixtures/define-command-types.ts
@@ -0,0 +1,84 @@
+/**
+ * Type-level assertions for the defineCommand schema, compiled by
+ * test/define-command.ts through this directory's tsconfig. It is kept out of
+ * the repo's own build because that build runs without strictNullChecks, which
+ * erases the `| undefined` these assertions exist to pin — and because the
+ * @ts-expect-error directives below only hold under strict mode.
+ */
+
+import {
+ arrayOption,
+ booleanOption,
+ defineCommand,
+ numberOption,
+ stringOption,
+} from "../../lib/common/define-command";
+
+type IsExact =
+ (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2
+ ? true
+ : false;
+
+const expectExactType = (): void => undefined;
+
+// A declared option is `T` only when the schema supplies a default; without
+// one the flag may simply be absent from the command line.
+defineCommand({
+ name: "typefixture|values",
+ options: {
+ verbose: booleanOption(),
+ release: booleanOption({ default: false }),
+ output: stringOption({ alias: "o" }),
+ target: stringOption({ default: "dist" }),
+ retries: numberOption(),
+ attempts: numberOption({ default: 3 }),
+ files: arrayOption(),
+ tags: arrayOption({ default: [] }),
+ },
+ run(ctx) {
+ expectExactType>();
+ expectExactType>();
+ expectExactType>();
+ expectExactType>();
+ expectExactType>();
+ expectExactType>();
+ expectExactType>();
+ expectExactType>();
+ expectExactType>();
+
+ // @ts-expect-error - the schema types ctx.options and nothing else
+ ctx.options.undeclared;
+ },
+});
+
+defineCommand({
+ name: "typefixture|no-options",
+ run(ctx) {
+ expectExactType>();
+ // `never` is what lets fail() end a branch without a return.
+ expectExactType, never>>();
+
+ // @ts-expect-error - nothing is declared, so any access is a typo
+ ctx.options.anything;
+ },
+});
+
+defineCommand({
+ name: "typefixture|refine",
+ options: { force: booleanOption({ default: false }) },
+ canExecute(ctx) {
+ expectExactType>();
+ return ctx.args.length === 1;
+ },
+ run: () => undefined,
+});
+
+// @ts-expect-error - `run` is the required handler field
+defineCommand({ name: "typefixture|no-run" });
+
+defineCommand({
+ name: "typefixture|bad-arguments",
+ // @ts-expect-error - `arguments` is a closed set
+ arguments: "one",
+ run: () => undefined,
+});
diff --git a/test/type-fixtures/tsconfig.json b/test/type-fixtures/tsconfig.json
new file mode 100644
index 0000000000..254bd447c0
--- /dev/null
+++ b/test/type-fixtures/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2018",
+ "module": "commonjs",
+ "noEmit": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": false,
+ "lib": ["ESNext"],
+ "types": []
+ },
+ "files": ["define-command-types.ts"]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 1e9b8deda9..45bf605cf7 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,5 +18,9 @@
"lib": ["ESNext"],
"strict": false
},
- "include": ["lib/", "test/"]
+ // test/type-fixtures has its own strict tsconfig and is compiled by the test
+ // that asserts on it; building it here would emit a testless file into dist
+ // and drop the strictness those assertions depend on
+ "include": ["lib/", "test/"],
+ "exclude": ["test/type-fixtures/"]
}