From 72de2702f9f51e472a1a7e29ec1804dff9ef02c1 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 23 Sep 2026 10:53:51 -0300 Subject: [PATCH 1/2] feat(commands): option groups, invocation scopes and the invocation record - `defineOptions(name, schema)` declares an option group: a schema that is also an injection token typed as its parsed values. `options` takes a schema or a list of groups and schemas, and `ctx.options` is typed as the merged values. Spellings collide only with different specs; an alias may not equal another option's spelling. - Every non-root group a command declares is provided in the invocation injector with its slice of the parsed values. `CliOptions` is the process-level group, provided at the root; the option table derives its global entries from it, and a command may list it but not redeclare its spellings. The built-ins that redeclared `--path` or `--help` list it. - `OptionContributions` adds groups to a command by any of its names, own or registered, or to the root, ahead of the parse that targets them. - `providedIn` on a provider, an implementation class (`@ProvidedIn`) or a contract puts the instance on the nearest injector of that scope in the resolving chain; the adapter opens each invocation's injector in the `invocation` scope and disposes it when the invocation ends. Resolving a scoped record from outside its scope is an error, `optional` or not. - `currentInvocationInjector()` resolves the synchronous injection context, then the invocation's asynchronous flow, then the most recently opened invocation. Hooks resolve their by-name dependencies against it, and a definition run as given defaults its scope to it. An in-process dispatch closes the invocations it opened. --- lib/commands/create-project.ts | 50 +- lib/commands/install.ts | 16 +- lib/commands/plugin/build-plugin.ts | 14 +- lib/commands/plugin/create-plugin.ts | 20 +- lib/commands/test-init.ts | 18 +- lib/common/bootstrap.ts | 1 + lib/common/commands/help.ts | 7 +- lib/common/contracts/cli-options.ts | 18 + lib/common/contracts/commands-service.ts | 8 +- lib/common/contracts/index.ts | 2 + lib/common/contracts/option-contributions.ts | 29 + lib/common/define-command.ts | 361 ++++++++++-- lib/common/di/contract.ts | 40 +- lib/common/di/index.ts | 6 +- lib/common/di/injector.ts | 116 +++- lib/common/di/providers.ts | 15 + lib/common/invocations.ts | 83 +++ .../services/command-definition-adapter.ts | 317 +++++++---- lib/common/services/commands-service.ts | 10 +- lib/common/services/hooks-service.ts | 18 +- lib/common/services/option-contributions.ts | 93 ++++ lib/contracts/index.ts | 17 + lib/options.ts | 52 +- test/define-command.ts | 520 ++++++++++++++++-- test/di.ts | 337 ++++++++++++ test/invocations.ts | 388 +++++++++++++ test/services/option-contributions.ts | 451 +++++++++++++++ test/type-fixtures/define-command-types.ts | 48 ++ 28 files changed, 2800 insertions(+), 255 deletions(-) create mode 100644 lib/common/contracts/cli-options.ts create mode 100644 lib/common/contracts/option-contributions.ts create mode 100644 lib/common/invocations.ts create mode 100644 lib/common/services/option-contributions.ts create mode 100644 test/invocations.ts create mode 100644 test/services/option-contributions.ts diff --git a/lib/commands/create-project.ts b/lib/commands/create-project.ts index 85c2eeec85..b8a504fa01 100644 --- a/lib/commands/create-project.ts +++ b/lib/commands/create-project.ts @@ -3,10 +3,12 @@ import { color } from "../color"; import { booleanOption, Command, + CommandOptionsInput, CommandOptionsSchema, - CommandOptionValues, + OptionValuesOf, stringOption, } from "../common/define-command"; +import { CliOptions } from "../common/contracts/cli-options"; import { inject } from "../common/di"; import { isInteractive } from "../common/helpers"; import * as constants from "../constants"; @@ -27,27 +29,29 @@ const TABS_TEMPLATE_KEY = "Tabs"; const TABS_TEMPLATE_DESCRIPTION = "An app with pre-built pages that uses tabs for navigation"; -const createProjectCommandOptions = { - js: booleanOption(), - ng: booleanOption(), - react: booleanOption(), - solid: booleanOption(), - svelte: booleanOption(), - tsc: booleanOption(), - vue: booleanOption(), - vuejs: booleanOption(), - vision: booleanOption(), - "vision-ng": booleanOption(), - "vision-react": booleanOption(), - "vision-solid": booleanOption(), - "vision-svelte": booleanOption(), - "vision-vue": booleanOption(), - template: stringOption(), - appid: stringOption(), - path: stringOption(), - force: booleanOption(), - ignoreScripts: booleanOption(), -} satisfies CommandOptionsSchema; +const createProjectCommandOptions = [ + CliOptions, + { + js: booleanOption(), + ng: booleanOption(), + react: booleanOption(), + solid: booleanOption(), + svelte: booleanOption(), + tsc: booleanOption(), + vue: booleanOption(), + vuejs: booleanOption(), + vision: booleanOption(), + "vision-ng": booleanOption(), + "vision-react": booleanOption(), + "vision-solid": booleanOption(), + "vision-svelte": booleanOption(), + "vision-vue": booleanOption(), + template: stringOption(), + appid: stringOption(), + force: booleanOption(), + ignoreScripts: booleanOption(), + } satisfies CommandOptionsSchema, +] satisfies CommandOptionsInput; interface ITemplateChoice { key?: string; @@ -217,7 +221,7 @@ const flavorTemplates: { [flavorName: string]: () => ITemplateChoice[] } = { /** The template a flavor flag selects, without asking anything. */ function selectTemplateFromOptions( - options: CommandOptionValues, + options: OptionValuesOf, ): string { if (options["vision-ng"] || (options.vision && options.ng)) { return constants.RESERVED_TEMPLATE_NAMES["vision-ng"]; diff --git a/lib/commands/install.ts b/lib/commands/install.ts index 7f861fa741..12b8aa1231 100644 --- a/lib/commands/install.ts +++ b/lib/commands/install.ts @@ -3,10 +3,12 @@ import { IFileSystem } from "../common/declarations"; import { booleanOption, CommandContext, + CommandOptionsInput, CommandOptionsSchema, defineCommand, stringOption, } from "../common/define-command"; +import { CliOptions } from "../common/contracts/cli-options"; import { PlatformTypes } from "../constants"; import { INodePackageManager, @@ -19,12 +21,14 @@ import { IProjectDataService } from "../definitions/project"; import { ProjectData } from "../contracts/project-data"; import { provideProject } from "./command-base"; -const installCommandOptions = { - frameworkPath: stringOption(), - disableNpmInstall: booleanOption(), - ignoreScripts: booleanOption(), - path: stringOption(), -} satisfies CommandOptionsSchema; +const installCommandOptions = [ + CliOptions, + { + frameworkPath: stringOption(), + disableNpmInstall: booleanOption(), + ignoreScripts: booleanOption(), + } satisfies CommandOptionsSchema, +] satisfies CommandOptionsInput; async function installProjectDependencies( context: CommandContext, diff --git a/lib/commands/plugin/build-plugin.ts b/lib/commands/plugin/build-plugin.ts index 306fd77ed9..1f62f7326c 100644 --- a/lib/commands/plugin/build-plugin.ts +++ b/lib/commands/plugin/build-plugin.ts @@ -8,17 +8,21 @@ import { import { IFileSystem } from "../../common/declarations"; import { Command, + CommandOptionsInput, CommandOptionsSchema, stringOption, } from "../../common/define-command"; +import { CliOptions } from "../../common/contracts/cli-options"; import { inject } from "../../common/di"; import { ITempService } from "../../definitions/temp-service"; -const buildPluginCommandOptions = { - path: stringOption(), - gradlePath: stringOption(), - gradleArgs: stringOption(), -} satisfies CommandOptionsSchema; +const buildPluginCommandOptions = [ + CliOptions, + { + gradlePath: stringOption(), + gradleArgs: stringOption(), + } satisfies CommandOptionsSchema, +] satisfies CommandOptionsInput; export class BuildPluginCommand extends Command({ name: "plugin|build", diff --git a/lib/commands/plugin/create-plugin.ts b/lib/commands/plugin/create-plugin.ts index f24d6b7b02..d003625cc1 100644 --- a/lib/commands/plugin/create-plugin.ts +++ b/lib/commands/plugin/create-plugin.ts @@ -4,9 +4,11 @@ import { INodePackageManager } from "../../declarations"; import { IErrors, IFileSystem, IChildProcess } from "../../common/declarations"; import { Command, + CommandOptionsInput, CommandOptionsSchema, stringOption, } from "../../common/define-command"; +import { CliOptions } from "../../common/contracts/cli-options"; import { inject } from "../../common/di"; import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service"; @@ -21,14 +23,16 @@ export const INCLUDE_ANGULAR_DEMO_MESSAGE = export const PATH_ALREADY_EXISTS_MESSAGE_TEMPLATE = "Path already exists and is not empty %s"; -const createPluginCommandOptions = { - path: stringOption(), - template: stringOption(), - username: stringOption(), - pluginName: stringOption(), - includeTypeScriptDemo: stringOption(), - includeAngularDemo: stringOption(), -} satisfies CommandOptionsSchema; +const createPluginCommandOptions = [ + CliOptions, + { + template: stringOption(), + username: stringOption(), + pluginName: stringOption(), + includeTypeScriptDemo: stringOption(), + includeAngularDemo: stringOption(), + } satisfies CommandOptionsSchema, +] satisfies CommandOptionsInput; export class CreatePluginCommand extends Command({ name: "plugin|create", diff --git a/lib/commands/test-init.ts b/lib/commands/test-init.ts index e61837b359..970ff217d1 100644 --- a/lib/commands/test-init.ts +++ b/lib/commands/test-init.ts @@ -7,10 +7,12 @@ import { INodePackageManager } from "../declarations"; import { IPluginsService } from "../definitions/plugins"; import { Command, + CommandOptionsInput, CommandOptionsSchema, booleanOption, stringOption, } from "../common/define-command"; +import { CliOptions } from "../common/contracts/cli-options"; import { inject } from "../common/di"; import { IDictionary, @@ -26,13 +28,15 @@ const karmaConfigAdditionalFrameworks: IDictionary = { mocha: ["chai"], }; -const testInitCommandOptions = { - framework: stringOption(), - disableNpmInstall: booleanOption(), - frameworkPath: stringOption(), - ignoreScripts: booleanOption(), - path: stringOption(), -} satisfies CommandOptionsSchema; +const testInitCommandOptions = [ + CliOptions, + { + framework: stringOption(), + disableNpmInstall: booleanOption(), + frameworkPath: stringOption(), + ignoreScripts: booleanOption(), + } satisfies CommandOptionsSchema, +] satisfies CommandOptionsInput; export class TestInitCommand extends Command({ name: "test|init", diff --git a/lib/common/bootstrap.ts b/lib/common/bootstrap.ts index ba51125a76..b77aaf058e 100644 --- a/lib/common/bootstrap.ts +++ b/lib/common/bootstrap.ts @@ -23,6 +23,7 @@ injector.require("stringParameter", "./command-params"); injector.require("stringParameterBuilder", "./command-params"); injector.require("commandsService", "./services/commands-service"); +injector.require("optionContributions", "./services/option-contributions"); injector.require("messagesService", "./services/messages-service"); diff --git a/lib/common/commands/help.ts b/lib/common/commands/help.ts index 97cf008244..2bda834ab3 100644 --- a/lib/common/commands/help.ts +++ b/lib/common/commands/help.ts @@ -1,15 +1,14 @@ import * as _ from "lodash"; import { CommandRegistry } from "../contracts/command-registry"; import { IHelpService } from "../declarations"; -import { booleanOption, defineCommand } from "../define-command"; +import { CliOptions } from "../contracts/cli-options"; +import { defineCommand } from "../define-command"; import { inject } from "../di"; export const helpCommandDefinition = defineCommand({ name: ["help", "/?"], description: "Shows the help for a command.", - options: { - help: booleanOption(), - }, + options: [CliOptions], // The command names whatever command it explains, so every argument after // the first is that command's own. params: "any", diff --git a/lib/common/contracts/cli-options.ts b/lib/common/contracts/cli-options.ts new file mode 100644 index 0000000000..b1628e60b3 --- /dev/null +++ b/lib/common/contracts/cli-options.ts @@ -0,0 +1,18 @@ +import { booleanOption, defineOptions, stringOption } from "../define-command"; + +/** + * The process-level options: parsed once at startup, before a command is + * chosen, and read by the services that run for every command — the logger, + * analytics, project resolution. Provided at the root, so any service injects + * it. Its spellings are protected: a command may not redeclare one. + */ +export const CliOptions = defineOptions("cli", { + log: stringOption(), + verbose: booleanOption(), + version: booleanOption({ alias: "v" }), + help: booleanOption({ alias: "h" }), + profileDir: stringOption({ hasSensitiveValue: true }), + analyticsClient: stringOption(), + path: stringOption({ alias: "p", hasSensitiveValue: true }), + config: stringOption({ alias: "c", hasSensitiveValue: true }), +}); diff --git a/lib/common/contracts/commands-service.ts b/lib/common/contracts/commands-service.ts index 85449eebc1..7d90b8bd9d 100644 --- a/lib/common/contracts/commands-service.ts +++ b/lib/common/contracts/commands-service.ts @@ -5,9 +5,11 @@ import type { CommandReference } from "../define-command"; export interface CommandDispatchOptions { /** * The injector a definition run as given is compiled against, the way - * Angular's `createComponent` takes one. Omitted, the call's own injection - * context is used, and the root when there is none. A registered name keeps - * the scope it was registered under, so passing one with a name throws. + * Angular's `createComponent` takes one. Omitted, the invocation running + * now is used - the call's own injection context, else the invocation its + * asynchronous flow belongs to, else the most recently opened one - and + * the root when there is none. A registered name keeps the scope it was + * registered under, so passing one with a name throws. */ injector?: Injector; } diff --git a/lib/common/contracts/index.ts b/lib/common/contracts/index.ts index cbc83f14d2..6a931f2840 100644 --- a/lib/common/contracts/index.ts +++ b/lib/common/contracts/index.ts @@ -19,5 +19,7 @@ export { COMMAND_PRECONDITIONS } from "./command-preconditions"; export type { CommandPrecondition } from "./command-preconditions"; export { CommandsService } from "./commands-service"; export type { CommandDispatchOptions } from "./commands-service"; +export { CliOptions } from "./cli-options"; +export { OptionContributions } from "./option-contributions"; export { ModuleRegistry } from "./module-registry"; export { PublicApiBuilder } from "./public-api-builder"; diff --git a/lib/common/contracts/option-contributions.ts b/lib/common/contracts/option-contributions.ts new file mode 100644 index 0000000000..45e9c7e339 --- /dev/null +++ b/lib/common/contracts/option-contributions.ts @@ -0,0 +1,29 @@ +import { Contract } from "../di/contract"; +import type { OptionsGroup } from "../define-command"; + +/** + * Option groups added from outside the command that parses them: a plugin + * adding flags to `ns run`, or a process-level flag next to the CLI's own. + * The adapter composes a command's own groups with what is contributed under + * its names when the invocation parses, and the root table composes the root + * contributions, so a contribution must be registered before the parse it + * targets. Spellings collide under the same rules as a command's own groups. + */ +@Contract({ name: "optionContributions" }) +export abstract class OptionContributions { + /** Adds `group` to every invocation of the command registered as `commandName`. */ + abstract contributeToCommand( + commandName: string, + group: OptionsGroup, + ): void; + + /** + * Adds `group` to the process-level options, parsed at startup and provided + * at the root like the CLI's own. + */ + abstract contributeToRoot(group: OptionsGroup): void; + + abstract forCommand(commandName: string): OptionsGroup[]; + + abstract forRoot(): OptionsGroup[]; +} diff --git a/lib/common/define-command.ts b/lib/common/define-command.ts index db8bb2b257..f37ab694cc 100644 --- a/lib/common/define-command.ts +++ b/lib/common/define-command.ts @@ -8,9 +8,12 @@ import { COMMAND_CONTEXT } from "./contracts/command-context"; import type { KeyShortcut } from "./contracts/key-shortcuts"; +import type { IDashedOption, IDictionary } from "./declarations"; import { inject } from "./di/inject"; +import { InjectionToken } from "./di/injection-token"; import type { Injector } from "./di/injector"; import type { Provider } from "./di/providers"; +import { OptionType } from "./enums"; /** * Symbol.for so that a definition produced by one copy of the CLI is still @@ -84,6 +87,257 @@ export type CommandOptionValues = { [K in keyof TSchema]: CommandOptionValue; }; +/** + * Symbol.for, as COMMAND_DEFINITION_MARKER: an extension's copy of this module + * must recognise a group minted by the running copy. + */ +export const OPTIONS_GROUP_MARKER: unique symbol = Symbol.for( + "nativescript:cli:optionsGroup", +); + +/** + * A named set of options declared once and used at both ends: a command lists + * the group under `options` and the parser fills it; a service injects the + * group and reads the parsed values, typed from the same declaration. The + * values are provided per invocation, so nothing outside one resolves them. + * The group's registry name is `options:`. + */ +export class OptionsGroup< + TSchema extends CommandOptionsSchema = CommandOptionsSchema, +> extends InjectionToken> { + readonly [OPTIONS_GROUP_MARKER] = true; + + constructor( + public readonly groupName: string, + public readonly schema: TSchema, + ) { + super(`options:${groupName}`); + } +} + +export function isOptionsGroup(value: any): value is OptionsGroup { + return ( + !!value && + typeof value === "object" && + (value)[OPTIONS_GROUP_MARKER] === true + ); +} + +/** What `options` takes: one schema, or a list of groups and inline schemas. */ +export type CommandOptionsInput = + | CommandOptionsSchema + | ReadonlyArray | CommandOptionsSchema>; + +type UnionToIntersection = ( + U extends any ? (member: U) => void : never +) extends (member: infer I) => void + ? I + : never; + +type OptionsSchemaPart = + T extends OptionsGroup + ? TSchema + : T extends CommandOptionsSchema + ? T + : never; + +/** The one schema an `options` input declares: its groups and inline specs merged. */ +export type OptionsSchemaOf = ( + TOptions extends ReadonlyArray + ? UnionToIntersection> + : OptionsSchemaPart +) extends infer TSchema + ? TSchema extends CommandOptionsSchema + ? TSchema + : {} + : never; + +export type OptionValuesOf = CommandOptionValues< + OptionsSchemaOf +>; + +export interface ResolvedCommandOptions { + /** Every declared option, groups and inline specs merged, keyed by the long name. */ + schema: CommandOptionsSchema; + /** The groups in declaration order; each is provided per invocation. */ + groups: OptionsGroup[]; +} + +const aliasesOf = (alias: string | string[] | undefined): string[] => + alias === undefined ? [] : Array.isArray(alias) ? alias : [alias]; + +const sameOptionSpec = (a: CommandOptionSpec, b: CommandOptionSpec): boolean => + a.type === b.type && + JSON.stringify(a.default) === JSON.stringify(b.default) && + JSON.stringify(aliasesOf(a.alias)) === JSON.stringify(aliasesOf(b.alias)) && + a.hasSensitiveValue === b.hasSensitiveValue && + a.required === b.required; + +/** + * Merges what `options` declares into one schema. A spelling — a name or an + * alias — may be declared once, or again with the same spec; two specs for one + * spelling would leave the parser with one meaning and the other declaration + * silently wrong, so that is reported instead. + */ +export function resolveCommandOptions( + options: CommandOptionsInput | undefined, + report: (problem: string) => never, + inlineSource: string = "the command's own options", +): ResolvedCommandOptions { + const parts: { source: string; schema: CommandOptionsSchema }[] = []; + const groups: OptionsGroup[] = []; + const list: ReadonlyArray | CommandOptionsSchema> = + options === undefined + ? [] + : Array.isArray(options) + ? options + : [options]; + + for (const part of list) { + if (isOptionsGroup(part)) { + if (groups.indexOf(part) === -1) { + groups.push(part); + parts.push({ + source: `option group '${part.groupName}'`, + schema: part.schema, + }); + } + } else { + parts.push({ source: inlineSource, schema: part }); + } + } + + const schema: CommandOptionsSchema = {}; + const owners: IDictionary<{ optionName: string; source: string }> = {}; + for (const { source, schema: partSchema } of parts) { + for (const optionName of Object.keys(partSchema)) { + const spec = partSchema[optionName]; + const existing = schema[optionName]; + if (existing && !sameOptionSpec(existing, spec)) { + report( + `option '--${optionName}' is declared by ${owners[optionName].source} and by ${source} with different specs`, + ); + } + if (!existing) { + const owner = owners[optionName]; + if (owner) { + report( + `option '--${optionName}' (${source}) is already an alias of '--${owner.optionName}' (${owner.source})`, + ); + } + schema[optionName] = spec; + owners[optionName] = { optionName, source }; + } + for (const alias of aliasesOf(spec.alias)) { + const owner = owners[alias]; + if (owner && owner.optionName !== optionName) { + report( + `alias '-${alias}' of '--${optionName}' (${source}) is already the spelling of '--${owner.optionName}' (${owner.source})`, + ); + } + if (!owner) { + owners[alias] = { optionName, source }; + } + } + } + } + + return { schema, groups }; +} + +/** Every spelling a schema answers to: the long names and their aliases. */ +export function optionSpellingsOf(schema: CommandOptionsSchema): string[] { + const spellings: string[] = []; + for (const optionName of Object.keys(schema)) { + spellings.push(optionName, ...aliasesOf(schema[optionName].alias)); + } + return spellings; +} + +const DASHED_OPTION_TYPES: IDictionary = { + boolean: OptionType.Boolean, + string: OptionType.String, + number: OptionType.Number, + array: OptionType.Array, + object: OptionType.Object, +}; + +/** The parser's shape of one spec, as `dashedOptions` and the CLI-wide table hold it. */ +export function compileOptionSpec(spec: CommandOptionSpec): IDashedOption { + const dashedOption: IDashedOption = { + type: DASHED_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; + } + return dashedOption; +} + +export function compileOptionsSchema( + schema: CommandOptionsSchema, +): IDictionary { + const dashedOptions: IDictionary = {}; + for (const optionName of Object.keys(schema)) { + dashedOptions[optionName] = compileOptionSpec(schema[optionName]); + } + return dashedOptions; +} + +/** + * The parsed value of every option in `schema`, read off the option service's + * per-name accessors, and nothing else. + */ +export function readOptionValues( + schema: TSchema, + source: any, +): CommandOptionValues { + const values: any = {}; + for (const optionName of Object.keys(schema)) { + values[optionName] = source[optionName]; + } + return values; +} + +const OPTIONS_GROUP_FORM = 'defineOptions("run", { watch: booleanOption() })'; + +/** + * Declares an option group: a named schema that is also the token its parsed + * values are injected by. Validated here, like a command definition, so a bad + * spec is reported where it was written. + */ +export function defineOptions( + name: string, + schema: TSchema, +): OptionsGroup { + const report = (problem: string): never => { + throw new Error( + `Invalid option group ${ + typeof name === "string" && name.trim() ? `'${name}'` : "(unnamed)" + }: ${problem}. Accepted form: ${OPTIONS_GROUP_FORM}`, + ); + }; + + if (typeof name !== "string" || !name.trim()) { + report("the name must be a non-empty string"); + } + if (!isPlainObject(schema)) { + report("the schema must be an object keyed by the long option name"); + } + for (const optionName of Object.keys(schema)) { + validateOptionSpec(report, optionName, schema[optionName]); + } + resolveCommandOptions(schema, report, `option group '${name}'`); + + return new OptionsGroup(name, schema); +} + /** * Positional parameters keyed by the declaring spec's `name`. A variadic spec * always yields an array; a non-variadic optional one is absent when the @@ -100,7 +354,7 @@ export type CommandArgumentValues = CommandParamValues; * One positional parameter. Specs are matched strictly by position: the first * spec takes the first argument, and so on. */ -export interface ParamSpec { +export interface ParamSpec { /** Key under which the value appears on `ctx.params`. */ name: string; /** Defaults to false. A required spec may not follow an optional one. */ @@ -119,18 +373,18 @@ export interface ParamSpec { } /** @deprecated Use ParamSpec. */ -export type ArgumentSpec = +export type ArgumentSpec = ParamSpec; /** * `"none"` rejects positional arguments; `"any"` accepts any number of them; * an array declares them one by one. */ -export type ParamsPolicy = +export type ParamsPolicy = "none" | "any" | ParamSpec[]; /** @deprecated Use ParamsPolicy. */ -export type ArgumentsPolicy = +export type ArgumentsPolicy = ParamsPolicy; export interface CommandFailOptions { @@ -141,13 +395,17 @@ export interface CommandFailOptions { help?: boolean; } -export interface CommandContext { +export interface CommandContext { /** Positional arguments, after the command name has been consumed. */ args: string[]; /** The same arguments keyed by the names the `params` specs declare. */ params: CommandParamValues; - /** Current value of every option declared in the schema, and nothing else. */ - options: CommandOptionValues; + /** + * Current value of every option the command declares, its groups and inline + * specs merged, and nothing else. A group contributed from outside the + * command is read by injecting the group. + */ + options: OptionValuesOf; /** * This invocation's injector, the one `inject()` resolves against before the * first `await`; after it, `inject()` stops working and this is the lookup. @@ -161,13 +419,18 @@ export interface CommandContext { } export interface CommandDefinition< - TSchema extends CommandOptionsSchema = {}, + TSchema extends CommandOptionsInput = {}, TResult = void, TSetup = void, > { /** `"widget|add"`; `|` separates hierarchy levels. Several names alias one command. */ name: CommandName; description?: string; + /** + * A schema keyed by the long option name, or a list of option groups and + * such schemas. One spelling may appear in several parts only with the same + * spec; a process-level spelling may not be redeclared at all. + */ options?: TSchema; /** * `"none"` (the default) rejects positional arguments; `"any"` accepts any @@ -230,7 +493,7 @@ export interface CommandDefinition< * define-time validation rather than any object of the right shape. */ export type DefinedCommand< - TSchema extends CommandOptionsSchema = {}, + TSchema extends CommandOptionsInput = {}, TResult = void, TSetup = void, > = CommandDefinition & { @@ -359,24 +622,25 @@ const validateName = (definition: any): void => { ); }; +const OPTION_HELPERS = + "booleanOption(), stringOption(), numberOption(), arrayOption() or objectOption()"; + const validateOptionSpec = ( - definition: any, + report: (problem: string) => never, optionName: string, spec: any, ): void => { if (!isPlainObject(spec)) { - invalid( - definition, - `option '${optionName}' must be declared with one of booleanOption(), stringOption(), numberOption(), arrayOption() or objectOption()`, + report( + `option '${optionName}' must be declared with one of ${OPTION_HELPERS}`, ); } if (OPTION_TYPES.indexOf(spec.type) === -1) { - invalid( - definition, + report( `option '${optionName}' has type '${spec.type}'; the supported types are ${OPTION_TYPES.join( ", ", - )} — declare it with one of booleanOption(), stringOption(), numberOption(), arrayOption() or objectOption()`, + )} — declare it with one of ${OPTION_HELPERS}`, ); } @@ -384,8 +648,7 @@ const validateOptionSpec = ( (field) => OPTION_SPEC_FIELDS.indexOf(field) === -1, ); if (unknownFields.length) { - invalid( - definition, + report( `option '${optionName}' has unknown field(s) ${unknownFields .map((field) => `'${field}'`) .join(", ")}; an option spec accepts ${OPTION_SPEC_FIELDS.join(", ")}`, @@ -393,11 +656,10 @@ const validateOptionSpec = ( } if (spec.required !== undefined && typeof spec.required !== "boolean") { - invalid(definition, `option '${optionName}': 'required' must be a boolean`); + report(`option '${optionName}': 'required' must be a boolean`); } if (spec.required === true && spec.default !== undefined) { - invalid( - definition, + report( `option '${optionName}' is required and has a default; one of the two`, ); } @@ -409,8 +671,7 @@ const validateOptionSpec = ( spec.alias.length > 0 && spec.alias.every((entry: any) => typeof entry === "string")); if (!aliasIsUsable) { - invalid( - definition, + report( `option '${optionName}' declares an 'alias' that is neither a string nor a non-empty array of strings`, ); } @@ -419,17 +680,11 @@ const validateOptionSpec = ( spec.hasSensitiveValue !== undefined && typeof spec.hasSensitiveValue !== "boolean" ) { - invalid( - definition, - `option '${optionName}' declares a non-boolean 'hasSensitiveValue'`, - ); + report(`option '${optionName}' declares a non-boolean 'hasSensitiveValue'`); } if (spec.description !== undefined && typeof spec.description !== "string") { - invalid( - definition, - `option '${optionName}' declares a non-string 'description'`, - ); + report(`option '${optionName}' declares a non-string 'description'`); } }; @@ -608,20 +863,24 @@ const validateDefinition = (definition: any): void => { } 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], - ); + const report = (problem: string): never => invalid(definition, problem); + const parts: any[] = Array.isArray(definition.options) + ? definition.options + : [definition.options]; + for (const part of parts) { + if (isOptionsGroup(part)) { + continue; + } + if (!isPlainObject(part)) { + report( + "'options' must be an object keyed by the long option name, or an array of option groups and such objects", + ); + } + for (const optionName of Object.keys(part)) { + validateOptionSpec(report, optionName, part[optionName]); + } } + resolveCommandOptions(definition.options, report); } }; @@ -674,7 +933,7 @@ const validateMeta = (meta: any): void => { * `CommandDefinition` widens it straight back to `string`. */ export type NamedCommand< - TSchema extends CommandOptionsSchema, + TSchema extends CommandOptionsInput, TResult, TSetup, TName extends CommandName, @@ -704,7 +963,7 @@ export type CommandNamesOf = TDefinition extends { : never; export function defineCommand< - TSchema extends CommandOptionsSchema = {}, + TSchema extends CommandOptionsInput = {}, TResult = void, TSetup = void, const TName extends CommandName = CommandName, @@ -747,7 +1006,7 @@ const COMMAND_CLASS_DEFINITION = Symbol.for( */ export type CommandMeta< TName extends CommandName = CommandName, - TSchema extends CommandOptionsSchema = {}, + TSchema extends CommandOptionsInput = {}, > = Omit< CommandDefinition, "name" | "setup" | "canExecute" | "run" | "postRun" | "shortcuts" @@ -758,14 +1017,14 @@ export type CommandMeta< * every `Command()` class — a subclass's declaration emit refers to it — not * because anything should extend it directly. */ -export abstract class CommandBase { +export abstract class CommandBase { /** * The instance is built once per invocation, as that invocation's `setup`, * so the context captured here is the one its own run was handed. */ protected readonly context: CommandContext = inject(COMMAND_CONTEXT); - protected get options(): CommandOptionValues { + protected get options(): OptionValuesOf { return this.context.options; } @@ -787,7 +1046,7 @@ export abstract class CommandBase { */ export type CommandClass< TName extends CommandName = CommandName, - TSchema extends CommandOptionsSchema = {}, + TSchema extends CommandOptionsInput = {}, > = (abstract new () => CommandBase) & { readonly definition: NamedCommand, TName>; readonly [COMMAND_CLASS_MARKER]: true; @@ -911,7 +1170,7 @@ export type CommandReference = string | RegisterableCommand; */ export function Command< const TName extends CommandName, - TSchema extends CommandOptionsSchema = {}, + TSchema extends CommandOptionsInput = {}, >(meta: CommandMeta): CommandClass { validateMeta(meta); diff --git a/lib/common/di/contract.ts b/lib/common/di/contract.ts index cc03be9f02..1da2cbe607 100644 --- a/lib/common/di/contract.ts +++ b/lib/common/di/contract.ts @@ -7,6 +7,9 @@ */ export const CONTRACT_NAME = Symbol.for("nativescript:di:contractName"); +/** Same `Symbol.for` reasoning as CONTRACT_NAME. */ +export const PROVIDED_IN = Symbol.for("nativescript:di:providedIn"); + export interface IContractOptions { /** * Canonical token name, without the `$` prefix. Must be an explicit string @@ -14,6 +17,38 @@ export interface IContractOptions { * minification. */ name: string; + /** + * The scope every implementation of the contract lives in, unless the + * implementation or its provider says otherwise. See `ProviderScope`. + */ + providedIn?: string; +} + +/** + * Marks a class with the scope its instances live in — `"invocation"` for a + * service that reads the invocation's option groups or context. Read when the + * class is registered, by class or by name. + */ +export function ProvidedIn(scope: string): (target: Function) => void { + return (target: Function): void => { + Object.defineProperty(target, PROVIDED_IN, { + value: scope, + writable: false, + enumerable: false, + configurable: false, + }); + }; +} + +/** Own-property read, as for the contract name. */ +export function getProvidedIn(target: any): string | undefined { + if ( + typeof target === "function" && + Object.prototype.hasOwnProperty.call(target, PROVIDED_IN) + ) { + return (target)[PROVIDED_IN]; + } + return undefined; } // Per module instance on purpose: a duplicated CLI copy in an extensions tree @@ -52,7 +87,7 @@ export function mintTokenName(name: string, owner: object): void { export function Contract( options: IContractOptions, ): (target: Function) => void { - const { name } = options; + const { name, providedIn } = options; return (target: Function): void => { mintTokenName(name, target); Object.defineProperty(target, CONTRACT_NAME, { @@ -61,6 +96,9 @@ export function Contract( enumerable: false, configurable: false, }); + if (providedIn !== undefined) { + ProvidedIn(providedIn)(target); + } }; } diff --git a/lib/common/di/index.ts b/lib/common/di/index.ts index f50e90a688..8c17a6d4da 100644 --- a/lib/common/di/index.ts +++ b/lib/common/di/index.ts @@ -1,11 +1,14 @@ export { Injector } from "./injector"; -export type { InjectOptions } from "./injector"; +export type { InjectOptions, CreateChildOptions } from "./injector"; export { inject, getCurrentInjector, runInInjectionContext } from "./inject"; export { forwardRef, resolveForwardRef } from "./forward-ref"; export { Contract, getContractName, CONTRACT_NAME, + ProvidedIn, + getProvidedIn, + PROVIDED_IN, clearMintedContractNames, } from "./contract"; export type { IContractOptions } from "./contract"; @@ -17,6 +20,7 @@ export { export { provide, provideLazy } from "./providers"; export type { Provider, + ProviderScope, TypeProvider, ObjectProvider, InternalProvider, diff --git a/lib/common/di/injector.ts b/lib/common/di/injector.ts index 1c52d9add2..f1ef965bb8 100644 --- a/lib/common/di/injector.ts +++ b/lib/common/di/injector.ts @@ -1,5 +1,5 @@ import { annotate } from "../helpers"; -import { getContractName } from "./contract"; +import { getContractName, getProvidedIn } from "./contract"; import { resolveForwardRef } from "./forward-ref"; import { runInInjectionContext } from "./inject"; import { getInjectionTokenName } from "./injection-token"; @@ -48,6 +48,16 @@ interface IProviderRecord { constructing: boolean; /** One record per `multi` provider, in registration order. */ multiRecords?: IProviderRecord[]; + /** + * Instances live on the nearest injector of this scope in the resolving + * chain, not on the record's owner; see `Injector.scoped`. + */ + providedIn?: string; +} + +export interface CreateChildOptions { + /** The scope name `providedIn` providers match against. */ + scope?: string; } // Shared across the whole injector tree so cycle reports show the full path @@ -57,10 +67,13 @@ const resolutionStack: string[] = []; export class Injector { private providers = new Map(); private instantiationOrder: any[] = []; + /** Instances of `providedIn` records that this injector is the scope of. */ + private scopedInstances = new Map(); constructor( providers: Provider[] = [], private parent?: Injector, + private readonly scope?: string, ) { this.register({ provide: Injector, useValue: this }); this.register(providers); @@ -100,9 +113,80 @@ export class Injector { } throw new Error("unable to resolve " + displayNameOf(token)); } + if (this.isScoped(found.record)) { + return this.scoped(found.record); + } return found.owner.instantiate(found.record); } + /** + * A deferred registration only declares its scope once its loader has run, + * so the loader runs before the scope is read: an instance built at the + * owner because the marker was not visible yet would outlive its scope. + */ + private isScoped(record: IProviderRecord): boolean { + if (record.pendingLoader) { + const loader = record.pendingLoader; + loader(); + record.pendingLoader = undefined; + } + return !!record.providedIn; + } + + /** + * A `providedIn` record resolves at the nearest injector of its scope above + * the one the lookup started from, which caches the instance and is the + * injector its dependencies resolve against. No such injector in the chain + * is an error rather than a fallback: a root singleton holding a per-scope + * instance would be the leak the scope exists to prevent. + */ + private scoped(record: IProviderRecord): any { + const host = this.nearestScope(record.providedIn); + if (!host) { + throw new Error( + `${record.displayName} is provided in the '${record.providedIn}' scope; it cannot be resolved from outside one`, + ); + } + if (host.scopedInstances.has(record)) { + return host.scopedInstances.get(record); + } + if (record.kind === undefined) { + throw new Error("no resolver registered for " + record.displayName); + } + if (record.kind === "value") { + return record.instances[0]; + } + if (record.constructing) { + const cyclePath = resolutionStack.concat(record.displayName).join(" -> "); + throw new Error( + `Cyclic dependency detected on dependency '${record.displayName}'. Resolution path: ${cyclePath}`, + ); + } + record.constructing = true; + resolutionStack.push(record.displayName); + let instance: any; + try { + instance = host.construct(record); + } finally { + resolutionStack.pop(); + record.constructing = false; + } + host.scopedInstances.set(record, instance); + host.instantiationOrder.push(instance); + return instance; + } + + private nearestScope(scope: string): Injector | undefined { + let injector: Injector | undefined = this; + while (injector) { + if (injector.scope === scope || (scope === "root" && !injector.parent)) { + return injector; + } + injector = injector.parent; + } + return undefined; + } + /** * The legacy facade's channel for Yok's `resolve(name, bag)` sites: the * bag applies to the construction this call itself triggers, with raw @@ -119,11 +203,17 @@ export class Injector { if (!found) { throw new Error("unable to resolve " + displayNameOf(token)); } + if (this.isScoped(found.record)) { + return this.scoped(found.record); + } return found.owner.instantiate(found.record, ctorArguments); } - public createChild(providers: Provider[] = []): Injector { - return new Injector(providers, this); + public createChild( + providers: Provider[] = [], + options: CreateChildOptions = {}, + ): Injector { + return new Injector(providers, this, options.scope); } /** @@ -167,6 +257,11 @@ export class Injector { `${record.displayName} is registered as a single provider; it cannot also take multi providers`, ); } + if (scopeOf(provider)) { + throw new Error( + `${record.displayName}: a multi provider cannot be scoped with providedIn; scope the token's consumers instead`, + ); + } const entry: IProviderRecord = { displayName: record.displayName, shared: true, @@ -183,6 +278,9 @@ export class Injector { ); } this.applyProvider(record, provider); + if (!("useLazyRequire" in provider)) { + record.providedIn = scopeOf(provider); + } } for (const key of keys) { this.providers.set(key, record); @@ -451,6 +549,18 @@ function normalizeName(name: string): string { return name[0] === "$" ? name.slice(1) : name; } +/** The provider's own field, else the implementation's marker, else the token's. */ +function scopeOf(provider: ObjectProvider): string | undefined { + if ("useLazyRequire" in provider) { + return undefined; + } + return ( + provider.providedIn || + getProvidedIn((provider).useClass || (provider).useLegacyClass) || + getProvidedIn(provider.provide) + ); +} + /** The name a non-string token aliases in the legacy registry, if it has one. */ function tokenNameOf(token: ProviderToken): string | undefined { const injectionTokenName = getInjectionTokenName(token); diff --git a/lib/common/di/providers.ts b/lib/common/di/providers.ts index bb8aad6084..6466e189c2 100644 --- a/lib/common/di/providers.ts +++ b/lib/common/di/providers.ts @@ -6,10 +6,25 @@ export type AbstractType = abstract new (...args: any[]) => T; export type ProviderToken = string | Type | AbstractType | InjectionToken; +/** + * Where an instance lives. `"root"` is the injector with no parent; + * `"invocation"` the injector a command invocation opens. Any other string + * names a scope an injector was created with. + */ +export type ProviderScope = "root" | "invocation" | (string & {}); + interface IBaseProvider { provide: ProviderToken; /** Defaults to true. `false` constructs a fresh instance per resolution. */ shared?: boolean; + /** + * Instantiates and caches at the nearest injector of that scope in the + * chain the resolution started from, as Angular's `providedIn` does; the + * record itself may live at the root. Resolving from a chain with no such + * scope is an error, `optional` or not. Defaults to the class's own + * `@ProvidedIn` marker, else the token's, else the owning injector. + */ + providedIn?: ProviderScope; /** * Contributes to an array under the token instead of replacing it: every * `multi` provider for one token is resolved, in registration order, and diff --git a/lib/common/invocations.ts b/lib/common/invocations.ts new file mode 100644 index 0000000000..b888866d69 --- /dev/null +++ b/lib/common/invocations.ts @@ -0,0 +1,83 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { getCurrentInjector } from "./di/inject"; +import type { Injector } from "./di/injector"; + +/** + * On globalThis under a `Symbol.for` key, as the injection context is: a hook + * or extension may load a second copy of this module, and its lookups must + * see the invocations the running copy opened. + */ +const INVOCATIONS_SLOT = Symbol.for("nativescript:cli:invocations"); + +interface IInvocations { + /** The invocation whose asynchronous flow the caller is in. */ + context: AsyncLocalStorage; + /** Every invocation opened and not yet closed, innermost last. */ + open: Injector[]; +} + +function invocations(): IInvocations { + const g = globalThis; + if (!g[INVOCATIONS_SLOT]) { + g[INVOCATIONS_SLOT] = { + context: new AsyncLocalStorage(), + open: [], + }; + } + return g[INVOCATIONS_SLOT]; +} + +/** + * The injector of the invocation running now, for code that resolves by name + * outside any injection context — a hook, a plugin's callback. Tried in order: + * the synchronous injection context; the invocation whose asynchronous flow + * the caller is in; the most recently opened invocation still running, for a + * callback that lost its asynchronous context (an emitter another invocation + * registered, a library timer); then null, and the caller's own fallback, + * which for the legacy facade is the process-wide injector. + */ +export function currentInvocationInjector(): Injector | null { + const { context, open } = invocations(); + return ( + getCurrentInjector() || context.getStore() || open[open.length - 1] || null + ); +} + +/** Runs `fn` with `injector` as the invocation of its asynchronous flow. */ +export function runInInvocation(injector: Injector, fn: () => T): T { + return invocations().context.run(injector, fn); +} + +/** + * Records an opened invocation; the returned function closes it. The first + * invocation of the process — the command line's own — stays open for the + * life of the process: a long-lived command's callbacks keep resolving + * through it after its run has returned. + */ +export function openInvocation(injector: Injector): () => void { + const { open } = invocations(); + open.push(injector); + return (): void => { + const index = open.lastIndexOf(injector); + if (index > 0) { + open.splice(index, 1); + } + }; +} + +/** How many invocations are open; what a dispatch trims back to when it ends. */ +export function openInvocationCount(): number { + return invocations().open.length; +} + +/** + * Closes every invocation opened since the count was `depth`. An in-process + * dispatch bounds the invocations it opens: a command asked only whether it + * could run never executes, so nothing else would close what that check + * opened, and an invocation a dispatch opened is never the command line's + * own, so the first-stays rule of `openInvocation` does not apply here. + */ +export function closeInvocationsAbove(depth: number): void { + const { open } = invocations(); + open.length = Math.min(depth, open.length); +} diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index 89539d8ad0..1925d22b04 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -1,7 +1,9 @@ import { EOL } from "os"; -import { OptionType } from "../enums"; import { getRootInjector } from "../yok"; +import { CliOptions } from "../contracts/cli-options"; +import { OptionContributions } from "../contracts/option-contributions"; import { getCurrentInjector, runInInjectionContext } from "../di/inject"; +import { openInvocation, runInInvocation } from "../invocations"; import { Injector } from "../di/injector"; import { IDictionary, IDashedOption, IErrors } from "../declarations"; import { ICommand } from "../definitions/commands"; @@ -34,23 +36,22 @@ import { CommandName, CommandNamesOf, CommandOptionSpec, - CommandOptionType, + CommandOptionsInput, CommandOptionsSchema, + compileOptionSpec, DefinedCommand, + isOptionsGroup, + OptionsGroup, + optionSpellingsOf, + readOptionValues, RegisterableCommand, + ResolvedCommandOptions, + resolveCommandOptions, defineCommand, toCommandDefinition, isPlainObject, } from "../define-command"; -const OPTION_TYPES: IDictionary = { - boolean: OptionType.Boolean, - string: OptionType.String, - number: OptionType.Number, - array: OptionType.Array, - object: OptionType.Object, -}; - const compileOptions = ( schema: CommandOptionsSchema, cliOptions?: IDictionary, @@ -59,34 +60,21 @@ const compileOptions = ( for (const optionName of Object.keys(schema)) { const spec = schema[optionName]; + const dashedOption = compileOptionSpec(spec); // Declaring an option the CLI already defines replaces its entry // wholesale (see setupOptions), so anything left unspecified here is // carried over rather than silently dropped for this command. const cliOption = cliOptions && cliOptions[optionName]; - const dashedOption: IDashedOption = { - type: OPTION_TYPES[spec.type], - hasSensitiveValue: - spec.hasSensitiveValue !== undefined - ? spec.hasSensitiveValue === true - : cliOption - ? cliOption.hasSensitiveValue === true - : false, - }; - - if (spec.default !== undefined) { - dashedOption.default = spec.default; - } else if (cliOption && cliOption.default !== undefined) { - dashedOption.default = cliOption.default; - } - - if (spec.alias !== undefined) { - dashedOption.alias = spec.alias; - } else if (cliOption && cliOption.alias !== undefined) { - dashedOption.alias = cliOption.alias; - } - - if (spec.description !== undefined) { - dashedOption.describe = spec.description; + if (cliOption) { + if (spec.hasSensitiveValue === undefined) { + dashedOption.hasSensitiveValue = cliOption.hasSensitiveValue === true; + } + if (spec.default === undefined && cliOption.default !== undefined) { + dashedOption.default = cliOption.default; + } + if (spec.alias === undefined && cliOption.alias !== undefined) { + dashedOption.alias = cliOption.alias; + } } dashedOptions[optionName] = dashedOption; @@ -108,7 +96,7 @@ const aliasList = (alias: string | string[]): string[] => const isRedeclarationOf = ( spec: CommandOptionSpec, cliOption: IDashedOption, -): boolean => OPTION_TYPES[spec.type] === cliOption.type; +): boolean => compileOptionSpec(spec).type === cliOption.type; const warnOnCliOptionCollisions = ( targetInjector: Injector, @@ -197,33 +185,112 @@ const warnOnCliOptionCollisions = ( * lifetime, so that record is replaced per invocation. */ export function createCommandFromDefinition< - TSchema extends CommandOptionsSchema, + TSchema extends CommandOptionsInput, TResult = any, TSetup = any, >( definition: CommandDefinition, targetInjector: Injector = (getRootInjector()), providers: Provider[] = [], + registeredNames: readonly string[] = [], ): ICommand { - const schema = definition.options || {}; + // Its own names first, then the names it was registered under, so a + // contribution made under a manifest key reaches it too. + const commandNames: readonly string[] = ( + Array.isArray(definition.name) ? definition.name : [definition.name] + ).concat(registeredNames.filter((name) => name)); + const commandName = commandNames[0]; + const compileError = (problem: string): never => { + throw new Error(`Command '${commandName}': ${problem}`); + }; + + const ownParts: ReadonlyArray | CommandOptionsSchema> = + definition.options === undefined + ? [] + : Array.isArray(definition.options) + ? definition.options + : [definition.options]; + const own = resolveCommandOptions(ownParts, compileError); + const schema = own.schema; const optionNames = Object.keys(schema); // Only a definition that declares options may depend on the options service // being registered - a bare command must work without one. - const optionsService: IOptions | undefined = optionNames.length + let optionsService: IOptions | undefined = optionNames.length ? targetInjector.get("options") : null; + const optionsServiceFor = (resolved: ResolvedCommandOptions): any => { + if (!optionsService && Object.keys(resolved.schema).length) { + optionsService = targetInjector.get("options"); + } + return optionsService; + }; + // The CLI-wide table as it stands when the command is compiled: what a + // redeclaration carries over from. + const cliOptions: IDictionary | undefined = + optionsService && optionsService.options + ? { ...optionsService.options } + : undefined; + + const contributions = targetInjector.get(OptionContributions, { + optional: true, + }); - const dashedOptions = compileOptions( - schema, - optionsService && optionsService.options, - ); + const rootGroups = (): OptionsGroup[] => [ + CliOptions, + ...(contributions ? contributions.forRoot() : []), + ]; + + // A process-level spelling is parsed at startup for every command and + // provided at the root; a command redeclaring one would give it a second + // meaning for the length of its run. Listing the root group itself is not a + // redeclaration: that reads the same declaration onto ctx.options. + const guardRootSpellings = (resolved: ResolvedCommandOptions): void => { + const roots = rootGroups(); + const rootSpellings = new Set(); + for (const group of roots) { + for (const spelling of optionSpellingsOf(group.schema)) { + rootSpellings.add(spelling); + } + } + const declaredHere: CommandOptionsSchema = {}; + for (const part of ownParts) { + if (!isOptionsGroup(part)) { + Object.assign(declaredHere, part); + } + } + for (const group of resolved.groups) { + if (roots.indexOf(group) === -1) { + Object.assign(declaredHere, group.schema); + } + } + for (const spelling of optionSpellingsOf(declaredHere)) { + if (rootSpellings.has(spelling)) { + compileError( + `'${spelling.length === 1 ? "-" : "--"}${spelling}' is a process-level option; list CliOptions under 'options', or inject it, instead of redeclaring it`, + ); + } + } + }; - warnOnCliOptionCollisions(targetInjector, definition, schema, optionsService); + // Composed at every read, because a contribution may be registered after + // the command was compiled and before it runs. + const resolveAllOptions = (): ResolvedCommandOptions => { + const contributed = contributions + ? commandNames.reduce[]>( + (groups, name) => groups.concat(contributions.forCommand(name)), + [], + ) + : []; + const all = contributed.length + ? resolveCommandOptions([...ownParts, ...contributed], compileError) + : own; + guardRootSpellings(all); + return all; + }; - const commandName = Array.isArray(definition.name) - ? definition.name[0] - : definition.name; + guardRootSpellings(own); + warnOnCliOptionCollisions(targetInjector, definition, schema, optionsService); const fail = (message: string, options?: CommandFailOptions): never => { if (typeof message !== "string" || !message.trim()) { @@ -273,15 +340,12 @@ export function createCommandFromDefinition< // 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, params: mapParams(args), - options, + options: ( + (optionNames.length ? readOptionValues(schema, optionsService) : {}) + ), // The invocation's child injector provides this very object under // COMMAND_CONTEXT, so it can only be created - and assigned here - // once the context exists. @@ -389,6 +453,12 @@ export function createCommandFromDefinition< setup: Promise>; hasRun: boolean; runResult?: Awaited; + /** + * Takes the invocation off the process's record of open ones and disposes + * its injector. Runs after the last stage - `postRun` when there is one, + * else `run` - or when `canExecute` ends the invocation early. + */ + end: () => void; } // An entry may be one precondition or a list of them: a factory that @@ -449,20 +519,49 @@ export function createCommandFromDefinition< const beginInvocation = (args: string[]): Invocation => { const context = buildContext(args); + // Every group the invocation parsed - the command's own and the + // contributed ones - is provided here with its slice of the values, so + // a service in reach of this injector injects the group, never the + // options service. + const all = resolveAllOptions(); + const source = optionsServiceFor(all); + const roots = rootGroups(); + const groupProviders: Provider[] = all.groups + .filter((group) => roots.indexOf(group) === -1) + .map((group) => ({ + provide: group, + useValue: readOptionValues(group.schema, source), + })); // Per-command providers live here rather than in a registration-time // scope so that a factory or class among them can inject the // invocation; the price is one instance per invocation. - const injector = targetInjector.createChild([ - { provide: COMMAND_CONTEXT, useValue: context }, - ...(definition.providers || []), - ...providers, - ]); + const injector = targetInjector.createChild( + [ + { provide: COMMAND_CONTEXT, useValue: context }, + ...groupProviders, + ...(definition.providers || []), + ...providers, + ], + { scope: "invocation" }, + ); context.injector = injector; + const close = openInvocation(injector); + let ended = false; const invocation: Invocation = { context, injector, setup: undefined, hasRun: false, + end: (): void => { + if (ended) { + return; + } + ended = true; + close(); + // What the invocation built for itself - scoped services above + // all - goes with it; root singletons it reached are the root's. + injector.dispose(); + }, }; // Preconditions judge the environment and run ahead of setup and of the // arguments policy, so being outside a project is what a bad invocation @@ -521,7 +620,9 @@ export function createCommandFromDefinition< return { allowedParameters: [], - dashedOptions, + get dashedOptions(): IDictionary { + return compileOptions(resolveAllOptions().schema, cliOptions); + }, ...(definition.disableAnalytics === undefined ? {} : { disableAnalytics: definition.disableAnalytics }), @@ -537,15 +638,21 @@ export function createCommandFromDefinition< postCommandAction: async (args: string[]): Promise => { const invocation = currentInvocation || beginInvocation(args); const context = invocation.context; - const setupResult = await invocation.setup; - await runInInjectionContext(invocation.injector, () => - definition.postRun.call( - definition, - context, - invocation.runResult, - setupResult, - ), - ); + try { + await runInInvocation(invocation.injector, async () => { + const setupResult = await invocation.setup; + await runInInjectionContext(invocation.injector, () => + definition.postRun.call( + definition, + context, + invocation.runResult, + setupResult, + ), + ); + }); + } finally { + invocation.end(); + } }, }), canExecute: async (args: string[]): Promise => { @@ -556,21 +663,33 @@ export function createCommandFromDefinition< // reports that before an arity complaint. const invocation = beginInvocation(args); const context = invocation.context; - const setupResult = await invocation.setup; - - await enforceParams(context); - enforceRequiredOptions(context); - - const refine = definition.canExecute; - if (!refine) { - return true; + // A verdict of false, or a throw, is the end of this invocation: + // execute never follows it. + let verdict = false; + try { + verdict = await runInInvocation(invocation.injector, async () => { + const setupResult = await invocation.setup; + + await enforceParams(context); + enforceRequiredOptions(context); + + 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(invocation.injector, () => + refine.call(definition, context, setupResult), + ); + }); + } finally { + if (!verdict) { + invocation.end(); + } } - - // Same first-await rule as execute: runInInjectionContext is - // synchronous, so inject() is available up to the first await. - return await runInInjectionContext(invocation.injector, () => - refine.call(definition, context, setupResult), - ); + return verdict; }, execute: async (args: string[]): Promise => { const invocation = @@ -580,14 +699,28 @@ export function createCommandFromDefinition< const context = invocation.context; invocation.hasRun = true; - const setupResult = await invocation.setup; - invocation.runResult = await runInInjectionContext( - invocation.injector, - () => definition.run.call(definition, context, setupResult), - ); - - if (definition.shortcuts) { - attachShortcuts(invocation, context, setupResult); + // With a postRun, the invocation ends after it; a failed run ends it + // here, since the dispatcher will not reach postRun. + let failed = false; + try { + await runInInvocation(invocation.injector, async () => { + const setupResult = await invocation.setup; + invocation.runResult = await runInInjectionContext( + invocation.injector, + () => definition.run.call(definition, context, setupResult), + ); + + if (definition.shortcuts) { + attachShortcuts(invocation, context, setupResult); + } + }); + } catch (error) { + failed = true; + throw error; + } finally { + if (failed || !definition.postRun) { + invocation.end(); + } } }, }; @@ -599,7 +732,7 @@ export function createCommandFromDefinition< * name, so the name is a parameter rather than read off the definition. */ export function registerDefinitionAs< - TSchema extends CommandOptionsSchema, + TSchema extends CommandOptionsInput, TResult = any, TSetup = any, >( @@ -614,7 +747,7 @@ export function registerDefinitionAs< // 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, providers), + createCommandFromDefinition(definition, targetInjector, providers, [name]), ); } @@ -650,7 +783,7 @@ const contextInjector = (): Injector => * CLI itself outside one. */ export function registerCommand< - TSchema extends CommandOptionsSchema, + TSchema extends CommandOptionsInput, TResult = any, TSetup = any, >( diff --git a/lib/common/services/commands-service.ts b/lib/common/services/commands-service.ts index 50d25416c5..e1fe254a77 100644 --- a/lib/common/services/commands-service.ts +++ b/lib/common/services/commands-service.ts @@ -18,8 +18,12 @@ import { CommandDispatchOptions, CommandsService as CommandsServiceContract, } from "../contracts/commands-service"; -import { getCurrentInjector } from "../di/inject"; import type { Injector } from "../di/injector"; +import { + closeInvocationsAbove, + currentInvocationInjector, + openInvocationCount, +} from "../invocations"; import { CommandReference, toCommandDefinition } from "../define-command"; import { createCommandFromDefinition } from "./command-definition-adapter"; import { @@ -440,9 +444,11 @@ export class CommandsService commandName: this.describeReference(reference), }; this.inProcessDispatches.push(dispatch); + const openBefore = openInvocationCount(); try { return await this.dispatchContext.run(dispatch, () => body(dispatch)); } finally { + closeInvocationsAbove(openBefore); this.inProcessDispatches.pop(); } } @@ -478,7 +484,7 @@ export class CommandsService } return ( options.injector || - getCurrentInjector() || + currentInvocationInjector() || (this.$injector) ); } diff --git a/lib/common/services/hooks-service.ts b/lib/common/services/hooks-service.ts index c7a843e02d..48b6f0f4da 100644 --- a/lib/common/services/hooks-service.ts +++ b/lib/common/services/hooks-service.ts @@ -6,6 +6,7 @@ import { reportDeprecation } from "../deprecation"; import { createHookInvocation, isHookDefinition } from "../define-hook"; import type { HookMiddleware, HookDefinition } from "../define-hook"; import { runInInjectionContext } from "../di/inject"; +import { currentInvocationInjector } from "../invocations"; import { AnalyticsEventLabelDelimiter } from "../../constants"; import { IOptions, IPerformanceService } from "../../declarations"; import { @@ -308,8 +309,13 @@ export class HooksService implements IHooksService { }); } - const maybePromise = this.$injector.resolve( + // A hook fires inside a command, so its by-name dependencies come + // from that invocation when one is running: the invocation's own + // providers and scoped services, then the root as before. + const hookInjector = currentInvocationInjector() || this.$injector; + const maybePromise = hookInjector.createInstance( hookEntryPoint, + [], hookArguments, ); if (maybePromise) { @@ -395,8 +401,9 @@ export class HooksService implements IHooksService { }); try { - const returnedValue = await runInInjectionContext(this.$injector, () => - definition.run(context), + const returnedValue = await runInInjectionContext( + currentInvocationInjector() || this.$injector, + () => definition.run(context), ); if (typeof returnedValue === "function") { @@ -621,10 +628,13 @@ export class HooksService implements IHooksService { // We need to annotate the hook in order to have the arguments of the constructor. annotate(hookConstructor); + // Checked where the hook will resolve them: a dependency provided only by + // the running invocation is valid there and nowhere else. + const hookInjector = currentInvocationInjector() || this.$injector; _.each(hookConstructor.$inject.args, (argument: string) => { try { if (argument !== this.hookArgsName) { - this.$injector.resolve(argument); + hookInjector.get(argument); } } catch (err) { this.$logger.trace( diff --git a/lib/common/services/option-contributions.ts b/lib/common/services/option-contributions.ts new file mode 100644 index 0000000000..7c05fbbd43 --- /dev/null +++ b/lib/common/services/option-contributions.ts @@ -0,0 +1,93 @@ +import { CliOptions } from "../contracts/cli-options"; +import { OptionContributions } from "../contracts/option-contributions"; +import { + isOptionsGroup, + OptionsGroup, + optionSpellingsOf, + readOptionValues, + resolveCommandOptions, +} from "../define-command"; +import type { Injector } from "../di/injector"; +import { injector } from "../yok"; + +export class OptionContributionsRegistry extends OptionContributions { + private byCommand = new Map[]>(); + private root: OptionsGroup[] = []; + + constructor(private $injector: Injector) { + super(); + } + + public contributeToCommand( + commandName: string, + group: OptionsGroup, + ): void { + if (typeof commandName !== "string" || !commandName.trim()) { + throw new Error( + "An option contribution names the command it applies to.", + ); + } + assertGroup(group); + const groups = this.byCommand.get(commandName) || []; + if (groups.indexOf(group) === -1) { + groups.push(group); + } + this.byCommand.set(commandName, groups); + } + + public contributeToRoot(group: OptionsGroup): void { + assertGroup(group); + if (this.root.indexOf(group) !== -1) { + return; + } + // The root table is one parse, so its groups collide like a command's, + // and a spelling already at the root is refused even with the same + // spec: the group would claim a flag it does not own. + const report = (problem: string): never => { + throw new Error(`Option group '${group.groupName}': ${problem}`); + }; + const rootSpellings = new Set(); + for (const rootGroup of [CliOptions, ...this.root]) { + for (const spelling of optionSpellingsOf(rootGroup.schema)) { + rootSpellings.add(spelling); + } + } + for (const spelling of optionSpellingsOf(group.schema)) { + if (rootSpellings.has(spelling)) { + report( + `'${spelling.length === 1 ? "-" : "--"}${spelling}' is already a process-level option`, + ); + } + } + resolveCommandOptions([CliOptions, ...this.root, group], report); + this.root.push(group); + // Provided next to CliOptions on the root injector. Read at every + // injection rather than cached: the table is parsed again when the + // group joins it, and an injection before that parse must not pin the + // values it saw. + this.$injector.register({ + provide: group, + shared: false, + useFactory: () => + readOptionValues(group.schema, this.$injector.get("options")), + }); + } + + public forCommand(commandName: string): OptionsGroup[] { + return (this.byCommand.get(commandName) || []).slice(); + } + + public forRoot(): OptionsGroup[] { + return this.root.slice(); + } +} + +function assertGroup(group: any): void { + if (!isOptionsGroup(group)) { + throw new Error( + "An option contribution is an option group, as defineOptions() returns.", + ); + } +} + +injector.register("optionContributions", OptionContributionsRegistry); diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts index 1c9bcf72f1..13b35aab39 100644 --- a/lib/contracts/index.ts +++ b/lib/contracts/index.ts @@ -21,6 +21,11 @@ export { forwardRef, resolveForwardRef } from "../common/di/forward-ref"; export { Injector } from "../common/di/injector"; export type { InjectOptions } from "../common/di/injector"; export { provide, provideLazy } from "../common/di/providers"; +export type { ProviderScope } from "../common/di/providers"; +export { ProvidedIn } from "../common/di/contract"; +// The invocation running now, for code that resolves by name outside any +// injection context. +export { currentInvocationInjector } from "../common/invocations"; export type { Provider, ProviderToken, @@ -67,6 +72,10 @@ export { numberOption, arrayOption, objectOption, + defineOptions, + OptionsGroup, + isOptionsGroup, + OPTIONS_GROUP_MARKER, } from "../common/define-command"; export type { ParamSpec, @@ -89,11 +98,19 @@ export type { CommandFailOptions, CommandOptionSpec, DefaultedCommandOptionSpec, + CommandOptionsInput, CommandOptionsSchema, CommandOptionSpecInit, CommandOptionType, CommandOptionValues, + OptionsSchemaOf, + OptionValuesOf, + ResolvedCommandOptions, } from "../common/define-command"; +// The process-level option group, and the seam a contribution to a command's +// or the root's options goes through. +export { CliOptions } from "../common/contracts/cli-options"; +export { OptionContributions } from "../common/contracts/option-contributions"; // Promoted from the internal contracts index: the class form reads it in a // field initializer, and a per-command provider is written against it. export { COMMAND_CONTEXT } from "../common/contracts/command-context"; diff --git a/lib/options.ts b/lib/options.ts index b367f0f01f..bba3fda75b 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -11,23 +11,38 @@ import { import { OptionType } from "./common/enums"; import { injector } from "./common/yok"; import { APP_FOLDER_NAME } from "./constants"; +import { CliOptions } from "./common/contracts/cli-options"; +import { OptionContributions } from "./common/contracts/option-contributions"; +import { + compileOptionsSchema, + readOptionValues, +} from "./common/define-command"; +import type { Injector } from "./common/di/injector"; export class Options { private static DASHED_OPTION_REGEX = /(.+?)([A-Z])(.*)/; private static NONDASHED_OPTION_REGEX = /(.+?)[-]([a-zA-Z])(.*)/; private optionsWhiteList = ["_", "$0"]; // yargs artifacts, not options - private globalOptions: IDictionary = { - log: { type: OptionType.String, hasSensitiveValue: false }, - verbose: { type: OptionType.Boolean, hasSensitiveValue: false }, - version: { type: OptionType.Boolean, alias: "v", hasSensitiveValue: false }, - help: { type: OptionType.Boolean, alias: "h", hasSensitiveValue: false }, - profileDir: { type: OptionType.String, hasSensitiveValue: true }, - analyticsClient: { type: OptionType.String, hasSensitiveValue: false }, - path: { type: OptionType.String, alias: "p", hasSensitiveValue: true }, - config: { type: OptionType.String, alias: "c", hasSensitiveValue: true }, + + /** + * The process-level table: CliOptions and every root group contributed so + * far, so it is read again whenever the table is rebuilt. + */ + private get globalOptions(): IDictionary { + const contributions = this.$injector.get(OptionContributions, { + optional: true, + }); + const table: IDictionary = {}; + for (const group of [ + CliOptions, + ...(contributions ? contributions.forRoot() : []), + ]) { + _.extend(table, compileOptionsSchema(group.schema)); + } // This will parse all non-hyphenated values as strings. - _: { type: OptionType.String, hasSensitiveValue: false }, - }; + table._ = { type: OptionType.String, hasSensitiveValue: false }; + return table; + } private initialArgv: yargs.Arguments; public argv: yargs.Arguments; @@ -36,8 +51,12 @@ export class Options { public setupOptions( commandSpecificDashedOptions?: IDictionary, ): void { - if (commandSpecificDashedOptions) { - _.extend(this.options, commandSpecificDashedOptions); + const rootOptions = this.globalOptions; + const rootGrew = Object.keys(rootOptions).some( + (optionName) => !this.options[optionName], + ); + if (commandSpecificDashedOptions || rootGrew) { + _.extend(this.options, rootOptions, commandSpecificDashedOptions || {}); this.setArgv(); } @@ -69,6 +88,7 @@ export class Options { private $errors: IErrors, private $settingsService: ISettingsService, private $logger: ILogger, + private $injector: Injector, ) { this.options = _.extend({}, this.commonOptions, this.globalOptions); this.setArgv(); @@ -542,3 +562,9 @@ export class Options { } } injector.register("options", Options); +// The startup parse, which no command changes, so one read serves the process. +injector.register({ + provide: CliOptions, + useFactory: () => + readOptionValues(CliOptions.schema, injector.resolve("options")), +}); diff --git a/test/define-command.ts b/test/define-command.ts index 75573b5eb4..48d9733844 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -9,6 +9,7 @@ import { runInInjectionContext, } from "../lib/common/di"; import { COMMAND_CONTEXT } from "../lib/common/contracts/command-context"; +import { CliOptions } from "../lib/common/contracts/cli-options"; import { COMMAND_PRECONDITIONS } from "../lib/common/contracts/command-preconditions"; import { COMMAND_OWNER, @@ -26,9 +27,12 @@ import { booleanOption, Command, defineCommand, + defineOptions, isCommandClass, isCommandDefinition, + isOptionsGroup, numberOption, + OPTIONS_GROUP_MARKER, stringOption, } from "../lib/common/define-command"; import { @@ -453,7 +457,7 @@ describe("defineCommand", () => { describe("execute", () => { it("passes args and the declared options through, inside an injection context", async () => { const testInjector = createTestInjector({ - verbose: true, + quiet: true, output: "dist", undeclared: "ignored", }); @@ -467,7 +471,7 @@ describe("defineCommand", () => { defineCommand({ name: "dctestexec", options: { - verbose: booleanOption(), + quiet: booleanOption(), output: stringOption(), }, run(context) { @@ -482,27 +486,27 @@ describe("defineCommand", () => { await command.execute(["one", "two"]); assert.deepEqual(capturedArgs, ["one", "two"]); - assert.deepEqual(capturedOptions, { verbose: true, output: "dist" }); + assert.deepEqual(capturedOptions, { quiet: true, output: "dist" }); assert.strictEqual(greeting, "hello"); }); it("reads option values at execution time", async () => { - const optionsService: any = { verbose: false }; + const optionsService: any = { quiet: false }; const testInjector = createTestInjector(optionsService); let seen: boolean; const command = createCommandFromDefinition( defineCommand({ name: "dctestlate", - options: { verbose: booleanOption() }, + options: { quiet: booleanOption() }, run: (context) => { - seen = context.options.verbose; + seen = context.options.quiet; }, }), testInjector, ); - optionsService.verbose = true; + optionsService.quiet = true; await command.execute([]); assert.isTrue(seen); @@ -530,7 +534,7 @@ describe("defineCommand", () => { it("carries the declared option values onto the run context", async () => { const testInjector = createTestInjector({ - verbose: true, + quiet: true, output: "dist", retries: 3, files: ["a.ts"], @@ -541,7 +545,7 @@ describe("defineCommand", () => { defineCommand({ name: "dctesttypes", options: { - verbose: booleanOption(), + quiet: booleanOption(), output: stringOption(), retries: numberOption(), files: arrayOption(), @@ -556,7 +560,7 @@ describe("defineCommand", () => { await command.execute([]); assert.deepEqual(seen, { - verbose: true, + quiet: true, output: "dist", retries: 3, files: ["a.ts"], @@ -729,7 +733,7 @@ describe("defineCommand", () => { defineCommand({ name: "dctestdashed", options: { - verbose: booleanOption({ default: false }), + quiet: booleanOption({ default: false }), output: stringOption({ alias: "o" }), retries: numberOption({ default: 3 }), files: arrayOption(), @@ -744,7 +748,7 @@ describe("defineCommand", () => { ); assert.deepEqual(command.dashedOptions, { - verbose: { type: "boolean", hasSensitiveValue: false, default: false }, + quiet: { 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 }, @@ -762,25 +766,25 @@ describe("defineCommand", () => { name: "dctestredeclare", options: { // Redeclared only to give this command its own default. - path: stringOption({ default: "./here" }), + output: stringOption({ default: "./here" }), watch: booleanOption({ default: false }), }, run: (): void => undefined, }), createTestInjector({ options: { - path: { type: "string", alias: "p", hasSensitiveValue: true }, + output: { type: "string", alias: "o", hasSensitiveValue: true }, watch: { type: "boolean", hasSensitiveValue: false }, }, }), ); assert.deepEqual(command.dashedOptions, { - path: { + output: { type: "string", hasSensitiveValue: true, default: "./here", - alias: "p", + alias: "o", }, watch: { type: "boolean", hasSensitiveValue: false, default: false }, }); @@ -791,19 +795,19 @@ describe("defineCommand", () => { defineCommand({ name: "dctestoverride", options: { - path: stringOption({ alias: "q", hasSensitiveValue: false }), + output: stringOption({ alias: "q", hasSensitiveValue: false }), }, run: (): void => undefined, }), createTestInjector({ options: { - path: { type: "string", alias: "p", hasSensitiveValue: true }, + output: { type: "string", alias: "o", hasSensitiveValue: true }, }, }), ); assert.deepEqual(command.dashedOptions, { - path: { type: "string", hasSensitiveValue: false, alias: "q" }, + output: { type: "string", hasSensitiveValue: false, alias: "q" }, }); }); @@ -819,8 +823,8 @@ describe("defineCommand", () => { 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" }, + quiet: { type: "boolean" }, + output: { type: "string", alias: "o" }, }, }); @@ -828,8 +832,8 @@ describe("defineCommand", () => { defineCommand({ name: "dctestshadow", options: { - verbose: stringOption(), - output: stringOption({ alias: ["p", "o"] }), + quiet: stringOption(), + target: stringOption({ alias: ["o", "t"] }), fresh: booleanOption({ alias: "f" }), }, run: (): void => undefined, @@ -840,21 +844,21 @@ describe("defineCommand", () => { const logger: LoggerStub = testInjector.resolve("logger"); assert.include( logger.warnOutput, - "'--verbose' with the CLI option '--verbose'", + "'--quiet' with the CLI option '--quiet'", ); assert.include( logger.warnOutput, - "alias '-p' of '--output' with the CLI option '--path'", + "alias '-o' of '--target' with the CLI option '--output'", ); assert.notInclude(logger.warnOutput, "--fresh"); - assert.notInclude(logger.warnOutput, "'-o'"); + assert.notInclude(logger.warnOutput, "'-t'"); }); it("stays quiet when a command only redefines a CLI-wide option's default", () => { const testInjector = createTestInjector({ options: { watch: { type: "boolean" }, - path: { type: "string", alias: "p" }, + output: { type: "string", alias: "o" }, }, }); @@ -863,7 +867,7 @@ describe("defineCommand", () => { name: "dctestredeclare", options: { watch: booleanOption({ default: true }), - path: stringOption({ alias: "p" }), + output: stringOption({ alias: "o" }), }, run: (): void => undefined, }), @@ -1359,14 +1363,14 @@ describe("defineCommand", () => { }); it("validates the declared options and runs the command", async () => { - const testInjector = createCommandsServiceInjector({ verbose: true }); + const testInjector = createCommandsServiceInjector({ quiet: true }); let ran: any; runInInjectionContext(testInjector, () => registerCommand( defineCommand({ name: "dctest-e2e", - options: { verbose: booleanOption({ default: false }) }, + options: { quiet: booleanOption({ default: false }) }, params: "any", run: (context) => { ran = context; @@ -1380,10 +1384,10 @@ describe("defineCommand", () => { await commandsService.tryExecuteCommand("dctest-e2e", ["alpha"]); assert.deepEqual(validatedOptions, { - verbose: { type: "boolean", hasSensitiveValue: false, default: false }, + quiet: { type: "boolean", hasSensitiveValue: false, default: false }, }); assert.deepEqual(ran.args, ["alpha"]); - assert.deepEqual(ran.options, { verbose: true }); + assert.deepEqual(ran.options, { quiet: true }); }); it("rejects parameters when arguments are 'none'", async () => { @@ -3479,4 +3483,456 @@ describe("defineCommand", () => { assert.isTrue(await verdict); }); }); + + describe("option groups", () => { + describe("defineOptions", () => { + it("returns a token named after the group that carries its schema", () => { + const schema = { watch: booleanOption() }; + const group = defineOptions("dctest-grp-basic", schema); + + assert.isTrue(isOptionsGroup(group)); + assert.instanceOf(group, InjectionToken); + assert.strictEqual(group.groupName, "dctest-grp-basic"); + assert.strictEqual(group.schema, schema); + assert.strictEqual(group.description, "options:dctest-grp-basic"); + assert.isTrue(group[OPTIONS_GROUP_MARKER]); + assert.isFalse(isOptionsGroup(schema)); + assert.isFalse(isOptionsGroup(null)); + assert.isFalse(isOptionsGroup(new InjectionToken("dctest-grp-plain"))); + }); + + it("keys the same injector record as its registry name", () => { + const group = defineOptions("dctest-grp-byname", { + watch: booleanOption(), + }); + const testInjector = createTestInjector(); + testInjector.register({ provide: group, useValue: { watch: true } }); + + assert.deepEqual(testInjector.resolve("options:dctest-grp-byname"), { + watch: true, + }); + assert.deepEqual(testInjector.get(group), { watch: true }); + }); + + it("rejects an unusable name, schema or spec, naming the group", () => { + assert.throws( + () => defineOptions("", {}), + /^Invalid option group \(unnamed\): the name must be a non-empty string\. Accepted form: defineOptions/, + ); + assert.throws( + () => defineOptions("dctest-grp-noschema", null), + /^Invalid option group 'dctest-grp-noschema': the schema must be an object keyed by the long option name/, + ); + assert.throws( + () => + defineOptions("dctest-grp-badspec", { + watch: { type: "bool" }, + }), + /^Invalid option group 'dctest-grp-badspec': option 'watch' has type 'bool'/, + ); + }); + + it("rejects a spelling two of its options would share", () => { + assert.throws( + () => + defineOptions("dctest-grp-aliasclash", { + watch: booleanOption(), + wait: booleanOption({ alias: "watch" }), + }), + /^Invalid option group 'dctest-grp-aliasclash': alias '-watch' of '--wait' \(option group 'dctest-grp-aliasclash'\) is already the spelling of '--watch' \(option group 'dctest-grp-aliasclash'\)/, + ); + assert.throws( + () => + defineOptions("dctest-grp-nameclash", { + watch: booleanOption({ alias: "w" }), + w: booleanOption(), + }), + /^Invalid option group 'dctest-grp-nameclash': option '--w' \(option group 'dctest-grp-nameclash'\) is already an alias of '--watch' \(option group 'dctest-grp-nameclash'\)/, + ); + }); + + it("refuses a second group under a name already in use", () => { + defineOptions("dctest-grp-dup", {}); + + assert.throws( + () => defineOptions("dctest-grp-dup", {}), + /Token name 'options:dctest-grp-dup' is already used/, + ); + }); + }); + + describe("options as a list", () => { + const runOptionsFor = (name: string) => + defineOptions(name, { + watch: booleanOption({ default: true }), + device: stringOption({ alias: "d" }), + }); + + it("merges the groups and the inline specs onto ctx.options", async () => { + const RunOptions = runOptionsFor("dctest-grp-merge"); + const testInjector = createTestInjector({ + watch: false, + device: "emulator-5554", + extra: true, + undeclared: "ignored", + }); + + let seen: any; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-grp-merge", + options: [RunOptions, { extra: booleanOption() }], + run: (ctx) => { + seen = ctx.options; + }, + }), + testInjector, + ); + await command.execute([]); + + assert.deepEqual(seen, { + watch: false, + device: "emulator-5554", + extra: true, + }); + }); + + it("compiles every part into dashedOptions", () => { + const RunOptions = runOptionsFor("dctest-grp-dashed"); + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-grp-dashed", + options: [RunOptions, { extra: booleanOption() }], + run: (): void => undefined, + }), + createTestInjector(), + ); + + assert.deepEqual(command.dashedOptions, { + watch: { type: "boolean", hasSensitiveValue: false, default: true }, + device: { type: "string", hasSensitiveValue: false, alias: "d" }, + extra: { type: "boolean", hasSensitiveValue: false }, + }); + }); + + it("provides each group's slice to the invocation, not to the target injector", async () => { + const RunOptions = runOptionsFor("dctest-grp-provide"); + const testInjector = createTestInjector({ + watch: false, + device: "emulator-5554", + extra: true, + }); + const DEVICE = new InjectionToken("dctest-grp-provide-device"); + const seen: any[] = []; + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-grp-provide", + options: [RunOptions, { extra: booleanOption() }], + providers: [ + { provide: DEVICE, useFactory: () => inject(RunOptions).device }, + ], + setup: () => { + seen.push(["setup", inject(RunOptions)]); + }, + canExecute: (ctx) => { + seen.push(["canExecute", ctx.injector.get(RunOptions)]); + return true; + }, + run: (ctx) => { + seen.push(["run", inject(RunOptions)]); + seen.push(["service", ctx.injector.get(DEVICE)]); + }, + }), + testInjector, + ); + + assert.isTrue(await command.canExecute([])); + await command.execute([]); + + const slice = { watch: false, device: "emulator-5554" }; + assert.deepEqual(seen, [ + ["setup", slice], + ["canExecute", slice], + ["run", slice], + ["service", "emulator-5554"], + ]); + assert.isNull(testInjector.get(RunOptions, { optional: true })); + }); + + it("reads the group's values again for every invocation", async () => { + const RunOptions = runOptionsFor("dctest-grp-fresh"); + const optionsService: any = { watch: false, device: "a" }; + const testInjector = createTestInjector(optionsService); + const seen: any[] = []; + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-grp-fresh", + options: [RunOptions], + run: () => { + seen.push(inject(RunOptions)); + }, + }), + testInjector, + ); + + await command.execute([]); + optionsService.watch = true; + optionsService.device = "b"; + await command.execute([]); + + assert.deepEqual(seen, [ + { watch: false, device: "a" }, + { watch: true, device: "b" }, + ]); + }); + }); + + describe("collisions", () => { + it("rejects one long name declared by two parts with different specs", () => { + const Shared = defineOptions("dctest-grp-clash", { + watch: booleanOption({ default: true }), + }); + const Other = defineOptions("dctest-grp-clash-other", { + watch: booleanOption(), + }); + + assert.throws( + () => + defineCommand({ + name: "dctest-grp-clash-inline", + options: [Shared, { watch: booleanOption() }], + run: (): void => undefined, + }), + /^Invalid command definition for 'dctest-grp-clash-inline': option '--watch' is declared by option group 'dctest-grp-clash' and by the command's own options with different specs/, + ); + assert.throws( + () => + defineCommand({ + name: "dctest-grp-clash-groups", + options: [Shared, Other], + run: (): void => undefined, + }), + /option '--watch' is declared by option group 'dctest-grp-clash' and by option group 'dctest-grp-clash-other' with different specs/, + ); + }); + + it("treats a different alias, default or sensitivity as a different spec", () => { + const Output = defineOptions("dctest-grp-clash-spec", { + output: stringOption({ alias: "o" }), + }); + + for (const redeclared of [ + stringOption({ alias: "O" }), + stringOption({ alias: "o", hasSensitiveValue: true }), + stringOption({ alias: "o", default: "dist" }), + ]) { + assert.throws( + () => + defineCommand({ + name: "dctest-grp-clash-spec", + options: [Output, { output: redeclared }], + run: (): void => undefined, + }), + /option '--output' is declared by option group 'dctest-grp-clash-spec' and by the command's own options with different specs/, + ); + } + }); + + it("accepts the same spec declared by two parts", () => { + const Output = defineOptions("dctest-grp-same", { + output: stringOption({ alias: "o" }), + }); + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-grp-same", + options: [Output, { output: stringOption({ alias: "o" }) }], + run: (): void => undefined, + }), + createTestInjector(), + ); + + assert.deepEqual(command.dashedOptions, { + output: { type: "string", hasSensitiveValue: false, alias: "o" }, + }); + }); + + it("rejects an alias of one part that is a spelling of another", () => { + const Output = defineOptions("dctest-grp-alias", { + output: stringOption({ alias: "o" }), + }); + + assert.throws( + () => + defineCommand({ + name: "dctest-grp-alias", + options: [Output, { target: stringOption({ alias: "output" }) }], + run: (): void => undefined, + }), + /alias '-output' of '--target' \(the command's own options\) is already the spelling of '--output' \(option group 'dctest-grp-alias'\)/, + ); + assert.throws( + () => + defineCommand({ + name: "dctest-grp-alias", + options: [Output, { o: booleanOption() }], + run: (): void => undefined, + }), + /option '--o' \(the command's own options\) is already an alias of '--output' \(option group 'dctest-grp-alias'\)/, + ); + }); + + it("applies the same rules to the Command() meta", () => { + const Watch = defineOptions("dctest-grp-clash-class", { + watch: booleanOption({ default: true }), + }); + + assert.throws( + () => + Command({ + name: "dctest-grp-clash-class", + options: [Watch, { watch: booleanOption() }], + }), + /^Invalid command definition for 'dctest-grp-clash-class': option '--watch' is declared by option group 'dctest-grp-clash-class'/, + ); + }); + }); + + describe("process-level options", () => { + const compile = (options: any) => + createCommandFromDefinition( + defineCommand({ + name: "dctest-grp-root", + options, + run: (): void => undefined, + }), + createTestInjector(), + ); + + it("refuses an inline option that redeclares a CliOptions name", () => { + assert.throws( + () => compile({ path: stringOption() }), + "Command 'dctest-grp-root': '--path' is a process-level option; list CliOptions under 'options', or inject it, instead of redeclaring it", + ); + }); + + it("refuses an alias that is a CliOptions shorthand", () => { + assert.throws( + () => compile({ project: stringOption({ alias: "p" }) }), + "Command 'dctest-grp-root': '-p' is a process-level option", + ); + }); + + it("refuses a group of the command's own that redeclares one", () => { + const Logging = defineOptions("dctest-grp-root-group", { + verbose: booleanOption(), + }); + + assert.throws( + () => compile([Logging]), + "Command 'dctest-grp-root': '--verbose' is a process-level option", + ); + }); + + it("lets CliOptions itself be listed, reading its values onto ctx.options", async () => { + const testInjector = createTestInjector({ + verbose: true, + path: "/app", + output: "dist", + }); + + let seen: any; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-grp-root-listed", + options: [CliOptions, { output: stringOption() }], + run: (ctx) => { + seen = ctx.options; + }, + }), + testInjector, + ); + await command.execute([]); + + assert.sameMembers(Object.keys(seen), [ + ...Object.keys(CliOptions.schema), + "output", + ]); + assert.isTrue(seen.verbose); + assert.strictEqual(seen.path, "/app"); + assert.strictEqual(seen.output, "dist"); + assert.deepEqual(command.dashedOptions.path, { + type: "string", + hasSensitiveValue: true, + alias: "p", + }); + }); + + it("does not provide CliOptions per invocation", async () => { + const Extra = defineOptions("dctest-grp-root-extra", { + extra: booleanOption(), + }); + const testInjector = createTestInjector({ verbose: true, extra: true }); + + let provided: any[]; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-grp-root-unprovided", + options: [CliOptions, Extra], + run: (ctx) => { + provided = [ + ctx.injector.get(CliOptions, { optional: true }), + ctx.injector.get(Extra, { optional: true }), + ]; + }, + }), + testInjector, + ); + await command.execute([]); + + assert.deepEqual(provided, [null, { extra: true }]); + }); + }); + + describe("class form", () => { + it("types and merges this.options from a list of groups and specs", async () => { + const RunOptions = defineOptions("dctest-grp-class", { + watch: booleanOption({ default: true }), + device: stringOption(), + }); + const testInjector = createTestInjector({ + watch: false, + device: "emulator-5554", + extra: true, + }); + const seen: any[] = []; + + class GroupedWidget extends Command({ + name: "dctest-grp-class", + options: [RunOptions, { extra: booleanOption({ default: false }) }], + }) { + private runOptions = inject(RunOptions); + + public run(): void { + const watch: boolean = this.options.watch; + const extra: boolean = this.options.extra; + seen.push([watch, this.options.device, extra], this.runOptions); + } + } + + const command = createCommandFromDefinition( + GroupedWidget.definition, + testInjector, + ); + await command.execute([]); + + assert.deepEqual(seen, [ + [false, "emulator-5554", true], + { watch: false, device: "emulator-5554" }, + ]); + }); + }); + }); }); diff --git a/test/di.ts b/test/di.ts index 4222b3041b..94490f71da 100644 --- a/test/di.ts +++ b/test/di.ts @@ -7,6 +7,7 @@ import { InjectionToken, provide, forwardRef, + ProvidedIn, } from "../lib/common/di"; @Contract({ name: "diTestGreeter" }) @@ -729,3 +730,339 @@ describe("di: multi providers", () => { ); }); }); + +describe("di: providedIn scopes", () => { + const SCOPED_ARGS = new InjectionToken<{ args: string[] }>( + "diTestScopedArgs", + ); + + @Contract({ name: "diTestScopedMarkedContract", providedIn: "invocation" }) + abstract class MarkedContract { + abstract id: number; + } + + @Contract({ name: "diTestScopedPlainContract" }) + abstract class PlainContract { + abstract id: number; + } + + let seq = 0; + + class Counter { + public id = ++seq; + } + + @ProvidedIn("invocation") + class MarkedCounter { + public id = ++seq; + } + + @ProvidedIn("root") + class RootMarkedCounter { + public id = ++seq; + } + + const invocationOf = (root: Injector, providers: any[] = []): Injector => + root.createChild(providers, { scope: "invocation" }); + + it("instantiates once per scoped injector, not on the record's owner", () => { + const root = new Injector([ + { + provide: "diTestScopedCounter", + providedIn: "invocation", + useClass: Counter, + }, + ]); + const first = invocationOf(root); + const second = invocationOf(root); + + const a = first.get("diTestScopedCounter"); + assert.strictEqual(first.get("diTestScopedCounter"), a); + assert.notStrictEqual(second.get("diTestScopedCounter"), a); + assert.strictEqual(first.createChild().get("diTestScopedCounter"), a); + }); + + it("resolves the instance's inject() dependencies against the scoped injector", () => { + class ReadsArgs { + public args = inject(SCOPED_ARGS); + public injector = inject(Injector); + } + const root = new Injector([ + { + provide: "diTestScopedReader", + providedIn: "invocation", + useClass: ReadsArgs, + }, + ]); + const payload = { args: ["one"] }; + const invocation = invocationOf(root, [ + { provide: SCOPED_ARGS, useValue: payload }, + ]); + + const reader = invocation + .createChild() + .get("diTestScopedReader"); + assert.strictEqual(reader.args, payload); + assert.strictEqual(reader.injector, invocation); + }); + + it("resolves a legacy class's $-parameters against the scoped injector", () => { + class LegacyReader { + constructor(public $diTestScopedArgs: any) {} + } + const root = new Injector([ + { + provide: "diTestScopedLegacy", + providedIn: "invocation", + useLegacyClass: LegacyReader, + }, + ]); + const payload = { args: ["legacy"] }; + const invocation = invocationOf(root, [ + { provide: SCOPED_ARGS, useValue: payload }, + ]); + + assert.strictEqual( + invocation.get("diTestScopedLegacy").$diTestScopedArgs, + payload, + ); + }); + + it("reads @ProvidedIn from useClass, useLegacyClass and a bare class provider", () => { + const root = new Injector([ + { provide: "diTestScopedViaUseClass", useClass: MarkedCounter }, + { provide: "diTestScopedViaLegacy", useLegacyClass: MarkedCounter }, + MarkedCounter, + ]); + const invocation = invocationOf(root); + + for (const token of [ + "diTestScopedViaUseClass", + "diTestScopedViaLegacy", + MarkedCounter, + ]) { + assert.throws( + () => root.get(token), + /provided in the 'invocation' scope/, + ); + const instance = invocation.get(token); + assert.instanceOf(instance, MarkedCounter); + assert.strictEqual(invocation.get(token), instance); + } + }); + + it("reads providedIn from the token's @Contract", () => { + class MarkedImpl extends MarkedContract { + public id = ++seq; + } + const root = new Injector([provide(MarkedContract, MarkedImpl)]); + + assert.throws( + () => root.get(MarkedContract), + /diTestScopedMarkedContract is provided in the 'invocation' scope/, + ); + const first = invocationOf(root).get(MarkedContract); + assert.instanceOf(first, MarkedImpl); + assert.notStrictEqual(invocationOf(root).get(MarkedContract), first); + }); + + it("prefers the provider field, then the class marker, then the token marker", () => { + class PlainImpl extends MarkedContract { + public id = ++seq; + } + @ProvidedIn("root") + class RootMarkedImpl extends MarkedContract { + public id = ++seq; + } + @ProvidedIn("invocation") + class InvocationMarkedImpl extends PlainContract { + public id = ++seq; + } + + const fieldOverToken = new Injector([ + { provide: MarkedContract, providedIn: "root", useClass: PlainImpl }, + ]); + assert.strictEqual( + invocationOf(fieldOverToken).get(MarkedContract), + fieldOverToken.get(MarkedContract), + ); + + const fieldOverClass = new Injector([ + { + provide: "diTestScopedFieldOverClass", + providedIn: "root", + useClass: MarkedCounter, + }, + ]); + assert.instanceOf( + fieldOverClass.get("diTestScopedFieldOverClass"), + MarkedCounter, + ); + + const classOverToken = new Injector([ + provide(MarkedContract, RootMarkedImpl), + ]); + assert.instanceOf(classOverToken.get(MarkedContract), RootMarkedImpl); + + const classOverUnmarkedToken = new Injector([ + provide(PlainContract, InvocationMarkedImpl), + ]); + assert.throws( + () => classOverUnmarkedToken.get(PlainContract), + /provided in the 'invocation' scope/, + ); + }); + + it("matches 'root' to the injector with no parent", () => { + const root = new Injector([RootMarkedCounter]); + const nested = invocationOf(root).createChild(); + + assert.strictEqual( + nested.get(RootMarkedCounter), + root.get(RootMarkedCounter), + ); + }); + + it("matches any scope name given to createChild", () => { + const root = new Injector([ + { + provide: "diTestScopedDevice", + providedIn: "device", + useClass: Counter, + }, + ]); + const deviceA = root.createChild([], { scope: "device" }); + const deviceB = root.createChild([], { scope: "device" }); + + assert.throws( + () => invocationOf(root).get("diTestScopedDevice"), + /provided in the 'device' scope/, + ); + assert.notStrictEqual( + deviceA.get("diTestScopedDevice"), + deviceB.get("diTestScopedDevice"), + ); + }); + + it("throws outside the scope even for an optional lookup", () => { + const root = new Injector([ + { + provide: "diTestScopedOutside", + providedIn: "invocation", + useClass: Counter, + }, + ]); + const expected = + /diTestScopedOutside is provided in the 'invocation' scope; it cannot be resolved from outside one/; + + assert.throws(() => root.get("diTestScopedOutside"), expected); + assert.throws( + () => root.createChild().get("diTestScopedOutside"), + expected, + ); + assert.throws( + () => root.get("diTestScopedOutside", { optional: true }), + expected, + ); + runInInjectionContext(root, () => { + assert.throws( + () => inject("diTestScopedOutside", { optional: true }), + expected, + ); + }); + }); + + it("refuses a root singleton that injects an invocation-scoped service", () => { + class HoldsScoped { + public scoped = inject("diTestScopedHeld"); + } + const root = new Injector([ + { + provide: "diTestScopedHeld", + providedIn: "invocation", + useClass: Counter, + }, + { provide: "diTestScopedHolder", useClass: HoldsScoped }, + ]); + + // The singleton is built by its owner, the root, even when the lookup + // starts inside an invocation. + assert.throws( + () => invocationOf(root).get("diTestScopedHolder"), + /diTestScopedHeld is provided in the 'invocation' scope/, + ); + }); + + it("reports a cycle between scoped services", () => { + class ScopedA { + constructor(public $diTestScopedCycleB: any) {} + } + class ScopedB { + constructor(public $diTestScopedCycleA: any) {} + } + const root = new Injector([ + { + provide: "diTestScopedCycleA", + providedIn: "invocation", + useLegacyClass: ScopedA, + }, + { + provide: "diTestScopedCycleB", + providedIn: "invocation", + useLegacyClass: ScopedB, + }, + ]); + + assert.throws( + () => invocationOf(root).get("diTestScopedCycleA"), + /Cyclic dependency detected on dependency 'diTestScopedCycleA'/, + ); + }); + + it("leaves disposal of scoped instances to the scoped injector", () => { + const disposed: string[] = []; + const root = new Injector([ + { + provide: "diTestScopedDisposable", + providedIn: "invocation", + useFactory: () => ({ dispose: () => disposed.push("scoped") }), + }, + ]); + const invocation = invocationOf(root); + invocation.get("diTestScopedDisposable"); + + root.dispose(); + assert.deepEqual(disposed, []); + + invocation.dispose(); + assert.deepEqual(disposed, ["scoped"]); + }); + + it("rejects providedIn on a multi provider", () => { + const MULTI = new InjectionToken("diTestScopedMulti"); + const expected = + /InjectionToken\(diTestScopedMulti\): a multi provider cannot be scoped with providedIn; scope the token's consumers instead/; + + assert.throws( + () => + new Injector([ + { + provide: MULTI, + multi: true, + providedIn: "invocation", + useValue: 1, + }, + ]), + expected, + ); + assert.throws( + () => + new Injector().register({ + provide: MULTI, + multi: true, + useClass: MarkedCounter, + }), + expected, + ); + }); +}); diff --git a/test/invocations.ts b/test/invocations.ts new file mode 100644 index 0000000000..16eea9fdc9 --- /dev/null +++ b/test/invocations.ts @@ -0,0 +1,388 @@ +import { assert } from "chai"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Yok } from "../lib/common/yok"; +import { IInjector } from "../lib/common/definitions/yok"; +import { IHooksService } from "../lib/common/declarations"; +import { Injector, runInInjectionContext } from "../lib/common/di"; +import { + currentInvocationInjector, + openInvocation, + runInInvocation, +} from "../lib/common/invocations"; +import { defineCommand } from "../lib/common/define-command"; +import { createCommandFromDefinition } from "../lib/common/services/command-definition-adapter"; +import { HooksService } from "../lib/common/services/hooks-service"; +import { ErrorsStub, LoggerStub } from "./stubs"; + +const openStack = (): Injector[] => { + // Creates the process-wide slot if nothing has touched it yet. + currentInvocationInjector(); + return (globalThis)[Symbol.for("nativescript:cli:invocations")].open; +}; + +// The record is process-wide; every test starts from an empty stack so the +// "first entry stays open" rule applies to the test's own first open. +const resetOpenStack = (): void => { + openStack().length = 0; +}; + +const tick = (): Promise => + new Promise((resolve) => setTimeout(resolve, 1)); + +describe("invocations: record and lookup", () => { + beforeEach(resetOpenStack); + + it("returns null with nothing open and no context", () => { + assert.isNull(currentInvocationInjector()); + }); + + it("prefers the injection context, then the async store, then the stack top", () => { + const opened = new Injector(); + const stored = new Injector(); + const contextual = new Injector(); + openInvocation(opened); + + assert.strictEqual(currentInvocationInjector(), opened); + runInInvocation(stored, () => { + assert.strictEqual(currentInvocationInjector(), stored); + runInInjectionContext(contextual, () => { + assert.strictEqual(currentInvocationInjector(), contextual); + }); + assert.strictEqual(currentInvocationInjector(), stored); + }); + assert.strictEqual(currentInvocationInjector(), opened); + }); + + it("tracks the innermost open invocation through nested open and close", () => { + const first = new Injector(); + const second = new Injector(); + const third = new Injector(); + openInvocation(first); + const closeSecond = openInvocation(second); + const closeThird = openInvocation(third); + + assert.strictEqual(currentInvocationInjector(), third); + closeThird(); + assert.strictEqual(currentInvocationInjector(), second); + closeSecond(); + assert.strictEqual(currentInvocationInjector(), first); + assert.deepEqual(openStack(), [first]); + }); + + it("closes an entry opened out of order without disturbing the others", () => { + const first = new Injector(); + const second = new Injector(); + const third = new Injector(); + openInvocation(first); + const closeSecond = openInvocation(second); + openInvocation(third); + + closeSecond(); + + assert.deepEqual(openStack(), [first, third]); + }); + + it("never closes the first invocation of the process", () => { + const first = new Injector(); + const closeFirst = openInvocation(first); + + closeFirst(); + + assert.deepEqual(openStack(), [first]); + assert.strictEqual(currentInvocationInjector(), first); + }); + + it("treats a second close of the same entry as a no-op", () => { + const first = new Injector(); + const second = new Injector(); + const third = new Injector(); + openInvocation(first); + const closeSecond = openInvocation(second); + openInvocation(third); + + closeSecond(); + closeSecond(); + + assert.deepEqual(openStack(), [first, third]); + }); + + it("keeps the async store across awaits inside runInInvocation only", async () => { + const stored = new Injector(); + + const seen = await runInInvocation(stored, async () => { + await tick(); + const afterAwait = currentInvocationInjector(); + const inTimer = await new Promise((resolve) => + setTimeout(() => resolve(currentInvocationInjector()), 1), + ); + return { afterAwait, inTimer }; + }); + + assert.strictEqual(seen.afterAwait, stored); + assert.strictEqual(seen.inTimer, stored); + assert.isNull(currentInvocationInjector()); + }); +}); + +const createCommandInjector = (): IInjector => { + const testInjector = new Yok(); + testInjector.register("options", {}); + testInjector.register("logger", LoggerStub); + testInjector.register("errors", { + failWithHelp: (message: string) => { + throw new Error(message); + }, + }); + return testInjector; +}; + +describe("invocations: command adapter", () => { + beforeEach(resetOpenStack); + + it("keeps the invocation injector current across awaits and detached callbacks in run", async () => { + const testInjector = createCommandInjector(); + const seen: any = {}; + + const command = createCommandFromDefinition( + defineCommand({ + name: "invtest-als", + run: async (ctx) => { + seen.injector = ctx.injector; + await tick(); + seen.afterAwait = currentInvocationInjector(); + seen.inTimer = await new Promise((resolve) => + setTimeout(() => resolve(currentInvocationInjector()), 1), + ); + }, + }), + testInjector, + ); + + await command.execute([]); + + assert.instanceOf(seen.injector, Injector); + assert.strictEqual(seen.afterAwait, seen.injector); + assert.strictEqual(seen.inTimer, seen.injector); + }); + + it("closes a nested invocation when its execute settles and keeps the first one open", async () => { + const testInjector = createCommandInjector(); + const seen: any = {}; + + const inner = createCommandFromDefinition( + defineCommand({ + name: "invtest-inner", + run: (ctx) => { + seen.inner = ctx.injector; + seen.stackDuringInner = openStack().slice(); + }, + }), + testInjector, + ); + const outer = createCommandFromDefinition( + defineCommand({ + name: "invtest-outer", + run: async (ctx) => { + seen.outer = ctx.injector; + await inner.execute([]); + seen.stackAfterInner = openStack().slice(); + seen.currentAfterInner = currentInvocationInjector(); + }, + }), + testInjector, + ); + + await outer.execute([]); + + assert.deepEqual(seen.stackDuringInner, [seen.outer, seen.inner]); + assert.deepEqual(seen.stackAfterInner, [seen.outer]); + assert.strictEqual(seen.currentAfterInner, seen.outer); + assert.deepEqual(openStack(), [seen.outer]); + }); + + it("closes the invocation when canExecute refuses", async () => { + const testInjector = createCommandInjector(); + openInvocation(new Injector()); + const baseline = openStack().slice(); + + const command = createCommandFromDefinition( + defineCommand({ + name: "invtest-refused", + canExecute: () => false, + run: (): void => undefined, + }), + testInjector, + ); + + assert.isFalse(await command.canExecute([])); + assert.deepEqual(openStack(), baseline); + }); + + it("closes the invocation when canExecute throws", async () => { + const testInjector = createCommandInjector(); + openInvocation(new Injector()); + const baseline = openStack().slice(); + + const command = createCommandFromDefinition( + defineCommand({ + name: "invtest-throws", + canExecute: () => { + throw new Error("invtest refusal"); + }, + run: (): void => undefined, + }), + testInjector, + ); + + let error: Error; + try { + await command.canExecute([]); + } catch (err) { + error = err; + } + assert.match(error && error.message, /invtest refusal/); + assert.deepEqual(openStack(), baseline); + }); +}); + +// Hook fixtures load the API through the published entry point, as a real +// hook does. +const apiPath = require.resolve("../lib/contracts"); + +const createHooksInjector = (projectDir: string): IInjector => { + const testInjector = new Yok(); + testInjector.register("logger", LoggerStub); + testInjector.register("errors", ErrorsStub); + testInjector.register("fs", { + exists: (p: string) => fs.existsSync(p), + getFsStats: (p: string) => fs.statSync(p), + readDirectory: (p: string) => fs.readdirSync(p), + readText: (p: string) => fs.readFileSync(p, "utf8"), + }); + testInjector.register("childProcess", {}); + testInjector.register("config", { DISABLE_HOOKS: false }); + testInjector.register("staticConfig", { + CLIENT_NAME: "tns", + version: "0.0.0", + }); + testInjector.register("projectHelper", { projectDir }); + testInjector.register("options", { hooks: true }); + testInjector.register("performanceService", { + now: () => 0, + processExecutionData: () => { + /* not measured here */ + }, + }); + testInjector.register("projectConfigService", { + getValue: (_key: string, defaultValue: any) => defaultValue, + }); + testInjector.register("projectData", { fromContainer: true }); + testInjector.register("hooksService", HooksService); + return testInjector; +}; + +describe("invocations: hooks", () => { + let projectDir: string; + let testInjector: IInjector; + let capture: any; + + const hooksService = (): IHooksService => + testInjector.resolve("hooksService"); + + const writeHook = (hookName: string, source: string): void => { + const hooksDir = path.join(projectDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, `${hookName}.js`), source); + }; + + beforeEach(() => { + resetOpenStack(); + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-invocations-")); + testInjector = createHooksInjector(projectDir); + capture = (global).__invHookCapture = {}; + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + delete (global).__invHookCapture; + }); + + it("resolves a plain hook's $-parameters against the running invocation", async () => { + writeHook( + "before-invcase1", + `module.exports = function ($invTestHookThing) { + global.__invHookCapture.thing = $invTestHookThing; + };`, + ); + testInjector.register("invTestHookThing", { from: "root" }); + const invocation = testInjector.createChild( + [{ provide: "invTestHookThing", useValue: { from: "invocation" } }], + { scope: "invocation" }, + ); + + await runInInvocation(invocation, () => + hooksService().executeBeforeHooks("invcase1"), + ); + + assert.deepEqual(capture.thing, { from: "invocation" }); + }); + + it("gives a plain hook an invocation-scoped service by name", async () => { + writeHook( + "before-invcase2", + `module.exports = function ($invTestScopedService) { + global.__invHookCapture.service = $invTestScopedService; + };`, + ); + testInjector.register({ + provide: "invTestScopedService", + providedIn: "invocation", + useFactory: () => ({ scoped: true }), + }); + const invocation = testInjector.createChild([], { scope: "invocation" }); + + await runInInvocation(invocation, () => + hooksService().executeBeforeHooks("invcase2"), + ); + + assert.strictEqual(capture.service, invocation.get("invTestScopedService")); + }); + + it("runs a defineHook definition in the running invocation's injection context", async () => { + writeHook( + "before-invcase3", + `const { defineHook, inject, Injector } = require(${JSON.stringify(apiPath)}); + module.exports = defineHook("before-invcase3", () => { + global.__invHookCapture.container = inject(Injector); + global.__invHookCapture.thing = inject("invTestHookThing"); + });`, + ); + const invocation = testInjector.createChild( + [{ provide: "invTestHookThing", useValue: { from: "invocation" } }], + { scope: "invocation" }, + ); + + await runInInvocation(invocation, () => + hooksService().executeBeforeHooks("invcase3"), + ); + + assert.strictEqual(capture.container, invocation); + assert.deepEqual(capture.thing, { from: "invocation" }); + }); + + it("falls back to the root injector outside any invocation", async () => { + writeHook( + "before-invcase4", + `module.exports = function ($invTestHookThing) { + global.__invHookCapture.thing = $invTestHookThing; + };`, + ); + testInjector.register("invTestHookThing", { from: "root" }); + + await hooksService().executeBeforeHooks("invcase4"); + + assert.deepEqual(capture.thing, { from: "root" }); + }); +}); diff --git a/test/services/option-contributions.ts b/test/services/option-contributions.ts new file mode 100644 index 0000000000..96c62c339a --- /dev/null +++ b/test/services/option-contributions.ts @@ -0,0 +1,451 @@ +import { assert } from "chai"; +import { Yok } from "../../lib/common/yok"; +import { IInjector } from "../../lib/common/definitions/yok"; +import { inject, InjectionToken } from "../../lib/common/di"; +import { CliOptions } from "../../lib/common/contracts/cli-options"; +import { OptionContributions } from "../../lib/common/contracts/option-contributions"; +import { OptionContributionsRegistry } from "../../lib/common/services/option-contributions"; +import { + booleanOption, + defineCommand, + defineOptions, + numberOption, + stringOption, +} from "../../lib/common/define-command"; +import { createCommandFromDefinition } from "../../lib/common/services/command-definition-adapter"; +import { Errors } from "../../lib/common/errors"; +import { Options } from "../../lib/options"; +import { LoggerStub } from "../stubs"; + +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); + }, + }); + testInjector.register("optionContributions", OptionContributionsRegistry); + return testInjector; +}; + +const contributionsOf = (testInjector: IInjector): OptionContributions => + testInjector.get(OptionContributions); + +describe("OptionContributionsRegistry", () => { + describe("registry", () => { + it("records a command's contributions in order, each group once", () => { + const contributions = contributionsOf(createTestInjector()); + const First = defineOptions("occtest-order-first", { + first: booleanOption(), + }); + const Second = defineOptions("occtest-order-second", { + second: booleanOption(), + }); + + contributions.contributeToCommand("occtest-run", First); + contributions.contributeToCommand("occtest-run", Second); + contributions.contributeToCommand("occtest-run", First); + + assert.sameOrderedMembers(contributions.forCommand("occtest-run"), [ + First, + Second, + ]); + assert.deepEqual(contributions.forCommand("occtest-build"), []); + assert.deepEqual(contributions.forRoot(), []); + }); + + it("hands out copies of its lists", () => { + const contributions = contributionsOf(createTestInjector()); + const Plugin = defineOptions("occtest-copy-plugin", { + flavor: stringOption(), + }); + const Root = defineOptions("occtest-copy-root", { + copyLevel: stringOption(), + }); + contributions.contributeToCommand("occtest-run", Plugin); + contributions.contributeToRoot(Root); + + contributions.forCommand("occtest-run").push(Root); + contributions.forRoot().push(Plugin); + + assert.sameOrderedMembers(contributions.forCommand("occtest-run"), [ + Plugin, + ]); + assert.sameOrderedMembers(contributions.forRoot(), [Root]); + }); + + it("rejects what is not an option group, and a missing command name", () => { + const contributions = contributionsOf(createTestInjector()); + + assert.throws( + () => + contributions.contributeToCommand("occtest-run", { + flavor: stringOption(), + }), + /An option contribution is an option group/, + ); + assert.throws( + () => + contributions.contributeToRoot( + new InjectionToken("occtest-plain-token"), + ), + /An option contribution is an option group/, + ); + assert.throws( + () => + contributions.contributeToCommand( + "", + defineOptions("occtest-unnamed-target", {}), + ), + /names the command it applies to/, + ); + }); + }); + + describe("command contributions", () => { + it("adds the group's specs to dashedOptions, even after compilation", () => { + const testInjector = createTestInjector(); + const Plugin = defineOptions("occtest-dashed-plugin", { + flavor: stringOption({ alias: "f" }), + }); + const command = createCommandFromDefinition( + defineCommand({ + name: "occtest-dashed", + options: { watch: booleanOption() }, + run: (): void => undefined, + }), + testInjector, + ); + + contributionsOf(testInjector).contributeToCommand( + "occtest-dashed", + Plugin, + ); + + assert.deepEqual(command.dashedOptions, { + watch: { type: "boolean", hasSensitiveValue: false }, + flavor: { type: "string", hasSensitiveValue: false, alias: "f" }, + }); + }); + + it("applies under any of the definition's names", () => { + const testInjector = createTestInjector(); + const Plugin = defineOptions("occtest-alias-plugin", { + flavor: stringOption(), + }); + contributionsOf(testInjector).contributeToCommand( + "occtest-alias-second", + Plugin, + ); + + const command = createCommandFromDefinition( + defineCommand({ + name: ["occtest-alias-first", "occtest-alias-second"], + run: (): void => undefined, + }), + testInjector, + ); + + assert.deepEqual(command.dashedOptions, { + flavor: { type: "string", hasSensitiveValue: false }, + }); + }); + + it("provides the group per invocation and keeps it off ctx.options", async () => { + const testInjector = createTestInjector({ watch: true, flavor: "free" }); + const Plugin = defineOptions("occtest-provide-plugin", { + flavor: stringOption(), + }); + contributionsOf(testInjector).contributeToCommand( + "occtest-provide", + Plugin, + ); + + let seenOptions: any; + let seenGroup: any; + const command = createCommandFromDefinition( + defineCommand({ + name: "occtest-provide", + options: { watch: booleanOption() }, + run: (ctx) => { + seenOptions = ctx.options; + seenGroup = inject(Plugin); + }, + }), + testInjector, + ); + await command.execute([]); + + assert.deepEqual(seenOptions, { watch: true }); + assert.deepEqual(seenGroup, { flavor: "free" }); + assert.isNull(testInjector.get(Plugin, { optional: true })); + }); + + it("reaches a command that declares no options of its own", async () => { + const testInjector = createTestInjector({ flavor: "paid" }); + const Plugin = defineOptions("occtest-bare-plugin", { + flavor: stringOption(), + }); + + let seenOptions: any; + let seenGroup: any; + const command = createCommandFromDefinition( + defineCommand({ + name: "occtest-bare", + run: (ctx) => { + seenOptions = ctx.options; + seenGroup = ctx.injector.get(Plugin); + }, + }), + testInjector, + ); + contributionsOf(testInjector).contributeToCommand("occtest-bare", Plugin); + await command.execute([]); + + assert.deepEqual(seenOptions, {}); + assert.deepEqual(seenGroup, { flavor: "paid" }); + }); + + it("reports a contribution that redeclares the command's own option differently", async () => { + const testInjector = createTestInjector(); + const Plugin = defineOptions("occtest-clash-plugin", { + flavor: stringOption({ alias: "f" }), + }); + const command = createCommandFromDefinition( + defineCommand({ + name: "occtest-clash", + options: { flavor: stringOption() }, + run: (): void => undefined, + }), + testInjector, + ); + contributionsOf(testInjector).contributeToCommand( + "occtest-clash", + Plugin, + ); + + const expected = + "Command 'occtest-clash': option '--flavor' is declared by the command's own options and by option group 'occtest-clash-plugin' with different specs"; + assert.throws(() => command.dashedOptions, expected); + await assert.isRejected(command.execute([]), expected); + }); + + it("refuses a contributed group that redeclares a process-level option", () => { + const testInjector = createTestInjector(); + const Plugin = defineOptions("occtest-rootclash-plugin", { + config: stringOption(), + }); + const command = createCommandFromDefinition( + defineCommand({ + name: "occtest-rootclash", + run: (): void => undefined, + }), + testInjector, + ); + contributionsOf(testInjector).contributeToCommand( + "occtest-rootclash", + Plugin, + ); + + assert.throws( + () => command.dashedOptions, + "Command 'occtest-rootclash': '--config' is a process-level option", + ); + }); + }); + + describe("root contributions", () => { + it("provides the group on the injector, read off the options service", () => { + const testInjector = createTestInjector({ telemetryLevel: "full" }); + const Root = defineOptions("occtest-root-provide", { + telemetryLevel: stringOption(), + }); + + contributionsOf(testInjector).contributeToRoot(Root); + + assert.deepEqual(testInjector.get(Root), { telemetryLevel: "full" }); + assert.deepEqual(testInjector.resolve("options:occtest-root-provide"), { + telemetryLevel: "full", + }); + assert.sameOrderedMembers(contributionsOf(testInjector).forRoot(), [ + Root, + ]); + }); + + it("rejects a group that collides with CliOptions or another root group", () => { + const contributions = contributionsOf(createTestInjector()); + const Verbose = defineOptions("occtest-root-verbose", { + verbose: stringOption(), + }); + const Vendor = defineOptions("occtest-root-vendor", { + vendor: stringOption({ alias: "v" }), + }); + const Level = defineOptions("occtest-root-level", { + level: stringOption(), + }); + const LevelAgain = defineOptions("occtest-root-level-again", { + level: numberOption(), + }); + + assert.throws( + () => contributions.contributeToRoot(Verbose), + "Option group 'occtest-root-verbose': '--verbose' is already a process-level option", + ); + assert.throws( + () => contributions.contributeToRoot(Vendor), + "Option group 'occtest-root-vendor': '-v' is already a process-level option", + ); + contributions.contributeToRoot(Level); + assert.throws( + () => contributions.contributeToRoot(LevelAgain), + "Option group 'occtest-root-level-again': '--level' is already a process-level option", + ); + + assert.sameOrderedMembers(contributions.forRoot(), [Level]); + }); + + it("rejects a group that repeats a CliOptions spec exactly", () => { + const testInjector = createTestInjector(); + const contributions = contributionsOf(testInjector); + const Verbose = defineOptions("occtest-root-verbose-copy", { + verbose: booleanOption(), + }); + + assert.throws( + () => contributions.contributeToRoot(Verbose), + "Option group 'occtest-root-verbose-copy': '--verbose' is already a process-level option", + ); + assert.deepEqual(contributions.forRoot(), []); + assert.isNull(testInjector.get(Verbose, { optional: true })); + }); + + it("makes the group's spellings process-level for every command", async () => { + const testInjector = createTestInjector({ telemetryLevel: "full" }); + const Root = defineOptions("occtest-root-guard", { + telemetryLevel: stringOption(), + }); + contributionsOf(testInjector).contributeToRoot(Root); + + assert.throws( + () => + createCommandFromDefinition( + defineCommand({ + name: "occtest-root-redeclare", + options: { telemetryLevel: stringOption() }, + run: (): void => undefined, + }), + testInjector, + ), + "Command 'occtest-root-redeclare': '--telemetryLevel' is a process-level option", + ); + + let seen: any; + const command = createCommandFromDefinition( + defineCommand({ + name: "occtest-root-listed", + options: [Root], + run: (ctx) => { + seen = ctx.options; + }, + }), + testInjector, + ); + await command.execute([]); + + assert.deepEqual(seen, { telemetryLevel: "full" }); + }); + }); + + describe("Options.globalOptions", () => { + let failures: string[]; + let originalArgv: string[]; + + const createOptionsInjector = (): IInjector => { + 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("optionContributions", OptionContributionsRegistry); + testInjector.register("options", Options); + return testInjector; + }; + + beforeEach(() => { + failures = []; + originalArgv = process.argv; + process.env.NS_STRICT_OPTIONS = "error"; + }); + + afterEach(() => { + process.argv = originalArgv; + delete process.env.NS_STRICT_OPTIONS; + }); + + it("parses a root group contributed before the options service is built", () => { + const testInjector = createOptionsInjector(); + const Root = defineOptions("occtest-global-before", { + occBefore: stringOption({ alias: "B" }), + }); + contributionsOf(testInjector).contributeToRoot(Root); + process.argv = [originalArgv[0], originalArgv[1], "--occ-before", "x"]; + + const options: any = testInjector.resolve("options"); + options.validateOptions(); + + assert.deepEqual(failures, []); + assert.deepEqual(options.globalOptions.occBefore, { + type: "string", + hasSensitiveValue: false, + alias: "B", + }); + assert.deepEqual(options.globalOptions.path, { + type: "string", + hasSensitiveValue: true, + alias: "p", + }); + assert.deepEqual(testInjector.get(Root), { occBefore: "x" }); + }); + + it("takes in a root group contributed after the options service is built", () => { + const testInjector = createOptionsInjector(); + const Root = defineOptions("occtest-global-after", { + occAfter: booleanOption(), + }); + process.argv = [originalArgv[0], originalArgv[1], "--occ-after"]; + + const options: any = testInjector.resolve("options"); + assert.notProperty(options.globalOptions, "occAfter"); + contributionsOf(testInjector).contributeToRoot(Root); + options.validateOptions(); + + assert.deepEqual(failures, []); + assert.property(options.globalOptions, "occAfter"); + assert.deepEqual(testInjector.get(Root), { occAfter: true }); + }); + + it("still rejects the flag when no root group declares it", () => { + const testInjector = createOptionsInjector(); + process.argv = [originalArgv[0], originalArgv[1], "--occnobody", "x"]; + + const options: any = testInjector.resolve("options"); + options.validateOptions(); + + assert.lengthOf(failures, 1); + assert.match(failures[0], /'occnobody' is not supported/); + assert.includeMembers( + Object.keys(options.globalOptions), + Object.keys(CliOptions.schema), + ); + }); + }); +}); diff --git a/test/type-fixtures/define-command-types.ts b/test/type-fixtures/define-command-types.ts index 6e6ac3282b..1e2750a738 100644 --- a/test/type-fixtures/define-command-types.ts +++ b/test/type-fixtures/define-command-types.ts @@ -11,6 +11,7 @@ import { booleanOption, Command, defineCommand, + defineOptions, numberOption, stringOption, } from "../../lib/common/define-command"; @@ -20,6 +21,7 @@ import { registerLazyCommand, } from "../../lib/common/services/command-definition-adapter"; import type { Injector } from "../../lib/common/di/injector"; +import { inject } from "../../lib/common/di/inject"; type IsExact = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 @@ -363,3 +365,49 @@ registerLazyCommand( "typefixture|class-vision2", () => require("./commands/clean").TypefixtureAliased, ); + +// An option group types its values off the same declaration at both ends: the +// command's ctx.options and whatever injects the group. +const TypefixtureRunOptions = defineOptions("typefixture-run", { + watch: booleanOption({ default: true }), + device: stringOption(), +}); + +defineCommand({ + name: "typefixture|group", + options: [ + TypefixtureRunOptions, + { extra: booleanOption({ default: false }) }, + ], + run(ctx) { + expectExactType>(); + expectExactType>(); + expectExactType>(); + + const injected = inject(TypefixtureRunOptions); + expectExactType>(); + expectExactType>(); + // @ts-expect-error - the group's values hold the group's own keys only + injected.extra; + + // @ts-expect-error - neither the group nor the inline specs declare it + ctx.options.undeclared; + }, +}); + +class TypefixtureGroupClass extends Command({ + name: "typefixture|class-group", + options: [ + TypefixtureRunOptions, + { extra: booleanOption({ default: false }) }, + ], +}) { + run(): void { + expectExactType>(); + expectExactType>(); + expectExactType>(); + + // @ts-expect-error - neither the group nor the inline specs declare it + this.options.undeclared; + } +} From 75009b0557d95137e30ff75dee554f70879eaa01 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 23 Sep 2026 10:53:53 -0300 Subject: [PATCH 2/2] docs(commands): option groups, process-level options, scopes and the invocation record Option groups and the list form of `options`, the collision rules, `CliOptions` and the spellings it protects, contributions and their timing, `providedIn` with its disposal, and `currentInvocationInjector()` with its lookup order. extensions.md states what an extension can use today and that a manifest-level contribution does not exist yet. --- defining-commands.md | 275 ++++++++++++++++++++++++++++++++++++++++--- extensions.md | 17 +++ 2 files changed, 275 insertions(+), 17 deletions(-) diff --git a/defining-commands.md b/defining-commands.md index 0d93be3d16..129461536d 100644 --- a/defining-commands.md +++ b/defining-commands.md @@ -102,8 +102,9 @@ Options ------- `options` is a schema keyed by the long option name — `output` is passed as -`--output`. Declare each entry with one of the five helpers, which fix the -value type: +`--output`. It may also be a list of option groups and such schemas; see +[Option groups](#option-groups). Declare each entry with one of the five +helpers, which fix the value type: | Helper | Declared with `default` | Declared without | | --------------- | ----------------------- | ----------------------- | @@ -153,9 +154,12 @@ options: { The schema types `ctx.options` and nothing else: `ctx.options` carries exactly the declared keys, and a typo is a compile error. There is deliberately no "give me everything" escape hatch — a command declares every option it reads, -CLI-wide ones (`--release`, `--path`, `--bundle`, …) included. Declaring one +CLI-wide ones (`--release`, `--watch`, `--bundle`, …) included. Declaring one that the CLI already knows is supported and carries its value through to -`ctx.options` exactly as a command-specific one does. +`ctx.options` exactly as a command-specific one does. The process-level +options (`--path`, `--log`, `--verbose` and the rest of `CliOptions`) are the +exception: a command lists their group instead of redeclaring them. See +[Process-level options](#process-level-options-clioptions). ### Sharing a schema between commands @@ -170,9 +174,84 @@ const buildOptions = { } satisfies CommandOptionsSchema; ``` +### Option groups + +`defineOptions(name, schema)` declares an option group: a named schema that is +also an injection token. A command lists groups under `options`, next to +inline schemas: + +```ts +import { + booleanOption, + defineCommand, + defineOptions, + stringOption, +} from "nativescript/contracts"; + +export const WidgetOptions = defineOptions("widget", { + theme: stringOption(), + compact: booleanOption({ default: false }), +}); + +export default defineCommand({ + name: "widget|add", + options: [WidgetOptions, { output: stringOption({ alias: "o" }) }], + run(ctx) { + // ctx.options -> { theme: string | undefined; compact: boolean; + // output: string | undefined } + }, +}); +``` + +`ctx.options` is typed as the merged values of every part. The class form +takes the same list, and types `this.options` the same way. + +Each invocation provides the values of every group it parsed in its own +injector, under the group. So a per-command provider, or a service scoped to +the invocation, injects the group and gets the same typed values: + +```ts +const widget = inject(WidgetOptions); // { theme: string | undefined; compact: boolean } +``` + +Nothing outside an invocation can resolve a group; the root injector does not +provide it. A service that needs one is scoped to the invocation; see +[Services scoped to the invocation](#services-scoped-to-the-invocation-providedin). + +`defineOptions` validates the schema where it is written, as `defineCommand` +does, and reports a problem as `Invalid option group '': .` +followed by the accepted form. + +The group's registry name is `options:`. Group names share one +namespace with contract and token names, so minting a second group with a +name already taken throws an error that starts with `Token name +'options:widget' is already used by an injection token.` Pick a name that is +unique to your package. + +### Collisions between parts + +A spelling is an option's long name or one of its aliases. One spelling may +appear in several parts of `options` only when every part declares it with an +identical spec: the same type, `default`, `alias` and `hasSensitiveValue`. +`description` is not compared. An alias may not equal another option's name +or alias. + +`defineCommand` and `Command()` check this when they are called, and +`defineOptions` checks it within its own schema. The message names both +declarations: + +``` +Invalid command definition for 'widget|add': option '--compact' is declared +by option group 'widget' and by the command's own options with different specs. +``` + +followed by the accepted form. A group contributed from outside the command +collides under the same rules; see +[Option groups contributed from outside the command](#option-groups-contributed-from-outside-the-command). + ### Redeclaring a CLI-wide option, and shadowing one -`--verbose`, `--path`, `--log`, `--release`, `--env` and friends are declared by +`--release`, `--env`, `--watch`, `--device` and friends are declared by the CLI itself. A command's declaration is merged over the CLI-wide dictionary for the duration of that command, and that merge is the sanctioned way to give a global option a per-command default — `watch`, `hmr` and `skipNative` all @@ -190,14 +269,96 @@ still warns about at registration is a redeclaration that changes what the spelling _means_: - a declared option whose name matches a CLI-wide one but whose type differs — - `verbose: stringOption()` against the CLI's boolean `--verbose`; + `release: stringOption()` against the CLI's boolean `--release`; - an alias that belongs to a _different_ CLI-wide option — `output: -stringOption({ alias: "p" })` steals `--path`'s shorthand. Restating an - option's own shorthand (`path: stringOption({ alias: "p" })`) is fine. +stringOption({ alias: "f" })` steals `--force`'s shorthand. Restating an + option's own shorthand (`force: booleanOption({ alias: "f" })`) is fine. A redeclaration that leaves `alias`, `default` or `hasSensitiveValue` unset -keeps what the CLI-wide declaration carries for them, so `path: stringOption()` -still answers to `-p` and stays out of the logs; set one only to change it. +keeps what the CLI-wide declaration carries for them, so `release: +booleanOption()` still answers to `-r`, and `device: stringOption()` stays out +of the logs; set one only to change it. + +None of this applies to the process-level options. Redeclaring one of those is +an error, not a warning. + +### Process-level options: `CliOptions` + +`CliOptions` is the group of options the CLI parses once at startup, before a +command is chosen: `--log`, `--verbose`, `--version` (`-v`), `--help` (`-h`), +`--profileDir`, `--analyticsClient`, `--path` (`-p`) and `--config` (`-c`). +The services that run for every command read them: the logger, analytics, +project resolution. The group is provided at the root, so any service injects +it, inside an invocation or not: + +```ts +import { CliOptions, inject } from "nativescript/contracts"; + +const { path, verbose } = inject(CliOptions); +``` + +A command may not redeclare any of these spellings, as a name or as an alias. +The CLI refuses the command when it compiles it, on the first resolution of a +registered command or when `runCommand` is given the definition: + +``` +Command 'widget|add': '--path' is a process-level option; list CliOptions under 'options', or inject it, instead of redeclaring it +``` + +To have the values on `ctx.options`, list the group itself. That is not a +redeclaration: it reads the same declaration. `install` does this: + +```ts +const installCommandOptions = [ + CliOptions, + { + frameworkPath: stringOption(), + disableNpmInstall: booleanOption(), + ignoreScripts: booleanOption(), + } satisfies CommandOptionsSchema, +] satisfies CommandOptionsInput; +``` + +`ctx.options.path` is then `string | undefined`. A listed `CliOptions` is not +provided again per invocation; injecting it still resolves the root's values. + +### Option groups contributed from outside the command + +The `OptionContributions` contract adds a group to a command the caller does +not own, or to the process-level options: + +```ts +import { inject, OptionContributions } from "nativescript/contracts"; + +const contributions = inject(OptionContributions); +contributions.contributeToCommand("run|ios", WidgetOptions); +contributions.contributeToRoot(TelemetryOptions); +``` + +`forCommand(name)` and `forRoot()` return what has been contributed so far. + +A group contributed to a command is parsed with that command's options and +provided per invocation, like the command's own groups. It is not on +`ctx.options`, since the command's type does not know it; a handler or service +reads it by injecting the group. It collides under the rules above, with the +command's own parts and with other contributions, and it may not redeclare a +process-level spelling. A collision is reported when the command's options are +parsed, as `Command '': `. The name is one the definition +declares or one it was registered under, such as an extension's manifest key, +and only commands defined with `defineCommand` or `Command()` read +contributions. + +A group contributed to the root joins the process-level table. It is parsed +with `CliOptions` on the next parse after it is registered, its values are +provided at the root, and its spellings are process-level: no command may +redeclare them. `contributeToRoot` throws `Option group '': ` +when the group collides with `CliOptions` or with an earlier root group. + +A contribution is read when the parse it targets happens, so it must be +registered before that parse. One registered later does not change a parse +that already happened. There is no manifest-level way to declare a +contribution yet, so only code that has already run can contribute; see +[extensions.md](extensions.md#options-and-option-groups). ### How validation behaves @@ -358,8 +519,9 @@ The run context declare, `{}` when there are none. It is spelled `params` because `params` is a reserved binding name in strict mode, so a destructuring `const { args, arguments } = ctx` would not even parse. -- `ctx.options` — the current value of each declared option, read at the moment - the command executes. +- `ctx.options` — the value of each option the command declares, its groups and + inline schemas merged, read when the invocation opens. A group contributed + from outside the command is not on it; inject the group instead. - `ctx.injector` — this invocation's injector, a child of the one the command was registered against; see [Injection, and the first `await`](#injection-and-the-first-await). @@ -444,8 +606,8 @@ async run(ctx) { `ctx.inject(...)`: it is a visibly different mechanism because it obeys different rules, and mistaking one for the other is exactly the bug this shape prevents. It is the **invocation's own injector**: a child of the one the -command was registered against, holding the context under `COMMAND_CONTEXT` -and any per-command providers — see +command was registered against, holding the context under `COMMAND_CONTEXT`, +the values of the option groups it parsed, and any per-command providers — see [Registering a definition](#registering-a-definition). `inject()` before the first `await` and `ctx.injector.get()` after it are therefore the same lookup against the same injector. The same guidance, and the reasoning behind it, is @@ -570,6 +732,57 @@ Sharing is either of two things, and neither of them is a bag: which asks that command itself; see [Asking another command](#asking-another-command). +### Services scoped to the invocation: `providedIn` + +A service that reads the invocation, through its option groups or +`COMMAND_CONTEXT`, cannot live at the root: the root provides neither. Scope +it to the invocation instead, in one of three places: + +```ts +import { + COMMAND_CONTEXT, + Contract, + inject, + ProvidedIn, +} from "nativescript/contracts"; + +// on the implementation class +@ProvidedIn("invocation") +export class WidgetRenderer { + private widget = inject(WidgetOptions); + private context = inject(COMMAND_CONTEXT); +} + +// on a contract token, for every implementation of it +@Contract({ name: "widgetRenderer", providedIn: "invocation" }) +export abstract class WidgetRendererContract {} + +// on one provider +{ provide: WidgetRenderer, useClass: WidgetRenderer, providedIn: "invocation" } +``` + +The provider's `providedIn` wins over the class's marker, and the class's over +the token's. The registration may stay where it is, the root included; the +instance is built and cached on the nearest invocation injector above the +lookup, and its own dependencies resolve there. That is why it can inject +option groups and `COMMAND_CONTEXT`. Each invocation gets its own instance, +an in-process dispatch included. + +Resolving such a service from outside an invocation is an error, even with +`optional: true`. That covers the root, and a root singleton that injects it +in its constructor or a field, because a root singleton resolves against the +root: + +``` + is provided in the 'invocation' scope; it cannot be resolved from outside one +``` + +A scoped instance is recorded on the invocation injector that holds it and is +disposed with that injector when the invocation ends: after `postRun` when +the command has one, else after `run`, or when `canExecute` refuses. A scoped +service with a `dispose()` method gets per-invocation cleanup for free; the +root singletons the invocation reached are the root's and stay. + ### `setup`, when a command has one `setup(ctx)` runs once per invocation, after the preconditions and before the @@ -1003,9 +1216,11 @@ name still identifies it for hooks and reporting. A definition run as given is compiled against an injector chosen at the call, the way Angular's `createComponent` takes one: the `injector` in the options -bag when one is passed, otherwise the injection context the call is made from, -otherwise the CLI's root. So a definition dispatched from inside an -extension's command lands under the extension's scope, where `registerCommand` +bag when one is passed, otherwise the invocation running now, which starts with +the injection context the call is made from (see [The invocation running +now](#the-invocation-running-now)), otherwise the CLI's root. So a definition +dispatched from inside an extension's command lands under the extension's +scope, where `registerCommand` would have placed it, and a caller holding a scope of its own names it: ```ts @@ -1052,6 +1267,32 @@ argument list to a child that declares fewer is a rejection, not a wider check. `canExecuteCommand` follows `runCommand` in everything else: the same option priming and restoration, the same routing of a parent name to its subcommand. +### The invocation running now + +Code that resolves by name outside an injection context, such as a hook or a +plugin's callback, reaches the running invocation through +`currentInvocationInjector()`, exported from `"nativescript/contracts"`. It +tries, in order: + +1. the synchronous injection context, when there is one; +2. the invocation whose asynchronous flow the caller is in; +3. the most recently opened invocation still open, for a callback that lost + its asynchronous context, such as an emitter another invocation registered + or a library timer; +4. `null`. + +The CLI uses it in two places. A hook runs against it, so its by-name +dependencies can come from the invocation's providers and scoped services; it +falls back to the root when there is no invocation. `runCommand(definition)` +with no `injector` option compiles the definition against it, with the same +fallback. + +The first invocation of the process, the command line's own, stays open for +the life of the process, so a long-lived command's callbacks keep resolving +through it after its `run` has returned. Every later invocation closes when it +finishes, and an in-process dispatch closes any invocation it opened, +including one opened only to answer `canExecuteCommand`. + ### Key shortcuts The interactive keys `ns start` and `ns run` offer are the CLI's own caller. A diff --git a/extensions.md b/extensions.md index 41ff6b3d9e..139599b0f4 100644 --- a/extensions.md +++ b/extensions.md @@ -205,6 +205,23 @@ the command itself, executing that command fails with an error naming the extension, the command and the module — the entry points at the wrong file, or the file is not doing what the entry promises. +Options and option groups +------------------------- + +An extension's own commands declare options exactly as built-in commands do, +option groups included. `defineOptions`, `CliOptions` and the option helpers +are exported from `"nativescript/contracts"`, and a group a command lists under +`options` is parsed with it and can be injected by services in its invocation +(see [defining-commands.md](defining-commands.md#option-groups)). + +Adding options to a command the extension does not own, or to the +process-level options, is not supported for a manifest-declared extension yet. +The `OptionContributions` contract exists as a programmatic seam, and code +whose module is loaded before the target parse can call it. A command module +named in the manifest is loaded only when its own command is resolved, so it +cannot add options to a built-in command the user invoked. A manifest-level +way to declare a contribution does not exist yet. + Command names -------------