From 27543f894df3ec7e643f6ad9fc86f87f7729d9cb Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 21:59:49 -0300 Subject: [PATCH 1/5] feat(commands): add defineCommand with typed options via an ICommand adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands can now be declared as plain objects: a name, an option schema built from booleanOption/stringOption/numberOption/arrayOption, and a run function whose context carries the positional args plus the declared options, typed by inference from the schema. lib/common/define-command holds the types and the pure factories only, so it stays side-effect-free and can be re-exported from nativescript/contracts. The runtime bridge lives in lib/common/services/command-definition-adapter, which compiles a definition into the ICommand the legacy registry expects and runs it inside an injection context. canExecute is emitted only when the definition supplies one or opts into arguments: "any"; CommandsService skips all parameter validation as soon as canExecute exists, so omitting it is what lets the framework reject stray positional arguments for arguments: "none". Fully additive — existing ICommand classes are untouched. --- defining-commands.md | 251 ++++++++++ lib/common/define-command.ts | 104 ++++ .../services/command-definition-adapter.ts | 120 +++++ lib/contracts/index.ts | 18 + test/define-command.ts | 461 ++++++++++++++++++ 5 files changed, 954 insertions(+) create mode 100644 defining-commands.md create mode 100644 lib/common/define-command.ts create mode 100644 lib/common/services/command-definition-adapter.ts create mode 100644 test/define-command.ts 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..0956aa1db7 --- /dev/null +++ b/lib/common/services/command-definition-adapter.ts @@ -0,0 +1,120 @@ +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 optionsService: any = targetInjector.resolve("options"); + const options: any = {}; + 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).di, () => + 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..011ff59f31 --- /dev/null +++ b/test/define-command.ts @@ -0,0 +1,461 @@ +import { assert } from "chai"; +import { Yok, injector as globalInjector } 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, + }); + + // The parent dispatcher is synthesized onto the global facade by the + // legacy registry, so registration happens there too. + registerCommandDefinition(definition, globalInjector); + + const command = globalInjector.resolveCommand("dctestwidget|add"); + assert.isFunction(command.execute); + assert.deepEqual(command.allowedParameters, []); + + const parent = globalInjector.resolveCommand("dctestwidget"); + assert.isTrue(parent.isHierarchicalCommand); + + assert.include( + globalInjector.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); + }); + }); +}); From 5d9b2bbe38ddadcdcad38f1a25291abb954657da Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:07:33 -0300 Subject: [PATCH 2/5] fix(commands): don't resolve the options service for option-less definitions A definition with no declared options must be executable in a container that has no options service registered - manifest-loaded extension commands run in exactly that situation. --- lib/common/services/command-definition-adapter.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index 0956aa1db7..c56577775f 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -65,12 +65,16 @@ export function createCommandFromDefinition< // Resolved per call: the options service only holds parsed values once // validateOptions has run for this command. const buildContext = (args: string[]): ICommandContext => { - const optionsService: any = targetInjector.resolve("options"); const options: any = {}; - for (const optionName of optionNames) { - options[optionName] = optionsService - ? optionsService[optionName] - : undefined; + // 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 }; From ef6e755f2919524d52b09489288453ef6d49576e Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:39:13 -0300 Subject: [PATCH 3/5] test(commands): register hierarchical definitions on a local injector The parent-dispatcher leak onto the module-level injector is fixed in the base branch, so the round-trip test no longer needs the global facade. --- test/define-command.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/test/define-command.ts b/test/define-command.ts index 011ff59f31..da866802d2 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -1,5 +1,5 @@ import { assert } from "chai"; -import { Yok, injector as globalInjector } from "../lib/common/yok"; +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"; @@ -54,19 +54,18 @@ describe("defineCommand", () => { run: () => undefined, }); - // The parent dispatcher is synthesized onto the global facade by the - // legacy registry, so registration happens there too. - registerCommandDefinition(definition, globalInjector); + const testInjector = createTestInjector(); + registerCommandDefinition(definition, testInjector); - const command = globalInjector.resolveCommand("dctestwidget|add"); + const command = testInjector.resolveCommand("dctestwidget|add"); assert.isFunction(command.execute); assert.deepEqual(command.allowedParameters, []); - const parent = globalInjector.resolveCommand("dctestwidget"); + const parent = testInjector.resolveCommand("dctestwidget"); assert.isTrue(parent.isHierarchicalCommand); assert.include( - globalInjector.getRegisteredCommandsNames(false), + testInjector.getRegisteredCommandsNames(false), "dctestwidget|add", ); }); From 018070e132ee0b2492736e538643b1feb6692274 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:44:15 -0300 Subject: [PATCH 4/5] refactor(commands): use the typed di bridge on IInjector --- lib/common/services/command-definition-adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index c56577775f..5b1c3da997 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -86,7 +86,7 @@ export function createCommandFromDefinition< 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).di, () => + await runInInjectionContext(targetInjector.di, () => definition.run(buildContext(args)), ); }, From a1ba0efd8586c7e3d1b23ab1a602bbde724551fb Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 23:27:00 -0300 Subject: [PATCH 5/5] refactor(commands): the target injector IS the injection context Yok extends Injector on the base branch; the di bridge is gone. --- lib/common/services/command-definition-adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index 5b1c3da997..e92e5a88c8 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -86,7 +86,7 @@ export function createCommandFromDefinition< 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.di, () => + await runInInjectionContext(targetInjector, () => definition.run(buildContext(args)), ); },