From ae2f54efb2827bc7234c09787052a5d7a14abd9b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 16:44:50 -0300 Subject: [PATCH 01/19] feat(commands): extend defineCommand for the CLI's own commands Options and arguments are declared on the definition and validated before run. Setup runs ahead of argument enforcement so a definition can derive its arguments; a redeclared CLI option keeps whatever it leaves unspecified; unknown options are tolerated instead of skipping validation; objectOption covers --env.* style values; a missing required argument keeps the command's preamble in the error. --- defining-commands.md | 284 +++++- lib/commands/preview.ts | 2 +- lib/common/define-command.ts | 262 +++++- lib/common/definitions/commands.d.ts | 5 +- .../services/command-definition-adapter.ts | 262 +++++- lib/common/services/commands-service.ts | 10 +- lib/contracts/index.ts | 4 + lib/declarations.d.ts | 2 +- lib/options.ts | 15 +- test/commands-service.ts | 18 +- test/define-command.ts | 852 +++++++++++++++++- test/type-fixtures/define-command-types.ts | 104 +++ 12 files changed, 1696 insertions(+), 124 deletions(-) diff --git a/defining-commands.md b/defining-commands.md index ef7fc024c1..dcbf1ba012 100644 --- a/defining-commands.md +++ b/defining-commands.md @@ -59,10 +59,11 @@ accepted form: ``` Invalid command definition for 'widget|add': unknown field(s) 'handler'; a -definition accepts name, description, options, arguments, canExecute, -disableAnalytics, enableHooks, run. Accepted form: defineCommand({ name: -"widget|add", run(ctx) { ... } }) — with the optional fields description, -options, arguments, canExecute, disableAnalytics and enableHooks. +definition accepts name, description, options, arguments, allowUnknownOptions, +canExecute, disableAnalytics, enableHooks, setup, run, postRun. Accepted form: +defineCommand({ name: "widget|add", run(ctx) { ... } }) — with the optional +fields description, options, arguments, allowUnknownOptions, setup, canExecute, +postRun, disableAnalytics and enableHooks. ``` Names and the command hierarchy @@ -132,9 +133,11 @@ options: { nothing renders it yet. The schema types `ctx.options` and nothing else: `ctx.options` carries exactly -the declared keys, and a typo is a compile error. Values that the CLI parses -globally (`--path`, `--log`, …) are not exposed there; resolve the `options` -service if you need them. +the declared keys, and a typo is a compile error. There is deliberately no +"give me everything" escape hatch — a command declares every option it reads, +CLI-wide ones (`--release`, `--path`, `--bundle`, …) included. Declaring one +that the CLI already knows is supported and carries its value through to +`ctx.options` exactly as a command-specific one does. ### Sharing a schema between commands @@ -149,17 +152,34 @@ const buildOptions = { } satisfies CommandOptionsSchema; ``` -### Do not shadow a CLI-wide option +### Redeclaring a CLI-wide option, and shadowing one `--verbose`, `--path`, `--log`, `--release`, `--env` and friends are declared by -the CLI itself. Declaring one of those names in a command's schema makes the -command's declaration win for the duration of that command, which means the -same flag means different things depending on which command is running. The CLI -warns at registration naming both sides of the collision; pick another name. +the CLI itself. A command's declaration is merged over the CLI-wide dictionary +for the duration of that command, and that merge is the sanctioned way to give +a global option a per-command default — `watch`, `hmr` and `skipNative` all +carry different defaults on `build`, `prepare`, `deploy` and `test`: -Aliases count too, in both directions: an `alias: "p"` collides with `--path`'s -shorthand just as `output: stringOption()` would collide with a CLI-wide -`--output`. +```ts +options: { + // CLI-wide --watch, but this command defaults it off + watch: booleanOption({ default: false }), +} +``` + +So a redeclaration of the same name with the same type is silent. What the CLI +still warns about at registration is a redeclaration that changes what the +spelling *means*: + +- a declared option whose name matches a CLI-wide one but whose type differs — + `verbose: stringOption()` against the CLI's boolean `--verbose`; +- an alias that belongs to a *different* CLI-wide option — `output: +stringOption({ alias: "p" })` steals `--path`'s shorthand. Restating an + option's own shorthand (`path: stringOption({ alias: "p" })`) is fine. + +The merge replaces the CLI-wide entry rather than patching it, so a +redeclaration inherits nothing: restate the `alias` and `hasSensitiveValue` the +global declaration carries if the command still wants them. ### How validation behaves @@ -181,17 +201,101 @@ So adding an option is a matter of adding a schema entry; forgetting to declare one that users pass is a warning today and a failure later, never a silent `undefined`. +### `allowUnknownOptions` + +A command that forwards its command line to a separately installed CLI cannot +know which flags are legitimate, so validating them here would reject the other +CLI's own options. `allowUnknownOptions: true` turns the check off for that +command: + +```ts +defineCommand({ + name: "preview", + allowUnknownOptions: true, + options: { disableNpmInstall: booleanOption({ default: false }) }, + async run(ctx) { + /* spawn the other CLI with process.argv */ + }, +}); +``` + +It maps onto `skipOptionsValidation` on the compiled command, which means the +CLI never re-primes its parser for this command at all. A command-specific +option therefore never reaches `ctx.options` under this flag — only options the +CLI already knows globally carry values. Reach for it only when forwarding. + Positional arguments -------------------- -`arguments` declares whether the command takes positional arguments at all: +`arguments` declares what the command takes after its name: - `"none"` (the default) — the command accepts no positional arguments. Passing any is rejected with `This command doesn't accept parameters.` -- `"any"` — positional arguments are accepted and handed to `run` as - `ctx.args`. +- `"any"` — any number of positional arguments is accepted and handed to `run` + as `ctx.args`. +- an array of specs — each argument is declared, named, and validated. -Anything finer than that belongs in `canExecute`. +### Declared arguments + +```ts +defineCommand({ + name: "widget|add", + arguments: [ + { + name: "platform", + required: true, + errorMessage: "Specify the platform to add the widget for.", + validate: (value) => + ["android", "ios"].includes(value) || + `'${value}' is not a supported platform.`, + }, + { name: "template" }, + { name: "files", variadic: true }, + ], + async run(ctx) { + ctx.arguments.platform; // "android" + ctx.arguments.template; // "blank", or absent + ctx.arguments.files; // string[], possibly empty + }, +}); +``` + +A spec accepts: + +- `name` — the key the value appears under on `ctx.arguments`, and the name + messages use. +- `required` — defaults to false. A required argument may not follow an + optional one; positional matching would never be able to satisfy it. +- `variadic` — collects every remaining argument as a `string[]`. Must be the + last spec. A required variadic wants at least one value. +- `description` — reserved for generated help, like an option's. +- `errorMessage` — replaces `Missing required argument ''.` when the + argument is required and absent. +- `validate(value, ctx)` — run per value, `ctx` being the same context `run` + receives. Return `true` to accept; return `false` for a default message, or + return the message itself as a string. It may be `async`. + +Enforcement happens before `canExecute`, in this order: missing required +arguments (every missing one is named at once), then too many arguments, then +each `validate`. + +### Matching is strictly positional + +The first spec takes the first argument, the second spec the second, and so on. +This is a deliberate divergence from the `ICommandParameter` machinery a +hand-written command class uses, where `CommandsService` scans the validators +and lets a mandatory parameter claim whichever argument happens to satisfy it — +so `ns command b a` could satisfy `[a, b]`. Nothing in the CLI depends on that +behaviour, and positional is what the declaration reads like. + +The practical consequence: `ctx.arguments.template` is `args[1]` whether or not +`args[1]` looks like a template. An argument that could be several things is a +job for `validate` or for `canExecute`, not for the matcher. + +`ctx.arguments` is always present, even with `arguments: "none"` or `"any"` — +it is simply `{}` when no specs are declared. An optional non-variadic argument +the command line did not reach is absent from it; a variadic one is always +there, as an array. ### `canExecute` refines, it does not replace @@ -230,8 +334,12 @@ The run context - `ctx.args` — `string[]`, the positional arguments left after the command name (including any subcommand segments) has been consumed. +- `ctx.arguments` — the same arguments keyed by the names the `arguments` specs + declare, `{}` when there are none. - `ctx.options` — the current value of each declared option, read at the moment the command executes. +- `ctx.injector` — the injector this command was registered against; see + [Injection, and the first `await`](#injection-and-the-first-await). - `ctx.fail(message)` — fails the command with `message` and a usage help suggestion. @@ -265,8 +373,11 @@ Throwing is equivalent and keeps working — `ctx.fail` is sugar over the --help`" line. Throw when you already have an `Error` to propagate; call `ctx.fail` when you are writing the message. -`run` starts inside a dependency-injection context, so `inject()` works -directly: +Injection, and the first `await` +-------------------------------- + +`setup`, `canExecute`, `run` and `postRun` each start inside a +dependency-injection context, so `inject()` works directly: ```ts import { defineCommand, inject } from "nativescript/contracts"; @@ -281,10 +392,85 @@ export default defineCommand({ }); ``` -The injection context is synchronous: `inject()` is valid up to the first -`await` in `run`, and not after it. Capture what you need at the top of `run`, -or inject the `Injector` itself and use `injector.get()` for late lookups. See -`dependency-injection.md`. +The injection context is synchronous, so **`inject()` is valid up to the first +`await` in a handler, and not after it**. After that first `await`, use +`ctx.injector.get(token)`: + +```ts +async run(ctx) { + const packageManager = inject(PackageManager); // fine, no await yet + await packageManager.install(name); + // inject() would throw here + const platform = ctx.injector.get(PlatformService); +} +``` + +`ctx.injector` is deliberately the injector itself rather than a bound +`ctx.inject(...)`: it is a visibly different mechanism because it obeys +different rules, and mistaking one for the other is exactly the bug this shape +prevents. It is the injector the command was **registered against**, so it also +resolves providers a child scope supplied — see +[Registering a definition](#registering-a-definition). The same guidance, and +the reasoning behind it, is in `dependency-injection.md`. + +`setup` — hoisting work out of `run` +------------------------------------ + +`setup(ctx)` runs once per invocation, before `canExecute`, and its return +value is handed to `canExecute`, `run` and `postRun` as their second argument: + +```ts +export default defineCommand({ + name: "widget|add", + arguments: "any", + setup() { + const projectData = inject(ProjectData); + projectData.initializeProjectData(); + return { projectData, widgets: inject(WidgetService) }; + }, + canExecute(ctx, { projectData }) { + return !!projectData.projectDir; + }, + async run(ctx, { widgets }) { + await widgets.add(ctx.args); + }, +}); +``` + +It exists for two reasons. It is the place to inject services before the first +`await` when several handlers need them, and it is where the work a command +class used to do in its constructor goes — most often +`$projectData.initializeProjectData()`. + +`setup` is sugar. A command may ignore it entirely and call `inject()` at the +top of `run`; nothing else changes. "Once per invocation" means once across +`canExecute`, `run` and `postRun` together — whichever of them the CLI reaches +first triggers it, and the rest reuse the value. + +`run`'s return value, and `postRun` +----------------------------------- + +`run` may return a value. When the definition declares `postRun`, that value is +passed to it after `run` succeeds: + +```ts +export default defineCommand({ + name: "create", + arguments: [{ name: "appName", required: true }], + async run(ctx) { + const projectDir = await createProject(ctx.arguments.appName as string); + return { projectDir }; + }, + postRun(ctx, { projectDir }) { + printSuccessMessage(projectDir); + }, +}); +``` + +`postRun` maps onto the legacy `postCommandAction`: the CLI runs it after the +command itself, outside the command's own error handling. The value travels +through `run`'s return rather than through a mutable field on the definition, +because a definition object is shared by every registration of it. Other flags ----------- @@ -327,6 +513,30 @@ Extensions do not need `registerCommandDefinition` at all: a exports a definition, and the CLI adapts and registers it lazily under the manifest key (see [extensions.md](extensions.md)). +### One definition, several registrations + +A family of commands that differ only in a value — `run|android` and `run|ios`, +say — is one definition registered several times, each against a child injector +that provides the value: + +```ts +const PLATFORM = new InjectionToken("commandPlatform"); + +for (const platform of ["android", "ios"]) { + registerCommandDefinition( + { ...definition, name: `run|${platform}` }, + injector.createChild([{ provide: PLATFORM, useValue: platform }]), + ); +} +``` + +The definition then reads `inject(PLATFORM)` — or `ctx.injector.get(PLATFORM)` +after the first `await` — and needs to know nothing else. The spread keeps the +`defineCommand` marker, so the copy is still a `DefinedCommand`. + +This replaces the class-inheritance pattern the legacy commands use, where a +per-platform command subclasses a shared base to override one field. + Relationship to `ICommand` -------------------------- @@ -334,19 +544,25 @@ A definition is compiled into an ordinary `ICommand`, so nothing downstream — the registry, the router, hooks, help, analytics — knows the difference. The mapping is: -| Definition | `ICommand` | -| --------------------------------- | -------------------------------------------------- | -| `options` | `dashedOptions` | -| `run` | `execute`, wrapped in an injection context | -| `arguments`, `canExecute` | `canExecute`: policy enforced, then the refinement | -| — | `allowedParameters`, always `[]` | -| `disableAnalytics`, `enableHooks` | passed through unchanged | +| Definition | `ICommand` | +| --------------------------------- | --------------------------------------------------- | +| `options` | `dashedOptions` | +| `run` | `execute`, wrapped in an injection context | +| `arguments`, `canExecute` | `canExecute`: policy enforced, then the refinement | +| `setup` | — run inside `canExecute`/`execute`, memoised | +| `postRun` | `postCommandAction`, with `run`'s return value | +| `allowUnknownOptions` | `skipOptionsValidation` | +| — | `allowedParameters`, always `[]` | +| `disableAnalytics`, `enableHooks` | passed through unchanged | The compiled command always exposes `canExecute`, because `CommandsService` stops consulting `allowedParameters` as soon as a command has one — the adapter -therefore enforces the `arguments` policy itself. +therefore enforces the `arguments` policy itself. `allowedParameters` stays +empty, which is why declared `arguments` are matched positionally rather than +by the `ICommandParameter` scan. Existing command classes need no migration. Reach for a definition when a command is mostly "parse these flags and do this"; a class still makes sense when a command needs constructor-injected collaborators shared across several -methods, custom `ICommandParameter` validators, or a `postCommandAction`. +methods, or `ICommandParameter` validators whose claim-any-argument matching it +actually depends on. diff --git a/lib/commands/preview.ts b/lib/commands/preview.ts index 64bbb9e8eb..e6dc122ce8 100644 --- a/lib/commands/preview.ts +++ b/lib/commands/preview.ts @@ -12,7 +12,7 @@ const PREVIEW_CLI_PACKAGE = "@nativescript/preview-cli"; export class PreviewCommand implements ICommand { allowedParameters: ICommandParameter[] = []; - skipOptionsValidation = true; + allowUnknownOptions = true; constructor( private $logger: ILogger, diff --git a/lib/common/define-command.ts b/lib/common/define-command.ts index c5cf28e5fe..4deb6d0f92 100644 --- a/lib/common/define-command.ts +++ b/lib/common/define-command.ts @@ -6,6 +6,8 @@ * lib/common/services/command-definition-adapter. */ +import type { Injector } from "./di/injector"; + /** * Symbol.for so that a definition produced by one copy of the CLI is still * recognised by another — extensions bundle their own node_modules. `unique @@ -15,7 +17,8 @@ export const COMMAND_DEFINITION_MARKER: unique symbol = Symbol.for( "nativescript:cli:commandDefinition", ); -export type CommandOptionType = "boolean" | "string" | "number" | "array"; +export type CommandOptionType = + "boolean" | "string" | "number" | "array" | "object"; export interface CommandOptionSpec { type: CommandOptionType; @@ -65,29 +68,103 @@ export type CommandOptionValues = { [K in keyof TSchema]: CommandOptionValue; }; +/** + * Positional arguments keyed by the declaring spec's `name`. A variadic spec + * always yields an array; a non-variadic optional one is absent when the + * command line did not reach it. + */ +export interface CommandArgumentValues { + [argumentName: string]: string | string[]; +} + +/** + * One positional argument. Specs are matched strictly by position: the first + * spec takes the first argument, and so on. + */ +export interface ArgumentSpec { + /** Key under which the value appears on `ctx.arguments`. */ + name: string; + /** Defaults to false. A required spec may not follow an optional one. */ + required?: boolean; + /** Collects every remaining argument as `string[]`. Must be the last spec. */ + variadic?: boolean; + /** Reserved for generated help; nothing renders it yet. */ + description?: string; + /** Replaces the default message when a required argument is missing. */ + errorMessage?: string; + /** `false` or a message string rejects the value; a string is the message. */ + validate?( + value: string, + context: CommandContext, + ): boolean | string | Promise; +} + +/** + * `"none"` rejects positional arguments; `"any"` accepts any number of them; + * an array declares them one by one. + */ +export type ArgumentsPolicy = + "none" | "any" | ArgumentSpec[]; + export interface CommandContext { /** Positional arguments, after the command name has been consumed. */ args: string[]; + /** The same arguments keyed by the names the `arguments` specs declare. */ + arguments: CommandArgumentValues; /** Current value of every option declared in the schema, and nothing else. */ options: CommandOptionValues; + /** + * The injector the command was registered against. `inject()` stops working + * after the first `await`; this is the supported late lookup. + */ + injector: Injector; /** Fails the command with `message` and the usage help suggestion. */ fail(message: string): never; } -export interface CommandDefinition { +export interface CommandDefinition< + TSchema extends CommandOptionsSchema = {}, + TResult = void, + TSetup = void, +> { /** `"widget|add"`; `|` separates hierarchy levels. Several names alias one command. */ name: string | string[]; description?: string; options?: TSchema; /** - * `"none"` (the default) rejects positional arguments; `"any"` accepts them. - * Anything finer belongs in `canExecute`, which runs after this policy. + * `"none"` (the default) rejects positional arguments; `"any"` accepts any + * number; an array declares them positionally. Anything finer belongs in + * `canExecute`, which runs after this policy. */ - arguments?: "none" | "any"; - canExecute?(context: CommandContext): Promise | boolean; + arguments?: ArgumentsPolicy; + /** + * Hands options this CLI does not know through to the command instead of + * reporting them. Only for commands that forward their command line to + * another CLI. + */ + allowUnknownOptions?: boolean; disableAnalytics?: boolean; enableHooks?: boolean; - run(context: CommandContext): Promise | void; + /** + * Runs once per invocation, before `canExecute`, and its result is handed to + * `canExecute`, `run` and `postRun`. Sugar: a command may ignore it and call + * `inject()` at the top of `run` instead. + */ + setup?(context: CommandContext): TSetup | Promise; + canExecute?( + context: CommandContext, + setupResult: Awaited, + ): Promise | boolean; + run( + context: CommandContext, + setupResult: Awaited, + ): TResult | Promise; + /** Runs after `run` succeeds, with whatever `run` returned. */ + postRun?( + context: CommandContext, + result: Awaited, + setupResult: Awaited, + ): Promise | void; } /** @@ -95,10 +172,13 @@ export interface CommandDefinition { * so `registerCommandDefinition` can require a definition that went through * define-time validation rather than any object of the right shape. */ -export type DefinedCommand = - CommandDefinition & { - readonly [COMMAND_DEFINITION_MARKER]: true; - }; +export type DefinedCommand< + TSchema extends CommandOptionsSchema = {}, + TResult = void, + TSetup = void, +> = CommandDefinition & { + readonly [COMMAND_DEFINITION_MARKER]: true; +}; interface IOptionHelper { ( @@ -117,16 +197,30 @@ export const booleanOption = optionHelper("boolean"); export const stringOption = optionHelper("string"); export const numberOption = optionHelper("number"); export const arrayOption = optionHelper("array"); +/** For flags the parser nests, such as --env.production or --teamId. */ +export const objectOption = optionHelper("object"); const DEFINITION_FIELDS = [ "name", "description", "options", "arguments", + "allowUnknownOptions", "canExecute", "disableAnalytics", "enableHooks", + "setup", "run", + "postRun", +]; + +const ARGUMENT_SPEC_FIELDS = [ + "name", + "required", + "variadic", + "description", + "errorMessage", + "validate", ]; const OPTION_SPEC_FIELDS = [ @@ -142,12 +236,13 @@ const OPTION_TYPES: CommandOptionType[] = [ "string", "number", "array", + "object", ]; const ACCEPTED_FORM = 'defineCommand({ name: "widget|add", run(ctx) { ... } }) — with the ' + - "optional fields description, options, arguments, canExecute, " + - "disableAnalytics and enableHooks."; + "optional fields description, options, arguments, allowUnknownOptions, " + + "setup, canExecute, postRun, disableAnalytics and enableHooks."; const describeDefinition = (definition: any): string => { const name = definition && definition.name; @@ -255,6 +350,94 @@ const validateOptionSpec = ( } }; +const validateArgumentSpecs = (definition: any, specs: any[]): void => { + const seen: string[] = []; + let optionalSeen: string | null = null; + + for (let index = 0; index < specs.length; index++) { + const spec = specs[index]; + const position = `argument #${index + 1}`; + + if (!isPlainObject(spec)) { + invalid( + definition, + `${position} of 'arguments' must be an object declaring at least a 'name'`, + ); + } + + if (typeof spec.name !== "string" || !spec.name.trim()) { + invalid(definition, `${position} of 'arguments' has no usable 'name'`); + } + + const unknownFields = Object.keys(spec).filter( + (field) => ARGUMENT_SPEC_FIELDS.indexOf(field) === -1, + ); + if (unknownFields.length) { + invalid( + definition, + `argument '${spec.name}' has unknown field(s) ${unknownFields + .map((field) => `'${field}'`) + .join( + ", ", + )}; an argument spec accepts ${ARGUMENT_SPEC_FIELDS.join(", ")}`, + ); + } + + if (seen.indexOf(spec.name) !== -1) { + invalid( + definition, + `'arguments' declares '${spec.name}' twice; argument names key ctx.arguments and must be unique`, + ); + } + seen.push(spec.name); + + for (const flag of ["required", "variadic"]) { + if (spec[flag] !== undefined && typeof spec[flag] !== "boolean") { + invalid( + definition, + `argument '${spec.name}' declares a non-boolean '${flag}'`, + ); + } + } + + for (const text of ["description", "errorMessage"]) { + if (spec[text] !== undefined && typeof spec[text] !== "string") { + invalid( + definition, + `argument '${spec.name}' declares a non-string '${text}'`, + ); + } + } + + if (spec.validate !== undefined && typeof spec.validate !== "function") { + invalid( + definition, + `argument '${spec.name}' has a non-function 'validate'`, + ); + } + + if (spec.variadic === true && index !== specs.length - 1) { + invalid( + definition, + `argument '${spec.name}' is variadic but is not the last one; a variadic argument collects everything after it`, + ); + } + + // Positional matching gives an optional argument the slot regardless of + // what follows, so a later required one could never be satisfied. + if (spec.required === true && optionalSeen) { + invalid( + definition, + `argument '${spec.name}' is required but follows the optional '${optionalSeen}'; required arguments come first`, + ); + } + + if (spec.required !== true) { + optionalSeen = spec.name; + } + } +}; + const validateDefinition = (definition: any): void => { if (!isPlainObject(definition)) { invalid(definition, "expected an object"); @@ -278,25 +461,34 @@ const validateDefinition = (definition: any): void => { invalid(definition, "'run' must be a function"); } - if ( - definition.arguments !== undefined && - definition.arguments !== "none" && - definition.arguments !== "any" - ) { - invalid( - definition, - `'arguments' is '${definition.arguments}'; it must be "none" or "any"`, - ); + if (definition.arguments !== undefined) { + if (Array.isArray(definition.arguments)) { + validateArgumentSpecs(definition, definition.arguments); + } else if ( + definition.arguments !== "none" && + definition.arguments !== "any" + ) { + invalid( + definition, + `'arguments' is '${definition.arguments}'; it must be "none", "any" or an array of argument specs`, + ); + } } - if ( - definition.canExecute !== undefined && - typeof definition.canExecute !== "function" - ) { - invalid(definition, "'canExecute' must be a function"); + for (const handler of ["canExecute", "setup", "postRun"]) { + if ( + definition[handler] !== undefined && + typeof definition[handler] !== "function" + ) { + invalid(definition, `'${handler}' must be a function`); + } } - for (const flag of ["disableAnalytics", "enableHooks"]) { + for (const flag of [ + "disableAnalytics", + "enableHooks", + "allowUnknownOptions", + ]) { if ( definition[flag] !== undefined && typeof definition[flag] !== "boolean" @@ -329,9 +521,13 @@ const validateDefinition = (definition: any): void => { } }; -export function defineCommand( - definition: CommandDefinition, -): DefinedCommand { +export function defineCommand< + TSchema extends CommandOptionsSchema = {}, + TResult = void, + TSetup = void, +>( + definition: CommandDefinition, +): DefinedCommand { validateDefinition(definition); const marked: any = { ...definition }; @@ -339,6 +535,8 @@ export function defineCommand( return marked; } -export function isCommandDefinition(value: any): value is DefinedCommand { +export function isCommandDefinition( + value: any, +): value is DefinedCommand { return !!value && (value)[COMMAND_DEFINITION_MARKER] === true; } diff --git a/lib/common/definitions/commands.d.ts b/lib/common/definitions/commands.d.ts index b2e43470e6..9750ebe2be 100644 --- a/lib/common/definitions/commands.d.ts +++ b/lib/common/definitions/commands.d.ts @@ -19,9 +19,10 @@ interface ICommand extends ICommandOptions { /** * Set on commands that forward their options to another CLI: the options * they accept are not knowable from this CLI's option dictionary, so - * validating them here would reject the other CLI's flags. + * rejecting them here would reject the other CLI's flags. The command's + * own declared options are still merged and checked. */ - skipOptionsValidation?: boolean; + allowUnknownOptions?: boolean; /** * Describes the action that will be executed after the command succeeds. diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index 5ccefc5116..bc09e25903 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -1,13 +1,17 @@ +import { EOL } from "os"; import { OptionType } from "../enums"; import { injector } from "../yok"; import { runInInjectionContext } from "../di/inject"; +import { Injector } from "../di/injector"; import { IDictionary, IDashedOption, IErrors } from "../declarations"; -import { IInjector } from "../definitions/yok"; import { ICommand } from "../definitions/commands"; import { CommandRegistry } from "../contracts/command-registry"; import { + ArgumentSpec, + CommandArgumentValues, CommandContext, CommandDefinition, + CommandOptionSpec, CommandOptionType, CommandOptionsSchema, DefinedCommand, @@ -19,26 +23,41 @@ const OPTION_TYPES: IDictionary = { string: OptionType.String, number: OptionType.Number, array: OptionType.Array, + object: OptionType.Object, }; const compileOptions = ( schema: CommandOptionsSchema, + cliOptions?: IDictionary, ): IDictionary => { const dashedOptions: IDictionary = {}; for (const optionName of Object.keys(schema)) { const spec = schema[optionName]; + // 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 === true, + 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) { @@ -55,13 +74,20 @@ const aliasList = (alias: string | string[]): string[] => alias === undefined ? [] : Array.isArray(alias) ? alias : [alias]; /** - * A command option that shadows a CLI-wide one wins the re-parse for this - * command only, so the same spelling means different things depending on which - * command is running. Warned rather than rejected while the policy is open. + * Redeclaring a CLI-wide option with the same type is the sanctioned way to + * give it a per-command default — `setupOptions` merges the command's + * declaration over the CLI-wide one. Only a redeclaration that changes what + * the spelling MEANS is a collision: a different type, or an alias that + * belongs to some other CLI-wide option. */ +const isRedeclarationOf = ( + spec: CommandOptionSpec, + cliOption: IDashedOption, +): boolean => OPTION_TYPES[spec.type] === cliOption.type; + const warnOnCliOptionCollisions = ( - targetInjector: IInjector, - definition: CommandDefinition, + targetInjector: Injector, + definition: CommandDefinition, schema: CommandOptionsSchema, optionsService: any, ): void => { @@ -81,14 +107,29 @@ const warnOnCliOptionCollisions = ( const collisions: string[] = []; for (const optionName of Object.keys(schema)) { - if (cliSpellings[optionName]) { + const spec = schema[optionName]; + + // A spelling owned by the option of the same name is the redeclaration + // pattern; one owned by a different option is genuine shadowing. + const shadows = (spelling: string): boolean => { + const owner = cliSpellings[spelling]; + if (!owner) { + return false; + } + + return ( + owner !== optionName || !isRedeclarationOf(spec, cliOptions[owner]) + ); + }; + + if (shadows(optionName)) { collisions.push( `'--${optionName}' with the CLI option '--${cliSpellings[optionName]}'`, ); } - for (const alias of aliasList(schema[optionName].alias)) { - if (cliSpellings[alias]) { + for (const alias of aliasList(spec.alias)) { + if (shadows(alias)) { collisions.push( `alias '-${alias}' of '--${optionName}' with the CLI option '--${cliSpellings[alias]}'`, ); @@ -123,23 +164,33 @@ const warnOnCliOptionCollisions = ( * skips `allowedParameters` entirely once it is present: the adapter enforces * the declared `arguments` policy itself and only then consults the * definition's own `canExecute`, so the two fields compose. + * + * CommandsService calls canExecute, execute and postCommandAction as three + * separate entry points into one invocation, which is why the setup result and + * the run result are held here rather than passed between them. */ export function createCommandFromDefinition< TSchema extends CommandOptionsSchema, + TResult = any, + TSetup = any, >( - definition: CommandDefinition, - targetInjector: IInjector = injector, + definition: CommandDefinition, + targetInjector: Injector = injector, ): ICommand { const schema = definition.options || {}; const optionNames = Object.keys(schema); - const dashedOptions = compileOptions(schema); // Only a definition that declares options may depend on the options service // being registered - a bare command must work without one. const optionsService: any = optionNames.length - ? targetInjector.resolve("options") + ? targetInjector.get("options") : null; + const dashedOptions = compileOptions( + schema, + optionsService && optionsService.options, + ); + warnOnCliOptionCollisions(targetInjector, definition, schema, optionsService); const commandName = Array.isArray(definition.name) @@ -153,10 +204,37 @@ export function createCommandFromDefinition< ); } - const errors: IErrors = targetInjector.resolve("errors"); + const errors: IErrors = targetInjector.get("errors"); return errors.failWithHelp(message); }; + const argumentSpecs: ArgumentSpec[] = Array.isArray( + definition.arguments, + ) + ? definition.arguments + : null; + const acceptsArguments = definition.arguments === "any"; + + // Strictly positional: spec[i] owns args[i], and a trailing variadic spec + // takes everything from its own position on. + const mapArguments = (args: string[]): CommandArgumentValues => { + const values: CommandArgumentValues = {}; + if (!argumentSpecs) { + return values; + } + + for (let index = 0; index < argumentSpecs.length; index++) { + const spec = argumentSpecs[index]; + if (spec.variadic) { + values[spec.name] = args.slice(index); + } else if (index < args.length) { + values[spec.name] = args[index]; + } + } + + return values; + }; + // Read per call rather than snapshotted here: the options service only holds // this command's parsed values once validateOptions has run for it. const buildContext = (args: string[]): CommandContext => { @@ -165,10 +243,101 @@ export function createCommandFromDefinition< options[optionName] = optionsService[optionName]; } - return { args, options, fail }; + return { + args, + arguments: mapArguments(args), + options, + injector: targetInjector, + fail, + }; }; - const acceptsArguments = definition.arguments === "any"; + const missingArgumentMessage = (spec: ArgumentSpec): string => + spec.errorMessage || `Missing required argument '${spec.name}'.`; + + const enforceArguments = async ( + context: CommandContext, + ): Promise => { + const args = context.args; + + if (!argumentSpecs) { + if (!acceptsArguments && args.length) { + fail("This command doesn't accept parameters."); + } + + return; + } + + const missing = argumentSpecs.filter( + (spec, index) => spec.required && index >= args.length, + ); + if (missing.length) { + // The preamble is what the parameter machinery printed ahead of the + // individual messages, so a command reads the same either way. + fail( + [ + "You need to provide all the required parameters.", + ...missing.map(missingArgumentMessage), + ].join(EOL), + ); + } + + const variadic = + argumentSpecs.length > 0 && + argumentSpecs[argumentSpecs.length - 1].variadic; + if (!variadic && args.length > argumentSpecs.length) { + fail( + argumentSpecs.length === 0 + ? "This command doesn't accept parameters." + : `This command accepts at most ${argumentSpecs.length} parameter(s), but ${args.length} were provided.`, + ); + } + + for (let index = 0; index < argumentSpecs.length; index++) { + const spec = argumentSpecs[index]; + if (!spec.validate) { + continue; + } + + const values = spec.variadic + ? args.slice(index) + : args.slice(index, index + 1); + for (const value of values) { + const verdict = await spec.validate.call(spec, value, context); + if (verdict === true) { + continue; + } + + fail( + typeof verdict === "string" && verdict.trim() + ? verdict + : `The parameter '${value}' is not valid for '${spec.name}'.`, + ); + } + } + }; + + // One invocation spans canExecute, execute and postCommandAction, which the + // CommandsService calls separately; setup must run for the first of them + // that happens and be reused by the rest. + let setupPromise: Promise> = null; + const ensureSetup = ( + context: CommandContext, + ): Promise> => { + if (!setupPromise) { + setupPromise = definition.setup + ? Promise.resolve( + runInInjectionContext(targetInjector, () => + definition.setup.call(definition, context), + ), + ) + : Promise.resolve(>undefined); + } + + return setupPromise; + }; + + let runResult: Awaited; return { allowedParameters: [], @@ -179,10 +348,35 @@ export function createCommandFromDefinition< ...(definition.enableHooks === undefined ? {} : { enableHooks: definition.enableHooks }), + ...(definition.allowUnknownOptions === undefined + ? {} + : { allowUnknownOptions: definition.allowUnknownOptions }), + ...(definition.postRun === undefined + ? {} + : { + postCommandAction: async (args: string[]): Promise => { + const context = buildContext(args); + const setupResult = await ensureSetup(context); + await runInInjectionContext(targetInjector, () => + definition.postRun.call( + definition, + context, + runResult, + setupResult, + ), + ); + }, + }), canExecute: async (args: string[]): Promise => { - if (!acceptsArguments && args.length) { - fail("This command doesn't accept parameters."); - } + const context = buildContext(args); + // Setup first: it stands in for the constructor work legacy commands + // did at resolution time, which ran before anything looked at the + // arguments - so an argument validator can rely on it, and a command + // run in the wrong place still reports that before complaining about + // arity. + const setupResult = await ensureSetup(context); + + await enforceArguments(context); const refine = definition.canExecute; if (!refine) { @@ -192,12 +386,14 @@ export function createCommandFromDefinition< // Same first-await rule as execute: runInInjectionContext is // synchronous, so inject() is available up to the first await. return await runInInjectionContext(targetInjector, () => - refine.call(definition, buildContext(args)), + refine.call(definition, context, setupResult), ); }, execute: async (args: string[]): Promise => { - await runInInjectionContext(targetInjector, () => - definition.run(buildContext(args)), + const context = buildContext(args); + const setupResult = await ensureSetup(context); + runResult = await runInInjectionContext(targetInjector, () => + definition.run.call(definition, context, setupResult), ); }, }; @@ -208,10 +404,14 @@ export function createCommandFromDefinition< * manifests route by their own key, which need not be the definition's own * name, so the name is a parameter rather than read off the definition. */ -export function registerDefinitionAs( +export function registerDefinitionAs< + TSchema extends CommandOptionsSchema, + TResult = any, + TSetup = any, +>( name: string, - definition: DefinedCommand, - targetInjector: IInjector = injector, + definition: DefinedCommand, + targetInjector: Injector = injector, ): void { // The registry facet rather than the injector itself, so a child injector // that provides its own CommandRegistry receives the registration. @@ -223,9 +423,13 @@ export function registerDefinitionAs( ); } -export function registerCommandDefinition( - definition: DefinedCommand, - targetInjector: IInjector = injector, +export function registerCommandDefinition< + TSchema extends CommandOptionsSchema, + TResult = any, + TSetup = any, +>( + definition: DefinedCommand, + targetInjector: Injector = injector, ): void { if (!isCommandDefinition(definition)) { throw new Error( diff --git a/lib/common/services/commands-service.ts b/lib/common/services/commands-service.ts index 37aeab6242..54247722ba 100644 --- a/lib/common/services/commands-service.ts +++ b/lib/common/services/commands-service.ts @@ -158,12 +158,12 @@ export class CommandsService implements ICommandsService { commandArguments: string[], ): Promise { const command = this.$injector.resolveCommand(commandName); - if ( - !command || - (!command.isHierarchicalCommand && !command.skipOptionsValidation) - ) { + if (!command || !command.isHierarchicalCommand) { const dashedOptions = command ? command.dashedOptions : null; - this.$options.validateOptions(dashedOptions); + this.$options.validateOptions( + dashedOptions, + command && command.allowUnknownOptions, + ); } return this.canExecuteCommand(commandName, commandArguments); diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts index c0c8e74d0e..6c843708c5 100644 --- a/lib/contracts/index.ts +++ b/lib/contracts/index.ts @@ -54,8 +54,12 @@ export { stringOption, numberOption, arrayOption, + objectOption, } from "../common/define-command"; export type { + ArgumentSpec, + ArgumentsPolicy, + CommandArgumentValues, CommandDefinition, DefinedCommand, CommandContext, diff --git a/lib/declarations.d.ts b/lib/declarations.d.ts index 79f4559d76..466f3830fe 100644 --- a/lib/declarations.d.ts +++ b/lib/declarations.d.ts @@ -616,7 +616,7 @@ interface IOptions argv: IYargArgv; validateOptions( commandSpecificDashedOptions?: IDictionary, - projectData?: IProjectData, + allowUnknownOptions?: boolean, ): void; options: IDictionary; shorthands: string[]; diff --git a/lib/options.ts b/lib/options.ts index df6ce62f4d..b367f0f01f 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -260,6 +260,7 @@ export class Options { public validateOptions( commandSpecificDashedOptions?: IDictionary, + allowUnknownOptions?: boolean, ): void { this.setupOptions(commandSpecificDashedOptions); @@ -287,11 +288,15 @@ export class Options { validated.push(dedupeKey); if (!this.isOptionSupported(optionName)) { - this.reportInvalidOption( - `The option '${this.getReportedOptionName( - originalOptionName, - )}' is not supported.`, - ); + // A command that forwards its flags to another CLI cannot know + // them; its own declared options are still merged and checked. + if (!allowUnknownOptions) { + this.reportInvalidOption( + `The option '${this.getReportedOptionName( + originalOptionName, + )}' is not supported.`, + ); + } continue; } diff --git a/test/commands-service.ts b/test/commands-service.ts index 0bd644fdee..5be486216a 100644 --- a/test/commands-service.ts +++ b/test/commands-service.ts @@ -5,10 +5,12 @@ import { ICommand } from "../lib/common/definitions/commands"; function createTestInjector(command: ICommand): { injector: Yok; - validatedWith: { called: boolean }; + validatedWith: { called: boolean; allowUnknown?: boolean }; } { const injector = new Yok(); - const validatedWith = { called: false }; + const validatedWith: { called: boolean; allowUnknown?: boolean } = { + called: false, + }; injector.register("errors", { fail: (message: string): void => { @@ -21,8 +23,9 @@ function createTestInjector(command: ICommand): { injector.register("hooksService", {}); injector.register("logger", { warn: (): void => undefined }); injector.register("options", { - validateOptions: (): void => { + validateOptions: (dashedOptions: any, allowUnknown?: boolean): void => { validatedWith.called = true; + validatedWith.allowUnknown = allowUnknown; }, }); injector.register("staticConfig", {}); @@ -51,16 +54,19 @@ describe("commands-service", () => { assert.isTrue(validatedWith.called); }); - it("skips validation for a command that forwards its options", async () => { + it("tolerates unknown options for a command that forwards them", async () => { const { injector, validatedWith } = createTestInjector({ ...baseCommand, - skipOptionsValidation: true, + allowUnknownOptions: true, }); const service = injector.resolve(CommandsService); await (service).tryExecuteCommandAction("preview", []); - assert.isFalse(validatedWith.called); + // Validation still runs so the command's own options are merged; + // only the rejection of foreign flags is suppressed. + assert.isTrue(validatedWith.called); + assert.isTrue(validatedWith.allowUnknown); }); }); }); diff --git a/test/define-command.ts b/test/define-command.ts index f249669849..e6cce3b542 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -3,7 +3,7 @@ import { spawnSync } from "child_process"; import * as path from "path"; import { Yok } from "../lib/common/yok"; import { IInjector } from "../lib/common/definitions/yok"; -import { inject } from "../lib/common/di"; +import { inject, InjectionToken } from "../lib/common/di"; import { CommandRegistry } from "../lib/common/contracts/command-registry"; import { CommandsService } from "../lib/common/services/commands-service"; import { Options } from "../lib/options"; @@ -101,7 +101,7 @@ describe("defineCommand", () => { it("rejects an unusable arguments policy", () => { rejects( { name: "dctest-args", arguments: "one", run: (): void => undefined }, - /'arguments' is 'one'; it must be "none" or "any"/, + /'arguments' is 'one'; it must be "none", "any" or an array of argument specs/, ); }); @@ -224,11 +224,17 @@ describe("defineCommand", () => { { encoding: "utf8" }, ); - assert.strictEqual( - result.status, - 0, - `${result.stdout || ""}${result.stderr || ""}`, - ); + // define-command.ts reaches the DI types, which drag in most of the + // repo — none of which was ever strict-clean. Only the fixture and the + // module it pins are under test here. + const underTest = + /^(.*[\\/])?(define-command|define-command-types)\.ts\(/; + const failures = `${result.stdout || ""}${result.stderr || ""}` + .split(/\r?\n/) + .filter((line) => /\.ts\(\d+,\d+\): error TS/.test(line)) + .filter((line) => underTest.test(line)); + + assert.deepEqual(failures, []); }); }); @@ -487,6 +493,57 @@ describe("defineCommand", () => { }); }); + it("carries over what a redeclared CLI option leaves unspecified", () => { + const command = createCommandFromDefinition( + defineCommand({ + name: "dctestredeclare", + options: { + // Redeclared only to give this command its own default. + path: stringOption({ default: "./here" }), + watch: booleanOption({ default: false }), + }, + run: (): void => undefined, + }), + createTestInjector({ + options: { + path: { type: "string", alias: "p", hasSensitiveValue: true }, + watch: { type: "boolean", hasSensitiveValue: false }, + }, + }), + ); + + assert.deepEqual(command.dashedOptions, { + path: { + type: "string", + hasSensitiveValue: true, + default: "./here", + alias: "p", + }, + watch: { type: "boolean", hasSensitiveValue: false, default: false }, + }); + }); + + it("lets a redeclaration override what it does specify", () => { + const command = createCommandFromDefinition( + defineCommand({ + name: "dctestoverride", + options: { + path: stringOption({ alias: "q", hasSensitiveValue: false }), + }, + run: (): void => undefined, + }), + createTestInjector({ + options: { + path: { type: "string", alias: "p", hasSensitiveValue: true }, + }, + }), + ); + + assert.deepEqual(command.dashedOptions, { + path: { type: "string", hasSensitiveValue: false, alias: "q" }, + }); + }); + it("is empty when no options are declared", () => { const command = createCommandFromDefinition( defineCommand({ name: "dctestnoopts", run: (): void => undefined }), @@ -508,7 +565,7 @@ describe("defineCommand", () => { defineCommand({ name: "dctestshadow", options: { - verbose: booleanOption(), + verbose: stringOption(), output: stringOption({ alias: ["p", "o"] }), fresh: booleanOption({ alias: "f" }), }, @@ -530,6 +587,32 @@ describe("defineCommand", () => { assert.notInclude(logger.warnOutput, "'-o'"); }); + 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" }, + }, + }); + + createCommandFromDefinition( + defineCommand({ + name: "dctestredeclare", + options: { + watch: booleanOption({ default: true }), + path: stringOption({ alias: "p" }), + }, + run: (): void => undefined, + }), + testInjector, + ); + + assert.strictEqual( + (testInjector.resolve("logger")).warnOutput, + "", + ); + }); + it("stays quiet when nothing collides", () => { const testInjector = createTestInjector({ options: { path: { type: "string", alias: "p" } }, @@ -755,6 +838,7 @@ describe("defineCommand", () => { interface IValidationRun { failures: string[]; options: any; + injector: IInjector; } // The options service parses process.argv in its constructor, so each run @@ -782,7 +866,7 @@ describe("defineCommand", () => { const command = createCommandFromDefinition(definition, testInjector); const options: any = testInjector.resolve("options"); options.validateOptions(command.dashedOptions); - return { failures, options }; + return { failures, options, injector: testInjector }; } finally { process.argv = originalArgv; } @@ -810,6 +894,40 @@ describe("defineCommand", () => { } }); + it("carries a declared option that is also CLI-wide onto ctx.options", async () => { + const definition = defineCommand({ + name: "dctest-cliwide", + options: { + // --release is declared by the CLI itself; a command that reads it + // declares it too, and the declaration only supplies the default. + release: booleanOption({ default: false, alias: "r" }), + outputDir: stringOption(), + }, + run: (): void => undefined, + }); + + const run = validate(definition, ["--release", "--output-dir", "dist"]); + assert.deepEqual(run.failures, []); + + let seen: any; + const command = createCommandFromDefinition( + { + ...definition, + run: (ctx): void => { + seen = ctx.options; + }, + }, + run.injector, + ); + await command.execute([]); + + assert.deepEqual(seen, { release: true, outputDir: "dist" }); + assert.strictEqual( + (run.injector.resolve("logger")).warnOutput, + "", + ); + }); + it("still rejects an option the definition did not declare", () => { const definition = defineCommand({ name: "dctest-alias2", @@ -982,4 +1100,720 @@ describe("defineCommand", () => { assert.deepEqual(runs, [["beta"], []]); }); }); + + describe("positional argument specs", () => { + const platformCommand = (extra: any = {}) => + createCommandFromDefinition( + defineCommand({ + name: "dctest-positional", + arguments: [ + { name: "platform", required: true }, + { name: "target" }, + ...(extra.variadic ? [{ name: "rest", variadic: true }] : []), + ], + run: (ctx) => { + extra.seen = ctx.arguments; + }, + }), + createTestInjector(), + ); + + it("maps arguments onto ctx.arguments strictly by position", async () => { + const extra: any = {}; + const command = platformCommand(extra); + + assert.isTrue(await command.canExecute(["android", "device"])); + await command.execute(["android", "device"]); + + assert.deepEqual(extra.seen, { platform: "android", target: "device" }); + }); + + it("leaves an unfilled optional argument off ctx.arguments", async () => { + const extra: any = {}; + const command = platformCommand(extra); + + await command.execute(["android"]); + + assert.deepEqual(extra.seen, { platform: "android" }); + }); + + it("collects the rest into a variadic argument, empty array included", async () => { + const extra: any = { variadic: true }; + const command = platformCommand(extra); + + await command.execute(["android", "device", "a", "b"]); + assert.deepEqual(extra.seen, { + platform: "android", + target: "device", + rest: ["a", "b"], + }); + + await command.execute(["android", "device"]); + assert.deepEqual(extra.seen, { + platform: "android", + target: "device", + rest: [], + }); + }); + + it("exposes an empty ctx.arguments when no specs are declared", async () => { + let seen: any; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-noargspecs", + arguments: "any", + run: (ctx) => { + seen = ctx.arguments; + }, + }), + createTestInjector(), + ); + + await command.execute(["one"]); + + assert.deepEqual(seen, {}); + }); + + it("fails naming every missing required argument", async () => { + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-missing", + arguments: [ + { name: "platform", required: true }, + { + name: "device", + required: true, + errorMessage: "Provide a device identifier.", + }, + ], + run: (): void => undefined, + }), + createTestInjector(), + ); + + await assert.isRejected( + command.canExecute([]), + /Missing required argument 'platform'[\s\S]*Provide a device identifier\./, + ); + // The generic preamble precedes the specific messages, as the + // parameter machinery printed it. + await assert.isRejected( + command.canExecute(["android"]), + /^You need to provide all the required parameters\.\s+Provide a device identifier\.$/, + ); + assert.isTrue(await command.canExecute(["android", "emulator-1"])); + }); + + it("treats a required variadic argument as needing at least one value", async () => { + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-reqvariadic", + arguments: [{ name: "files", required: true, variadic: true }], + run: (): void => undefined, + }), + createTestInjector(), + ); + + await assert.isRejected( + command.canExecute([]), + /Missing required argument 'files'/, + ); + assert.isTrue(await command.canExecute(["a.ts", "b.ts"])); + }); + + it("rejects more arguments than the specs declare", async () => { + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-toomany", + arguments: [{ name: "platform" }], + run: (): void => undefined, + }), + createTestInjector(), + ); + + await assert.isRejected( + command.canExecute(["android", "extra"]), + /accepts at most 1 parameter\(s\), but 2 were provided/, + ); + assert.isTrue(await command.canExecute(["android"])); + }); + + it("rejects any argument when the spec array is empty", async () => { + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-emptyspecs", + arguments: [], + run: (): void => undefined, + }), + createTestInjector(), + ); + + await assert.isRejected( + command.canExecute(["stray"]), + /doesn't accept parameters/, + ); + }); + + it("runs validate per value and uses a returned string as the message", async () => { + const seen: string[] = []; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-validate", + arguments: [ + { + name: "platforms", + variadic: true, + validate: async (value) => { + seen.push(value); + await Promise.resolve(); + return ( + value === "android" || `'${value}' is not a known platform.` + ); + }, + }, + ], + run: (): void => undefined, + }), + createTestInjector(), + ); + + assert.isTrue(await command.canExecute(["android", "android"])); + assert.deepEqual(seen, ["android", "android"]); + + await assert.isRejected( + command.canExecute(["android", "blackberry"]), + /'blackberry' is not a known platform\./, + ); + }); + + it("falls back to a default message when validate just returns false", async () => { + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-validate-false", + arguments: [{ name: "platform", validate: () => false }], + run: (): void => undefined, + }), + createTestInjector(), + ); + + await assert.isRejected( + command.canExecute(["ios"]), + /The parameter 'ios' is not valid for 'platform'\./, + ); + }); + + it("hands validate the command context", async () => { + const testInjector = createTestInjector({ force: true }); + let capturedContext: any; + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-validate-ctx", + options: { force: booleanOption() }, + arguments: [ + { + name: "platform", + validate: (value, ctx) => { + capturedContext = ctx; + return true; + }, + }, + ], + run: (): void => undefined, + }), + testInjector, + ); + + await command.canExecute(["android"]); + + assert.deepEqual(capturedContext.options, { force: true }); + assert.deepEqual(capturedContext.arguments, { platform: "android" }); + }); + + it("enforces the specs before consulting the definition canExecute", async () => { + let refined = false; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-specs-first", + arguments: [{ name: "platform", required: true }], + canExecute: () => { + refined = true; + return true; + }, + run: (): void => undefined, + }), + createTestInjector(), + ); + + await assert.isRejected(command.canExecute([]), /Missing required/); + assert.isFalse(refined); + }); + }); + + describe("argument-spec validation", () => { + const rejects = (specs: any, expected: RegExp) => + assert.throws( + () => + defineCommand({ + name: "dctest-spec", + arguments: specs, + run: (): void => undefined, + }), + expected, + ); + + it("rejects a spec that is not an object, or has no name", () => { + rejects(["platform"], /argument #1 of 'arguments' must be an object/); + rejects( + [{ required: true }], + /argument #1 of 'arguments' has no usable 'name'/, + ); + rejects( + [{ name: " " }], + /argument #1 of 'arguments' has no usable 'name'/, + ); + }); + + it("rejects a typo'd spec field", () => { + rejects( + [{ name: "platform", requried: true }], + /argument 'platform' has unknown field\(s\) 'requried'/, + ); + }); + + it("rejects a duplicate argument name", () => { + rejects( + [{ name: "platform" }, { name: "platform" }], + /'arguments' declares 'platform' twice/, + ); + }); + + it("rejects unusable required, variadic, description, errorMessage and validate", () => { + rejects( + [{ name: "platform", required: "yes" }], + /argument 'platform' declares a non-boolean 'required'/, + ); + rejects( + [{ name: "platform", variadic: 1 }], + /argument 'platform' declares a non-boolean 'variadic'/, + ); + rejects( + [{ name: "platform", description: 5 }], + /argument 'platform' declares a non-string 'description'/, + ); + rejects( + [{ name: "platform", errorMessage: 5 }], + /argument 'platform' declares a non-string 'errorMessage'/, + ); + rejects( + [{ name: "platform", validate: "nope" }], + /argument 'platform' has a non-function 'validate'/, + ); + }); + + it("rejects a variadic argument that is not the last one", () => { + rejects( + [{ name: "rest", variadic: true }, { name: "platform" }], + /argument 'rest' is variadic but is not the last one/, + ); + }); + + it("rejects a required argument that follows an optional one", () => { + rejects( + [{ name: "platform" }, { name: "device", required: true }], + /argument 'device' is required but follows the optional 'platform'/, + ); + }); + + it("accepts a well-formed spec array", () => { + assert.doesNotThrow(() => + defineCommand({ + name: "dctest-spec-ok", + arguments: [ + { + name: "platform", + required: true, + description: "The platform", + errorMessage: "Provide a platform.", + validate: () => true, + }, + { name: "rest", variadic: true }, + ], + run: (): void => undefined, + }), + ); + }); + }); + + describe("setup", () => { + it("runs before canExecute and hands its result to every stage", async () => { + const order: string[] = []; + const testInjector = createTestInjector(); + testInjector.register("dcTestProject", { dir: "/app" }); + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-setup", + setup: () => { + order.push("setup"); + return inject("dcTestProject").dir; + }, + canExecute: (ctx, projectDir) => { + order.push(`canExecute:${projectDir}`); + return true; + }, + run: (ctx, projectDir) => { + order.push(`run:${projectDir}`); + return projectDir.length; + }, + postRun: (ctx, result, projectDir) => { + order.push(`postRun:${result}:${projectDir}`); + }, + }), + testInjector, + ); + + await command.canExecute([]); + await command.execute([]); + await command.postCommandAction([]); + + assert.deepEqual(order, [ + "setup", + "canExecute:/app", + "run:/app", + "postRun:4:/app", + ]); + }); + + it("runs once per invocation, whichever stage comes first", async () => { + let runs = 0; + const build = () => + createCommandFromDefinition( + defineCommand({ + name: "dctest-setup-once", + setup: async () => { + runs++; + return runs; + }, + run: (): void => undefined, + }), + createTestInjector(), + ); + + const viaCanExecute = build(); + await viaCanExecute.canExecute([]); + await viaCanExecute.execute([]); + assert.strictEqual(runs, 1); + + runs = 0; + const viaExecute = build(); + await viaExecute.execute([]); + assert.strictEqual(runs, 1); + }); + + it("hands undefined through when no setup is declared", async () => { + let seen: any = "untouched"; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-nosetup", + run: (ctx, setupResult) => { + seen = setupResult; + }, + }), + createTestInjector(), + ); + + await command.execute([]); + + assert.isUndefined(seen); + }); + + it("can fail the command from setup", async () => { + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-setup-fail", + setup: (ctx) => ctx.fail("no project found"), + run: (): void => undefined, + }), + createTestInjector(), + ); + + await assert.isRejected(command.canExecute([]), /no project found/); + }); + }); + + describe("postRun", () => { + it("is exposed as postCommandAction only when declared", () => { + const withPostRun = createCommandFromDefinition( + defineCommand({ + name: "dctest-postrun", + run: (): void => undefined, + postRun: (): void => undefined, + }), + createTestInjector(), + ); + const withoutPostRun = createCommandFromDefinition( + defineCommand({ + name: "dctest-nopostrun", + run: (): void => undefined, + }), + createTestInjector(), + ); + + assert.isFunction(withPostRun.postCommandAction); + assert.isFalse("postCommandAction" in withoutPostRun); + }); + + it("receives what run returned, inside an injection context", async () => { + const testInjector = createTestInjector(); + testInjector.register("dcTestReporter", { name: "reporter" }); + let seen: any; + let injected: string; + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-postrun-result", + arguments: "any", + run: async () => { + await Promise.resolve(); + return { created: "my-app" }; + }, + postRun: (ctx, result) => { + injected = inject("dcTestReporter").name; + seen = { args: ctx.args, result }; + }, + }), + testInjector, + ); + + await command.execute(["my-app"]); + await command.postCommandAction(["my-app"]); + + assert.deepEqual(seen, { + args: ["my-app"], + result: { created: "my-app" }, + }); + assert.strictEqual(injected, "reporter"); + }); + }); + + describe("allowUnknownOptions", () => { + it("sets allowUnknownOptions on the compiled command", () => { + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-unknown", + allowUnknownOptions: true, + run: (): void => undefined, + }), + createTestInjector(), + ); + + assert.isTrue(command.allowUnknownOptions); + }); + + it("leaves it absent when the definition omits it", () => { + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-unknown-off", + run: (): void => undefined, + }), + createTestInjector(), + ); + + assert.isFalse("allowUnknownOptions" in command); + }); + + it("keeps the command's own options working alongside unknown ones", () => { + 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 failures: string[] = []; + const errors = new Errors(testInjector); + errors.failWithHelp = ((message: string) => failures.push(message)); + errors.fail = ((message: string) => failures.push(message)); + testInjector.register("errors", errors); + testInjector.register("options", Options); + + const definition = defineCommand({ + name: "dctest-passthrough", + allowUnknownOptions: true, + options: { tag: stringOption() }, + run: (): void => undefined, + }); + + const originalArgv = process.argv; + process.argv = [ + originalArgv[0], + originalArgv[1], + "--tag", + "beta", + "--flag-of-another-cli", + ]; + process.env.NS_STRICT_OPTIONS = "error"; + try { + const command = createCommandFromDefinition(definition, testInjector); + const options: any = testInjector.resolve("options"); + options.validateOptions( + command.dashedOptions, + command.allowUnknownOptions, + ); + + // The foreign flag is tolerated... + assert.deepEqual(failures, []); + // ...and the command's own option is still parsed. + assert.equal(options.tag, "beta"); + } finally { + process.argv = originalArgv; + delete process.env.NS_STRICT_OPTIONS; + } + }); + + it("rejects a non-boolean allowUnknownOptions", () => { + assert.throws( + () => + defineCommand({ + name: "dctest-unknown-bad", + allowUnknownOptions: "yes", + run: (): void => undefined, + }), + /'allowUnknownOptions' must be a boolean/, + ); + }); + + it("tells CommandsService to tolerate unknown options", async () => { + let validatedWith: any; + const testInjector = new Yok(); + testInjector.register("errors", { + beginCommand: async (action: () => Promise) => action(), + failWithHelp: (message: string) => { + throw new Error(message); + }, + }); + testInjector.register("hooksService", HooksServiceStub); + testInjector.register("logger", LoggerStub); + testInjector.register("staticConfig", { + disableAnalytics: true, + disableCommandHooks: true, + }); + testInjector.register("extensibilityService", {}); + testInjector.register("optionsTracker", {}); + testInjector.register("options", { + validateOptions: (dashedOptions: any, allowUnknown: boolean) => { + validatedWith = { dashedOptions, allowUnknown }; + }, + }); + testInjector.register("commandsService", CommandsService); + + let ran = false; + registerCommandDefinition( + defineCommand({ + name: "dctest-unknown-e2e", + allowUnknownOptions: true, + arguments: "any", + run: () => { + ran = true; + }, + }), + testInjector, + ); + + const commandsService: ICommandsService = + testInjector.resolve("commandsService"); + await commandsService.tryExecuteCommand("dctest-unknown-e2e", ["stray"]); + + assert.isTrue(ran); + // Validation still runs - it is the rejection of unknown flags that + // is suppressed, so a passthrough command keeps its own options. + assert.isTrue(validatedWith.allowUnknown); + }); + }); + + describe("ctx.injector", () => { + it("is the injector the command was registered against", async () => { + const testInjector = createTestInjector(); + let seen: any; + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-injector", + run: (ctx) => { + seen = ctx.injector; + }, + }), + testInjector, + ); + + await command.execute([]); + + assert.strictEqual(seen, testInjector); + }); + + it("resolves after the first await, where inject() no longer can", async () => { + const testInjector = createTestInjector(); + testInjector.register("dcTestLate", { value: 42 }); + let late: any; + let injectFailed = false; + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-injector-late", + run: async (ctx) => { + await Promise.resolve(); + try { + inject("dcTestLate"); + } catch (err) { + injectFailed = true; + } + late = ctx.injector.get("dcTestLate").value; + }, + }), + testInjector, + ); + + await command.execute([]); + + assert.isTrue(injectFailed); + assert.strictEqual(late, 42); + }); + }); + + describe("per-registration parameterization with a child injector", () => { + it("registers one definition per platform and resolves the child provider", async () => { + const PLATFORM = new InjectionToken("dcTestCommandPlatform"); + const testInjector = createTestInjector({ release: true }); + const ran: string[] = []; + + const definition = defineCommand({ + name: "dctest-run", + options: { release: booleanOption({ default: false }) }, + arguments: "any", + setup: () => inject(PLATFORM), + run: (ctx, platform) => { + ran.push( + `${platform}:${ctx.options.release}:${ctx.injector.get(PLATFORM)}`, + ); + }, + }); + + for (const platform of ["android", "ios"]) { + registerCommandDefinition( + { ...definition, name: `dctest-run|${platform}` }, + testInjector.createChild([{ provide: PLATFORM, useValue: platform }]), + ); + } + + for (const platform of ["android", "ios"]) { + const command = testInjector.resolveCommand(`dctest-run|${platform}`); + assert.isTrue(await command.canExecute([])); + await command.execute([]); + } + + assert.deepEqual(ran, ["android:true:android", "ios:true:ios"]); + }); + }); }); diff --git a/test/type-fixtures/define-command-types.ts b/test/type-fixtures/define-command-types.ts index e8d1f80d49..68fc931643 100644 --- a/test/type-fixtures/define-command-types.ts +++ b/test/type-fixtures/define-command-types.ts @@ -13,6 +13,8 @@ import { numberOption, stringOption, } from "../../lib/common/define-command"; +import type { CommandArgumentValues } from "../../lib/common/define-command"; +import type { Injector } from "../../lib/common/di/injector"; type IsExact = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 @@ -82,3 +84,105 @@ defineCommand({ arguments: "one", run: () => undefined, }); + +// `arguments` accepts positional specs, and `ctx.arguments` keys the values by +// the declared names. The keys are not inferred from the spec array — the +// value type is what the declaration pins. +defineCommand({ + name: "typefixture|positional", + arguments: [ + { name: "platform", required: true }, + { name: "extra", variadic: true }, + ], + run(ctx) { + expectExactType>(); + expectExactType< + IsExact<(typeof ctx.arguments)["platform"], string | string[]> + >(); + }, +}); + +defineCommand({ + name: "typefixture|bad-argument-spec", + // @ts-expect-error - an argument spec is a closed shape + arguments: [{ name: "platform", requried: true }], + run: () => undefined, +}); + +defineCommand({ + name: "typefixture|validate", + options: { force: booleanOption({ default: false }) }, + arguments: [ + { + name: "platform", + validate(value, ctx) { + expectExactType>(); + expectExactType>(); + return value.length > 0; + }, + }, + ], + run: () => undefined, +}); + +// The injector is the escape hatch for lookups after the first await. +defineCommand({ + name: "typefixture|injector", + run(ctx) { + expectExactType>(); + }, +}); + +// setup flows into canExecute, run and postRun; run's value flows into postRun. +defineCommand({ + name: "typefixture|lifecycle", + async setup() { + return { projectDir: "app" }; + }, + canExecute(ctx, setupResult) { + expectExactType>(); + return true; + }, + async run(ctx, setupResult) { + expectExactType>(); + return setupResult.projectDir.length; + }, + postRun(ctx, result, setupResult) { + expectExactType>(); + expectExactType>(); + }, +}); + +// A synchronous setup and a synchronous run land on the same types. +defineCommand({ + name: "typefixture|lifecycle-sync", + setup: () => "ready", + run(ctx, setupResult) { + expectExactType>(); + return true; + }, + postRun(ctx, result) { + expectExactType>(); + }, +}); + +// Without a setup, the second parameter is void — there is nothing to read. +defineCommand({ + name: "typefixture|no-setup", + run(ctx, setupResult) { + expectExactType>(); + }, +}); + +defineCommand({ + name: "typefixture|unknown-options", + allowUnknownOptions: true, + run: () => undefined, +}); + +defineCommand({ + name: "typefixture|bad-unknown-options", + // @ts-expect-error - allowUnknownOptions is a boolean + allowUnknownOptions: "yes", + run: () => undefined, +}); From e4bf7dad047c414e5e591eb54836ab9c0d713d78 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 16:44:50 -0300 Subject: [PATCH 02/19] refactor(commands): migrate every command to defineCommand Platform validation, dynamic delegation, native-add and widget, test, create/install/post-install-cli/help, enforced-parameter, device, self-contained, platform, plugin and hooks, open|*, and the rest. Class-based surfaces the migration left without callers are deprecated rather than removed, since extensions may import them. --- lib/bootstrap.ts | 2 - lib/commands/add-platform.ts | 143 +-- lib/commands/apple-login.ts | 102 +- lib/commands/appstore-list.ts | 190 ++-- lib/commands/appstore-upload.ts | 300 +++--- lib/commands/build.ts | 372 +++---- lib/commands/clean.ts | 589 +++++------ lib/commands/command-base.ts | 175 +++- lib/commands/config.ts | 203 ++-- lib/commands/create-project.ts | 913 +++++++++--------- lib/commands/debug.ts | 430 +++++---- lib/commands/deploy.ts | 125 ++- lib/commands/embedding/embed.ts | 187 ++-- .../extensibility/install-extension.ts | 72 +- lib/commands/extensibility/list-extensions.ts | 47 +- .../extensibility/uninstall-extension.ts | 62 +- lib/commands/fonts.ts | 63 +- lib/commands/generate-assets.ts | 179 ++-- lib/commands/generate-help.ts | 27 +- lib/commands/generate.ts | 75 +- lib/commands/hooks/common.ts | 186 ++-- lib/commands/hooks/hooks-lock.ts | 197 ++-- lib/commands/hooks/hooks.ts | 178 ++-- lib/commands/info.ts | 27 +- lib/commands/install.ts | 207 ++-- lib/commands/list-platforms.ts | 82 +- lib/commands/migrate.ts | 70 +- lib/commands/native-add.ts | 589 +++++------ lib/commands/open.ts | 252 +++++ lib/commands/platform-clean.ts | 142 ++- lib/commands/plugin/add-plugin.ts | 81 +- lib/commands/plugin/build-plugin.ts | 183 ++-- lib/commands/plugin/create-plugin.ts | 430 +++++---- lib/commands/plugin/list-plugins.ts | 91 +- lib/commands/plugin/remove-plugin.ts | 99 +- lib/commands/plugin/update-plugin.ts | 104 +- lib/commands/post-install.ts | 135 +-- lib/commands/prepare.ts | 156 +-- lib/commands/preview.ts | 208 ++-- lib/commands/remove-platform.ts | 93 +- lib/commands/resources/resources-update.ts | 98 +- lib/commands/run.ts | 369 +++---- lib/commands/setup.ts | 27 +- lib/commands/start.ts | 29 +- lib/commands/test-init.ts | 286 +++--- lib/commands/test.ts | 504 +++++----- lib/commands/typings.ts | 455 +++++---- lib/commands/update-platform.ts | 132 ++- lib/commands/update.ts | 158 +-- lib/commands/widget.ts | 86 +- lib/common/command-params.ts | 8 + lib/common/commands/analytics.ts | 211 ++-- lib/common/commands/autocompletion.ts | 150 +-- .../commands/device/device-log-stream.ts | 109 ++- lib/common/commands/device/get-file.ts | 128 ++- .../commands/device/list-applications.ts | 108 ++- lib/common/commands/device/list-devices.ts | 353 ++++--- lib/common/commands/device/list-files.ts | 123 ++- lib/common/commands/device/put-file.ts | 127 ++- lib/common/commands/device/run-application.ts | 105 +- .../commands/device/stop-application.ts | 88 +- .../commands/device/uninstall-application.ts | 77 +- lib/common/commands/doctor.ts | 95 +- lib/common/commands/generate-messages.ts | 61 +- lib/common/commands/help.ts | 93 +- lib/common/commands/package-manager-get.ts | 56 +- lib/common/commands/package-manager-set.ts | 63 +- lib/common/commands/post-install.ts | 32 +- lib/common/commands/preuninstall.ts | 96 +- lib/common/commands/proxy/proxy-base.ts | 47 +- lib/common/commands/proxy/proxy-clear.ts | 40 +- lib/common/commands/proxy/proxy-get.ts | 37 +- lib/common/commands/proxy/proxy-set.ts | 324 ++++--- lib/common/declarations.d.ts | 1 + lib/common/definitions/commands.d.ts | 2 + lib/common/test/unit-tests/preuninstall.ts | 44 +- lib/key-commands/bootstrap.ts | 22 +- lib/key-commands/index.ts | 282 +----- lib/platform-command-param.ts | 6 +- test/commands/post-install.ts | 23 +- test/platform-commands.ts | 93 +- test/plugin-create.ts | 58 +- test/plugins-service.ts | 13 +- test/project-commands.ts | 7 +- test/tns-appstore-upload.ts | 20 +- test/update.ts | 12 +- 86 files changed, 7199 insertions(+), 5825 deletions(-) create mode 100644 lib/commands/open.ts diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 634c916e47..a293bde809 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -207,8 +207,6 @@ injector.require( "vitestExecutionService", "./services/vitest-execution-service", ); -injector.requireCommand("dev-test|android", "./commands/test"); -injector.requireCommand("dev-test|ios", "./commands/test"); injector.requireCommand("test|android", "./commands/test"); injector.requireCommand("test|ios", "./commands/test"); injector.requireCommand("test|vision", "./commands/test"); diff --git a/lib/commands/add-platform.ts b/lib/commands/add-platform.ts index fdf6b93eb4..ca982bafb4 100644 --- a/lib/commands/add-platform.ts +++ b/lib/commands/add-platform.ts @@ -1,73 +1,102 @@ -import { ValidatePlatformCommandBase } from "./command-base"; -import { IProjectData } from "../definitions/project"; import { - IOptions, - IPlatformCommandHelper, - IPlatformValidationService, -} from "../declarations"; -import { IPlatformsDataService } from "../definitions/platform"; -import { ICommandParameter, ICommand } from "../common/definitions/commands"; + canExecuteCommandBase, + injectPlatformCommandServices, + IPlatformCommandServices, +} from "./command-base"; +import { IPlatformCommandHelper } from "../declarations"; import { IErrors } from "../common/declarations"; -import { injector } from "../common/yok"; +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; + +const addPlatformCommandOptions = { + frameworkPath: stringOption(), +} satisfies CommandOptionsSchema; -export class AddPlatformCommand - extends ValidatePlatformCommandBase - implements ICommand -{ - public allowedParameters: ICommandParameter[] = []; +export type AddPlatformCommandContext = CommandContext< + typeof addPlatformCommandOptions +>; + +export interface IAddPlatformCommandServices extends IPlatformCommandServices { + $errors: IErrors; + $platformCommandHelper: IPlatformCommandHelper; +} + +export function setupAddPlatformCommand(): IAddPlatformCommandServices { + const services = { + ...injectPlatformCommandServices(), + $errors: inject("errors"), + $platformCommandHelper: inject( + "platformCommandHelper", + ), + }; + services.$projectData.initializeProjectData(); + + return services; +} - constructor( - $options: IOptions, - private $platformCommandHelper: IPlatformCommandHelper, - $platformValidationService: IPlatformValidationService, - $projectData: IProjectData, - $platformsDataService: IPlatformsDataService, - private $errors: IErrors - ) { - super( - $options, - $platformsDataService, - $platformValidationService, - $projectData +export async function canExecuteAddPlatformCommand( + context: AddPlatformCommandContext, + services: IAddPlatformCommandServices, +): Promise { + const args = context.args; + if (!args || args.length === 0) { + services.$errors.failWithHelp( + "No platform specified. Please specify a platform to add.", ); - this.$projectData.initializeProjectData(); } - public async execute(args: string[]): Promise { - await this.$platformCommandHelper.addPlatforms( - args, - this.$projectData, - this.$options.frameworkPath + let canExecute = true; + for (const arg of args) { + services.$platformValidationService.validatePlatform( + arg, + services.$projectData, ); - } - public async canExecute(args: string[]): Promise { - if (!args || args.length === 0) { - this.$errors.failWithHelp( - "No platform specified. Please specify a platform to add." + if ( + !services.$platformValidationService.isPlatformSupportedForOS( + arg, + services.$projectData, + ) + ) { + services.$errors.fail( + `Applications for platform ${arg} cannot be built on this OS`, ); } - let canExecute = true; - for (const arg of args) { - this.$platformValidationService.validatePlatform(arg, this.$projectData); - - if ( - !this.$platformValidationService.isPlatformSupportedForOS( - arg, - this.$projectData - ) - ) { - this.$errors.fail( - `Applications for platform ${arg} cannot be built on this OS` - ); - } + // The assignment overwrites the previous platform's verdict, so only the + // last one decides. Kept as it was. + canExecute = await canExecuteCommandBase(services, arg); + } - canExecute = await super.canExecuteCommandBase(arg); - } + return canExecute; +} - return canExecute; - } +export async function runAddPlatformCommand( + context: AddPlatformCommandContext, + services: IAddPlatformCommandServices, +): Promise { + await services.$platformCommandHelper.addPlatforms( + context.args, + services.$projectData, + context.options.frameworkPath, + ); } -injector.registerCommand("platform|add", AddPlatformCommand); +export const addPlatformCommandDefinition = defineCommand({ + name: "platform|add", + description: + "Configures the current project to target the selected platform.", + options: addPlatformCommandOptions, + arguments: "any", + setup: setupAddPlatformCommand, + canExecute: canExecuteAddPlatformCommand, + run: runAddPlatformCommand, +}); + +registerCommand(addPlatformCommandDefinition); diff --git a/lib/commands/apple-login.ts b/lib/commands/apple-login.ts index c6e45c5326..cfc21e17bf 100644 --- a/lib/commands/apple-login.ts +++ b/lib/commands/apple-login.ts @@ -1,49 +1,65 @@ -import { StringCommandParameter } from "../common/command-params"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { IErrors } from "../common/declarations"; -import { IInjector } from "../common/definitions/yok"; -import { injector } from "../common/yok"; +import { CommandContext, defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; import { IApplePortalSessionService } from "../services/apple-portal/definitions"; -export class AppleLogin implements ICommand { - public allowedParameters: ICommandParameter[] = [ - new StringCommandParameter(this.$injector), - new StringCommandParameter(this.$injector), - ]; - - constructor( - private $applePortalSessionService: IApplePortalSessionService, - private $errors: IErrors, - private $injector: IInjector, - private $logger: ILogger, - private $prompter: IPrompter - ) {} - - public async execute(args: string[]): Promise { - let username = args[0]; - if (!username) { - username = await this.$prompter.getString("Apple ID", { - allowEmpty: false, - }); - } - - let password = args[1]; - if (!password) { - password = await this.$prompter.getPassword("Apple ID password"); - } - - const user = await this.$applePortalSessionService.createUserSession({ - username, - password, +export type AppleLoginCommandContext = CommandContext; + +export interface IAppleLoginCommandServices { + $applePortalSessionService: IApplePortalSessionService; + $errors: IErrors; + $logger: ILogger; + $prompter: IPrompter; +} + +export function setupAppleLoginCommand(): IAppleLoginCommandServices { + return { + $applePortalSessionService: inject( + "applePortalSessionService", + ), + $errors: inject("errors"), + $logger: inject("logger"), + $prompter: inject("prompter"), + }; +} + +export async function runAppleLoginCommand( + context: AppleLoginCommandContext, + services: IAppleLoginCommandServices, +): Promise { + let username = context.args[0]; + if (!username) { + username = await services.$prompter.getString("Apple ID", { + allowEmpty: false, }); - if (!user.areCredentialsValid) { - this.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.` - ); - } - - const output = Buffer.from(user.userSessionCookie).toString("base64"); - this.$logger.info(output); } + + let password = context.args[1]; + if (!password) { + password = await services.$prompter.getPassword("Apple ID password"); + } + + const user = await services.$applePortalSessionService.createUserSession({ + username, + password, + }); + if (!user.areCredentialsValid) { + services.$errors.fail( + `Invalid username and password combination. Used '${username}' as the username.`, + ); + } + + const output = Buffer.from(user.userSessionCookie).toString("base64"); + services.$logger.info(output); } -injector.registerCommand("apple-login", AppleLogin); + +export const appleLoginCommandDefinition = defineCommand({ + name: "apple-login", + description: "Logs in to an Apple account and prints the session cookie.", + arguments: [{ name: "appleId" }, { name: "password" }], + setup: setupAppleLoginCommand, + run: runAppleLoginCommand, +}); + +registerCommand(appleLoginCommandDefinition); diff --git a/lib/commands/appstore-list.ts b/lib/commands/appstore-list.ts index 910d038b57..529e6a29b5 100644 --- a/lib/commands/appstore-list.ts +++ b/lib/commands/appstore-list.ts @@ -1,98 +1,134 @@ +import { IErrors } from "../common/declarations"; +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; import { createTable } from "../common/helpers"; -import { StringCommandParameter } from "../common/command-params"; +import { registerCommand } from "../common/services/command-definition-adapter"; +import { IPlatformValidationService } from "../declarations"; import { IProjectData } from "../definitions/project"; -import { IPlatformValidationService, IOptions } from "../declarations"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { IInjector } from "../common/definitions/yok"; -import { injector } from "../common/yok"; -import { IErrors } from "../common/declarations"; import { IApplePortalApplicationService, IApplePortalSessionService, } from "../services/apple-portal/definitions"; -export class ListiOSApps implements ICommand { - public allowedParameters: ICommandParameter[] = [ - new StringCommandParameter(this.$injector), - new StringCommandParameter(this.$injector), - ]; +const listiOSAppsCommandOptions = { + appleSessionBase64: stringOption(), +} satisfies CommandOptionsSchema; + +export type ListiOSAppsCommandContext = CommandContext< + typeof listiOSAppsCommandOptions +>; + +export interface IListiOSAppsCommandServices { + $applePortalApplicationService: IApplePortalApplicationService; + $applePortalSessionService: IApplePortalSessionService; + $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; + $errors: IErrors; + $logger: ILogger; + $platformValidationService: IPlatformValidationService; + $projectData: IProjectData; + $prompter: IPrompter; +} + +export function setupListiOSAppsCommand(): IListiOSAppsCommandServices { + const services = { + $applePortalApplicationService: inject( + "applePortalApplicationService", + ), + $applePortalSessionService: inject( + "applePortalSessionService", + ), + $devicePlatformsConstants: inject( + "devicePlatformsConstants", + ), + $errors: inject("errors"), + $logger: inject("logger"), + $platformValidationService: inject( + "platformValidationService", + ), + $projectData: inject("projectData"), + $prompter: inject("prompter"), + }; + services.$projectData.initializeProjectData(); + + return services; +} - constructor( - private $injector: IInjector, - private $applePortalApplicationService: IApplePortalApplicationService, - private $applePortalSessionService: IApplePortalSessionService, - private $logger: ILogger, - private $projectData: IProjectData, - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - private $platformValidationService: IPlatformValidationService, - private $errors: IErrors, - private $prompter: IPrompter, - private $options: IOptions +export async function runListiOSAppsCommand( + context: ListiOSAppsCommandContext, + services: IListiOSAppsCommandServices, +): Promise { + if ( + !services.$platformValidationService.isPlatformSupportedForOS( + services.$devicePlatformsConstants.iOS, + services.$projectData, + ) ) { - this.$projectData.initializeProjectData(); + services.$errors.fail( + `Applications for platform ${services.$devicePlatformsConstants.iOS} can not be built on this OS`, + ); } - public async execute(args: string[]): Promise { - if ( - !this.$platformValidationService.isPlatformSupportedForOS( - this.$devicePlatformsConstants.iOS, - this.$projectData - ) - ) { - this.$errors.fail( - `Applications for platform ${this.$devicePlatformsConstants.iOS} can not be built on this OS` - ); - } - - let username = args[0]; - let password = args[1]; + let username = context.args[0]; + let password = context.args[1]; - if (!username) { - username = await this.$prompter.getString("Apple ID", { - allowEmpty: false, - }); - } + if (!username) { + username = await services.$prompter.getString("Apple ID", { + allowEmpty: false, + }); + } - if (!password) { - password = await this.$prompter.getPassword("Apple ID password"); - } + if (!password) { + password = await services.$prompter.getPassword("Apple ID password"); + } - const user = await this.$applePortalSessionService.createUserSession( - { username, password }, - { - sessionBase64: this.$options.appleSessionBase64, - } + const user = await services.$applePortalSessionService.createUserSession( + { username, password }, + { + sessionBase64: context.options.appleSessionBase64, + }, + ); + if (!user.areCredentialsValid) { + services.$errors.fail( + `Invalid username and password combination. Used '${username}' as the username.`, ); - if (!user.areCredentialsValid) { - this.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.` - ); - } + } - const applications = await this.$applePortalApplicationService.getApplications( - user - ); + const applications = + await services.$applePortalApplicationService.getApplications(user); - if (!applications || !applications.length) { - this.$logger.info("Seems you don't have any applications yet."); - } else { - const table: any = createTable( - ["Application Name", "Bundle Identifier", "In Flight Version"], - applications.map((application) => { - const version = - (application && - application.versionSets && - application.versionSets.length && - application.versionSets[0].inFlightVersion && - application.versionSets[0].inFlightVersion.version) || - ""; - return [application.name, application.bundleId, version]; - }) - ); + if (!applications || !applications.length) { + services.$logger.info("Seems you don't have any applications yet."); + } else { + const table: any = createTable( + ["Application Name", "Bundle Identifier", "In Flight Version"], + applications.map((application) => { + const version = + (application && + application.versionSets && + application.versionSets.length && + application.versionSets[0].inFlightVersion && + application.versionSets[0].inFlightVersion.version) || + ""; + return [application.name, application.bundleId, version]; + }), + ); - this.$logger.info(table.toString()); - } + services.$logger.info(table.toString()); } } -injector.registerCommand("appstore|*list", ListiOSApps); +export const listiOSAppsCommandDefinition = defineCommand({ + name: "appstore|*list", + description: "Lists the applications in App Store Connect.", + options: listiOSAppsCommandOptions, + arguments: [{ name: "appleId" }, { name: "password" }], + setup: setupListiOSAppsCommand, + run: runListiOSAppsCommand, +}); + +registerCommand(listiOSAppsCommandDefinition); diff --git a/lib/commands/appstore-upload.ts b/lib/commands/appstore-upload.ts index 1d80a11715..118edbe57c 100644 --- a/lib/commands/appstore-upload.ts +++ b/lib/commands/appstore-upload.ts @@ -1,148 +1,204 @@ import * as path from "path"; -import { StringCommandParameter } from "../common/command-params"; +import { IErrors, IHostInfo } from "../common/declarations"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, + objectOption, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; import { BuildController } from "../controllers/build-controller"; import { IOSBuildData } from "../data/build-data"; -import { IProjectData } from "../definitions/project"; import { IITMSTransporterService, IOptions, IPlatformValidationService, } from "../declarations"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { IInjector } from "../common/definitions/yok"; -import { injector } from "../common/yok"; -import { IHostInfo, IErrors } from "../common/declarations"; +import { IProjectData } from "../definitions/project"; import { IApplePortalSessionService } from "../services/apple-portal/definitions"; -export class PublishIOS implements ICommand { - public allowedParameters: ICommandParameter[] = [ - new StringCommandParameter(this.$injector), - new StringCommandParameter(this.$injector), - new StringCommandParameter(this.$injector), - new StringCommandParameter(this.$injector), - ]; - - constructor( - private $applePortalSessionService: IApplePortalSessionService, - private $injector: IInjector, - private $itmsTransporterService: IITMSTransporterService, - private $logger: ILogger, - private $projectData: IProjectData, - private $options: IOptions, - private $prompter: IPrompter, - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - private $hostInfo: IHostInfo, - private $errors: IErrors, - private $buildController: BuildController, - private $platformValidationService: IPlatformValidationService - ) { - this.$projectData.initializeProjectData(); - } +const publishIOSCommandOptions = { + appleApplicationSpecificPassword: stringOption(), + appleSessionBase64: stringOption(), + ipa: stringOption(), + provision: objectOption(), + release: booleanOption(), + teamId: objectOption(), +} satisfies CommandOptionsSchema; + +export type PublishIOSCommandContext = CommandContext< + typeof publishIOSCommandOptions +>; + +export interface IPublishIOSCommandServices { + $applePortalSessionService: IApplePortalSessionService; + $buildController: BuildController; + $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; + $errors: IErrors; + $hostInfo: IHostInfo; + $itmsTransporterService: IITMSTransporterService; + $logger: ILogger; + $options: IOptions; + $platformValidationService: IPlatformValidationService; + $projectData: IProjectData; + $prompter: IPrompter; +} - public async execute(args: string[]): Promise { - await this.$itmsTransporterService.validate( - this.$options.appleApplicationSpecificPassword - ); +export function setupPublishIOSCommand(): IPublishIOSCommandServices { + const services = { + $applePortalSessionService: inject( + "applePortalSessionService", + ), + $buildController: inject("buildController"), + $devicePlatformsConstants: inject( + "devicePlatformsConstants", + ), + $errors: inject("errors"), + $hostInfo: inject("hostInfo"), + $itmsTransporterService: inject( + "itmsTransporterService", + ), + $logger: inject("logger"), + $options: inject("options"), + $platformValidationService: inject( + "platformValidationService", + ), + $projectData: inject("projectData"), + $prompter: inject("prompter"), + }; + services.$projectData.initializeProjectData(); + + return services; +} - const username = - args[0] || - (await this.$prompter.getString("Apple ID", { allowEmpty: false })); - - const password = - args[1] || (await this.$prompter.getPassword("Apple ID password")); - - const user = await this.$applePortalSessionService.createUserSession( - { username, password }, - { - applicationSpecificPassword: - this.$options.appleApplicationSpecificPassword, - sessionBase64: this.$options.appleSessionBase64, - requireInteractiveConsole: true, - requireApplicationSpecificPassword: true, - } +export function canExecutePublishIOSCommand( + context: PublishIOSCommandContext, + services: IPublishIOSCommandServices, +): boolean { + if (!services.$hostInfo.isDarwin) { + services.$errors.fail("iOS publishing is only available on macOS."); + } + + if ( + !services.$platformValidationService.isPlatformSupportedForOS( + services.$devicePlatformsConstants.iOS, + services.$projectData, + ) + ) { + services.$errors.fail( + `Applications for platform ${services.$devicePlatformsConstants.iOS} can not be built on this OS`, ); - if (!user.areCredentialsValid) { - this.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.` - ); - } + } - const mobileProvisionIdentifier = this.$options.provision ?? args[2]; + return true; +} - let ipaFilePath = this.$options.ipa - ? path.resolve(this.$options.ipa) - : null; +export async function runPublishIOSCommand( + context: PublishIOSCommandContext, + services: IPublishIOSCommandServices, +): Promise { + await services.$itmsTransporterService.validate( + context.options.appleApplicationSpecificPassword, + ); + + const username = + context.args[0] || + (await services.$prompter.getString("Apple ID", { allowEmpty: false })); + + const password = + context.args[1] || + (await services.$prompter.getPassword("Apple ID password")); + + const user = await services.$applePortalSessionService.createUserSession( + { username, password }, + { + applicationSpecificPassword: + context.options.appleApplicationSpecificPassword, + sessionBase64: context.options.appleSessionBase64, + requireInteractiveConsole: true, + requireApplicationSpecificPassword: true, + }, + ); + if (!user.areCredentialsValid) { + services.$errors.fail( + `Invalid username and password combination. Used '${username}' as the username.`, + ); + } - if (!mobileProvisionIdentifier && !ipaFilePath) { - this.$logger.warn( - "No mobile provision identifier set. A default mobile provision will be used. You can set one in app/App_Resources/iOS/build.xcconfig" - ); - } + const mobileProvisionIdentifier = + context.options.provision ?? context.args[2]; - this.$options.release = true; - - if (!ipaFilePath) { - const platform = this.$devicePlatformsConstants.iOS.toLowerCase(); - // No .ipa path provided, build .ipa on out own. - if (mobileProvisionIdentifier) { - // This is not very correct as if we build multiple targets we will try to sign all of them using the signing identity here. - this.$logger.info( - "Building .ipa with the selected mobile provision and/or certificate. " + - mobileProvisionIdentifier - ); - - this.$options.provision = mobileProvisionIdentifier; - - const buildData = new IOSBuildData( - this.$projectData.projectDir, - platform, - { ...this.$options.argv, buildForAppStore: true, watch: false } - ); - ipaFilePath = await this.$buildController.prepareAndBuild(buildData); - } else { - this.$logger.info( - "No .ipa, mobile provision or certificate set. Perfect! Now we'll build .xcarchive and let Xcode pick the distribution certificate and provisioning profile for you when exporting .ipa for AppStore submission." - ); - const buildData = new IOSBuildData( - this.$projectData.projectDir, - platform, - { ...this.$options.argv, buildForAppStore: true, watch: false } - ); - ipaFilePath = await this.$buildController.prepareAndBuild(buildData); - this.$logger.info(`Export at: ${ipaFilePath}`); - } - } + let ipaFilePath = context.options.ipa + ? path.resolve(context.options.ipa) + : null; - await this.$itmsTransporterService.upload({ - credentials: { username, password }, - user, - applicationSpecificPassword: - this.$options.appleApplicationSpecificPassword, - ipaFilePath, - shouldExtractIpa: !!this.$options.ipa, - verboseLogging: this.$logger.getLevel() === "TRACE", - teamId: this.$options.teamId, - }); + if (!mobileProvisionIdentifier && !ipaFilePath) { + services.$logger.warn( + "No mobile provision identifier set. A default mobile provision will be used. You can set one in app/App_Resources/iOS/build.xcconfig", + ); } - public async canExecute(args: string[]): Promise { - if (!this.$hostInfo.isDarwin) { - this.$errors.fail("iOS publishing is only available on macOS."); - } + // The build data is spread off the parsed command line, so the flags the + // upload implies have to be set on the options service rather than on the + // context, which is a copy. + services.$options.release = true; + + if (!ipaFilePath) { + const platform = services.$devicePlatformsConstants.iOS.toLowerCase(); + // No .ipa path provided, build .ipa on out own. + if (mobileProvisionIdentifier) { + // This is not very correct as if we build multiple targets we will try to sign all of them using the signing identity here. + services.$logger.info( + "Building .ipa with the selected mobile provision and/or certificate. " + + mobileProvisionIdentifier, + ); - if ( - !this.$platformValidationService.isPlatformSupportedForOS( - this.$devicePlatformsConstants.iOS, - this.$projectData - ) - ) { - this.$errors.fail( - `Applications for platform ${this.$devicePlatformsConstants.iOS} can not be built on this OS` + services.$options.provision = mobileProvisionIdentifier; + + const buildData = new IOSBuildData( + services.$projectData.projectDir, + platform, + { ...services.$options.argv, buildForAppStore: true, watch: false }, + ); + ipaFilePath = await services.$buildController.prepareAndBuild(buildData); + } else { + services.$logger.info( + "No .ipa, mobile provision or certificate set. Perfect! Now we'll build .xcarchive and let Xcode pick the distribution certificate and provisioning profile for you when exporting .ipa for AppStore submission.", ); + const buildData = new IOSBuildData( + services.$projectData.projectDir, + platform, + { ...services.$options.argv, buildForAppStore: true, watch: false }, + ); + ipaFilePath = await services.$buildController.prepareAndBuild(buildData); + services.$logger.info(`Export at: ${ipaFilePath}`); } - - return true; } + + await services.$itmsTransporterService.upload({ + credentials: { username, password }, + user, + applicationSpecificPassword: + context.options.appleApplicationSpecificPassword, + ipaFilePath, + shouldExtractIpa: !!context.options.ipa, + verboseLogging: services.$logger.getLevel() === "TRACE", + teamId: context.options.teamId, + }); } -injector.registerCommand(["publish|ios", "appstore|upload"], PublishIOS); +export const publishIOSCommandDefinition = defineCommand({ + name: ["publish|ios", "appstore|upload"], + description: "Uploads a project to App Store Connect.", + options: publishIOSCommandOptions, + // Arguments have never been rejected here, only ignored past the third. + arguments: "any", + setup: setupPublishIOSCommand, + canExecute: canExecutePublishIOSCommand, + run: runPublishIOSCommand, +}); + +registerCommand(publishIOSCommandDefinition); diff --git a/lib/commands/build.ts b/lib/commands/build.ts index 7216e8a3fc..913c514914 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -2,279 +2,161 @@ import { ANDROID_RELEASE_BUILD_ERROR_MESSAGE, AndroidAppBundleMessages, } from "../constants"; -import { ValidatePlatformCommandBase } from "./command-base"; -import { hasValidAndroidSigning } from "../common/helpers"; -import { IProjectData } from "../definitions/project"; import { - IOptions, - IPlatformValidationService, - IAndroidBundleValidatorHelper, -} from "../declarations"; -import { IPlatformsDataService } from "../definitions/platform"; + canExecuteCommandBase, + injectPlatformCommandServices, + IPlatformCommandServices, + validatePlatformOptions, +} from "./command-base"; +import { hasValidAndroidSigning } from "../common/helpers"; +import { IAndroidBundleValidatorHelper } from "../declarations"; import { IBuildController, IBuildDataService } from "../definitions/build"; import { IMigrateController } from "../definitions/migrate"; import { IErrors } from "../common/declarations"; -import { OptionType } from "../common/enums"; -import { ICommandParameter, ICommand } from "../common/definitions/commands"; +import { + booleanOption, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject, InjectionToken } from "../common/di"; +import { registerCommandDefinition } from "../common/services/command-definition-adapter"; import { injector } from "../common/yok"; -export abstract class BuildCommandBase extends ValidatePlatformCommandBase { - constructor( - $options: IOptions, - protected $errors: IErrors, - $projectData: IProjectData, - $platformsDataService: IPlatformsDataService, - protected $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - protected $buildController: IBuildController, - $platformValidationService: IPlatformValidationService, - private $buildDataService: IBuildDataService, - protected $logger: ILogger, - ) { - super( - $options, - $platformsDataService, - $platformValidationService, - $projectData, - ); - this.$projectData.initializeProjectData(); - } - - public dashedOptions = { - watch: { - type: OptionType.Boolean, - default: false, - hasSensitiveValue: false, - }, - hmr: { type: OptionType.Boolean, default: false, hasSensitiveValue: false }, - }; +/** + * Which `$devicePlatformsConstants` entry this registration builds for. The + * constants stay the source of truth for the platform spelling. + */ +const BUILD_PLATFORM = new InjectionToken<"iOS" | "Android" | "visionOS">( + "buildCommandPlatform", +); + +const buildCommandOptions = { + watch: booleanOption({ default: false }), + hmr: booleanOption({ default: false }), + force: booleanOption(), + release: booleanOption(), + aab: booleanOption(), + keyStorePath: stringOption(), + keyStorePassword: stringOption(), + keyStoreAlias: stringOption(), + keyStoreAliasPassword: stringOption(), +} satisfies CommandOptionsSchema; + +interface IBuildCommandServices extends IPlatformCommandServices { + platform: string; + isAndroid: boolean; + $errors: IErrors; + $logger: ILogger; + $buildController: IBuildController; + $buildDataService: IBuildDataService; + $migrateController: IMigrateController; + $androidBundleValidatorHelper: IAndroidBundleValidatorHelper; +} - public async executeCore(args: string[]): Promise { - const platform = args[0].toLowerCase(); - const buildData = this.$buildDataService.getBuildData( - this.$projectData.projectDir, - platform, - this.$options, +export const buildCommandDefinition = defineCommand({ + name: "build", + description: "Builds the project for the selected target platform.", + options: buildCommandOptions, + arguments: "none", + setup(): IBuildCommandServices { + const devicePlatformsConstants = inject( + "devicePlatformsConstants", ); - const outputPath = await this.$buildController.prepareAndBuild(buildData); - - return outputPath; - } + const platform = devicePlatformsConstants[inject(BUILD_PLATFORM)]; + const isAndroid = devicePlatformsConstants.isAndroid(platform); + const services = { + ...injectPlatformCommandServices(), + platform, + isAndroid, + $errors: inject("errors"), + $logger: inject("logger"), + $buildController: inject("buildController"), + $buildDataService: inject("buildDataService"), + $migrateController: inject("migrateController"), + // Only the android build checks the runtime version. + $androidBundleValidatorHelper: isAndroid + ? inject("androidBundleValidatorHelper") + : null, + }; + services.$projectData.initializeProjectData(); + + return services; + }, + async canExecute(context, services): Promise { + const { platform } = services; + + if (!context.options.force) { + await services.$migrateController.validate({ + projectDir: services.$projectData.projectDir, + platforms: [platform], + }); + } - protected validatePlatform(platform: string): void { - if ( - !this.$platformValidationService.isPlatformSupportedForOS( + if (services.isAndroid) { + services.$androidBundleValidatorHelper.validateRuntimeVersion( + services.$projectData, + ); + } else if ( + !services.$platformValidationService.isPlatformSupportedForOS( platform, - this.$projectData, + services.$projectData, ) ) { - this.$errors.fail( + services.$errors.fail( `Applications for platform ${platform} can not be built on this OS`, ); } - } - protected async validateArgs( - args: string[], - platform: string, - ): Promise { - if (args.length !== 0) { - this.$errors.failWithHelp( - `The arguments '${args.join( - " ", - )}' are not valid for the current command.`, - ); + if (!(await canExecuteCommandBase(services, platform))) { + return false; } - const result = await this.$platformValidationService.validateOptions( - this.$options.provision, - this.$options.teamId, - this.$projectData, - platform, - ); - - return result; - } -} - -export class BuildIosCommand extends BuildCommandBase implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - protected $options: IOptions, - $errors: IErrors, - $projectData: IProjectData, - $platformsDataService: IPlatformsDataService, - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - $buildController: IBuildController, - $platformValidationService: IPlatformValidationService, - $logger: ILogger, - $buildDataService: IBuildDataService, - protected $migrateController: IMigrateController, - ) { - super( - $options, - $errors, - $projectData, - $platformsDataService, - $devicePlatformsConstants, - $buildController, - $platformValidationService, - $buildDataService, - $logger, - ); - } - - public async execute(args: string[]): Promise { - await this.executeCore([this.$devicePlatformsConstants.iOS.toLowerCase()]); - } - - public async canExecute(args: string[]): Promise { - const platform = this.$devicePlatformsConstants.iOS; - if (!this.$options.force) { - await this.$migrateController.validate({ - projectDir: this.$projectData.projectDir, - platforms: [platform], - }); - } - - super.validatePlatform(platform); - - let canExecute = await super.canExecuteCommandBase(platform); - if (canExecute) { - canExecute = await super.validateArgs(args, platform); + if ( + services.isAndroid && + context.options.release && + !hasValidAndroidSigning(context.options) + ) { + services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } - return canExecute; - } -} - -injector.registerCommand("build|ios", BuildIosCommand); - -export class BuildAndroidCommand extends BuildCommandBase implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - protected $options: IOptions, - protected $errors: IErrors, - $projectData: IProjectData, - platformsDataService: IPlatformsDataService, - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - $buildController: IBuildController, - $platformValidationService: IPlatformValidationService, - protected $androidBundleValidatorHelper: IAndroidBundleValidatorHelper, - $buildDataService: IBuildDataService, - protected $logger: ILogger, - private $migrateController: IMigrateController, - ) { - super( - $options, - $errors, - $projectData, - platformsDataService, - $devicePlatformsConstants, - $buildController, - $platformValidationService, - $buildDataService, - $logger, + return validatePlatformOptions(services, platform); + }, + async run(context, services): Promise { + const buildData = services.$buildDataService.getBuildData( + services.$projectData.projectDir, + services.platform.toLowerCase(), + services.$options, ); - } + const outputPath = + await services.$buildController.prepareAndBuild(buildData); - public async execute(args: string[]): Promise { - await this.executeCore([ - this.$devicePlatformsConstants.Android.toLowerCase(), - ]); - - if (this.$options.aab) { - this.$logger.info( + if (services.isAndroid && context.options.aab) { + services.$logger.info( AndroidAppBundleMessages.ANDROID_APP_BUNDLE_DOCS_MESSAGE, ); - if (this.$options.release) { - this.$logger.info( + if (context.options.release) { + services.$logger.info( AndroidAppBundleMessages.ANDROID_APP_BUNDLE_PUBLISH_DOCS_MESSAGE, ); } } - } - - public async canExecute(args: string[]): Promise { - const platform = this.$devicePlatformsConstants.Android; - if (!this.$options.force) { - await this.$migrateController.validate({ - projectDir: this.$projectData.projectDir, - platforms: [platform], - }); - } - this.$androidBundleValidatorHelper.validateRuntimeVersion( - this.$projectData, - ); - let canExecute = await super.canExecuteCommandBase(platform); - if (canExecute) { - if (this.$options.release && !hasValidAndroidSigning(this.$options)) { - this.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); - } - - canExecute = await super.validateArgs(args, platform); - } - return canExecute; - } -} - -injector.registerCommand("build|android", BuildAndroidCommand); - -export class BuildVisionOsCommand extends BuildIosCommand implements ICommand { - constructor( - protected $options: IOptions, - $errors: IErrors, - $projectData: IProjectData, - $platformsDataService: IPlatformsDataService, - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - $buildController: IBuildController, - $platformValidationService: IPlatformValidationService, - $logger: ILogger, - $buildDataService: IBuildDataService, - protected $migrateController: IMigrateController, - ) { - super( - $options, - $errors, - $projectData, - $platformsDataService, - $devicePlatformsConstants, - $buildController, - $platformValidationService, - $logger, - $buildDataService, - $migrateController, - ); - } - - public async execute(args: string[]): Promise { - await this.executeCore([ - this.$devicePlatformsConstants.visionOS.toLowerCase(), - ]); - } - - public async canExecute(args: string[]): Promise { - const platform = this.$devicePlatformsConstants.visionOS; - if (!this.$options.force) { - await this.$migrateController.validate({ - projectDir: this.$projectData.projectDir, - platforms: [platform], - }); - } - - super.validatePlatform(platform); - - let canExecute = await super.canExecuteCommandBase(platform); - if (canExecute) { - canExecute = await super.validateArgs(args, platform); - } - - return canExecute; - } + return outputPath; + }, +}); + +const buildCommandPlatforms: [string, "iOS" | "Android" | "visionOS"][] = [ + ["build|ios", "iOS"], + ["build|android", "Android"], + ["build|vision", "visionOS"], + ["build|visionos", "visionOS"], +]; + +for (const [name, platform] of buildCommandPlatforms) { + registerCommandDefinition( + { ...buildCommandDefinition, name }, + injector.createChild([{ provide: BUILD_PLATFORM, useValue: platform }]), + ); } - -injector.registerCommand("build|vision", BuildVisionOsCommand); -injector.registerCommand("build|visionos", BuildVisionOsCommand); diff --git a/lib/commands/clean.ts b/lib/commands/clean.ts index d891ead677..f45b1e6116 100644 --- a/lib/commands/clean.ts +++ b/lib/commands/clean.ts @@ -1,7 +1,20 @@ +import { readdir } from "fs/promises"; +import * as os from "os"; +import { resolve } from "path"; +import type { PromptObject } from "prompts"; import { color } from "../color"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { injector } from "../common/yok"; +import { IChildProcess } from "../common/declarations"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { isInteractive } from "../common/helpers"; +import { registerCommand } from "../common/services/command-definition-adapter"; import * as constants from "../constants"; +import { IStaticConfig } from "../declarations"; import { IProjectCleanupResult, IProjectCleanupService, @@ -9,19 +22,10 @@ import { IProjectData, IProjectService, } from "../definitions/project"; - -import type { PromptObject } from "prompts"; -import { IOptions, IStaticConfig } from "../declarations"; import { ITerminalSpinner, ITerminalSpinnerService, } from "../definitions/terminal-spinner-service"; -import { IChildProcess } from "../common/declarations"; -import * as os from "os"; - -import { resolve } from "path"; -import { readdir } from "fs/promises"; -import { isInteractive } from "../common/helpers"; function bytesToHumanReadable(bytes: number): string { const units = ["B", "KB", "MB", "GB", "TB"]; @@ -78,313 +82,356 @@ function promiseMap( }); } -export class CleanCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - private $projectCleanupService: IProjectCleanupService, - private $projectConfigService: IProjectConfigService, - private $projectData: IProjectData, - private $terminalSpinnerService: ITerminalSpinnerService, - private $projectService: IProjectService, - private $prompter: IPrompter, - private $logger: ILogger, - private $options: IOptions, - private $childProcess: IChildProcess, - private $staticConfig: IStaticConfig, - ) {} - - public async execute(args: string[]): Promise { - const isDryRun = this.$options.dryRun ?? false; - const isJSON = this.$options.json ?? false; - - const spinner = this.$terminalSpinnerService.createSpinner({ - isSilent: isJSON, - }); - - if (!this.$projectService.isValidNativeScriptProject()) { - return this.cleanMultipleProjects(spinner); - } - - spinner.start("Cleaning project...\n"); - - let pathsToClean = [ - constants.HOOKS_DIR_NAME, - this.$projectData.getBuildRelativeDirectoryPath(), - constants.NODE_MODULES_FOLDER_NAME, - ]; +const cleanCommandOptions = { + dryRun: booleanOption(), + json: booleanOption(), +} satisfies CommandOptionsSchema; + +export type CleanCommandContext = CommandContext; + +export interface ICleanCommandServices { + $childProcess: IChildProcess; + $logger: ILogger; + $projectCleanupService: IProjectCleanupService; + $projectConfigService: IProjectConfigService; + $projectData: IProjectData; + $projectService: IProjectService; + $prompter: IPrompter; + $staticConfig: IStaticConfig; + $terminalSpinnerService: ITerminalSpinnerService; +} - try { - const overridePathsToClean = - this.$projectConfigService.getValue("cli.pathsToClean"); - const additionalPaths = this.$projectConfigService.getValue( - "cli.additionalPathsToClean", - ); +export function setupCleanCommand(): ICleanCommandServices { + return { + $childProcess: inject("childProcess"), + $logger: inject("logger"), + $projectCleanupService: inject( + "projectCleanupService", + ), + $projectConfigService: inject( + "projectConfigService", + ), + $projectData: inject("projectData"), + $projectService: inject("projectService"), + $prompter: inject("prompter"), + $staticConfig: inject("staticConfig"), + $terminalSpinnerService: inject( + "terminalSpinnerService", + ), + }; +} - // allow overriding default paths to clean - if (Array.isArray(overridePathsToClean)) { - pathsToClean = overridePathsToClean; - } +async function getNSProjectPathsInDirectory( + services: ICleanCommandServices, + dir = process.cwd(), +): Promise { + let nsDirs: string[] = []; - if (Array.isArray(additionalPaths)) { - pathsToClean.push(...additionalPaths); - } - } catch (err) { - // ignore + const getFiles = async (dir: string) => { + if (dir.includes("node_modules")) { + // skip traversing node_modules + return; } - const res = await this.$projectCleanupService.clean(pathsToClean, { - dryRun: isDryRun, - silent: isJSON, - stats: isJSON, - }); + const dirents = await readdir(dir, { withFileTypes: true }).catch( + (err): any[] => { + services.$logger.trace( + 'Failed to read directory "%s". Error is:', + dir, + err, + ); + return []; + }, + ); - if (res.stats && isJSON) { - console.log( - JSON.stringify( - { - ok: res.ok, - dryRun: isDryRun, - stats: Object.fromEntries(res.stats.entries()), - }, - null, - 2, - ), - ); + const hasNSConfig = dirents.some( + (ent) => + ent.name.includes("nativescript.config.ts") || + ent.name.includes("nativescript.config.js"), + ); + if (hasNSConfig) { + nsDirs.push(dir); + // found a NativeScript project, stop traversing return; } - if (res.ok) { - spinner.succeed("Project successfully cleaned."); - } else { - spinner.fail(color.red("Project unsuccessfully cleaned.")); - } - } - - private async cleanMultipleProjects(spinner: ITerminalSpinner) { - if (!isInteractive() || this.$options.json) { - // interactive terminal is required, and we can't output json in an interactive command. - this.$logger.warn("No project found in the current directory."); - return; - } + await Promise.all( + dirents.map((dirent: any) => { + const res = resolve(dir, dirent.name); - const shouldScan = await this.$prompter.confirm( - "No project found in the current directory. Would you like to scan for all projects in sub-directories instead?", + if (dirent.isDirectory()) { + return getFiles(res); + } + }), ); + }; - if (!shouldScan) { - return; - } + await getFiles(dir); - spinner.start("Scanning for projects... Please wait."); - const paths = await this.getNSProjectPathsInDirectory(); - spinner.succeed(`Found ${paths.length} projects.`); + return nsDirs; +} - let computed = 0; - const updateProgress = () => { - const current = color.grey(`${computed}/${paths.length}`); - spinner.start( - `Gathering cleanable sizes. This may take a while... ${current}`, - ); - }; +async function cleanMultipleProjects( + context: CleanCommandContext, + services: ICleanCommandServices, + spinner: ITerminalSpinner, +) { + if (!isInteractive() || context.options.json) { + // interactive terminal is required, and we can't output json in an interactive command. + services.$logger.warn("No project found in the current directory."); + return; + } - // update the progress initially - updateProgress(); - - const projects = new Map(); - - await promiseMap( - paths, - (p) => { - return this.$childProcess - .exec( - `node ${this.$staticConfig.cliBinPath} clean --dry-run --json --disable-analytics`, - { - cwd: p, - }, - ) - .then((res) => { - const paths: Record = JSON.parse(res).stats; - return Object.values(paths).reduce((a, b) => a + b, 0); - }) - .catch((err) => { - this.$logger.trace( - "Failed to get project size for %s, Error is:", - p, - err, - ); - return -1; - }) - .then((size) => { - if (size > 0 || size === -1) { - // only store size if it's larger than 0 or -1 (error while getting size) - projects.set(p, size); - } - // update the progress after each processed project - computed++; - updateProgress(); - }); - }, - os.cpus().length, - ); + const shouldScan = await services.$prompter.confirm( + "No project found in the current directory. Would you like to scan for all projects in sub-directories instead?", + ); - spinner.clear(); - spinner.stop(); - - this.$logger.clearScreen(); - - const totalSize = Array.from(projects.values()) - .filter((s) => s > 0) - .reduce((a, b) => a + b, 0); - - const pathsToClean = await this.$prompter.promptForChoice( - `Found ${ - projects.size - } cleanable project(s) with a total size of: ${color.green( - bytesToHumanReadable(totalSize), - )}. Select projects to clean`, - Array.from(projects.keys()).map((p) => { - const size = projects.get(p); - let description; - if (size === -1) { - description = " - could not get size"; - } else { - description = ` - ${bytesToHumanReadable(size)}`; - } + if (!shouldScan) { + return; + } - return { - title: `${p}${color.grey(description)}`, - value: p, - }; - }), - true, - { - optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions - } as Partial, - ); - this.$logger.clearScreen(); - - spinner.warn( - `This will run "${color.yellow( - `ns clean`, - )}" in all the selected projects and ${color.styleText( - ["red", "bold"], - "delete files from your system", - )}!`, - ); - spinner.warn(`This action cannot be undone!`); + spinner.start("Scanning for projects... Please wait."); + const paths = await getNSProjectPathsInDirectory(services); + spinner.succeed(`Found ${paths.length} projects.`); - let confirmed = await this.$prompter.confirm( - "Are you sure you want to clean the selected projects?", + let computed = 0; + const updateProgress = () => { + const current = color.grey(`${computed}/${paths.length}`); + spinner.start( + `Gathering cleanable sizes. This may take a while... ${current}`, ); - if (!confirmed) { - return; - } + }; - spinner.info("Cleaning... This might take a while..."); + // update the progress initially + updateProgress(); - let totalSizeCleaned = 0; - for (let i = 0; i < pathsToClean.length; i++) { - const currentPath = pathsToClean[i]; + const projects = new Map(); - spinner.start( - `Cleaning ${color.cyan(currentPath)}... ${i + 1}/${pathsToClean.length}`, - ); - - const ok = await this.$childProcess + await promiseMap( + paths, + (p) => { + return services.$childProcess .exec( - `node ${this.$staticConfig.cliBinPath} clean ${ - this.$options.dryRun ? "--dry-run" : "" - } --json --disable-analytics`, + `node ${services.$staticConfig.cliBinPath} clean --dry-run --json --disable-analytics`, { - cwd: currentPath, + cwd: p, }, ) .then((res) => { - const cleanupRes = JSON.parse(res) as IProjectCleanupResult; - return cleanupRes.ok; + const paths: Record = JSON.parse(res).stats; + return Object.values(paths).reduce((a, b) => a + b, 0); }) .catch((err) => { - this.$logger.trace('Failed to clean project "%s"', currentPath, err); - return false; + services.$logger.trace( + "Failed to get project size for %s, Error is:", + p, + err, + ); + return -1; + }) + .then((size) => { + if (size > 0 || size === -1) { + // only store size if it's larger than 0 or -1 (error while getting size) + projects.set(p, size); + } + // update the progress after each processed project + computed++; + updateProgress(); }); - - if (ok) { - const cleanedSize = projects.get(currentPath); - const cleanedSizeStr = color.grey( - `- ${bytesToHumanReadable(cleanedSize)}`, - ); - spinner.succeed(`Cleaned ${color.cyan(currentPath)} ${cleanedSizeStr}`); - totalSizeCleaned += cleanedSize; + }, + os.cpus().length, + ); + + spinner.clear(); + spinner.stop(); + + services.$logger.clearScreen(); + + const totalSize = Array.from(projects.values()) + .filter((s) => s > 0) + .reduce((a, b) => a + b, 0); + + const pathsToClean = await services.$prompter.promptForChoice( + `Found ${ + projects.size + } cleanable project(s) with a total size of: ${color.green( + bytesToHumanReadable(totalSize), + )}. Select projects to clean`, + Array.from(projects.keys()).map((p) => { + const size = projects.get(p); + let description; + if (size === -1) { + description = " - could not get size"; } else { - spinner.fail(`Failed to clean ${color.cyan(currentPath)} - skipped`); + description = ` - ${bytesToHumanReadable(size)}`; } - } - spinner.clear(); - spinner.stop(); - spinner.succeed( - `Done! We've just freed up ${color.green( - bytesToHumanReadable(totalSizeCleaned), - )}! Woohoo! 🎉`, + + return { + title: `${p}${color.grey(description)}`, + value: p, + }; + }), + true, + { + optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions + } as Partial, + ); + services.$logger.clearScreen(); + + spinner.warn( + `This will run "${color.yellow( + `ns clean`, + )}" in all the selected projects and ${color.styleText( + ["red", "bold"], + "delete files from your system", + )}!`, + ); + spinner.warn(`This action cannot be undone!`); + + let confirmed = await services.$prompter.confirm( + "Are you sure you want to clean the selected projects?", + ); + if (!confirmed) { + return; + } + + spinner.info("Cleaning... This might take a while..."); + + let totalSizeCleaned = 0; + for (let i = 0; i < pathsToClean.length; i++) { + const currentPath = pathsToClean[i]; + + spinner.start( + `Cleaning ${color.cyan(currentPath)}... ${i + 1}/${pathsToClean.length}`, ); - if (this.$options.dryRun) { - spinner.info( - 'Note: the "--dry-run" flag was used, so no files were actually deleted.', + const ok = await services.$childProcess + .exec( + `node ${services.$staticConfig.cliBinPath} clean ${ + context.options.dryRun ? "--dry-run" : "" + } --json --disable-analytics`, + { + cwd: currentPath, + }, + ) + .then((res) => { + const cleanupRes = JSON.parse(res) as IProjectCleanupResult; + return cleanupRes.ok; + }) + .catch((err) => { + services.$logger.trace( + 'Failed to clean project "%s"', + currentPath, + err, + ); + return false; + }); + + if (ok) { + const cleanedSize = projects.get(currentPath); + const cleanedSizeStr = color.grey( + `- ${bytesToHumanReadable(cleanedSize)}`, ); + spinner.succeed(`Cleaned ${color.cyan(currentPath)} ${cleanedSizeStr}`); + totalSizeCleaned += cleanedSize; + } else { + spinner.fail(`Failed to clean ${color.cyan(currentPath)} - skipped`); } } + spinner.clear(); + spinner.stop(); + spinner.succeed( + `Done! We've just freed up ${color.green( + bytesToHumanReadable(totalSizeCleaned), + )}! Woohoo! 🎉`, + ); + + if (context.options.dryRun) { + spinner.info( + 'Note: the "--dry-run" flag was used, so no files were actually deleted.', + ); + } +} - private async getNSProjectPathsInDirectory( - dir = process.cwd(), - ): Promise { - let nsDirs: string[] = []; +export async function runCleanCommand( + context: CleanCommandContext, + services: ICleanCommandServices, +): Promise { + const isDryRun = context.options.dryRun ?? false; + const isJSON = context.options.json ?? false; - const getFiles = async (dir: string) => { - if (dir.includes("node_modules")) { - // skip traversing node_modules - return; - } + const spinner = services.$terminalSpinnerService.createSpinner({ + isSilent: isJSON, + }); - const dirents = await readdir(dir, { withFileTypes: true }).catch( - (err): any[] => { - this.$logger.trace( - 'Failed to read directory "%s". Error is:', - dir, - err, - ); - return []; - }, - ); + if (!services.$projectService.isValidNativeScriptProject()) { + return cleanMultipleProjects(context, services, spinner); + } - const hasNSConfig = dirents.some( - (ent) => - ent.name.includes("nativescript.config.ts") || - ent.name.includes("nativescript.config.js"), - ); + spinner.start("Cleaning project...\n"); - if (hasNSConfig) { - nsDirs.push(dir); - // found a NativeScript project, stop traversing - return; - } + let pathsToClean = [ + constants.HOOKS_DIR_NAME, + services.$projectData.getBuildRelativeDirectoryPath(), + constants.NODE_MODULES_FOLDER_NAME, + ]; - await Promise.all( - dirents.map((dirent: any) => { - const res = resolve(dir, dirent.name); + try { + const overridePathsToClean = + services.$projectConfigService.getValue("cli.pathsToClean"); + const additionalPaths = services.$projectConfigService.getValue( + "cli.additionalPathsToClean", + ); - if (dirent.isDirectory()) { - return getFiles(res); - } - }), - ); - }; + // allow overriding default paths to clean + if (Array.isArray(overridePathsToClean)) { + pathsToClean = overridePathsToClean; + } + + if (Array.isArray(additionalPaths)) { + pathsToClean.push(...additionalPaths); + } + } catch (err) { + // ignore + } + + const res = await services.$projectCleanupService.clean(pathsToClean, { + dryRun: isDryRun, + silent: isJSON, + stats: isJSON, + }); + + if (res.stats && isJSON) { + console.log( + JSON.stringify( + { + ok: res.ok, + dryRun: isDryRun, + stats: Object.fromEntries(res.stats.entries()), + }, + null, + 2, + ), + ); - await getFiles(dir); + return; + } - return nsDirs; + if (res.ok) { + spinner.succeed("Project successfully cleaned."); + } else { + spinner.fail(color.red("Project unsuccessfully cleaned.")); } } -injector.registerCommand("clean", CleanCommand); +export const cleanCommandDefinition = defineCommand({ + name: "clean", + description: "Cleans the project's build artefacts and dependencies.", + options: cleanCommandOptions, + arguments: "none", + setup: setupCleanCommand, + run: runCleanCommand, +}); + +registerCommand(cleanCommandDefinition); diff --git a/lib/commands/command-base.ts b/lib/commands/command-base.ts index 0f4e1f834c..748c54dcdd 100644 --- a/lib/commands/command-base.ts +++ b/lib/commands/command-base.ts @@ -6,66 +6,149 @@ import { ICanExecuteCommandOptions, INotConfiguredEnvOptions, } from "../common/definitions/commands"; +import { ArgumentSpec } from "../common/define-command"; +import { inject, Injector } from "../common/di"; +/** + * What the platform-validation helpers below need. A command definition's + * `setup` returns this shape (see `injectPlatformCommandServices`), so its + * result can be handed straight to them. + */ +export interface IPlatformCommandServices { + $options: IOptions; + $platformsDataService: IPlatformsDataService; + $platformValidationService: IPlatformValidationService; + $projectData: IProjectData; +} + +/** Callable from `setup` and from `canExecute` before their first `await`. */ +export function injectPlatformCommandServices(): IPlatformCommandServices { + return { + $options: inject("options"), + $platformsDataService: inject( + "platformsDataService", + ), + $platformValidationService: inject( + "platformValidationService", + ), + $projectData: inject("projectData"), + }; +} + +/** + * The declarative form of `$platformCommandParameter`. Initializing the + * project data is what makes the platform check possible, so it stays part of + * validating the argument instead of moving to `setup`, which the adapter runs + * only after argument enforcement. + */ +export function validatePlatformArgument( + targetInjector: Injector, + platform: string, +): void { + const projectData = targetInjector.get("projectData"); + projectData.initializeProjectData(); + targetInjector + .get("platformValidationService") + .validatePlatform(platform, projectData); +} + +/** The `platform` positional argument, shared by prepare, deploy and embed. */ +export const platformArgument: ArgumentSpec = { + name: "platform", + validate(value, context) { + validatePlatformArgument(context.injector, value); + return true; + }, +}; + +export function validatePlatformOptions( + services: IPlatformCommandServices, + platform: string, +): Promise { + return services.$platformValidationService.validateOptions( + services.$options.provision, + services.$options.teamId, + services.$projectData, + platform, + ); +} + +async function validatePlatformBase( + services: IPlatformCommandServices, + platform: string, + notConfiguredEnvOptions: INotConfiguredEnvOptions, +): Promise { + const platformData = services.$platformsDataService.getPlatformData( + platform, + services.$projectData, + ); + const platformProjectService = platformData.platformProjectService; + const result = await platformProjectService.validate( + services.$projectData, + services.$options, + notConfiguredEnvOptions, + ); + return result; +} + +function hasUsableEnvironment( + validatePlatformOutput: IValidatePlatformOutput, +): boolean { + return ( + validatePlatformOutput && + validatePlatformOutput.checkEnvironmentRequirementsOutput && + validatePlatformOutput.checkEnvironmentRequirementsOutput.canExecute + ); +} + +export async function canExecuteCommandBase( + services: IPlatformCommandServices, + platform: string, + options: ICanExecuteCommandOptions = {}, +): Promise { + const validatePlatformOutput = await validatePlatformBase( + services, + platform, + options.notConfiguredEnvOptions, + ); + const canExecute = hasUsableEnvironment(validatePlatformOutput); + let result = canExecute; + + if (canExecute && options.validateOptions) { + result = await validatePlatformOptions(services, platform); + } + + return result; +} + +/** + * @deprecated Nothing extends this any more; the exported functions beside it carry + * the same behaviour for definitions. + */ export abstract class ValidatePlatformCommandBase { constructor( protected $options: IOptions, protected $platformsDataService: IPlatformsDataService, protected $platformValidationService: IPlatformValidationService, - protected $projectData: IProjectData + protected $projectData: IProjectData, ) {} abstract allowedParameters: ICommandParameter[]; abstract execute(args: string[]): Promise; - public async canExecuteCommandBase( + public canExecuteCommandBase( platform: string, - options?: ICanExecuteCommandOptions + options?: ICanExecuteCommandOptions, ): Promise { - options = options || {}; - const validatePlatformOutput = await this.validatePlatformBase( - platform, - options.notConfiguredEnvOptions - ); - const canExecute = this.canExecuteCommand(validatePlatformOutput); - let result = canExecute; - - if (canExecute && options.validateOptions) { - result = await this.$platformValidationService.validateOptions( - this.$options.provision, - this.$options.teamId, - this.$projectData, - platform - ); - } - - return result; - } - - private async validatePlatformBase( - platform: string, - notConfiguredEnvOptions: INotConfiguredEnvOptions - ): Promise { - const platformData = this.$platformsDataService.getPlatformData( + return canExecuteCommandBase( + { + $options: this.$options, + $platformsDataService: this.$platformsDataService, + $platformValidationService: this.$platformValidationService, + $projectData: this.$projectData, + }, platform, - this.$projectData - ); - const platformProjectService = platformData.platformProjectService; - const result = await platformProjectService.validate( - this.$projectData, - this.$options, - notConfiguredEnvOptions - ); - return result; - } - - private canExecuteCommand( - validatePlatformOutput: IValidatePlatformOutput - ): boolean { - return ( - validatePlatformOutput && - validatePlatformOutput.checkEnvironmentRequirementsOutput && - validatePlatformOutput.checkEnvironmentRequirementsOutput.canExecute + options, ); } } diff --git a/lib/commands/config.ts b/lib/commands/config.ts index a37506bf47..7b4df4f3be 100644 --- a/lib/commands/config.ts +++ b/lib/commands/config.ts @@ -1,136 +1,147 @@ -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { injector } from "../common/yok"; import { IProjectConfigService } from "../definitions/project"; import { SupportedConfigValues } from "../tools/config-manipulation/config-transformer"; import { IErrors } from "../common/declarations"; +import { CommandContext, defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; import { color } from "../color"; -export class ConfigListCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export interface IConfigCommandServices { + $projectConfigService: IProjectConfigService; + $logger: ILogger; + $errors: IErrors; +} - constructor( - private $projectConfigService: IProjectConfigService, - private $logger: ILogger, - ) {} +export function injectConfigCommandServices(): IConfigCommandServices { + return { + $projectConfigService: inject( + "projectConfigService", + ), + $logger: inject("logger"), + $errors: inject("errors"), + }; +} - public async execute(args: string[]): Promise { - try { - const config = this.$projectConfigService.readConfig(); - this.$logger.info(this.getValueString(config as SupportedConfigValues)); - } catch (error) { - this.$logger.info("Failed to read config. Error is: ", error); - } +function getValueString(value: SupportedConfigValues, depth = 0): string { + const indent = () => " ".repeat(depth); + if (typeof value === "object") { + return ( + `${depth > 0 ? "\n" : ""}` + + Object.keys(value) + .map((key) => { + return ( + color.green(`${indent()}${key}: `) + + // @ts-ignore + getValueString(value[key], depth + 1) + ); + }) + .join("\n") + ); + } else { + return color.yellow( + typeof value === "undefined" ? "undefined" : value.toString(), + ); } +} - private getValueString(value: SupportedConfigValues, depth = 0): string { - const indent = () => " ".repeat(depth); - if (typeof value === "object") { - return ( - `${depth > 0 ? "\n" : ""}` + - Object.keys(value) - .map((key) => { - return ( - color.green(`${indent()}${key}: `) + - // @ts-ignore - this.getValueString(value[key], depth + 1) - ); - }) - .join("\n") - ); - } else { - return color.yellow(typeof value === 'undefined' ? 'undefined' : value.toString()); - } +function getConvertedValue(v: any): any { + try { + return JSON.parse(v); + } catch (e) { + // just treat it as a string + return `${v}`; } } -export class ConfigGetCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export function requireConfigKey( + context: CommandContext, + services: IConfigCommandServices, +): void { + if (!context.args[0]) { + services.$errors.failWithHelp("You must specify a key. Eg: ios.id"); + } +} - constructor( - private $projectConfigService: IProjectConfigService, - private $logger: ILogger, - private $errors: IErrors, - ) {} +export const configListCommandDefinition = defineCommand({ + name: "config|*list", + description: "Prints the project configuration.", + arguments: "none", + setup: injectConfigCommandServices, + async run(context, services): Promise { + try { + const config = services.$projectConfigService.readConfig(); + services.$logger.info(getValueString(config as SupportedConfigValues)); + } catch (error) { + services.$logger.info("Failed to read config. Error is: ", error); + } + }, +}); - public async execute(args: string[]): Promise { +export const configGetCommandDefinition = defineCommand({ + name: "config|get", + description: "Prints the value the project configuration holds for a key.", + arguments: "any", + setup: injectConfigCommandServices, + async canExecute(context, services): Promise { + requireConfigKey(context, services); + + return true; + }, + async run(context, services): Promise { try { - const [key] = args; - const current = this.$projectConfigService.getValue(key); - this.$logger.info(current); + const [key] = context.args; + const current = services.$projectConfigService.getValue(key); + services.$logger.info(current); } catch (err) { // ignore } - } - - public async canExecute(args: string[]): Promise { - if (!args[0]) { - this.$errors.failWithHelp("You must specify a key. Eg: ios.id"); + }, +}); + +export const configSetCommandDefinition = defineCommand({ + name: "config|set", + description: "Sets a value in the project configuration.", + arguments: "any", + setup: injectConfigCommandServices, + async canExecute(context, services): Promise { + requireConfigKey(context, services); + + if (!context.args[1]) { + services.$errors.failWithHelp("You must specify a value."); } return true; - } -} - -export class ConfigSetCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - private $projectConfigService: IProjectConfigService, - private $logger: ILogger, - private $errors: IErrors, - ) {} - - public async execute(args: string[]): Promise { - const [key, value] = args; - const current = this.$projectConfigService.getValue(key); + }, + async run(context, services): Promise { + const [key, value] = context.args; + const current = services.$projectConfigService.getValue(key); if (current && typeof current === "object") { - this.$errors.fail( + services.$errors.fail( `Unable to change object values. Please update individual values instead.\nEg: ns config set android.codeCache true`, ); } - const convertedValue = this.getConvertedValue(value); + const convertedValue = getConvertedValue(value); const existingKey = current !== undefined; const keyDisplay = color.green(key); // when current is undefined, return empty string to avoid throw const currentDisplay = current ? color.yellow(current) : ""; const updatedDisplay = color.cyan(convertedValue); - this.$logger.info( + services.$logger.info( `${existingKey ? "Updating" : "Setting"} ${keyDisplay}${ existingKey ? ` from ${currentDisplay} ` : " " }to ${updatedDisplay}`, ); try { - await this.$projectConfigService.setValue(key, convertedValue); - this.$logger.info("Done"); + await services.$projectConfigService.setValue(key, convertedValue); + services.$logger.info("Done"); } catch (error) { - this.$logger.info("Could not update conifg. Error is: ", error); - } - } - - public async canExecute(args: string[]): Promise { - if (!args[0]) { - this.$errors.failWithHelp("You must specify a key. Eg: ios.id"); - } - - if (!args[1]) { - this.$errors.failWithHelp("You must specify a value."); + services.$logger.info("Could not update conifg. Error is: ", error); } + }, +}); - return true; - } - - private getConvertedValue(v: any): any { - try { - return JSON.parse(v); - } catch (e) { - // just treat it as a string - return `${v}`; - } - } -} - -injector.registerCommand("config|*list", ConfigListCommand); -injector.registerCommand("config|get", ConfigGetCommand); -injector.registerCommand("config|set", ConfigSetCommand); +registerCommand(configListCommandDefinition); +registerCommand(configGetCommandDefinition); +registerCommand(configSetCommandDefinition); diff --git a/lib/commands/create-project.ts b/lib/commands/create-project.ts index c817d1e188..0b4167198e 100644 --- a/lib/commands/create-project.ts +++ b/lib/commands/create-project.ts @@ -1,491 +1,534 @@ -import * as constants from "../constants"; import * as path from "path"; +import { color } from "../color"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; import { isInteractive } from "../common/helpers"; +import { registerCommand } from "../common/services/command-definition-adapter"; +import * as constants from "../constants"; import { ICreateProjectData, IProjectService } from "../definitions/project"; -import { IOptions } from "../declarations"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { IErrors } from "../common/declarations"; -import { injector } from "../common/yok"; -import { color } from "../color"; -export class CreateProjectCommand implements ICommand { - public enableHooks = false; - public allowedParameters: ICommandParameter[] = [this.$stringParameter]; - private static BlankTemplateKey = "Blank"; - private static BlankTemplateDescription = "A blank app"; - private static BlankTsTemplateKey = "Blank Typescript"; - private static BlankTsTemplateDescription = "A blank typescript app"; - private static BlankVisionTemplateKey = "visionOS"; - private static BlankVisionTemplateDescription = "A visionOS app"; - private static HelloWorldTemplateKey = "Hello World"; - private static HelloWorldTemplateDescription = "A Hello World app"; - private static DrawerTemplateKey = "SideDrawer"; - private static DrawerTemplateDescription = - "An app with pre-built pages that uses a drawer for navigation"; - private static TabsTemplateKey = "Tabs"; - private static TabsTemplateDescription = - "An app with pre-built pages that uses tabs for navigation"; - private isInteractionIntroShown = false; - - private createdProjectData: ICreateProjectData; - - constructor( - private $projectService: IProjectService, - private $logger: ILogger, - private $errors: IErrors, - private $options: IOptions, - private $prompter: IPrompter, - private $stringParameter: ICommandParameter - ) {} - - public async execute(args: string[]): Promise { - const interactiveAdverbs = ["First", "Next", "Finally"]; - const getNextInteractiveAdverb = () => { - return interactiveAdverbs.shift() || "Next"; - }; - - if ( - (this.$options.tsc || - this.$options.ng || - this.$options.vue || - this.$options.react || - this.$options.solid || - this.$options.svelte || - this.$options.js) && - this.$options.template - ) { - this.$errors.failWithHelp( - "You cannot use a flavor option like --ng, --vue, --react, --solid, --svelte, --tsc and --js together with --template." - ); - } +const BLANK_TEMPLATE_KEY = "Blank"; +const BLANK_TEMPLATE_DESCRIPTION = "A blank app"; +const BLANK_TS_TEMPLATE_KEY = "Blank Typescript"; +const BLANK_TS_TEMPLATE_DESCRIPTION = "A blank typescript app"; +const BLANK_VISION_TEMPLATE_KEY = "visionOS"; +const BLANK_VISION_TEMPLATE_DESCRIPTION = "A visionOS app"; +const HELLO_WORLD_TEMPLATE_KEY = "Hello World"; +const HELLO_WORLD_TEMPLATE_DESCRIPTION = "A Hello World app"; +const DRAWER_TEMPLATE_KEY = "SideDrawer"; +const DRAWER_TEMPLATE_DESCRIPTION = + "An app with pre-built pages that uses a drawer for navigation"; +const TABS_TEMPLATE_KEY = "Tabs"; +const TABS_TEMPLATE_DESCRIPTION = + "An app with pre-built pages that uses tabs for navigation"; + +export 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; + +export type CreateProjectCommandContext = CommandContext< + typeof createProjectCommandOptions +>; + +export interface ICreateProjectCommandServices { + $projectService: IProjectService; + $logger: ILogger; + $prompter: IPrompter; +} - let projectName = args[0]; - let selectedTemplate: string; - if ( - this.$options["vision-ng"] || - (this.$options.vision && this.$options.ng) - ) { - selectedTemplate = constants.RESERVED_TEMPLATE_NAMES["vision-ng"]; - } else if ( - this.$options["vision-react"] || - (this.$options.vision && this.$options.react) - ) { - selectedTemplate = constants.RESERVED_TEMPLATE_NAMES["vision-react"]; - } else if ( - this.$options["vision-solid"] || - (this.$options.vision && this.$options.solid) - ) { - selectedTemplate = constants.RESERVED_TEMPLATE_NAMES["vision-solid"]; - } else if ( - this.$options["vision-svelte"] || - (this.$options.vision && this.$options.svelte) - ) { - selectedTemplate = constants.RESERVED_TEMPLATE_NAMES["vision-svelte"]; - } else if ( - this.$options["vision-vue"] || - (this.$options.vision && (this.$options.vue || this.$options.vuejs)) - ) { - selectedTemplate = constants.RESERVED_TEMPLATE_NAMES["vision-vue"]; - } else if ( - (this.$options.vue || this.$options.vuejs) && - this.$options.tsc - ) { - selectedTemplate = "@nativescript/template-blank-vue-ts"; - } else if (this.$options.vision) { - selectedTemplate = constants.RESERVED_TEMPLATE_NAMES["vision"]; - } else if (this.$options.js) { - selectedTemplate = constants.JAVASCRIPT_NAME; - } else if (this.$options.tsc) { - selectedTemplate = constants.TYPESCRIPT_NAME; - } else if (this.$options.ng) { - selectedTemplate = constants.ANGULAR_NAME; - } else if (this.$options.vue || this.$options.vuejs) { - selectedTemplate = constants.VUE_NAME; - } else if (this.$options.solid) { - selectedTemplate = constants.SOLID_NAME; - } else if (this.$options.react) { - selectedTemplate = constants.REACT_NAME; - } else if (this.$options.svelte) { - selectedTemplate = constants.SVELTE_NAME; - } else { - selectedTemplate = this.$options.template; - } +export function setupCreateProjectCommand(): ICreateProjectCommandServices { + return { + $projectService: inject("projectService"), + $logger: inject("logger"), + $prompter: inject("prompter"), + }; +} - if (!projectName && isInteractive()) { - this.printInteractiveCreationIntroIfNeeded(); - projectName = await this.$prompter.getString( - `${getNextInteractiveAdverb()}, what will be the name of your app?`, - { allowEmpty: false } - ); - this.$logger.info(); - } +interface ITemplateChoice { + key?: string; + value: string; + description?: string; +} - projectName = await this.$projectService.validateProjectName({ - projectName: projectName, - force: this.$options.force, - pathToProject: this.$options.path, - }); +function getJsTemplates(): ITemplateChoice[] { + return [ + { + key: HELLO_WORLD_TEMPLATE_KEY, + value: constants.RESERVED_TEMPLATE_NAMES.javascript, + description: HELLO_WORLD_TEMPLATE_DESCRIPTION, + }, + { + key: DRAWER_TEMPLATE_KEY, + value: "@nativescript/template-drawer-navigation", + description: DRAWER_TEMPLATE_DESCRIPTION, + }, + { + key: TABS_TEMPLATE_KEY, + value: "@nativescript/template-tab-navigation", + description: TABS_TEMPLATE_DESCRIPTION, + }, + ]; +} - if (!selectedTemplate && isInteractive()) { - this.printInteractiveCreationIntroIfNeeded(); - selectedTemplate = await this.interactiveFlavorAndTemplateSelection( - getNextInteractiveAdverb(), - getNextInteractiveAdverb() - ); - } +function getTsTemplates(): ITemplateChoice[] { + return [ + { + key: HELLO_WORLD_TEMPLATE_KEY, + value: constants.RESERVED_TEMPLATE_NAMES.typescript, + description: HELLO_WORLD_TEMPLATE_DESCRIPTION, + }, + { + key: DRAWER_TEMPLATE_KEY, + value: "@nativescript/template-drawer-navigation-ts", + description: DRAWER_TEMPLATE_DESCRIPTION, + }, + { + key: TABS_TEMPLATE_KEY, + value: "@nativescript/template-tab-navigation-ts", + description: TABS_TEMPLATE_DESCRIPTION, + }, + { + key: BLANK_VISION_TEMPLATE_KEY, + value: "@nativescript/template-hello-world-ts-vision", + description: BLANK_VISION_TEMPLATE_DESCRIPTION, + }, + ]; +} - this.createdProjectData = await this.$projectService.createProject({ - projectName: projectName, - template: selectedTemplate, - appId: this.$options.appid, - pathToProject: this.$options.path, - // its already validated above - force: true, - ignoreScripts: this.$options.ignoreScripts, - }); - } +function getNgTemplates(): ITemplateChoice[] { + return [ + { + key: HELLO_WORLD_TEMPLATE_KEY, + value: constants.RESERVED_TEMPLATE_NAMES.angular, + description: HELLO_WORLD_TEMPLATE_DESCRIPTION, + }, + { + key: DRAWER_TEMPLATE_KEY, + value: "@nativescript/template-drawer-navigation-ng", + description: DRAWER_TEMPLATE_DESCRIPTION, + }, + { + key: TABS_TEMPLATE_KEY, + value: "@nativescript/template-tab-navigation-ng", + description: TABS_TEMPLATE_DESCRIPTION, + }, + { + key: BLANK_VISION_TEMPLATE_KEY, + value: "@nativescript/template-hello-world-ng-vision", + description: BLANK_VISION_TEMPLATE_DESCRIPTION, + }, + ]; +} - private async interactiveFlavorAndTemplateSelection( - flavorAdverb: string, - templateAdverb: string - ) { - const selectedFlavor = await this.interactiveFlavorSelection(flavorAdverb); - const selectedTemplate: string = await this.interactiveTemplateSelection( - selectedFlavor, - templateAdverb - ); +function getReactTemplates(): ITemplateChoice[] { + return [ + { + key: HELLO_WORLD_TEMPLATE_KEY, + value: constants.RESERVED_TEMPLATE_NAMES.react, + description: HELLO_WORLD_TEMPLATE_DESCRIPTION, + }, + { + key: BLANK_VISION_TEMPLATE_KEY, + value: "@nativescript/template-blank-react-vision", + description: BLANK_VISION_TEMPLATE_DESCRIPTION, + }, + ]; +} - return selectedTemplate; +function getSolidTemplates(): ITemplateChoice[] { + return [ + { + key: HELLO_WORLD_TEMPLATE_KEY, + value: constants.RESERVED_TEMPLATE_NAMES.solid, + description: HELLO_WORLD_TEMPLATE_DESCRIPTION, + }, + { + key: `${HELLO_WORLD_TEMPLATE_KEY} using TypeScript`, + value: constants.RESERVED_TEMPLATE_NAMES.solidts, + description: `${HELLO_WORLD_TEMPLATE_DESCRIPTION} using TypeScript`, + }, + { + key: BLANK_VISION_TEMPLATE_KEY, + value: "@nativescript/template-blank-solid-vision", + description: BLANK_VISION_TEMPLATE_DESCRIPTION, + }, + ]; +} + +function getSvelteTemplates(): ITemplateChoice[] { + return [ + { + key: HELLO_WORLD_TEMPLATE_KEY, + value: constants.RESERVED_TEMPLATE_NAMES.svelte, + description: HELLO_WORLD_TEMPLATE_DESCRIPTION, + }, + { + key: BLANK_VISION_TEMPLATE_KEY, + value: "@nativescript/template-blank-svelte-vision", + description: BLANK_VISION_TEMPLATE_DESCRIPTION, + }, + ]; +} + +function getVueTemplates(): ITemplateChoice[] { + return [ + { + key: BLANK_TEMPLATE_KEY, + value: "@nativescript/template-blank-vue", + description: BLANK_TEMPLATE_DESCRIPTION, + }, + { + key: BLANK_TS_TEMPLATE_KEY, + value: "@nativescript/template-blank-vue-ts", + description: BLANK_TS_TEMPLATE_DESCRIPTION, + }, + { + key: DRAWER_TEMPLATE_KEY, + value: "@nativescript/template-drawer-navigation-vue", + description: DRAWER_TEMPLATE_DESCRIPTION, + }, + { + key: TABS_TEMPLATE_KEY, + value: "@nativescript/template-tab-navigation-vue", + description: TABS_TEMPLATE_DESCRIPTION, + }, + { + key: BLANK_VISION_TEMPLATE_KEY, + value: "@nativescript/template-blank-vue-vision", + description: BLANK_VISION_TEMPLATE_DESCRIPTION, + }, + ]; +} + +const flavorTemplates: { [flavorName: string]: () => ITemplateChoice[] } = { + [constants.NgFlavorName]: getNgTemplates, + [constants.ReactFlavorName]: getReactTemplates, + [constants.VueFlavorName]: getVueTemplates, + [constants.SolidFlavorName]: getSolidTemplates, + [constants.SvelteFlavorName]: getSvelteTemplates, + [constants.TsFlavorName]: getTsTemplates, + [constants.JsFlavorName]: getJsTemplates, +}; + +/** The template a flavor flag selects, without asking anything. */ +function selectTemplateFromOptions( + options: CreateProjectCommandContext["options"], +): string { + if (options["vision-ng"] || (options.vision && options.ng)) { + return constants.RESERVED_TEMPLATE_NAMES["vision-ng"]; } - private async interactiveFlavorSelection(adverb: string) { - const flavorSelection = await this.$prompter.promptForDetailedChoice( - `${adverb}, which style of NativeScript project would you like to use:`, - [ - { - key: constants.NgFlavorName, - description: "Learn more at https://nativescript.org/angular", - }, - { - key: constants.ReactFlavorName, - description: - "Learn more at https://github.com/shirakaba/react-nativescript", - }, - { - key: constants.VueFlavorName, - description: "Learn more at https://nativescript.org/vue", - }, - { - key: constants.SolidFlavorName, - description: "Learn more at https://www.solidjs.com", - }, - { - key: constants.SvelteFlavorName, - description: "Learn more at https://svelte-native.technology", - }, - { - key: constants.TsFlavorName, - description: "Learn more at https://nativescript.org/typescript", - }, - { - key: constants.JsFlavorName, - description: "Use NativeScript without any framework", - }, - ] - ); - return flavorSelection; + if (options["vision-react"] || (options.vision && options.react)) { + return constants.RESERVED_TEMPLATE_NAMES["vision-react"]; } - private printInteractiveCreationIntroIfNeeded() { - if (!this.isInteractionIntroShown) { - this.isInteractionIntroShown = true; - this.$logger.info(); - this.$logger.printMarkdown(`# Let’s create a NativeScript app!`); - this.$logger.printMarkdown(` -Answer the following questions to help us build the right app for you. (Note: you -can skip this prompt next time using the --template option, or using --ng, --react, --solid, --svelte, --vue, --ts, or --js flags.) -`); - } + if (options["vision-solid"] || (options.vision && options.solid)) { + return constants.RESERVED_TEMPLATE_NAMES["vision-solid"]; } - private async interactiveTemplateSelection( - flavorSelection: string, - adverb: string - ) { - const selectedFlavorTemplates: { - key?: string; - value: string; - description?: string; - }[] = []; - let selectedTemplate: string; - switch (flavorSelection) { - case constants.NgFlavorName: { - selectedFlavorTemplates.push(...this.getNgTemplates()); - break; - } - case constants.ReactFlavorName: { - selectedFlavorTemplates.push(...this.getReactTemplates()); - break; - } - case constants.VueFlavorName: { - selectedFlavorTemplates.push(...this.getVueTemplates()); - break; - } - case constants.SolidFlavorName: { - selectedFlavorTemplates.push(...this.getSolidTemplates()); - break; - } - case constants.SvelteFlavorName: { - selectedFlavorTemplates.push(...this.getSvelteTemplates()); - break; - } - case constants.TsFlavorName: { - selectedFlavorTemplates.push(...this.getTsTemplates()); - break; - } - case constants.JsFlavorName: { - selectedFlavorTemplates.push(...this.getJsTemplates()); - break; - } - } - if (selectedFlavorTemplates.length > 1) { - this.$logger.info(); - const templateChoices = selectedFlavorTemplates.map((template) => { - return { key: template.key, description: template.description }; - }); - const selectedTemplateKey = await this.$prompter.promptForDetailedChoice( - `${adverb}, which template would you like to start from:`, - templateChoices - ); - selectedTemplate = selectedFlavorTemplates.find( - (t) => t.key === selectedTemplateKey - ).value; - } else { - selectedTemplate = selectedFlavorTemplates[0].value; - } - return selectedTemplate; + if (options["vision-svelte"] || (options.vision && options.svelte)) { + return constants.RESERVED_TEMPLATE_NAMES["vision-svelte"]; } - private getJsTemplates() { - const templates = [ - { - key: CreateProjectCommand.HelloWorldTemplateKey, - value: constants.RESERVED_TEMPLATE_NAMES.javascript, - description: CreateProjectCommand.HelloWorldTemplateDescription, - }, - { - key: CreateProjectCommand.DrawerTemplateKey, - value: "@nativescript/template-drawer-navigation", - description: CreateProjectCommand.DrawerTemplateDescription, - }, - { - key: CreateProjectCommand.TabsTemplateKey, - value: "@nativescript/template-tab-navigation", - description: CreateProjectCommand.TabsTemplateDescription, - }, - ]; + if ( + options["vision-vue"] || + (options.vision && (options.vue || options.vuejs)) + ) { + return constants.RESERVED_TEMPLATE_NAMES["vision-vue"]; + } - return templates; + if ((options.vue || options.vuejs) && options.tsc) { + return "@nativescript/template-blank-vue-ts"; } - private getTsTemplates() { - const templates = [ - { - key: CreateProjectCommand.HelloWorldTemplateKey, - value: constants.RESERVED_TEMPLATE_NAMES.typescript, - description: CreateProjectCommand.HelloWorldTemplateDescription, - }, - { - key: CreateProjectCommand.DrawerTemplateKey, - value: "@nativescript/template-drawer-navigation-ts", - description: CreateProjectCommand.DrawerTemplateDescription, - }, - { - key: CreateProjectCommand.TabsTemplateKey, - value: "@nativescript/template-tab-navigation-ts", - description: CreateProjectCommand.TabsTemplateDescription, - }, - { - key: CreateProjectCommand.BlankVisionTemplateKey, - value: "@nativescript/template-hello-world-ts-vision", - description: CreateProjectCommand.BlankVisionTemplateDescription, - }, - ]; + if (options.vision) { + return constants.RESERVED_TEMPLATE_NAMES["vision"]; + } - return templates; + if (options.js) { + return constants.JAVASCRIPT_NAME; } - private getNgTemplates() { - const templates = [ - { - key: CreateProjectCommand.HelloWorldTemplateKey, - value: constants.RESERVED_TEMPLATE_NAMES.angular, - description: CreateProjectCommand.HelloWorldTemplateDescription, - }, - { - key: CreateProjectCommand.DrawerTemplateKey, - value: "@nativescript/template-drawer-navigation-ng", - description: CreateProjectCommand.DrawerTemplateDescription, - }, - { - key: CreateProjectCommand.TabsTemplateKey, - value: "@nativescript/template-tab-navigation-ng", - description: CreateProjectCommand.TabsTemplateDescription, - }, - { - key: CreateProjectCommand.BlankVisionTemplateKey, - value: "@nativescript/template-hello-world-ng-vision", - description: CreateProjectCommand.BlankVisionTemplateDescription, - }, - ]; + if (options.tsc) { + return constants.TYPESCRIPT_NAME; + } - return templates; + if (options.ng) { + return constants.ANGULAR_NAME; } - private getReactTemplates() { - const templates = [ - { - key: CreateProjectCommand.HelloWorldTemplateKey, - value: constants.RESERVED_TEMPLATE_NAMES.react, - description: CreateProjectCommand.HelloWorldTemplateDescription, - }, - { - key: CreateProjectCommand.BlankVisionTemplateKey, - value: "@nativescript/template-blank-react-vision", - description: CreateProjectCommand.BlankVisionTemplateDescription, - }, - ]; + if (options.vue || options.vuejs) { + return constants.VUE_NAME; + } - return templates; + if (options.solid) { + return constants.SOLID_NAME; } - private getSolidTemplates() { - const templates = [ - { - key: CreateProjectCommand.HelloWorldTemplateKey, - value: constants.RESERVED_TEMPLATE_NAMES.solid, - description: CreateProjectCommand.HelloWorldTemplateDescription, - }, - { - key: `${CreateProjectCommand.HelloWorldTemplateKey} using TypeScript`, - value: constants.RESERVED_TEMPLATE_NAMES.solidts, - description: `${CreateProjectCommand.HelloWorldTemplateDescription} using TypeScript`, - }, - { - key: CreateProjectCommand.BlankVisionTemplateKey, - value: "@nativescript/template-blank-solid-vision", - description: CreateProjectCommand.BlankVisionTemplateDescription, - }, - ]; + if (options.react) { + return constants.REACT_NAME; + } - return templates; + if (options.svelte) { + return constants.SVELTE_NAME; } - private getSvelteTemplates() { - const templates = [ + return options.template; +} + +function interactiveFlavorSelection( + services: ICreateProjectCommandServices, + adverb: string, +): Promise { + return services.$prompter.promptForDetailedChoice( + `${adverb}, which style of NativeScript project would you like to use:`, + [ { - key: CreateProjectCommand.HelloWorldTemplateKey, - value: constants.RESERVED_TEMPLATE_NAMES.svelte, - description: CreateProjectCommand.HelloWorldTemplateDescription, + key: constants.NgFlavorName, + description: "Learn more at https://nativescript.org/angular", }, { - key: CreateProjectCommand.BlankVisionTemplateKey, - value: "@nativescript/template-blank-svelte-vision", - description: CreateProjectCommand.BlankVisionTemplateDescription, + key: constants.ReactFlavorName, + description: + "Learn more at https://github.com/shirakaba/react-nativescript", }, - ]; - - return templates; - } - - private getVueTemplates() { - const templates = [ { - key: CreateProjectCommand.BlankTemplateKey, - value: "@nativescript/template-blank-vue", - description: CreateProjectCommand.BlankTemplateDescription, + key: constants.VueFlavorName, + description: "Learn more at https://nativescript.org/vue", }, { - key: CreateProjectCommand.BlankTsTemplateKey, - value: "@nativescript/template-blank-vue-ts", - description: CreateProjectCommand.BlankTsTemplateDescription, + key: constants.SolidFlavorName, + description: "Learn more at https://www.solidjs.com", }, { - key: CreateProjectCommand.DrawerTemplateKey, - value: "@nativescript/template-drawer-navigation-vue", - description: CreateProjectCommand.DrawerTemplateDescription, + key: constants.SvelteFlavorName, + description: "Learn more at https://svelte-native.technology", }, { - key: CreateProjectCommand.TabsTemplateKey, - value: "@nativescript/template-tab-navigation-vue", - description: CreateProjectCommand.TabsTemplateDescription, + key: constants.TsFlavorName, + description: "Learn more at https://nativescript.org/typescript", }, { - key: CreateProjectCommand.BlankVisionTemplateKey, - value: "@nativescript/template-blank-vue-vision", - description: CreateProjectCommand.BlankVisionTemplateDescription, + key: constants.JsFlavorName, + description: "Use NativeScript without any framework", }, - ]; + ], + ); +} + +async function interactiveTemplateSelection( + services: ICreateProjectCommandServices, + flavorSelection: string, + adverb: string, +): Promise { + const getTemplates = flavorTemplates[flavorSelection]; + const selectedFlavorTemplates: ITemplateChoice[] = getTemplates + ? getTemplates() + : []; + + if (selectedFlavorTemplates.length > 1) { + services.$logger.info(); + const templateChoices = selectedFlavorTemplates.map((template) => { + return { key: template.key, description: template.description }; + }); + const selectedTemplateKey = + await services.$prompter.promptForDetailedChoice( + `${adverb}, which template would you like to start from:`, + templateChoices, + ); - return templates; + return selectedFlavorTemplates.find((t) => t.key === selectedTemplateKey) + .value; } - public async postCommandAction(args: string[]): Promise { - const { projectDir, projectName } = this.createdProjectData; - const relativePath = path.relative(process.cwd(), projectDir); - - const greyDollarSign = color.grey("$"); - this.$logger.clearScreen(); - let runDebugNotes: Array = []; - if ( - this.$options.vision || - this.$options["vision-ng"] || - this.$options["vision-react"] || - this.$options["vision-solid"] || - this.$options["vision-svelte"] || - this.$options["vision-vue"] - ) { - runDebugNotes = [ - `Run the project on Vision Pro with:`, - "", - ` ${greyDollarSign} ${color.green("ns run visionos --no-hmr")}`, - ]; - } else { - runDebugNotes = [ - `Run the project on multiple devices:`, - "", - ` ${greyDollarSign} ${color.green("ns run ios")}`, - ` ${greyDollarSign} ${color.green("ns run android")}`, - "", - "Debug the project with Chrome DevTools:", - "", - ` ${greyDollarSign} ${color.green("ns debug ios")}`, - ` ${greyDollarSign} ${color.green("ns debug android")}`, - ]; + return selectedFlavorTemplates[0].value; +} + +async function interactiveFlavorAndTemplateSelection( + services: ICreateProjectCommandServices, + flavorAdverb: string, + templateAdverb: string, +): Promise { + const selectedFlavor = await interactiveFlavorSelection( + services, + flavorAdverb, + ); + + return interactiveTemplateSelection(services, selectedFlavor, templateAdverb); +} + +export async function runCreateProjectCommand( + context: CreateProjectCommandContext, + services: ICreateProjectCommandServices, +): Promise { + const options = context.options; + const interactiveAdverbs = ["First", "Next", "Finally"]; + const getNextInteractiveAdverb = () => { + return interactiveAdverbs.shift() || "Next"; + }; + + let isInteractionIntroShown = false; + const printInteractiveCreationIntroIfNeeded = () => { + if (isInteractionIntroShown) { + return; } - this.$logger.info( - [ - [ - color.green(`Project`), - color.cyan(projectName), - color.green(`was successfully created.`), - ].join(" "), - "", - `Now you can navigate to your project with ${color.cyan( - `cd ${relativePath}` - )} and then:`, - "", - ...runDebugNotes, - ``, - `For more options consult the docs or run ${color.green("ns --help")}`, - "", - ].join("\n") + + isInteractionIntroShown = true; + services.$logger.info(); + services.$logger.printMarkdown(`# Let’s create a NativeScript app!`); + services.$logger.printMarkdown(` +Answer the following questions to help us build the right app for you. (Note: you +can skip this prompt next time using the --template option, or using --ng, --react, --solid, --svelte, --vue, --ts, or --js flags.) +`); + }; + + if ( + (options.tsc || + options.ng || + options.vue || + options.react || + options.solid || + options.svelte || + options.js) && + options.template + ) { + context.fail( + "You cannot use a flavor option like --ng, --vue, --react, --solid, --svelte, --tsc and --js together with --template.", ); - // todo: add back ns preview - // this.$logger.printMarkdown( - // `After that you can preview it on device by executing \`$ ns preview\`` - // ); } + + let projectName = context.args[0]; + let selectedTemplate = selectTemplateFromOptions(options); + + if (!projectName && isInteractive()) { + printInteractiveCreationIntroIfNeeded(); + projectName = await services.$prompter.getString( + `${getNextInteractiveAdverb()}, what will be the name of your app?`, + { allowEmpty: false }, + ); + services.$logger.info(); + } + + projectName = await services.$projectService.validateProjectName({ + projectName: projectName, + force: options.force, + pathToProject: options.path, + }); + + if (!selectedTemplate && isInteractive()) { + printInteractiveCreationIntroIfNeeded(); + selectedTemplate = await interactiveFlavorAndTemplateSelection( + services, + getNextInteractiveAdverb(), + getNextInteractiveAdverb(), + ); + } + + return services.$projectService.createProject({ + projectName: projectName, + template: selectedTemplate, + appId: options.appid, + pathToProject: options.path, + // its already validated above + force: true, + ignoreScripts: options.ignoreScripts, + }); +} + +export function reportCreatedProject( + context: CreateProjectCommandContext, + createdProjectData: ICreateProjectData, + services: ICreateProjectCommandServices, +): void { + const { projectDir, projectName } = createdProjectData; + const relativePath = path.relative(process.cwd(), projectDir); + + const greyDollarSign = color.grey("$"); + services.$logger.clearScreen(); + let runDebugNotes: Array = []; + if ( + context.options.vision || + context.options["vision-ng"] || + context.options["vision-react"] || + context.options["vision-solid"] || + context.options["vision-svelte"] || + context.options["vision-vue"] + ) { + runDebugNotes = [ + `Run the project on Vision Pro with:`, + "", + ` ${greyDollarSign} ${color.green("ns run visionos --no-hmr")}`, + ]; + } else { + runDebugNotes = [ + `Run the project on multiple devices:`, + "", + ` ${greyDollarSign} ${color.green("ns run ios")}`, + ` ${greyDollarSign} ${color.green("ns run android")}`, + "", + "Debug the project with Chrome DevTools:", + "", + ` ${greyDollarSign} ${color.green("ns debug ios")}`, + ` ${greyDollarSign} ${color.green("ns debug android")}`, + ]; + } + services.$logger.info( + [ + [ + color.green(`Project`), + color.cyan(projectName), + color.green(`was successfully created.`), + ].join(" "), + "", + `Now you can navigate to your project with ${color.cyan( + `cd ${relativePath}`, + )} and then:`, + "", + ...runDebugNotes, + ``, + `For more options consult the docs or run ${color.green("ns --help")}`, + "", + ].join("\n"), + ); + // todo: add back ns preview + // this.$logger.printMarkdown( + // `After that you can preview it on device by executing \`$ ns preview\`` + // ); } -injector.registerCommand("create", CreateProjectCommand); +export const createProjectCommandDefinition = defineCommand({ + name: "create", + description: "Creates a new NativeScript project.", + options: createProjectCommandOptions, + arguments: [{ name: "projectName" }], + enableHooks: false, + setup: setupCreateProjectCommand, + run: runCreateProjectCommand, + postRun: reportCreatedProject, +}); + +registerCommand(createProjectCommandDefinition); diff --git a/lib/commands/debug.ts b/lib/commands/debug.ts index bd894e9019..f5cd2e237e 100644 --- a/lib/commands/debug.ts +++ b/lib/commands/debug.ts @@ -1,251 +1,297 @@ -import { cache } from "../common/decorators"; -import { ValidatePlatformCommandBase } from "./command-base"; +import { IErrors, ISysInfo } from "../common/declarations"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject, InjectionToken } from "../common/di"; import { hasValidAndroidSigning } from "../common/helpers"; +import { registerCommandDefinition } from "../common/services/command-definition-adapter"; +import { injector } from "../common/yok"; import { ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE } from "../constants"; -import { IProjectData } from "../definitions/project"; -import { IPlatformValidationService, IOptions } from "../declarations"; -import { IPlatformsDataService } from "../definitions/platform"; +import { ICleanupService } from "../definitions/cleanup-service"; import { - IDebugDataService, IDebugController, + IDebugDataService, IDebugOptions, } from "../definitions/debug"; import { IMigrateController } from "../definitions/migrate"; -import { ICommandParameter, ICommand } from "../common/definitions/commands"; -import { IErrors, ISysInfo } from "../common/declarations"; -import { ICleanupService } from "../definitions/cleanup-service"; -import { IInjector } from "../common/definitions/yok"; -import { injector } from "../common/yok"; -import * as _ from "lodash"; import { SystemWarningsSeverity } from "../definitions/system-warnings"; +import { + canExecuteCommandBase, + injectPlatformCommandServices, + IPlatformCommandServices, +} from "./command-base"; +import * as _ from "lodash"; + +/** Which `$devicePlatformsConstants` entry this registration debugs. */ +const DEBUG_PLATFORM = new InjectionToken<"iOS" | "Android" | "visionOS">( + "debugCommandPlatform", +); + +const debugCommandOptions = { + force: booleanOption(), + release: booleanOption(), + aab: booleanOption(), + start: booleanOption(), + emulator: booleanOption(), + forDevice: booleanOption(), + inspector: booleanOption(), + device: stringOption(), + timeout: stringOption(), + keyStorePath: stringOption(), + keyStorePassword: stringOption(), + keyStoreAlias: stringOption(), + keyStoreAliasPassword: stringOption(), +} satisfies CommandOptionsSchema; + +export type DebugCommandContext = CommandContext; + +export interface IDebugCommandServices extends IPlatformCommandServices { + platform: string; + $cleanupService: ICleanupService; + $debugController: IDebugController; + $debugDataService: IDebugDataService; + $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; + $devicesService: Mobile.IDevicesService; + $errors: IErrors; + $liveSyncCommandHelper: ILiveSyncCommandHelper; + $migrateController: IMigrateController; +} + +export function setupDebugCommand(): IDebugCommandServices { + const $devicePlatformsConstants = inject( + "devicePlatformsConstants", + ); + + return { + ...injectPlatformCommandServices(), + platform: $devicePlatformsConstants[inject(DEBUG_PLATFORM)], + $cleanupService: inject("cleanupService"), + $debugController: inject("debugController"), + $debugDataService: inject("debugDataService"), + $devicePlatformsConstants, + $devicesService: inject("devicesService"), + $errors: inject("errors"), + $liveSyncCommandHelper: inject( + "liveSyncCommandHelper", + ), + $migrateController: inject("migrateController"), + }; +} -export class DebugPlatformCommand - extends ValidatePlatformCommandBase - implements ICommand -{ - public allowedParameters: ICommandParameter[] = []; - - constructor( - private platform: string, - protected $devicesService: Mobile.IDevicesService, - $platformValidationService: IPlatformValidationService, - $projectData: IProjectData, - $options: IOptions, - $platformsDataService: IPlatformsDataService, - $cleanupService: ICleanupService, - protected $logger: ILogger, - protected $errors: IErrors, - private $debugDataService: IDebugDataService, - private $debugController: IDebugController, - private $liveSyncCommandHelper: ILiveSyncCommandHelper, - private $migrateController: IMigrateController, +export async function canExecuteDebugCommand( + context: DebugCommandContext, + services: IDebugCommandServices, +): Promise { + // Keeping the cleanup process alive is what makes a debugger able to stay + // attached, so it must not happen before the platform-specific checks that + // run ahead of this function have had their chance to fail the command. + services.$cleanupService.setShouldDispose(false); + + if (!context.options.force) { + await services.$migrateController.validate({ + projectDir: services.$projectData.projectDir, + platforms: [services.platform], + }); + } + + if ( + !services.$platformValidationService.isPlatformSupportedForOS( + services.platform, + services.$projectData, + ) ) { - super( - $options, - $platformsDataService, - $platformValidationService, - $projectData, + services.$errors.fail( + `Applications for platform ${services.platform} can not be built on this OS`, ); - $cleanupService.setShouldDispose(false); } - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - platform: this.platform, - deviceId: this.$options.device, - emulator: this.$options.emulator, - skipDeviceDetectionInterval: true, - }); + if (context.options.release) { + services.$errors.failWithHelp( + "--release flag is not applicable to this command.", + ); + } - const selectedDeviceForDebug = await this.$devicesService.pickSingleDevice({ - onlyEmulators: this.$options.emulator, - onlyDevices: this.$options.forDevice, - deviceId: this.$options.device, - }); + return canExecuteCommandBase(services, services.platform, { + validateOptions: true, + }); +} - if (this.$options.start) { - const debugOptions = _.cloneDeep(this.$options.argv); - const debugData = this.$debugDataService.getDebugData( - selectedDeviceForDebug.deviceInfo.identifier, - this.$projectData, - debugOptions, - ); - await this.$debugController.printDebugInformation( - await this.$debugController.startDebug(debugData), - ); - return; - } +export async function runDebugCommand( + context: DebugCommandContext, + services: IDebugCommandServices, +): Promise { + await services.$devicesService.initialize({ + platform: services.platform, + deviceId: context.options.device, + emulator: context.options.emulator, + skipDeviceDetectionInterval: true, + }); - await this.$liveSyncCommandHelper.executeLiveSyncOperation( - [selectedDeviceForDebug], - this.platform, - { - deviceDebugMap: { - [selectedDeviceForDebug.deviceInfo.identifier]: true, - }, - buildPlatform: undefined, - skipNativePrepare: false, - }, + const selectedDeviceForDebug = + await services.$devicesService.pickSingleDevice({ + onlyEmulators: context.options.emulator, + onlyDevices: context.options.forDevice, + deviceId: context.options.device, + }); + + if (context.options.start) { + // The debug services read the whole parsed command line, including flags + // no command declares, so the raw argv is what they get. + const debugOptions = _.cloneDeep(services.$options.argv); + const debugData = services.$debugDataService.getDebugData( + selectedDeviceForDebug.deviceInfo.identifier, + services.$projectData, + debugOptions, ); + await services.$debugController.printDebugInformation( + await services.$debugController.startDebug(debugData), + ); + return; } - public async canExecute(args: string[]): Promise { - if (!this.$options.force) { - await this.$migrateController.validate({ - projectDir: this.$projectData.projectDir, - platforms: [this.platform], - }); - } + await services.$liveSyncCommandHelper.executeLiveSyncOperation( + [selectedDeviceForDebug], + services.platform, + { + deviceDebugMap: { + [selectedDeviceForDebug.deviceInfo.identifier]: true, + }, + buildPlatform: undefined, + skipNativePrepare: false, + }, + ); +} - if ( - !this.$platformValidationService.isPlatformSupportedForOS( - this.platform, - this.$projectData, - ) - ) { - this.$errors.fail( - `Applications for platform ${this.platform} can not be built on this OS`, - ); - } +interface IDebugApplePlatformCommandServices extends IDebugCommandServices { + $sysInfo: ISysInfo; +} - if (this.$options.release) { - this.$errors.failWithHelp( - "--release flag is not applicable to this command.", - ); - } +function setupDebugApplePlatformCommand(): IDebugApplePlatformCommandServices { + const services = { + ...setupDebugCommand(), + $sysInfo: inject("sysInfo"), + }; + services.$projectData.initializeProjectData(); - const result = await super.canExecuteCommandBase(this.platform, { - validateOptions: true, - }); - return result; - } + // Do not dispose ios-device-lib, so the process will remain alive and the debug application (NativeScript Inspector or Chrome DevTools) will be able to connect to the socket. + // In case we dispose ios-device-lib, the socket will be closed and the code will fail when the debug application tries to read/send data to device socket. + // That's why the `$ ns debug ios --justlaunch` command will not release the terminal. + // In case we do not set it to false, the dispose will be called once the command finishes its execution, which will prevent the debugging. + inject("iosDeviceOperations").setShouldDispose(false); + inject( + "iOSSimulatorLogProvider", + ).setShouldDispose(false); + + return services; } -export class DebugIOSCommand implements ICommand { - @cache() - private get debugPlatformCommand(): DebugPlatformCommand { - return this.$injector.resolve(DebugPlatformCommand, { - platform: this.platform, - }); +function isValidTimeoutOption(timeout: string): boolean { + if (!timeout) { + return true; } - public allowedParameters: ICommandParameter[] = []; - - constructor( - protected $errors: IErrors, - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - private $platformValidationService: IPlatformValidationService, - private $options: IOptions, - private $injector: IInjector, - private $sysInfo: ISysInfo, - private $projectData: IProjectData, - $iosDeviceOperations: IIOSDeviceOperations, - $iOSSimulatorLogProvider: Mobile.IiOSSimulatorLogProvider, - ) { - this.$projectData.initializeProjectData(); - // Do not dispose ios-device-lib, so the process will remain alive and the debug application (NativeScript Inspector or Chrome DevTools) will be able to connect to the socket. - // In case we dispose ios-device-lib, the socket will be closed and the code will fail when the debug application tries to read/send data to device socket. - // That's why the `$ ns debug ios --justlaunch` command will not release the terminal. - // In case we do not set it to false, the dispose will be called once the command finishes its execution, which will prevent the debugging. - $iosDeviceOperations.setShouldDispose(false); - $iOSSimulatorLogProvider.setShouldDispose(false); + const parsed = parseInt(timeout, 10); + if (parsed === 0) { + return true; } - public execute(args: string[]): Promise { - return this.debugPlatformCommand.execute(args); + if (!parsed) { + return false; } - public async canExecute(args: string[]): Promise { + return true; +} + +export const debugApplePlatformCommandDefinition = defineCommand({ + name: "debug|ios", + description: "Debugs your project on a connected Apple device or simulator.", + options: debugCommandOptions, + // Arguments have never been rejected here, only ignored. + arguments: "any", + setup: setupDebugApplePlatformCommand, + async canExecute( + context: DebugCommandContext, + services: IDebugApplePlatformCommandServices, + ): Promise { if ( - !this.$platformValidationService.isPlatformSupportedForOS( - this.$devicePlatformsConstants.iOS, - this.$projectData, + !services.$platformValidationService.isPlatformSupportedForOS( + services.platform, + services.$projectData, ) ) { - this.$errors.fail( - `Applications for platform ${this.$devicePlatformsConstants.iOS} can not be built on this OS`, + services.$errors.fail( + `Applications for platform ${services.platform} can not be built on this OS`, ); } - const isValidTimeoutOption = this.isValidTimeoutOption(); - if (!isValidTimeoutOption) { - this.$errors.fail( + if (!isValidTimeoutOption(context.options.timeout)) { + services.$errors.fail( `Timeout option specifies the seconds NativeScript CLI will wait to find the inspector socket port from device's logs. Must be a number.`, ); } - if (this.$options.inspector) { - const macOSWarning = await this.$sysInfo.getMacOSWarningMessage(); + if (context.options.inspector) { + const macOSWarning = await services.$sysInfo.getMacOSWarningMessage(); if ( macOSWarning && macOSWarning.severity === SystemWarningsSeverity.high ) { - this.$errors.fail( + services.$errors.fail( `You cannot use NativeScript Inspector on this OS. To use it, please update your OS.`, ); } } - const result = await this.debugPlatformCommand.canExecute(args); - return result; - } - - private isValidTimeoutOption() { - if (!this.$options.timeout) { - return true; - } - - const timeout = parseInt(this.$options.timeout, 10); - if (timeout === 0) { - return true; - } - - if (!timeout) { - return false; - } - return true; - } + return canExecuteDebugCommand(context, services); + }, + run: runDebugCommand, +}); - public platform = this.$devicePlatformsConstants.iOS; -} +export const debugAndroidCommandDefinition = defineCommand({ + name: "debug|android", + description: "Debugs your project on a connected Android device or emulator.", + options: debugCommandOptions, + arguments: "any", + setup(): IDebugCommandServices { + const services = setupDebugCommand(); + services.$projectData.initializeProjectData(); -injector.registerCommand("debug|ios", DebugIOSCommand); - -export class DebugAndroidCommand implements ICommand { - @cache() - private get debugPlatformCommand(): DebugPlatformCommand { - return this.$injector.resolve(DebugPlatformCommand, { - platform: this.platform, - }); - } - - public allowedParameters: ICommandParameter[] = []; - - constructor( - protected $errors: IErrors, - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - private $injector: IInjector, - private $projectData: IProjectData, - private $options: IOptions, - ) { - this.$projectData.initializeProjectData(); - } - - public async execute(args: string[]): Promise { - return this.debugPlatformCommand.execute(args); - } - public async canExecute(args: string[]): Promise { - const canExecuteBase = await this.debugPlatformCommand.canExecute(args); + return services; + }, + async canExecute( + context: DebugCommandContext, + services: IDebugCommandServices, + ): Promise { + const canExecuteBase = await canExecuteDebugCommand(context, services); if (canExecuteBase) { - if (this.$options.aab && !hasValidAndroidSigning(this.$options)) { - this.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + if (context.options.aab && !hasValidAndroidSigning(context.options)) { + services.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } return canExecuteBase; - } + }, + run: runDebugCommand, +}); + +const debugApplePlatforms: [string, "iOS" | "visionOS"][] = [ + ["debug|ios", "iOS"], + ["debug|vision", "visionOS"], + ["debug|visionos", "visionOS"], +]; - public platform = this.$devicePlatformsConstants.Android; +for (const [name, platform] of debugApplePlatforms) { + registerCommandDefinition( + { ...debugApplePlatformCommandDefinition, name }, + injector.createChild([{ provide: DEBUG_PLATFORM, useValue: platform }]), + ); } -injector.registerCommand("debug|android", DebugAndroidCommand); +registerCommandDefinition( + debugAndroidCommandDefinition, + injector.createChild([{ provide: DEBUG_PLATFORM, useValue: "Android" }]), +); diff --git a/lib/commands/deploy.ts b/lib/commands/deploy.ts index 2d2da7614f..de2520d08f 100644 --- a/lib/commands/deploy.ts +++ b/lib/commands/deploy.ts @@ -2,93 +2,86 @@ import { ANDROID_RELEASE_BUILD_ERROR_MESSAGE, ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, } from "../constants"; -import { ValidatePlatformCommandBase } from "./command-base"; +import { + canExecuteCommandBase, + injectPlatformCommandServices, + platformArgument, +} from "./command-base"; import { DeployCommandHelper } from "../helpers/deploy-command-helper"; import { hasValidAndroidSigning } from "../common/helpers"; -import { IProjectData } from "../definitions/project"; -import { IPlatformValidationService, IOptions } from "../declarations"; -import { IPlatformsDataService } from "../definitions/platform"; import { IMigrateController } from "../definitions/migrate"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { IErrors } from "../common/declarations"; -import { OptionType } from "../common/enums"; -import { injector } from "../common/yok"; - -export class DeployOnDeviceCommand - extends ValidatePlatformCommandBase - implements ICommand -{ - public allowedParameters: ICommandParameter[] = []; - - public dashedOptions = { - watch: { - type: OptionType.Boolean, - default: false, - hasSensitiveValue: false, - }, - hmr: { type: OptionType.Boolean, default: false, hasSensitiveValue: false }, - }; +import { + booleanOption, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommandDefinition } from "../common/services/command-definition-adapter"; - constructor( - $platformValidationService: IPlatformValidationService, - private $platformCommandParameter: ICommandParameter, - $options: IOptions, - $projectData: IProjectData, - private $errors: IErrors, - private $mobileHelper: Mobile.IMobileHelper, - $platformsDataService: IPlatformsDataService, - private $deployCommandHelper: DeployCommandHelper, - private $migrateController: IMigrateController, - ) { - super( - $options, - $platformsDataService, - $platformValidationService, - $projectData, - ); - this.$projectData.initializeProjectData(); - } +const deployCommandOptions = { + watch: booleanOption({ default: false }), + hmr: booleanOption({ default: false }), + force: booleanOption(), + release: booleanOption(), + aab: booleanOption(), + keyStorePath: stringOption(), + keyStorePassword: stringOption(), + keyStoreAlias: stringOption(), + keyStoreAliasPassword: stringOption(), +} satisfies CommandOptionsSchema; - public async execute(args: string[]): Promise { - const platform = args[0]; +export const deployCommandDefinition = defineCommand({ + name: "deploy", + description: "Builds and deploys the project to a connected device.", + options: deployCommandOptions, + arguments: [platformArgument], + setup() { + const services = { + ...injectPlatformCommandServices(), + $errors: inject("errors"), + $mobileHelper: inject("mobileHelper"), + $deployCommandHelper: inject("deployCommandHelper"), + $migrateController: inject("migrateController"), + }; + services.$projectData.initializeProjectData(); - await this.$deployCommandHelper.deploy(platform); - } + return services; + }, + async canExecute(context, services): Promise { + const platform = context.args[0]; - public async canExecute(args: string[]): Promise { - const platform = args[0]; - if (!this.$options.force) { - await this.$migrateController.validate({ - projectDir: this.$projectData.projectDir, + if (!context.options.force) { + await services.$migrateController.validate({ + projectDir: services.$projectData.projectDir, platforms: [platform], }); } - if (!args || !args.length || args.length > 1) { - return false; - } - - if (!(await this.$platformCommandParameter.validate(platform))) { + if (!platform) { return false; } if ( - this.$mobileHelper.isAndroidPlatform(platform) && - (this.$options.release || this.$options.aab) && - !hasValidAndroidSigning(this.$options) + services.$mobileHelper.isAndroidPlatform(platform) && + (context.options.release || context.options.aab) && + !hasValidAndroidSigning(context.options) ) { - if (this.$options.release) { - this.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + if (context.options.release) { + services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - this.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + services.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } - const result = await super.canExecuteCommandBase(platform, { + return canExecuteCommandBase(services, platform, { validateOptions: true, }); - return result; - } -} + }, + async run(context, services): Promise { + await services.$deployCommandHelper.deploy(context.args[0]); + }, +}); -injector.registerCommand("deploy", DeployOnDeviceCommand); +registerCommandDefinition(deployCommandDefinition); diff --git a/lib/commands/embedding/embed.ts b/lib/commands/embedding/embed.ts index fd3b58ac63..ce520c8e05 100644 --- a/lib/commands/embedding/embed.ts +++ b/lib/commands/embedding/embed.ts @@ -1,62 +1,86 @@ -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; -import { injector } from "../../common/yok"; -import { PrepareCommand } from "../prepare"; -import { PrepareController } from "../../controllers/prepare-controller"; -import { IOptions, IPlatformValidationService } from "../../declarations"; -import { IProjectConfigService, IProjectData } from "../../definitions/project"; -import { IPlatformsDataService } from "../../definitions/platform"; -import { PrepareDataService } from "../../services/prepare-data-service"; -import { IMigrateController } from "../../definitions/migrate"; import { resolve } from "path"; -import { IFileSystem } from "../../common/declarations"; import { color } from "../../color"; +import { defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; +import { registerCommandDefinition } from "../../common/services/command-definition-adapter"; +import { IFileSystem } from "../../common/declarations"; +import { IProjectConfigService } from "../../definitions/project"; +import { platformArgument } from "../command-base"; +import { + canExecutePrepareCommand, + IPrepareCommandServices, + prepareCommandOptions, + runPrepareCommand, + setupPrepareCommand, +} from "../prepare"; + +interface IEmbedCommandServices extends IPrepareCommandServices { + $fs: IFileSystem; + $logger: ILogger; + hostProjectPath: string; + hostProjectModuleName: string; +} -export class EmbedCommand extends PrepareCommand implements ICommand { - constructor( - public $options: IOptions, - public $prepareController: PrepareController, - public $platformValidationService: IPlatformValidationService, - public $projectData: IProjectData, - public $platformCommandParameter: ICommandParameter, - public $platformsDataService: IPlatformsDataService, - public $prepareDataService: PrepareDataService, - public $migrateController: IMigrateController, +function resolveHostProjectPath( + projectDir: string, + hostProjectPath: string, +): string { + if (hostProjectPath.charAt(0) === ".") { + return resolve(projectDir, hostProjectPath); + } - private $logger: ILogger, - private $fs: IFileSystem, - private $projectConfigService: IProjectConfigService, - ) { - super( - $options, - $prepareController, - $platformValidationService, - $projectData, - $platformCommandParameter, - $platformsDataService, - $prepareDataService, - $migrateController, + return resolve(hostProjectPath); +} + +export const embedCommandDefinition = defineCommand({ + name: "embed", + description: + "Prepares the project so it can be embedded into a native host project.", + options: prepareCommandOptions, + arguments: [ + platformArgument, + { name: "hostProjectPath" }, + { name: "hostProjectModuleName" }, + ], + setup(context): IEmbedCommandServices { + const services = setupPrepareCommand(); + const $projectConfigService = inject( + "projectConfigService", ); - } + const platform = (context.args[0] || "").toLowerCase(); + // embed.., falling back to embed. + const configValue = (key: string) => + $projectConfigService.getValue( + `embed.${platform}.${key}`, + $projectConfigService.getValue(`embed.${key}`), + ); - private resolveHostProjectPath(hostProjectPath: string): string { - if (hostProjectPath.charAt(0) === ".") { - // resolve relative to the project dir - const projectDir = this.$projectData.projectDir; - return resolve(projectDir, hostProjectPath); + return { + ...services, + $fs: inject("fs"), + $logger: inject("logger"), + hostProjectPath: context.args[1] || configValue("hostProjectPath"), + hostProjectModuleName: + context.args[2] || configValue("hostProjectModuleName"), + }; + }, + async canExecute(context, services): Promise { + if (!(await canExecutePrepareCommand(context, services))) { + return false; } - return resolve(hostProjectPath); - } - - public async execute(args: string[]): Promise { - const hostProjectPath = args[1]; - const resolvedHostProjectPath = - this.resolveHostProjectPath(hostProjectPath); + return !!services.hostProjectPath; + }, + async run(context, services): Promise { + const resolvedHostProjectPath = resolveHostProjectPath( + services.$projectData.projectDir, + services.hostProjectPath, + ); - if (!this.$fs.exists(resolvedHostProjectPath)) { - this.$logger.error( + if (!services.$fs.exists(resolvedHostProjectPath)) { + services.$logger.error( `The host project path ${color.yellow( - hostProjectPath, + services.hostProjectPath, )} (resolved to: ${color.styleText( ["yellow", "dim"], resolvedHostProjectPath, @@ -65,64 +89,13 @@ export class EmbedCommand extends PrepareCommand implements ICommand { return; } - this.$options["hostProjectPath"] = resolvedHostProjectPath; - if (args.length > 2) { - this.$options["hostProjectModuleName"] = args[2]; - } - - return super.execute(args); - } - - public async canExecute(args: string[]): Promise { - const canSuperExecute = await super.canExecute(args); - - if (!canSuperExecute) { - return false; + services.$options.hostProjectPath = resolvedHostProjectPath; + if (services.hostProjectModuleName) { + services.$options.hostProjectModuleName = services.hostProjectModuleName; } - // args[0] is the platform - // args[1] is the path to the host project - // args[2] is the host project module name - - const platform = args[0].toLowerCase(); - - // also allow these to be set in the nativescript.config.ts - if (!args[1]) { - const hostProjectPath = this.getEmbedConfigForKey( - "hostProjectPath", - platform, - ); - if (hostProjectPath) { - args[1] = hostProjectPath; - } - } - - if (!args[2]) { - const hostProjectModuleName = this.getEmbedConfigForKey( - "hostProjectModuleName", - platform, - ); - if (hostProjectModuleName) { - args[2] = hostProjectModuleName; - } - } - - console.log(args); - - if (args.length < 2) { - return false; - } - - return true; - } - - private getEmbedConfigForKey(key: string, platform: string) { - // get the embed.. value, or fallback to embed. value - return this.$projectConfigService.getValue( - `embed.${platform}.${key}`, - this.$projectConfigService.getValue(`embed.${key}`), - ); - } -} + await runPrepareCommand(context, services); + }, +}); -injector.registerCommand("embed", EmbedCommand); +registerCommandDefinition(embedCommandDefinition); diff --git a/lib/commands/extensibility/install-extension.ts b/lib/commands/extensibility/install-extension.ts index 55c1718551..a0dd0093ac 100644 --- a/lib/commands/extensibility/install-extension.ts +++ b/lib/commands/extensibility/install-extension.ts @@ -1,36 +1,52 @@ -import { - ICommand, - IStringParameterBuilder, - ICommandParameter, -} from "../../common/definitions/commands"; -import { injector } from "../../common/yok"; +import { defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; +import { registerCommand } from "../../common/services/command-definition-adapter"; -export class InstallExtensionCommand implements ICommand { - constructor( - private $extensibilityService: IExtensibilityService, - private $stringParameterBuilder: IStringParameterBuilder, - private $logger: ILogger - ) {} +export interface IInstallExtensionCommandServices { + $extensibilityService: IExtensibilityService; + $logger: ILogger; +} + +export function setupInstallExtensionCommand(): IInstallExtensionCommandServices { + return { + $extensibilityService: inject( + "extensibilityService", + ), + $logger: inject("logger"), + }; +} - public async execute(args: string[]): Promise { - const extensionData = await this.$extensibilityService.installExtension( - args[0] +export const installExtensionCommandDefinition = defineCommand({ + name: "extension|install", + description: "Installs the specified extension.", + arguments: [ + { + name: "extensionName", + required: true, + errorMessage: + "You have to provide a valid name for extension that you want to install.", + }, + ], + setup: setupInstallExtensionCommand, + async run( + context, + services: IInstallExtensionCommandServices, + ): Promise { + const extensionData = await services.$extensibilityService.installExtension( + context.args[0], ); - this.$logger.info( - `Successfully installed extension ${extensionData.extensionName}.` + services.$logger.info( + `Successfully installed extension ${extensionData.extensionName}.`, ); - await this.$extensibilityService.loadExtension(extensionData.extensionName); - this.$logger.info( - `Successfully loaded extension ${extensionData.extensionName}.` + await services.$extensibilityService.loadExtension( + extensionData.extensionName, ); - } + services.$logger.info( + `Successfully loaded extension ${extensionData.extensionName}.`, + ); + }, +}); - allowedParameters: ICommandParameter[] = [ - this.$stringParameterBuilder.createMandatoryParameter( - "You have to provide a valid name for extension that you want to install." - ), - ]; -} -injector.registerCommand("extension|install", InstallExtensionCommand); +registerCommand(installExtensionCommandDefinition); diff --git a/lib/commands/extensibility/list-extensions.ts b/lib/commands/extensibility/list-extensions.ts index b25ab89dfc..28b9b3c9dc 100644 --- a/lib/commands/extensibility/list-extensions.ts +++ b/lib/commands/extensibility/list-extensions.ts @@ -1,30 +1,43 @@ import * as _ from "lodash"; -import * as helpers from "../../common/helpers"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; -import { injector } from "../../common/yok"; +import { defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; +import * as helpers from "../../common/helpers"; +import { registerCommand } from "../../common/services/command-definition-adapter"; + +export interface IListExtensionsCommandServices { + $extensibilityService: IExtensibilityService; + $logger: ILogger; +} -export class ListExtensionsCommand implements ICommand { - constructor( - private $extensibilityService: IExtensibilityService, - private $logger: ILogger - ) {} +export function setupListExtensionsCommand(): IListExtensionsCommandServices { + return { + $extensibilityService: inject( + "extensibilityService", + ), + $logger: inject("logger"), + }; +} - public async execute(args: string[]): Promise { - const installedExtensions = this.$extensibilityService.getInstalledExtensions(); +export const listExtensionsCommandDefinition = defineCommand({ + name: "extension|*list", + description: "Lists all installed extensions.", + setup: setupListExtensionsCommand, + run(context, services: IListExtensionsCommandServices): void { + const installedExtensions = + services.$extensibilityService.getInstalledExtensions(); if (_.keys(installedExtensions).length) { - this.$logger.info("Installed extensions:"); + services.$logger.info("Installed extensions:"); const data = _.map(installedExtensions, (version, name) => { return [name, version]; }); const table = helpers.createTable(["Name", "Version"], data); - this.$logger.info(table.toString()); + services.$logger.info(table.toString()); } else { - this.$logger.info("No extensions installed."); + services.$logger.info("No extensions installed."); } - } + }, +}); - allowedParameters: ICommandParameter[] = []; -} -injector.registerCommand("extension|*list", ListExtensionsCommand); +registerCommand(listExtensionsCommandDefinition); diff --git a/lib/commands/extensibility/uninstall-extension.ts b/lib/commands/extensibility/uninstall-extension.ts index cea51bc26d..0dce41187d 100644 --- a/lib/commands/extensibility/uninstall-extension.ts +++ b/lib/commands/extensibility/uninstall-extension.ts @@ -1,28 +1,44 @@ -import { - ICommand, - IStringParameterBuilder, - ICommandParameter, -} from "../../common/definitions/commands"; -import { injector } from "../../common/yok"; +import { defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; +import { registerCommand } from "../../common/services/command-definition-adapter"; -export class UninstallExtensionCommand implements ICommand { - constructor( - private $extensibilityService: IExtensibilityService, - private $stringParameterBuilder: IStringParameterBuilder, - private $logger: ILogger - ) {} - - public async execute(args: string[]): Promise { - const extensionName = args[0]; - await this.$extensibilityService.uninstallExtension(extensionName); - this.$logger.info(`Successfully uninstalled extension ${extensionName}`); - } +export interface IUninstallExtensionCommandServices { + $extensibilityService: IExtensibilityService; + $logger: ILogger; +} - allowedParameters: ICommandParameter[] = [ - this.$stringParameterBuilder.createMandatoryParameter( - "You have to provide a valid name for extension that you want to uninstall." +export function setupUninstallExtensionCommand(): IUninstallExtensionCommandServices { + return { + $extensibilityService: inject( + "extensibilityService", ), - ]; + $logger: inject("logger"), + }; } -injector.registerCommand("extension|uninstall", UninstallExtensionCommand); + +export const uninstallExtensionCommandDefinition = defineCommand({ + name: "extension|uninstall", + description: "Uninstalls the specified extension.", + arguments: [ + { + name: "extensionName", + required: true, + errorMessage: + "You have to provide a valid name for extension that you want to uninstall.", + }, + ], + setup: setupUninstallExtensionCommand, + async run( + context, + services: IUninstallExtensionCommandServices, + ): Promise { + const extensionName = context.args[0]; + await services.$extensibilityService.uninstallExtension(extensionName); + services.$logger.info( + `Successfully uninstalled extension ${extensionName}`, + ); + }, +}); + +registerCommand(uninstallExtensionCommandDefinition); diff --git a/lib/commands/fonts.ts b/lib/commands/fonts.ts index bcdf0a996e..4ba2752d50 100644 --- a/lib/commands/fonts.ts +++ b/lib/commands/fonts.ts @@ -1,46 +1,61 @@ import { IProjectConfigService, IProjectData } from "../definitions/project"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { injector } from "../common/yok"; import { IFileSystem } from "../common/declarations"; +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; import * as constants from "../constants"; import * as fontFinder from "font-finder"; import { createTable } from "../common/helpers"; import * as path from "path"; -export class FontsCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export interface IFontsCommandServices { + $projectData: IProjectData; + $fs: IFileSystem; + $logger: ILogger; + $projectConfigService: IProjectConfigService; +} + +export function setupFontsCommand(): IFontsCommandServices { + const services = { + $projectData: inject("projectData"), + $fs: inject("fs"), + $logger: inject("logger"), + $projectConfigService: inject( + "projectConfigService", + ), + }; + services.$projectData.initializeProjectData(); - constructor( - private $projectData: IProjectData, - private $fs: IFileSystem, - private $logger: ILogger, - private $projectConfigService: IProjectConfigService - ) { - this.$projectData.initializeProjectData(); - } + return services; +} - public async execute(args: string[]): Promise { +export const fontsCommandDefinition = defineCommand({ + name: "fonts", + description: "Lists the custom fonts the project bundles.", + arguments: "none", + setup: setupFontsCommand, + async run(context, services): Promise { const supportedExtensions = [".ttf", ".otf"]; const defaultFontsFolderPaths = [ path.join( - this.$projectConfigService.getValue("appPath") ?? "", - constants.FONTS_DIR + services.$projectConfigService.getValue("appPath") ?? "", + constants.FONTS_DIR, ), path.join(constants.APP_FOLDER_NAME, constants.FONTS_DIR), path.join(constants.SRC_DIR, constants.FONTS_DIR), - ].map((entry) => path.resolve(this.$projectData.projectDir, entry)); + ].map((entry) => path.resolve(services.$projectData.projectDir, entry)); const fontsFolderPath = defaultFontsFolderPaths.find((entry) => - this.$fs.exists(entry) + services.$fs.exists(entry), ); if (!fontsFolderPath) { - this.$logger.warn("No fonts folder found."); + services.$logger.warn("No fonts folder found."); return; } - const files = this.$fs + const files = services.$fs .readDirectory(fontsFolderPath) .map((entry) => path.parse(entry)) .filter((entry) => { @@ -48,7 +63,7 @@ export class FontsCommand implements ICommand { }); if (!files.length) { - this.$logger.warn("No custom fonts found."); + services.$logger.warn("No custom fonts found."); return; } @@ -62,8 +77,8 @@ export class FontsCommand implements ICommand { ]); } - this.$logger.info(table.toString()); - } -} + services.$logger.info(table.toString()); + }, +}); -injector.registerCommand("fonts", FontsCommand); +registerCommand(fontsCommandDefinition); diff --git a/lib/commands/generate-assets.ts b/lib/commands/generate-assets.ts index 5133cea3e0..d9a3242ab8 100644 --- a/lib/commands/generate-assets.ts +++ b/lib/commands/generate-assets.ts @@ -1,106 +1,99 @@ -import { IProjectData } from "../definitions/project"; -import { IOptions, IAssetsGenerationService } from "../declarations"; import { - ICommand, - ICommandParameter, - IStringParameterBuilder, -} from "../common/definitions/commands"; -import { IInjector } from "../common/definitions/yok"; -import { injector } from "../common/yok"; + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject, InjectionToken } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; +import { getInjector } from "../common/yok"; +import { + IAssetsGenerationService, + IResourceGenerationData, +} from "../declarations"; +import { IProjectData } from "../definitions/project"; -export abstract class GenerateCommandBase implements ICommand { - public allowedParameters: ICommandParameter[] = [ - this.$stringParameterBuilder.createMandatoryParameter( - "You have to provide path to image to generate other images based on it." - ), - ]; +/** Which set of assets a registration generates from the source image. */ +type GeneratedAssets = "icons" | "splashes"; - constructor( - protected $options: IOptions, - protected $injector: IInjector, - protected $projectData: IProjectData, - protected $stringParameterBuilder: IStringParameterBuilder, - protected $assetsGenerationService: IAssetsGenerationService - ) { - this.$projectData.initializeProjectData(); - } +const GENERATED_ASSETS = new InjectionToken("generatedAssets"); - public async execute(args: string[]): Promise { - const [imagePath] = args; - await this.generate(imagePath, this.$options.background); - } +const generators: Record< + GeneratedAssets, + ( + service: IAssetsGenerationService, + data: IResourceGenerationData, + ) => Promise +> = { + icons: (service, data) => service.generateIcons(data), + splashes: (service, data) => service.generateSplashScreens(data), +}; - protected abstract generate( - imagePath: string, - background?: string - ): Promise; -} +export const generateAssetsCommandOptions = { + background: stringOption(), +} satisfies CommandOptionsSchema; -export class GenerateIconsCommand - extends GenerateCommandBase - implements ICommand { - constructor( - protected $options: IOptions, - $injector: IInjector, - protected $projectData: IProjectData, - protected $stringParameterBuilder: IStringParameterBuilder, - $assetsGenerationService: IAssetsGenerationService - ) { - super( - $options, - $injector, - $projectData, - $stringParameterBuilder, - $assetsGenerationService - ); - } +export type GenerateAssetsCommandContext = CommandContext< + typeof generateAssetsCommandOptions +>; - protected async generate( - imagePath: string, - background?: string - ): Promise { - await this.$assetsGenerationService.generateIcons({ - imagePath, - background, - projectDir: this.$projectData.projectDir, - }); - } +export interface IGenerateAssetsCommandServices { + assets: GeneratedAssets; + $assetsGenerationService: IAssetsGenerationService; + $projectData: IProjectData; } -injector.registerCommand("resources|generate|icons", GenerateIconsCommand); +export function setupGenerateAssetsCommand(): IGenerateAssetsCommandServices { + const services = { + assets: inject(GENERATED_ASSETS), + $assetsGenerationService: inject( + "assetsGenerationService", + ), + $projectData: inject("projectData"), + }; + services.$projectData.initializeProjectData(); -export class GenerateSplashScreensCommand - extends GenerateCommandBase - implements ICommand { - constructor( - protected $options: IOptions, - $injector: IInjector, - protected $projectData: IProjectData, - protected $stringParameterBuilder: IStringParameterBuilder, - $assetsGenerationService: IAssetsGenerationService - ) { - super( - $options, - $injector, - $projectData, - $stringParameterBuilder, - $assetsGenerationService - ); - } + return services; +} - protected async generate( - imagePath: string, - background?: string - ): Promise { - await this.$assetsGenerationService.generateSplashScreens({ - imagePath, - background, - projectDir: this.$projectData.projectDir, - }); - } +export function runGenerateAssetsCommand( + context: GenerateAssetsCommandContext, + services: IGenerateAssetsCommandServices, +): Promise { + return generators[services.assets](services.$assetsGenerationService, { + imagePath: context.args[0], + background: context.options.background, + projectDir: services.$projectData.projectDir, + }); } -injector.registerCommand( - "resources|generate|splashes", - GenerateSplashScreensCommand -); +export const generateAssetsCommandDefinition = defineCommand({ + name: "resources|generate|icons", + description: + "Generates icons and splash screens based on the provided image.", + options: generateAssetsCommandOptions, + arguments: [ + { + name: "imagePath", + required: true, + errorMessage: + "You have to provide path to image to generate other images based on it.", + }, + ], + setup: setupGenerateAssetsCommand, + run: runGenerateAssetsCommand, +}); + +const generateAssetsCommands: [string, GeneratedAssets][] = [ + ["resources|generate|icons", "icons"], + ["resources|generate|splashes", "splashes"], +]; + +for (const [name, assets] of generateAssetsCommands) { + registerCommand( + { ...generateAssetsCommandDefinition, name }, + getInjector().createChild([ + { provide: GENERATED_ASSETS, useValue: assets }, + ]), + ); +} diff --git a/lib/commands/generate-help.ts b/lib/commands/generate-help.ts index e80e64c6ee..7c8c23f5f3 100644 --- a/lib/commands/generate-help.ts +++ b/lib/commands/generate-help.ts @@ -1,15 +1,18 @@ -import { ICommandParameter, ICommand } from "../common/definitions/commands"; import { IHelpService } from "../common/declarations"; -import { injector } from "../common/yok"; +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; -export class GenerateHelpCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export const generateHelpCommandDefinition = defineCommand({ + name: "dev-generate-help", + description: "Generates the HTML help pages from the man pages.", + arguments: "none", + setup: () => ({ + $helpService: inject("helpService"), + }), + run(context, services): Promise { + return services.$helpService.generateHtmlPages(); + }, +}); - constructor(private $helpService: IHelpService) {} - - public async execute(args: string[]): Promise { - return this.$helpService.generateHtmlPages(); - } -} - -injector.registerCommand("dev-generate-help", GenerateHelpCommand); +registerCommand(generateHelpCommandDefinition); diff --git a/lib/commands/generate.ts b/lib/commands/generate.ts index 96c1d2a176..50ff0d539f 100644 --- a/lib/commands/generate.ts +++ b/lib/commands/generate.ts @@ -1,67 +1,30 @@ // import { run, ExecutionOptions } from "@nativescript/schematics-executor"; -// import { IOptions } from "../declarations"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { IErrors } from "../common/declarations"; -import { injector } from "../common/yok"; - -export class GenerateCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - // private executionOptions: ExecutionOptions; - - constructor( - private $logger: ILogger, - // private $options: IOptions, - private $errors: IErrors, - ) {} - - public async execute(_rawArgs: string[]): Promise { +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; + +export const generateCommandDefinition = defineCommand({ + name: "generate", + description: "Executes a schematic in the project.", + arguments: "any", + setup: () => ({ + $logger: inject("logger"), + $errors: inject("errors"), + }), + async run(context, services): Promise { try { - this.$logger.info( + services.$logger.info( "If you have ideas for this command, please discuss at https://nativescript.org/discord", ); // await run(this.executionOptions); } catch (error) { - this.$errors.fail(error.message); + services.$errors.fail(error.message); } - } - - public async canExecute(rawArgs: string[]): Promise { - this.setExecutionOptions(rawArgs); - this.validateExecutionOptions(); - - return true; - } + }, +}); - private validateExecutionOptions() { - // if (!this.executionOptions.schematic) { - // this.$errors.failWithHelp( - // `The generate command requires a schematic name to be specified.` - // ); - // } - } - - private setExecutionOptions(rawArgs: string[]) { - // const options = this.parseRawArgs(rawArgs); - // this.executionOptions = { - // ...options, - // logger: this.$logger, - // directory: process.cwd(), - // }; - } - - // private parseRawArgs(rawArgs: string[]) { - // const collection = this.$options.collection; - // const schematic = rawArgs.shift(); - // const { options, args } = parseSchematicSettings(rawArgs); - - // return { - // collection, - // schematic, - // schematicOptions: options, - // schematicArgs: args, - // }; - // } -} +registerCommand(generateCommandDefinition); /** * Converts an array of command line arguments to options for the executed schematic. @@ -95,5 +58,3 @@ export class GenerateCommand implements ICommand { // [[], []] // ); // } - -injector.registerCommand("generate", GenerateCommand); diff --git a/lib/commands/hooks/common.ts b/lib/commands/hooks/common.ts index 4532a73326..a51bf8dac1 100644 --- a/lib/commands/hooks/common.ts +++ b/lib/commands/hooks/common.ts @@ -1,8 +1,7 @@ -import * as _ from "lodash"; import { IProjectData } from "../../definitions/project"; -import { IPluginData } from "../../definitions/plugins"; -import { ICommandParameter } from "../../common/definitions/commands"; +import { IPluginData, IPluginsService } from "../../definitions/plugins"; import { IErrors, IFileSystem } from "../../common/declarations"; +import { inject } from "../../common/di"; import path = require("path"); import * as crypto from "crypto"; @@ -17,102 +16,125 @@ export interface OutputPlugin { hooks: OutputHook[]; } -export class HooksVerify { - public allowedParameters: ICommandParameter[] = []; +export interface IHooksCommandServices { + $pluginsService: IPluginsService; + $projectData: IProjectData; + $errors: IErrors; + $fs: IFileSystem; + $logger: ILogger; +} + +/** Callable from `setup` and from `canExecute` before their first `await`. */ +export function injectHooksCommandServices(): IHooksCommandServices { + const services = { + $pluginsService: inject("pluginsService"), + $projectData: inject("projectData"), + $errors: inject("errors"), + $fs: inject("fs"), + $logger: inject("logger"), + }; + services.$projectData.initializeProjectData(); + + return services; +} - constructor( - protected $projectData: IProjectData, - protected $errors: IErrors, - protected $fs: IFileSystem, - protected $logger: ILogger, - ) { - this.$projectData.initializeProjectData(); +export function getPluginsWithHooks(plugins: IPluginData[]): IPluginData[] { + const pluginsWithHooks: IPluginData[] = []; + for (const plugin of plugins) { + if (plugin.nativescript?.hooks?.length > 0) { + pluginsWithHooks.push(plugin); + } } - protected async verifyHooksLock( - plugins: IPluginData[], - hooksLockPath: string, - ): Promise { - let lockFileContent: string; - let hooksLock: OutputPlugin[]; - - try { - lockFileContent = this.$fs.readText(hooksLockPath, "utf8"); - hooksLock = JSON.parse(lockFileContent); - } catch (err) { - this.$errors.fail( - `❌ Failed to read or parse ${LOCK_FILE_NAME} at ${hooksLockPath}`, - ); + return pluginsWithHooks; +} + +export async function verifyHooksLock( + services: IHooksCommandServices, + plugins: IPluginData[], + hooksLockPath: string, +): Promise { + let lockFileContent: string; + let hooksLock: OutputPlugin[]; + + try { + lockFileContent = services.$fs.readText(hooksLockPath, "utf8"); + hooksLock = JSON.parse(lockFileContent); + } catch (err) { + services.$errors.fail( + `❌ Failed to read or parse ${LOCK_FILE_NAME} at ${hooksLockPath}`, + ); + } + + const lockMap = new Map>(); // pluginName -> hookType -> hash + + for (const plugin of hooksLock) { + const hookMap = new Map(); + for (const hook of plugin.hooks) { + hookMap.set(hook.type, hook.hash); } + lockMap.set(plugin.name, hookMap); + } - const lockMap = new Map>(); // pluginName -> hookType -> hash + let isValid = true; - for (const plugin of hooksLock) { - const hookMap = new Map(); - for (const hook of plugin.hooks) { - hookMap.set(hook.type, hook.hash); - } - lockMap.set(plugin.name, hookMap); + for (const plugin of plugins) { + const pluginLockHooks = lockMap.get(plugin.name); + + if (!pluginLockHooks) { + services.$logger.error( + `❌ Plugin '${plugin.name}' not found in ${LOCK_FILE_NAME}`, + ); + isValid = false; + continue; } - let isValid = true; + for (const hook of plugin.nativescript?.hooks || []) { + const expectedHash = pluginLockHooks.get(hook.type); + + if (!expectedHash) { + services.$logger.error( + `❌ Missing hook '${hook.type}' for plugin '${plugin.name}' in ${LOCK_FILE_NAME}`, + ); + isValid = false; + continue; + } - for (const plugin of plugins) { - const pluginLockHooks = lockMap.get(plugin.name); + let fileContent: string | Buffer; - if (!pluginLockHooks) { - this.$logger.error( - `❌ Plugin '${plugin.name}' not found in ${LOCK_FILE_NAME}`, + try { + fileContent = services.$fs.readFile( + path.join(plugin.fullPath, hook.script), + ); + } catch (err) { + services.$logger.error( + `❌ Cannot read script file '${hook.script}' for hook '${hook.type}' in plugin '${plugin.name}'`, ); isValid = false; continue; } - for (const hook of plugin.nativescript?.hooks || []) { - const expectedHash = pluginLockHooks.get(hook.type); - - if (!expectedHash) { - this.$logger.error( - `❌ Missing hook '${hook.type}' for plugin '${plugin.name}' in ${LOCK_FILE_NAME}`, - ); - isValid = false; - continue; - } - - let fileContent: string | Buffer; - - try { - fileContent = this.$fs.readFile( - path.join(plugin.fullPath, hook.script), - ); - } catch (err) { - this.$logger.error( - `❌ Cannot read script file '${hook.script}' for hook '${hook.type}' in plugin '${plugin.name}'`, - ); - isValid = false; - continue; - } - - const actualHash = crypto - .createHash("sha256") - .update(fileContent) - .digest("hex"); - - if (actualHash !== expectedHash) { - this.$logger.error( - `❌ Hash mismatch for '${hook.script}' (${hook.type} in ${plugin.name}):`, - ); - this.$logger.error(` Expected: ${expectedHash}`); - this.$logger.error(` Actual: ${actualHash}`); - isValid = false; - } + const actualHash = crypto + .createHash("sha256") + .update(fileContent) + .digest("hex"); + + if (actualHash !== expectedHash) { + services.$logger.error( + `❌ Hash mismatch for '${hook.script}' (${hook.type} in ${plugin.name}):`, + ); + services.$logger.error(` Expected: ${expectedHash}`); + services.$logger.error(` Actual: ${actualHash}`); + isValid = false; } } + } - if (isValid) { - this.$logger.info("✅ All hooks verified successfully. No issues found."); - } else { - this.$errors.fail("❌ One or more hooks failed verification."); - } + if (isValid) { + services.$logger.info( + "✅ All hooks verified successfully. No issues found.", + ); + } else { + services.$errors.fail("❌ One or more hooks failed verification."); } } diff --git a/lib/commands/hooks/hooks-lock.ts b/lib/commands/hooks/hooks-lock.ts index 27399ac622..e5727c3587 100644 --- a/lib/commands/hooks/hooks-lock.ts +++ b/lib/commands/hooks/hooks-lock.ts @@ -1,135 +1,108 @@ -import { IProjectData } from "../../definitions/project"; -import { IPluginsService, IPluginData } from "../../definitions/plugins"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; -import { IErrors, IFileSystem } from "../../common/declarations"; -import { injector } from "../../common/yok"; +import { IPluginData } from "../../definitions/plugins"; +import { defineCommand } from "../../common/define-command"; +import { registerCommand } from "../../common/services/command-definition-adapter"; import path = require("path"); import * as crypto from "crypto"; import { - HooksVerify, + getPluginsWithHooks, + IHooksCommandServices, + injectHooksCommandServices, LOCK_FILE_NAME, OutputHook, OutputPlugin, + verifyHooksLock, } from "./common"; -export class HooksLockPluginCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - private $pluginsService: IPluginsService, - private $projectData: IProjectData, - private $errors: IErrors, - private $fs: IFileSystem, - private $logger: ILogger, - ) { - this.$projectData.initializeProjectData(); - } - - public async execute(): Promise { - const plugins: IPluginData[] = - await this.$pluginsService.getAllInstalledPlugins(this.$projectData); - if (plugins && plugins.length > 0) { - const pluginsWithHooks: IPluginData[] = []; - for (const plugin of plugins) { - if (plugin.nativescript?.hooks?.length > 0) { - pluginsWithHooks.push(plugin); - } +async function writeHooksLockFile( + services: IHooksCommandServices, + plugins: IPluginData[], + outputDir: string, +): Promise { + const output: OutputPlugin[] = []; + + for (const plugin of plugins) { + const hooks: OutputHook[] = []; + + for (const hook of plugin.nativescript?.hooks || []) { + try { + const fileContent = services.$fs.readFile( + path.join(plugin.fullPath, hook.script), + ); + const hash = crypto + .createHash("sha256") + .update(fileContent) + .digest("hex"); + + hooks.push({ + type: hook.type, + hash, + }); + } catch (err) { + services.$logger.warn( + `Warning: Failed to read script '${hook.script}' for plugin '${plugin.name}'. Skipping this hook.`, + ); + continue; } - - await this.writeHooksLockFile( - pluginsWithHooks, - this.$projectData.projectDir, - ); - } else { - this.$logger.info("No plugins with hooks found."); } - } - public async canExecute(args: string[]): Promise { - return true; + output.push({ name: plugin.name, hooks }); } - private async writeHooksLockFile( - plugins: IPluginData[], - outputDir: string, - ): Promise { - const output: OutputPlugin[] = []; - - for (const plugin of plugins) { - const hooks: OutputHook[] = []; - - for (const hook of plugin.nativescript?.hooks || []) { - try { - const fileContent = this.$fs.readFile( - path.join(plugin.fullPath, hook.script), - ); - const hash = crypto - .createHash("sha256") - .update(fileContent) - .digest("hex"); - - hooks.push({ - type: hook.type, - hash, - }); - } catch (err) { - this.$logger.warn( - `Warning: Failed to read script '${hook.script}' for plugin '${plugin.name}'. Skipping this hook.`, - ); - continue; - } - } - - output.push({ name: plugin.name, hooks }); - } - - const filePath = path.resolve(outputDir, LOCK_FILE_NAME); + const filePath = path.resolve(outputDir, LOCK_FILE_NAME); - try { - this.$fs.writeFile(filePath, JSON.stringify(output, null, 2), "utf8"); - this.$logger.info(`✅ ${LOCK_FILE_NAME} written to: ${filePath}`); - } catch (err) { - this.$errors.fail(`❌ Failed to write ${LOCK_FILE_NAME}: ${err}`); - } + try { + services.$fs.writeFile(filePath, JSON.stringify(output, null, 2), "utf8"); + services.$logger.info(`✅ ${LOCK_FILE_NAME} written to: ${filePath}`); + } catch (err) { + services.$errors.fail(`❌ Failed to write ${LOCK_FILE_NAME}: ${err}`); } } -export class HooksVerifyPluginCommand extends HooksVerify implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - private $pluginsService: IPluginsService, - $projectData: IProjectData, - $errors: IErrors, - $fs: IFileSystem, - $logger: ILogger, - ) { - super($projectData, $errors, $fs, $logger); - } - - public async execute(): Promise { +export const hooksLockCommandDefinition = defineCommand({ + name: "hooks|lock", + description: + "Records a hash of every plugin hook in the project's lock file.", + arguments: "any", + setup: injectHooksCommandServices, + async run(context, services): Promise { const plugins: IPluginData[] = - await this.$pluginsService.getAllInstalledPlugins(this.$projectData); + await services.$pluginsService.getAllInstalledPlugins( + services.$projectData, + ); if (plugins && plugins.length > 0) { - const pluginsWithHooks: IPluginData[] = []; - for (const plugin of plugins) { - if (plugin.nativescript?.hooks?.length > 0) { - pluginsWithHooks.push(plugin); - } - } - await this.verifyHooksLock( - pluginsWithHooks, - path.join(this.$projectData.projectDir, LOCK_FILE_NAME), + await writeHooksLockFile( + services, + getPluginsWithHooks(plugins), + services.$projectData.projectDir, ); } else { - this.$logger.info("No plugins with hooks found."); + services.$logger.info("No plugins with hooks found."); } - } - - public async canExecute(args: string[]): Promise { - return true; - } -} + }, +}); + +export const hooksVerifyCommandDefinition = defineCommand({ + name: "hooks|verify", + description: + "Checks every plugin hook against the hashes in the project's lock file.", + arguments: "any", + setup: injectHooksCommandServices, + async run(context, services): Promise { + const plugins: IPluginData[] = + await services.$pluginsService.getAllInstalledPlugins( + services.$projectData, + ); + if (plugins && plugins.length > 0) { + await verifyHooksLock( + services, + getPluginsWithHooks(plugins), + path.join(services.$projectData.projectDir, LOCK_FILE_NAME), + ); + } else { + services.$logger.info("No plugins with hooks found."); + } + }, +}); -injector.registerCommand(["hooks|lock"], HooksLockPluginCommand); -injector.registerCommand(["hooks|verify"], HooksVerifyPluginCommand); +registerCommand(hooksLockCommandDefinition); +registerCommand(hooksVerifyCommandDefinition); diff --git a/lib/commands/hooks/hooks.ts b/lib/commands/hooks/hooks.ts index 4971c648cf..3ed512273d 100644 --- a/lib/commands/hooks/hooks.ts +++ b/lib/commands/hooks/hooks.ts @@ -1,104 +1,114 @@ -import { IProjectData } from "../../definitions/project"; -import { IPluginsService, IPluginData } from "../../definitions/plugins"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; -import { injector } from "../../common/yok"; -import { IErrors, IFileSystem } from "../../common/declarations"; +import { IPluginData } from "../../definitions/plugins"; +import { CommandContext, defineCommand } from "../../common/define-command"; +import { registerCommand } from "../../common/services/command-definition-adapter"; import path = require("path"); import { HOOKS_DIR_NAME } from "../../constants"; import { createTable } from "../../common/helpers"; import nsHooks = require("@nativescript/hook"); -import { HooksVerify, LOCK_FILE_NAME } from "./common"; +import { + getPluginsWithHooks, + IHooksCommandServices, + injectHooksCommandServices, + LOCK_FILE_NAME, + verifyHooksLock, +} from "./common"; -export class HooksPluginCommand extends HooksVerify implements ICommand { - public allowedParameters: ICommandParameter[] = []; +function listHooks( + services: IHooksCommandServices, + pluginsWithHooks: IPluginData[], +): void { + const headers: string[] = ["Plugin", "HookName", "HookPath"]; + const hookDataData: string[][] = pluginsWithHooks.flatMap((plugin) => + plugin.nativescript.hooks.map((hook: { type: string; script: string }) => { + return [plugin.name, hook.type, hook.script]; + }), + ); + const hookDataTable: any = createTable(headers, hookDataData); + services.$logger.info("Hooks:"); + services.$logger.info(hookDataTable.toString()); +} + +async function installHooks( + services: IHooksCommandServices, + pluginsWithHooks: IPluginData[], +): Promise { + const hooksDir = path.join(services.$projectData.projectDir, HOOKS_DIR_NAME); - constructor( - private $pluginsService: IPluginsService, - $projectData: IProjectData, - $errors: IErrors, - $fs: IFileSystem, - $logger: ILogger, + if ( + services.$fs.exists( + path.join(services.$projectData.projectDir, LOCK_FILE_NAME), + ) ) { - super($projectData, $errors, $fs, $logger); + await verifyHooksLock( + services, + pluginsWithHooks, + path.join(services.$projectData.projectDir, LOCK_FILE_NAME), + ); } - public async execute(args: string[]): Promise { - const isList: boolean = - args.length > 0 && args[0] === "list" ? true : false; - const plugins: IPluginData[] = - await this.$pluginsService.getAllInstalledPlugins(this.$projectData); - if (plugins && plugins.length > 0) { - const hooksDir = path.join(this.$projectData.projectDir, HOOKS_DIR_NAME); - const pluginsWithHooks: IPluginData[] = []; - for (const plugin of plugins) { - if (plugin.nativescript?.hooks?.length > 0) { - pluginsWithHooks.push(plugin); - } - } - - if (isList) { - const headers: string[] = ["Plugin", "HookName", "HookPath"]; - const hookDataData: string[][] = pluginsWithHooks.flatMap((plugin) => - plugin.nativescript.hooks.map( - (hook: { type: string; script: string }) => { - return [plugin.name, hook.type, hook.script]; - }, - ), - ); - const hookDataTable: any = createTable(headers, hookDataData); - this.$logger.info("Hooks:"); - this.$logger.info(hookDataTable.toString()); - } else { - if ( - this.$fs.exists( - path.join(this.$projectData.projectDir, LOCK_FILE_NAME), - ) - ) { - await this.verifyHooksLock( - pluginsWithHooks, - path.join(this.$projectData.projectDir, LOCK_FILE_NAME), - ); - } - - if (pluginsWithHooks.length === 0) { - if (!this.$fs.exists(hooksDir)) { - this.$fs.createDirectory(hooksDir); - } - } - for (const plugin of pluginsWithHooks) { - nsHooks(plugin.fullPath).postinstall(); - } - } + if (pluginsWithHooks.length === 0) { + if (!services.$fs.exists(hooksDir)) { + services.$fs.createDirectory(hooksDir); } } - - public async canExecute(args: string[]): Promise { - if (args.length > 0 && args[0] !== "list") { - this.$errors.failWithHelp( - `Invalid argument ${args[0]}. Supported argument is "list".`, - ); - } - return true; + for (const plugin of pluginsWithHooks) { + nsHooks(plugin.fullPath).postinstall(); } } -export class HooksListPluginCommand extends HooksPluginCommand { - public allowedParameters: ICommandParameter[] = []; +export async function runHooksCommand( + services: IHooksCommandServices, + isList: boolean, +): Promise { + const plugins: IPluginData[] = + await services.$pluginsService.getAllInstalledPlugins( + services.$projectData, + ); + if (plugins && plugins.length > 0) { + const pluginsWithHooks = getPluginsWithHooks(plugins); - constructor( - $pluginsService: IPluginsService, - $projectData: IProjectData, - $errors: IErrors, - $fs: IFileSystem, - $logger: ILogger, - ) { - super($pluginsService, $projectData, $errors, $fs, $logger); + if (isList) { + listHooks(services, pluginsWithHooks); + } else { + await installHooks(services, pluginsWithHooks); + } } +} - public async execute(): Promise { - await super.execute(["list"]); +export function canExecuteHooksCommand( + context: CommandContext, + services: IHooksCommandServices, +): boolean { + if (context.args.length > 0 && context.args[0] !== "list") { + services.$errors.failWithHelp( + `Invalid argument ${context.args[0]}. Supported argument is "list".`, + ); } + return true; } -injector.registerCommand(["hooks|install"], HooksPluginCommand); -injector.registerCommand(["hooks|*list"], HooksListPluginCommand); +export const hooksInstallCommandDefinition = defineCommand({ + name: "hooks|install", + description: "Runs the postinstall hook of every installed plugin.", + arguments: "any", + setup: injectHooksCommandServices, + canExecute: canExecuteHooksCommand, + run(context, services): Promise { + return runHooksCommand(services, context.args[0] === "list"); + }, +}); + +export const hooksListCommandDefinition = defineCommand({ + name: "hooks|*list", + description: "Lists the hooks every installed plugin contributes.", + arguments: "any", + setup: injectHooksCommandServices, + // The name accepts "list" as its only argument, and lists either way. + canExecute: canExecuteHooksCommand, + run(context, services): Promise { + return runHooksCommand(services, true); + }, +}); + +registerCommand(hooksInstallCommandDefinition); +registerCommand(hooksListCommandDefinition); diff --git a/lib/commands/info.ts b/lib/commands/info.ts index 946f50f934..b745354004 100644 --- a/lib/commands/info.ts +++ b/lib/commands/info.ts @@ -1,15 +1,18 @@ import { IInfoService } from "../declarations"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { injector } from "../common/yok"; +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; -export class InfoCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export const infoCommandDefinition = defineCommand({ + name: "info", + description: "Displays version information about the CLI and its components.", + arguments: "none", + setup: () => ({ + $infoService: inject("infoService"), + }), + run(context, services): Promise { + return services.$infoService.printComponentsInfo(); + }, +}); - constructor(private $infoService: IInfoService) {} - - public async execute(args: string[]): Promise { - return this.$infoService.printComponentsInfo(); - } -} - -injector.registerCommand("info", InfoCommand); +registerCommand(infoCommandDefinition); diff --git a/lib/commands/install.ts b/lib/commands/install.ts index 32de375055..bc733cdd2f 100644 --- a/lib/commands/install.ts +++ b/lib/commands/install.ts @@ -1,99 +1,152 @@ import { EOL } from "os"; -import { IProjectData, IProjectDataService } from "../definitions/project"; +import { IFileSystem } from "../common/declarations"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; +import { PlatformTypes } from "../constants"; import { + INodePackageManager, IOptions, IPlatformCommandHelper, - INodePackageManager, } from "../declarations"; import { IPlatformsDataService } from "../definitions/platform"; import { IPluginsService } from "../definitions/plugins"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { IFileSystem } from "../common/declarations"; -import { injector } from "../common/yok"; -import { PlatformTypes } from "../constants"; +import { IProjectData, IProjectDataService } from "../definitions/project"; -export class InstallCommand implements ICommand { - public enableHooks = false; - public allowedParameters: ICommandParameter[] = [this.$stringParameter]; - - constructor( - private $options: IOptions, - private $mobileHelper: Mobile.IMobileHelper, - private $platformsDataService: IPlatformsDataService, - private $platformCommandHelper: IPlatformCommandHelper, - private $projectData: IProjectData, - private $projectDataService: IProjectDataService, - private $pluginsService: IPluginsService, - private $logger: ILogger, - private $fs: IFileSystem, - private $stringParameter: ICommandParameter, - private $packageManager: INodePackageManager - ) { - this.$projectData.initializeProjectData(); - } +export const installCommandOptions = { + frameworkPath: stringOption(), + disableNpmInstall: booleanOption(), + ignoreScripts: booleanOption(), + path: stringOption(), +} satisfies CommandOptionsSchema; - public async execute(args: string[]): Promise { - return args[0] - ? this.installModule(args[0]) - : this.installProjectDependencies(); - } +export type InstallCommandContext = CommandContext< + typeof installCommandOptions +>; - private async installProjectDependencies(): Promise { - let error: string = ""; +export interface IInstallCommandServices { + $options: IOptions; + $mobileHelper: Mobile.IMobileHelper; + $platformsDataService: IPlatformsDataService; + $platformCommandHelper: IPlatformCommandHelper; + $projectData: IProjectData; + $projectDataService: IProjectDataService; + $pluginsService: IPluginsService; + $logger: ILogger; + $fs: IFileSystem; + $packageManager: INodePackageManager; +} + +export function setupInstallCommand(): IInstallCommandServices { + const services = { + $options: inject("options"), + $mobileHelper: inject("mobileHelper"), + $platformsDataService: inject( + "platformsDataService", + ), + $platformCommandHelper: inject( + "platformCommandHelper", + ), + $projectData: inject("projectData"), + $projectDataService: inject("projectDataService"), + $pluginsService: inject("pluginsService"), + $logger: inject("logger"), + $fs: inject("fs"), + $packageManager: inject("packageManager"), + }; + services.$projectData.initializeProjectData(); - await this.$pluginsService.ensureAllDependenciesAreInstalled( - this.$projectData + return services; +} + +async function installProjectDependencies( + context: InstallCommandContext, + services: IInstallCommandServices, +): Promise { + let error: string = ""; + + await services.$pluginsService.ensureAllDependenciesAreInstalled( + services.$projectData, + ); + + for (const platform of services.$mobileHelper.platformNames) { + const platformData = services.$platformsDataService.getPlatformData( + platform, + services.$projectData, + ); + const frameworkPackageData = services.$projectDataService.getRuntimePackage( + services.$projectData.projectDir, + platformData.platformNameLowerCase, ); + if (frameworkPackageData && frameworkPackageData.version) { + try { + const platformProjectService = platformData.platformProjectService; + await platformProjectService.validate( + services.$projectData, + services.$options, + ); - for (const platform of this.$mobileHelper.platformNames) { - const platformData = this.$platformsDataService.getPlatformData( - platform, - this.$projectData - ); - const frameworkPackageData = this.$projectDataService.getRuntimePackage( - this.$projectData.projectDir, - platformData.platformNameLowerCase - ); - if (frameworkPackageData && frameworkPackageData.version) { - try { - const platformProjectService = platformData.platformProjectService; - await platformProjectService.validate( - this.$projectData, - this.$options - ); - - await this.$platformCommandHelper.addPlatforms( - [`${platform}@${frameworkPackageData.version}`], - this.$projectData, - this.$options.frameworkPath - ); - } catch (err) { - error = `${error}${EOL}${err}`; - } + await services.$platformCommandHelper.addPlatforms( + [`${platform}@${frameworkPackageData.version}`], + services.$projectData, + context.options.frameworkPath, + ); + } catch (err) { + error = `${error}${EOL}${err}`; } } - - if (error) { - this.$logger.error(error); - } } - private async installModule(moduleName: string): Promise { - const projectDir = this.$projectData.projectDir; + if (error) { + services.$logger.error(error); + } +} - const devPrefix = "nativescript-dev-"; - if (!this.$fs.exists(moduleName) && moduleName.indexOf(devPrefix) !== 0) { - moduleName = devPrefix + moduleName; - } +async function installModule( + context: InstallCommandContext, + services: IInstallCommandServices, + moduleName: string, +): Promise { + const projectDir = services.$projectData.projectDir; - await this.$packageManager.install(moduleName, projectDir, { - "save-dev": true, - disableNpmInstall: this.$options.disableNpmInstall, - frameworkPath: this.$options.frameworkPath, - ignoreScripts: this.$options.ignoreScripts, - path: this.$options.path, - }); + const devPrefix = "nativescript-dev-"; + if (!services.$fs.exists(moduleName) && moduleName.indexOf(devPrefix) !== 0) { + moduleName = devPrefix + moduleName; } + + await services.$packageManager.install(moduleName, projectDir, { + "save-dev": true, + disableNpmInstall: context.options.disableNpmInstall, + frameworkPath: context.options.frameworkPath, + ignoreScripts: context.options.ignoreScripts, + path: context.options.path, + }); } -injector.registerCommand("install", InstallCommand); +export function runInstallCommand( + context: InstallCommandContext, + services: IInstallCommandServices, +): Promise { + return context.args[0] + ? installModule(context, services, context.args[0]) + : installProjectDependencies(context, services); +} + +export const installCommandDefinition = defineCommand({ + name: "install", + description: + "Installs all platforms and dependencies described in the project, or a single plugin.", + options: installCommandOptions, + arguments: [{ name: "moduleName" }], + enableHooks: false, + setup: setupInstallCommand, + run: runInstallCommand, +}); + +registerCommand(installCommandDefinition); diff --git a/lib/commands/list-platforms.ts b/lib/commands/list-platforms.ts index 83c56e443c..be7b537b86 100644 --- a/lib/commands/list-platforms.ts +++ b/lib/commands/list-platforms.ts @@ -1,54 +1,74 @@ import * as helpers from "../common/helpers"; import { IProjectData } from "../definitions/project"; import { IPlatformCommandHelper } from "../declarations"; -import { ICommandParameter, ICommand } from "../common/definitions/commands"; -import { injector } from "../common/yok"; +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; -export class ListPlatformsCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export interface IListPlatformsCommandServices { + $platformCommandHelper: IPlatformCommandHelper; + $projectData: IProjectData; + $logger: ILogger; +} - constructor( - private $platformCommandHelper: IPlatformCommandHelper, - private $projectData: IProjectData, - private $logger: ILogger - ) { - this.$projectData.initializeProjectData(); - } +export function setupListPlatformsCommand(): IListPlatformsCommandServices { + const services = { + $platformCommandHelper: inject( + "platformCommandHelper", + ), + $projectData: inject("projectData"), + $logger: inject("logger"), + }; + services.$projectData.initializeProjectData(); - public async execute(args: string[]): Promise { - const installedPlatforms = this.$platformCommandHelper.getInstalledPlatforms( - this.$projectData - ); + return services; +} - if (installedPlatforms.length > 0) { - const preparedPlatforms = this.$platformCommandHelper.getPreparedPlatforms( - this.$projectData +export const listPlatformsCommandDefinition = defineCommand({ + name: "platform|*list", + description: "Lists all platforms that the project currently targets.", + arguments: "none", + setup: setupListPlatformsCommand, + async run(context, services): Promise { + const installedPlatforms = + services.$platformCommandHelper.getInstalledPlatforms( + services.$projectData, ); + + if (installedPlatforms.length > 0) { + const preparedPlatforms = + services.$platformCommandHelper.getPreparedPlatforms( + services.$projectData, + ); if (preparedPlatforms.length > 0) { - this.$logger.info( + services.$logger.info( "The project is prepared for: ", - helpers.formatListOfNames(preparedPlatforms, "and") + helpers.formatListOfNames(preparedPlatforms, "and"), ); } else { - this.$logger.info("The project is not prepared for any platform"); + services.$logger.info("The project is not prepared for any platform"); } - this.$logger.info( + services.$logger.info( "Installed platforms: ", - helpers.formatListOfNames(installedPlatforms, "and") + helpers.formatListOfNames(installedPlatforms, "and"), ); } else { const formattedPlatformsList = helpers.formatListOfNames( - this.$platformCommandHelper.getAvailablePlatforms(this.$projectData), - "and" + services.$platformCommandHelper.getAvailablePlatforms( + services.$projectData, + ), + "and", ); - this.$logger.info( + services.$logger.info( "Available platforms for this OS: ", - formattedPlatformsList + formattedPlatformsList, + ); + services.$logger.info( + "No installed platforms found. Use $ ns platform add", ); - this.$logger.info("No installed platforms found. Use $ ns platform add"); } - } -} + }, +}); -injector.registerCommand("platform|*list", ListPlatformsCommand); +registerCommand(listPlatformsCommandDefinition); diff --git a/lib/commands/migrate.ts b/lib/commands/migrate.ts index 879f3b5e80..373916df10 100644 --- a/lib/commands/migrate.ts +++ b/lib/commands/migrate.ts @@ -1,43 +1,59 @@ import { IProjectData } from "../definitions/project"; import { IMigrateController, IMigrationData } from "../definitions/migrate"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { injector } from "../common/yok"; +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; -export class MigrateCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export interface IMigrateCommandServices { + $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; + $migrateController: IMigrateController; + $staticConfig: Config.IStaticConfig; + $projectData: IProjectData; + $logger: ILogger; +} + +export function setupMigrateCommand(): IMigrateCommandServices { + const services = { + $devicePlatformsConstants: inject( + "devicePlatformsConstants", + ), + $migrateController: inject("migrateController"), + $staticConfig: inject("staticConfig"), + $projectData: inject("projectData"), + $logger: inject("logger"), + }; + services.$projectData.initializeProjectData(); - constructor( - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - private $migrateController: IMigrateController, - private $staticConfig: Config.IStaticConfig, - private $projectData: IProjectData, - private $logger: ILogger - ) { - this.$projectData.initializeProjectData(); - } + return services; +} - public async execute(args: string[]): Promise { +export const migrateCommandDefinition = defineCommand({ + name: "migrate", + description: + "Migrates the project's dependencies to the ones the current CLI supports.", + arguments: "none", + setup: setupMigrateCommand, + async run(context, services): Promise { const migrationData: IMigrationData = { - projectDir: this.$projectData.projectDir, + projectDir: services.$projectData.projectDir, platforms: [ - this.$devicePlatformsConstants.Android, - this.$devicePlatformsConstants.iOS, + services.$devicePlatformsConstants.Android, + services.$devicePlatformsConstants.iOS, ], }; - const shouldMigrateResult = await this.$migrateController.shouldMigrate( - migrationData - ); + const shouldMigrateResult = + await services.$migrateController.shouldMigrate(migrationData); if (!shouldMigrateResult) { - const cliVersion = this.$staticConfig.version; - this.$logger.printMarkdown( - `__Project is compatible with NativeScript \`v${cliVersion}\`__` + const cliVersion = services.$staticConfig.version; + services.$logger.printMarkdown( + `__Project is compatible with NativeScript \`v${cliVersion}\`__`, ); return; } - await this.$migrateController.migrate(migrationData); - } -} + await services.$migrateController.migrate(migrationData); + }, +}); -injector.registerCommand("migrate", MigrateCommand); +registerCommand(migrateCommandDefinition); diff --git a/lib/commands/native-add.ts b/lib/commands/native-add.ts index 3b5ca90f37..9131eedad6 100644 --- a/lib/commands/native-add.ts +++ b/lib/commands/native-add.ts @@ -1,90 +1,85 @@ -import { IProjectData } from "../definitions/project"; import * as fs from "fs"; -import { ICommandParameter, ICommand } from "../common/definitions/commands"; -import { IErrors } from "../common/declarations"; +import { EOL } from "os"; import * as path from "path"; -import { injector } from "../common/yok"; +import { IErrors } from "../common/declarations"; +import { defineCommand } from "../common/define-command"; +import { inject, InjectionToken } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; import { capitalizeFirstLetter } from "../common/utils"; -import { EOL } from "os"; +import { getInjector } from "../common/yok"; +import { IProjectData } from "../definitions/project"; -export class NativeAddCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +/** + * Which language a registration generates a source file for. It also decides + * the platform: java and kotlin write under App_Resources/Android, swift and + * objective-c under App_Resources/iOS. + */ +type NativeAddLanguage = "java" | "kotlin" | "swift" | "objective-c"; - constructor( - protected $projectData: IProjectData, - protected $logger: ILogger, - protected $errors: IErrors - ) { - this.$projectData.initializeProjectData(); - } +const NATIVE_ADD_LANGUAGE = new InjectionToken( + "nativeAddLanguage", +); - public async execute(args: string[]): Promise { - this.failWithUsage(); +export interface INativeAddCommandServices { + $projectData: IProjectData; + $logger: ILogger; + $errors: IErrors; +} - return Promise.resolve(); - } +interface INativeAddLanguageCommandServices extends INativeAddCommandServices { + language: NativeAddLanguage; +} - protected failWithUsage(): void { - this.$errors.failWithHelp( - "Usage: ns native add [swift|objective-c|java|kotlin] [class name]" - ); - } - public async canExecute(args: string[]): Promise { - this.failWithUsage(); - return false; - } +export function setupNativeAddCommand(): INativeAddCommandServices { + const services = { + $projectData: inject("projectData"), + $logger: inject("logger"), + $errors: inject("errors"), + }; + services.$projectData.initializeProjectData(); - protected getIosSourcePathBase() { - const resources = this.$projectData.getAppResourcesDirectoryPath(); - return path.join(resources, "iOS", "src"); - } + return services; +} - protected getAndroidSourcePathBase() { - const resources = this.$projectData.getAppResourcesDirectoryPath(); - return path.join(resources, "Android", "src", "main", "java"); - } +function failWithUsage(services: INativeAddCommandServices): void { + services.$errors.failWithHelp( + "Usage: ns native add [swift|objective-c|java|kotlin] [class name]", + ); } -export class NativeAddSingleCommand extends NativeAddCommand { - constructor($projectData: IProjectData, $logger: ILogger, $errors: IErrors) { - super($projectData, $logger, $errors); - } - public async canExecute(args: string[]): Promise { - if (!args || args.length !== 1) { - this.failWithUsage(); - } - return true; - } +function getIosSourcePathBase(services: INativeAddCommandServices): string { + const resources = services.$projectData.getAppResourcesDirectoryPath(); + return path.join(resources, "iOS", "src"); } -export class NativeAddAndroidCommand extends NativeAddSingleCommand { - constructor($projectData: IProjectData, $logger: ILogger, $errors: IErrors) { - super($projectData, $logger, $errors); - } +function getAndroidSourcePathBase(services: INativeAddCommandServices): string { + const resources = services.$projectData.getAppResourcesDirectoryPath(); + return path.join(resources, "Android", "src", "main", "java"); +} - private getPackageName(className: string): string { - const lastDotIndex = className.lastIndexOf("."); - if (lastDotIndex !== -1) { - return className.substring(0, lastDotIndex); - } - return ""; +function getPackageName(className: string): string { + const lastDotIndex = className.lastIndexOf("."); + if (lastDotIndex !== -1) { + return className.substring(0, lastDotIndex); } + return ""; +} - private getClassSimpleName(className: string): string { - const lastDotIndex = className.lastIndexOf("."); - if (lastDotIndex !== -1) { - return className.substring(lastDotIndex + 1); - } - return className; +function getClassSimpleName(className: string): string { + const lastDotIndex = className.lastIndexOf("."); + if (lastDotIndex !== -1) { + return className.substring(lastDotIndex + 1); } + return className; +} - private generateJavaClassContent( - packageName: string, - classSimpleName: string - ): string { - return ( - (packageName.length > 0 ? `package ${packageName};` : "") + - ` +function generateJavaClassContent( + packageName: string, + classSimpleName: string, +): string { + return ( + (packageName.length > 0 ? `package ${packageName};` : "") + + ` import android.util.Log; public class ${classSimpleName} { @@ -93,16 +88,16 @@ public class ${classSimpleName} { } } ` - ); - } + ); +} - private generateKotlinClassContent( - packageName: string, - classSimpleName: string - ): string { - return ( - (packageName.length > 0 ? `package ${packageName};` : "") + - ` +function generateKotlinClassContent( + packageName: string, + classSimpleName: string, +): string { + return ( + (packageName.length > 0 ? `package ${packageName};` : "") + + ` import android.util.Log @@ -112,197 +107,154 @@ class ${classSimpleName} { } } ` - ); - } - public doJavaKotlin(className: string, extension: string): void { - const fileExt = extension == "java" ? extension : "kt"; - const packageName = this.getPackageName(className); - const classSimpleName = this.getClassSimpleName(className); - const packagePath = path.join( - this.getAndroidSourcePathBase(), - ...packageName.split(".") - ); - const filePath = path.join(packagePath, `${classSimpleName}.${fileExt}`); + ); +} - if (fs.existsSync(filePath)) { - this.$errors.failWithHelp( - `${extension} file '${filePath}' already exists.` - ); - return; - } +function checkAndUpdateGradleProperties( + services: INativeAddCommandServices, +): boolean { + const resources = services.$projectData.getAppResourcesDirectoryPath(); - if (extension == "kotlin" && !this.checkAndUpdateGradleProperties()) { - return; - } + const filePath = path.join(resources, "Android", "gradle.properties"); - const fileContent = - extension == "java" - ? this.generateJavaClassContent(packageName, classSimpleName) - : this.generateKotlinClassContent(packageName, classSimpleName); - - fs.mkdirSync(packagePath, { recursive: true }); - fs.writeFileSync(filePath, fileContent); - this.$logger.info( - `${capitalizeFirstLetter( - extension - )} file '${filePath}' generated successfully.` - ); - } + if (fs.existsSync(filePath)) { + const fileContent = fs.readFileSync(filePath, "utf8"); + const propertyRegex = /^useKotlin\s*=\s*(true|false)$/m; + const match = propertyRegex.exec(fileContent); - private checkAndUpdateGradleProperties(): boolean { - const resources = this.$projectData.getAppResourcesDirectoryPath(); - - const filePath = path.join(resources, "Android", "gradle.properties"); - - if (fs.existsSync(filePath)) { - const fileContent = fs.readFileSync(filePath, "utf8"); - const propertyRegex = /^useKotlin\s*=\s*(true|false)$/m; - const match = propertyRegex.exec(fileContent); - - if (match) { - const useKotlin = match[1]; - - if (useKotlin === "false") { - this.$errors.failWithHelp( - "The useKotlin property is set to false. Stopping processing. Kotlin must be enabled in gradle.properties to use." - ); - return false; - } - - if (useKotlin === "true") { - return true; - } - } else { - fs.appendFileSync(filePath, `${EOL}useKotlin=true${EOL}`); - this.$logger.info( - 'Added "useKotlin=true" property to gradle.properties.' + if (match) { + const useKotlin = match[1]; + + if (useKotlin === "false") { + services.$errors.failWithHelp( + "The useKotlin property is set to false. Stopping processing. Kotlin must be enabled in gradle.properties to use.", ); + return false; + } + + if (useKotlin === "true") { + return true; } } else { - fs.writeFileSync(filePath, `useKotlin=true${EOL}`); - this.$logger.info( - 'Created gradle.properties with "useKotlin=true" property.' + fs.appendFileSync(filePath, `${EOL}useKotlin=true${EOL}`); + services.$logger.info( + 'Added "useKotlin=true" property to gradle.properties.', ); } - return true; + } else { + fs.writeFileSync(filePath, `useKotlin=true${EOL}`); + services.$logger.info( + 'Created gradle.properties with "useKotlin=true" property.', + ); } + return true; } -export class NativeAddJavaCommand extends NativeAddAndroidCommand { - constructor($projectData: IProjectData, $logger: ILogger, $errors: IErrors) { - super($projectData, $logger, $errors); - } - - public async execute(args: string[]): Promise { - this.doJavaKotlin(args[0], "java"); - - return Promise.resolve(); +export function generateJavaKotlin( + services: INativeAddCommandServices, + className: string, + extension: string, +): void { + const fileExt = extension == "java" ? extension : "kt"; + const packageName = getPackageName(className); + const classSimpleName = getClassSimpleName(className); + const packagePath = path.join( + getAndroidSourcePathBase(services), + ...packageName.split("."), + ); + const filePath = path.join(packagePath, `${classSimpleName}.${fileExt}`); + + if (fs.existsSync(filePath)) { + services.$errors.failWithHelp( + `${extension} file '${filePath}' already exists.`, + ); + return; } -} -export class NativeAddKotlinCommand extends NativeAddAndroidCommand { - constructor($projectData: IProjectData, $logger: ILogger, $errors: IErrors) { - super($projectData, $logger, $errors); + if (extension == "kotlin" && !checkAndUpdateGradleProperties(services)) { + return; } - public async execute(args: string[]): Promise { - this.doJavaKotlin(args[0], "kotlin"); - - return Promise.resolve(); - } + const fileContent = + extension == "java" + ? generateJavaClassContent(packageName, classSimpleName) + : generateKotlinClassContent(packageName, classSimpleName); + + fs.mkdirSync(packagePath, { recursive: true }); + fs.writeFileSync(filePath, fileContent); + services.$logger.info( + `${capitalizeFirstLetter( + extension, + )} file '${filePath}' generated successfully.`, + ); } -export class NativeAddObjectiveCCommand extends NativeAddSingleCommand { - constructor($projectData: IProjectData, $logger: ILogger, $errors: IErrors) { - super($projectData, $logger, $errors); - } +function generateOrUpdateModuleMap( + services: INativeAddCommandServices, + headerFileName: string, + moduleMapPath: string, +): void { + const moduleName = "LocalModule"; + const headerPath = headerFileName; - public async execute(args: string[]): Promise { - this.doObjectiveC(args[0]); + let moduleMapContent = ""; - return Promise.resolve(); - } - private doObjectiveC(className: string) { - const iosSourceBase = this.getIosSourcePathBase(); - - const classFilePath = path.join(iosSourceBase, `${className}.m`); - const headerFilePath = path.join(iosSourceBase, `${className}.h`); - - if ( - this.generateObjectiveCFiles(className, classFilePath, headerFilePath) - ) { - // Modify/Generate moduleMap - this.generateOrUpdateModuleMap( - `${className}.h`, - path.join(iosSourceBase, "module.modulemap") - ); - } + if (fs.existsSync(moduleMapPath)) { + moduleMapContent = fs.readFileSync(moduleMapPath, "utf8"); } - private generateOrUpdateModuleMap( - headerFileName: string, - moduleMapPath: string - ): void { - const moduleName = "LocalModule"; - const headerPath = headerFileName; + const headerDeclaration = `header "${headerPath}"`; - let moduleMapContent = ""; - - if (fs.existsSync(moduleMapPath)) { - moduleMapContent = fs.readFileSync(moduleMapPath, "utf8"); + if (moduleMapContent.includes(`module ${moduleName}`)) { + // Module declaration already exists in the module map + if (moduleMapContent.includes(headerDeclaration)) { + // Header is already present in the module map + services.$logger.warn( + `Header '${headerFileName}' is already added to the module map.`, + ); + return; } - const headerDeclaration = `header "${headerPath}"`; - - if (moduleMapContent.includes(`module ${moduleName}`)) { - // Module declaration already exists in the module map - if (moduleMapContent.includes(headerDeclaration)) { - // Header is already present in the module map - this.$logger.warn( - `Header '${headerFileName}' is already added to the module map.` - ); - return; - } + const updatedModuleMapContent = moduleMapContent.replace( + new RegExp(`module ${moduleName} {\\s*([^}]*)\\s*}`, "s"), + `module ${moduleName} {${EOL} $1${EOL} ${headerDeclaration}${EOL}}`, + ); - const updatedModuleMapContent = moduleMapContent.replace( - new RegExp(`module ${moduleName} {\\s*([^}]*)\\s*}`, "s"), - `module ${moduleName} {${EOL} $1${EOL} ${headerDeclaration}${EOL}}` - ); + fs.writeFileSync(moduleMapPath, updatedModuleMapContent); + } else { + // Module declaration does not exist in the module map + const moduleDeclaration = `module ${moduleName} {${EOL} ${headerDeclaration}${EOL} export *${EOL}}`; - fs.writeFileSync(moduleMapPath, updatedModuleMapContent); - } else { - // Module declaration does not exist in the module map - const moduleDeclaration = `module ${moduleName} {${EOL} ${headerDeclaration}${EOL} export *${EOL}}`; + moduleMapContent += `${EOL}${EOL}${moduleDeclaration}`; + fs.writeFileSync(moduleMapPath, moduleMapContent); + } - moduleMapContent += `${EOL}${EOL}${moduleDeclaration}`; - fs.writeFileSync(moduleMapPath, moduleMapContent); - } + services.$logger.info( + `Module map '${moduleMapPath}' has been updated with the header '${headerFileName}'.`, + ); +} - this.$logger.info( - `Module map '${moduleMapPath}' has been updated with the header '${headerFileName}'.` +function generateObjectiveCFiles( + services: INativeAddCommandServices, + className: string, + classFilePath: string, + interfaceFilePath: string, +): boolean { + if (fs.existsSync(classFilePath)) { + services.$errors.failWithHelp( + `Error: File '${classFilePath}' already exists.`, ); + return false; } - private generateObjectiveCFiles( - className: string, - classFilePath: string, - interfaceFilePath: string - ): boolean { - if (fs.existsSync(classFilePath)) { - this.$errors.failWithHelp( - `Error: File '${classFilePath}' already exists.` - ); - return false; - } - - if (fs.existsSync(interfaceFilePath)) { - this.$errors.failWithHelp( - `Error: File '${interfaceFilePath}' already exists.` - ); - return false; - } + if (fs.existsSync(interfaceFilePath)) { + services.$errors.failWithHelp( + `Error: File '${interfaceFilePath}' already exists.`, + ); + return false; + } - const interfaceContent = `#import + const interfaceContent = `#import @interface ${className} : NSObject @@ -311,7 +263,7 @@ export class NativeAddObjectiveCCommand extends NativeAddSingleCommand { @end `; - const classContent = `#import "${className}.h" + const classContent = `#import "${className}.h" @implementation ${className} @@ -322,50 +274,57 @@ export class NativeAddObjectiveCCommand extends NativeAddSingleCommand { @end `; - fs.writeFileSync(classFilePath, classContent); - this.$logger.trace( - `Objective-C class file '${classFilePath}' generated successfully.` - ); + fs.writeFileSync(classFilePath, classContent); + services.$logger.trace( + `Objective-C class file '${classFilePath}' generated successfully.`, + ); - fs.writeFileSync(interfaceFilePath, interfaceContent); - this.$logger.trace( - `Objective-C interface file '${interfaceFilePath}' generated successfully.` - ); - return true; - } + fs.writeFileSync(interfaceFilePath, interfaceContent); + services.$logger.trace( + `Objective-C interface file '${interfaceFilePath}' generated successfully.`, + ); + return true; } -export class NativeAddSwiftCommand extends NativeAddSingleCommand { - constructor($projectData: IProjectData, $logger: ILogger, $errors: IErrors) { - super($projectData, $logger, $errors); - } +export function generateObjectiveC( + services: INativeAddCommandServices, + className: string, +): void { + const iosSourceBase = getIosSourcePathBase(services); - public async execute(args: string[]): Promise { - this.doSwift(args[0]); + const classFilePath = path.join(iosSourceBase, `${className}.m`); + const headerFilePath = path.join(iosSourceBase, `${className}.h`); - return Promise.resolve(); - } - - private doSwift(className: string) { - const iosSourceBase = this.getIosSourcePathBase(); - const swiftFilePath = path.join(iosSourceBase, `${className}.swift`); - this.generateSwiftFile(className, swiftFilePath); + if ( + generateObjectiveCFiles(services, className, classFilePath, headerFilePath) + ) { + // Modify/Generate moduleMap + generateOrUpdateModuleMap( + services, + `${className}.h`, + path.join(iosSourceBase, "module.modulemap"), + ); } +} - private generateSwiftFile(className: string, filePath: string): void { - const directory = path.dirname(filePath); +function generateSwiftFile( + services: INativeAddCommandServices, + className: string, + filePath: string, +): void { + const directory = path.dirname(filePath); - if (!fs.existsSync(directory)) { - fs.mkdirSync(directory, { recursive: true }); - this.$logger.trace(`Created directory: '${directory}'.`); - } + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + services.$logger.trace(`Created directory: '${directory}'.`); + } - if (fs.existsSync(filePath)) { - this.$errors.failWithHelp(`Error: File '${filePath}' already exists.`); - return; - } + if (fs.existsSync(filePath)) { + services.$errors.failWithHelp(`Error: File '${filePath}' already exists.`); + return; + } - const content = `import Foundation; + const content = `import Foundation; import os; @objc class ${className}: NSObject { @@ -374,16 +333,84 @@ import os; } }`; - fs.writeFileSync(filePath, content); - this.$logger.info(`Swift file '${filePath}' generated successfully.`); - } + fs.writeFileSync(filePath, content); + services.$logger.info(`Swift file '${filePath}' generated successfully.`); } -injector.registerCommand(["native|add"], NativeAddCommand); -injector.registerCommand(["native|add|java"], NativeAddJavaCommand); -injector.registerCommand(["native|add|kotlin"], NativeAddKotlinCommand); -injector.registerCommand(["native|add|swift"], NativeAddSwiftCommand); -injector.registerCommand( - ["native|add|objective-c"], - NativeAddObjectiveCCommand -); +export function generateSwift( + services: INativeAddCommandServices, + className: string, +): void { + const iosSourceBase = getIosSourcePathBase(services); + const swiftFilePath = path.join(iosSourceBase, `${className}.swift`); + generateSwiftFile(services, className, swiftFilePath); +} + +const generators: Record< + NativeAddLanguage, + (services: INativeAddCommandServices, className: string) => void +> = { + java: (services, className) => + generateJavaKotlin(services, className, "java"), + kotlin: (services, className) => + generateJavaKotlin(services, className, "kotlin"), + swift: generateSwift, + "objective-c": generateObjectiveC, +}; + +export const nativeAddCommandDefinition = defineCommand({ + name: "native|add", + description: + "Commands to add native files to the application placing them in the correct directory.", + arguments: "any", + setup: setupNativeAddCommand, + canExecute(context, services: INativeAddCommandServices): boolean { + failWithUsage(services); + return false; + }, + run(context, services: INativeAddCommandServices): void { + failWithUsage(services); + }, +}); + +export const nativeAddLanguageCommandDefinition = defineCommand({ + name: "native|add|swift", + description: "Adds a native source file to the application.", + // The one usage message answers both too few and too many arguments; a + // declared argument spec would report them with two different ones. + arguments: "any", + setup(): INativeAddLanguageCommandServices { + return { + ...setupNativeAddCommand(), + language: inject(NATIVE_ADD_LANGUAGE), + }; + }, + canExecute(context, services: INativeAddLanguageCommandServices): boolean { + if (context.args.length !== 1) { + failWithUsage(services); + } + + return true; + }, + run(context, services: INativeAddLanguageCommandServices): void { + generators[services.language](services, context.args[0]); + }, +}); + +registerCommand(nativeAddCommandDefinition); + +const nativeAddLanguages: [string, NativeAddLanguage][] = [ + ["native|add|java", "java"], + ["native|add|kotlin", "kotlin"], + ["native|add|swift", "swift"], + ["native|add|objective-c", "objective-c"], +]; + +for (const [name, language] of nativeAddLanguages) { + registerCommand( + { ...nativeAddLanguageCommandDefinition, name }, + getInjector().createChild([ + { provide: NATIVE_ADD_LANGUAGE, useValue: language }, + ]), + ); +} diff --git a/lib/commands/open.ts b/lib/commands/open.ts new file mode 100644 index 0000000000..683dce71f0 --- /dev/null +++ b/lib/commands/open.ts @@ -0,0 +1,252 @@ +import * as fs from "fs"; +import { platform as currentPlatform } from "os"; +import * as path from "path"; +import { IChildProcess, IXcodeSelectService } from "../common/declarations"; +import { + booleanOption, + CommandOptionsSchema, + defineCommand, +} from "../common/define-command"; +import { ICommand } from "../common/definitions/commands"; +import { inject } from "../common/di"; +import { injector } from "../common/yok"; +import { IOptions } from "../declarations"; +import { IProjectData } from "../definitions/project"; +import type { IOSProjectService } from "../services/ios-project-service"; + +export interface IOpenXcodeProjectServices { + $iOSProjectService: IOSProjectService; + $logger: ILogger; + $childProcess: IChildProcess; + $projectData: IProjectData; + $xcodeSelectService: IXcodeSelectService; + $xcodebuildArgsService: IXcodebuildArgsService; +} + +export interface IOpenAndroidStudioServices { + $logger: ILogger; + $liveSyncCommandHelper: ILiveSyncCommandHelper; + $childProcess: IChildProcess; + $projectData: IProjectData; +} + +export function injectOpenXcodeProjectServices(): IOpenXcodeProjectServices { + return { + $iOSProjectService: inject("iOSProjectService"), + $logger: inject("logger"), + $childProcess: inject("childProcess"), + $projectData: inject("projectData"), + $xcodeSelectService: inject("xcodeSelectService"), + $xcodebuildArgsService: inject( + "xcodebuildArgsService", + ), + }; +} + +export function injectOpenAndroidStudioServices(): IOpenAndroidStudioServices { + return { + $logger: inject("logger"), + $liveSyncCommandHelper: inject( + "liveSyncCommandHelper", + ), + $childProcess: inject("childProcess"), + $projectData: inject("projectData"), + }; +} + +export function getAndroidStudioPath(): string | null { + const os = currentPlatform(); + + if (os === "darwin") { + const possibleStudioPaths = [ + "/Applications/Android Studio.app", + `${process.env.HOME}/Applications/Android Studio.app`, + ]; + + return possibleStudioPaths.find((p) => fs.existsSync(p)) || null; + } else if (os === "win32") { + const studioPath = path.join( + "C:", + "Program Files", + "Android", + "Android Studio", + "bin", + "studio64.exe", + ); + return fs.existsSync(studioPath) ? studioPath : null; + } else if (os === "linux") { + const studioPath = "/usr/local/android-studio/bin/studio.sh"; + return fs.existsSync(studioPath) ? studioPath : null; + } + + return null; +} + +/** + * `isInteractive` reflects the caller, not the terminal: a key command runs + * while `ns run` owns stdin and has to hand it back after `prepare` consumed + * it, a one-shot CLI command exits instead. + */ +export async function openAndroidStudioProject( + services: IOpenAndroidStudioServices, + platform: string, + isInteractive: boolean, +): Promise { + services.$liveSyncCommandHelper.validatePlatform(platform); + services.$projectData.initializeProjectData(); + const androidDir = `${services.$projectData.platformsDir}/android`; + + if (!fs.existsSync(androidDir)) { + const prepareCommand = injector.resolveCommand("prepare") as ICommand; + await prepareCommand.execute([platform]); + if (isInteractive) { + process.stdin.resume(); + } + } + + let studioPath = null; + + studioPath = process.env.NATIVESCRIPT_ANDROID_STUDIO_PATH; + + if (!studioPath) { + studioPath = getAndroidStudioPath(); + + if (!studioPath) { + services.$logger.error( + "Android Studio is not installed, or is not in a standard location. Use NATIVESCRIPT_ANDROID_STUDIO_PATH.", + ); + return; + } + } + + const os = currentPlatform(); + if (os === "darwin") { + services.$childProcess.exec(`open -a "${studioPath}" ${androidDir}`); + } else if (os === "win32") { + const child = services.$childProcess.spawn(studioPath, [androidDir], { + detached: true, + stdio: "ignore", + }); + child.unref(); + } else if (os === "linux") { + services.$childProcess.exec(`${studioPath} ${androidDir}`); + } +} + +export async function openXcodeProject( + services: IOpenXcodeProjectServices, + platformDirName: string, + isInteractive: boolean, +): Promise { + const os = currentPlatform(); + if (os !== "darwin") { + services.$logger.error("Opening a project in XCode requires macOS."); + return; + } + + services.$projectData.initializeProjectData(); + const platformDir = path.resolve( + services.$projectData.platformsDir, + platformDirName, + ); + + if (!fs.existsSync(platformDir)) { + const prepareCommand = injector.resolveCommand("prepare") as ICommand; + + await prepareCommand.execute([platformDirName]); + if (isInteractive) { + process.stdin.resume(); + } + } + const platformData = services.$iOSProjectService.getPlatformData( + services.$projectData, + ); + const xcprojectFile = services.$xcodebuildArgsService.getXcodeProjectArgs( + platformData, + services.$projectData, + )[1]; + + if (fs.existsSync(xcprojectFile)) { + services.$xcodeSelectService + .getDeveloperDirectoryPath() + .then(() => services.$childProcess.exec(`open ${xcprojectFile}`, {})) + .catch((e) => { + services.$logger.error(e.message); + }); + } else { + services.$logger.error(`Unable to open project file: ${xcprojectFile}`); + } +} + +export async function openVisionOSProject( + services: IOpenXcodeProjectServices, + $options: IOptions, + isInteractive: boolean, +): Promise { + $options.platformOverride = "visionOS"; + await openXcodeProject(services, "visionos", isInteractive); + $options.platformOverride = null; +} + +const openCommandOptions = { + watch: booleanOption({ default: false }), +} satisfies CommandOptionsSchema; + +/** + * `prepare` reads the options service rather than this command's context, so + * the CLI-wide `--watch` has to be pinned there and not just defaulted here. + */ +const disableWatch = ($options: IOptions): void => { + $options.watch = false; +}; + +export const iosOpenCommand = defineCommand({ + name: "open|ios", + description: "Opens the project in Xcode.", + options: openCommandOptions, + arguments: "none", + setup() { + return { + ...injectOpenXcodeProjectServices(), + $options: inject("options"), + }; + }, + async run(context, services): Promise { + disableWatch(services.$options); + await openXcodeProject(services, "ios", false); + }, +}); + +export const visionOpenCommand = defineCommand({ + name: ["open|visionos", "open|vision"], + description: "Opens the visionOS project in Xcode.", + options: openCommandOptions, + arguments: "none", + setup() { + return { + ...injectOpenXcodeProjectServices(), + $options: inject("options"), + }; + }, + async run(context, services): Promise { + disableWatch(services.$options); + await openVisionOSProject(services, services.$options, false); + }, +}); + +export const androidOpenCommand = defineCommand({ + name: "open|android", + description: "Opens the project in Android Studio.", + options: openCommandOptions, + arguments: "none", + setup() { + return { + ...injectOpenAndroidStudioServices(), + $options: inject("options"), + }; + }, + async run(context, services): Promise { + disableWatch(services.$options); + await openAndroidStudioProject(services, "Android", false); + }, +}); diff --git a/lib/commands/platform-clean.ts b/lib/commands/platform-clean.ts index 2cce2cd10c..0793d425c1 100644 --- a/lib/commands/platform-clean.ts +++ b/lib/commands/platform-clean.ts @@ -6,66 +6,114 @@ import { IPlatformValidationService, } from "../declarations"; import { IPlatformEnvironmentRequirements } from "../definitions/platform"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { IErrors } from "../common/declarations"; -import { injector } from "../common/yok"; +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; -export class CleanCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +const platformCleanCommandOptions = { + frameworkPath: stringOption(), +} satisfies CommandOptionsSchema; - constructor( - private $errors: IErrors, - private $options: IOptions, - private $platformCommandHelper: IPlatformCommandHelper, - private $platformValidationService: IPlatformValidationService, - private $platformEnvironmentRequirements: IPlatformEnvironmentRequirements, - private $projectData: IProjectData - ) { - this.$projectData.initializeProjectData(); - } +export type PlatformCleanCommandContext = CommandContext< + typeof platformCleanCommandOptions +>; + +export interface IPlatformCleanCommandServices { + $errors: IErrors; + $options: IOptions; + $platformCommandHelper: IPlatformCommandHelper; + $platformValidationService: IPlatformValidationService; + $platformEnvironmentRequirements: IPlatformEnvironmentRequirements; + $projectData: IProjectData; +} + +export function setupPlatformCleanCommand(): IPlatformCleanCommandServices { + const services = { + $errors: inject("errors"), + $options: inject("options"), + $platformCommandHelper: inject( + "platformCommandHelper", + ), + $platformValidationService: inject( + "platformValidationService", + ), + $platformEnvironmentRequirements: inject( + "platformEnvironmentRequirements", + ), + $projectData: inject("projectData"), + }; + services.$projectData.initializeProjectData(); - public async execute(args: string[]): Promise { - await this.$platformCommandHelper.cleanPlatforms( - args, - this.$projectData, - this.$options.frameworkPath + return services; +} + +export async function canExecutePlatformCleanCommand( + context: PlatformCleanCommandContext, + services: IPlatformCleanCommandServices, +): Promise { + const args = context.args; + if (!args || args.length === 0) { + services.$errors.failWithHelp( + "No platform specified. Please specify a platform to clean.", ); } - public async canExecute(args: string[]): Promise { - if (!args || args.length === 0) { - this.$errors.failWithHelp( - "No platform specified. Please specify a platform to clean." - ); - } - - _.each(args, (platform) => { - this.$platformValidationService.validatePlatform( - platform, - this.$projectData - ); - }); + _.each(args, (platform) => { + services.$platformValidationService.validatePlatform( + platform, + services.$projectData, + ); + }); - for (const platform of args) { - this.$platformValidationService.validatePlatformInstalled( - platform, - this.$projectData - ); + for (const platform of args) { + services.$platformValidationService.validatePlatformInstalled( + platform, + services.$projectData, + ); - const currentRuntimeVersion = this.$platformCommandHelper.getCurrentPlatformVersion( + const currentRuntimeVersion = + services.$platformCommandHelper.getCurrentPlatformVersion( platform, - this.$projectData + services.$projectData, ); - await this.$platformEnvironmentRequirements.checkEnvironmentRequirements({ + await services.$platformEnvironmentRequirements.checkEnvironmentRequirements( + { platform, - projectDir: this.$projectData.projectDir, + projectDir: services.$projectData.projectDir, runtimeVersion: currentRuntimeVersion, - options: this.$options, - }); - } - - return true; + options: services.$options, + }, + ); } + + return true; } -injector.registerCommand("platform|clean", CleanCommand); +export async function runPlatformCleanCommand( + context: PlatformCleanCommandContext, + services: IPlatformCleanCommandServices, +): Promise { + await services.$platformCommandHelper.cleanPlatforms( + context.args, + services.$projectData, + context.options.frameworkPath, + ); +} + +export const platformCleanCommandDefinition = defineCommand({ + name: "platform|clean", + description: "Removes and adds again the selected platform.", + options: platformCleanCommandOptions, + arguments: "any", + setup: setupPlatformCleanCommand, + canExecute: canExecutePlatformCleanCommand, + run: runPlatformCleanCommand, +}); + +registerCommand(platformCleanCommandDefinition); diff --git a/lib/commands/plugin/add-plugin.ts b/lib/commands/plugin/add-plugin.ts index b7102ba0f7..ef82450934 100644 --- a/lib/commands/plugin/add-plugin.ts +++ b/lib/commands/plugin/add-plugin.ts @@ -1,45 +1,62 @@ import * as _ from "lodash"; import { IProjectData } from "../../definitions/project"; import { IPluginsService, IPluginData } from "../../definitions/plugins"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; import { IErrors } from "../../common/declarations"; -import { injector } from "../../common/yok"; +import { CommandContext, defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; +import { registerCommand } from "../../common/services/command-definition-adapter"; -export class AddPluginCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export interface IAddPluginCommandServices { + $pluginsService: IPluginsService; + $projectData: IProjectData; + $errors: IErrors; +} - constructor( - private $pluginsService: IPluginsService, - private $projectData: IProjectData, - private $errors: IErrors - ) { - this.$projectData.initializeProjectData(); - } +export function setupAddPluginCommand(): IAddPluginCommandServices { + const services = { + $pluginsService: inject("pluginsService"), + $projectData: inject("projectData"), + $errors: inject("errors"), + }; + services.$projectData.initializeProjectData(); - public async execute(args: string[]): Promise { - return this.$pluginsService.add(args[0], this.$projectData); - } + return services; +} - public async canExecute(args: string[]): Promise { - if (!args[0]) { - this.$errors.failWithHelp("You must specify plugin name."); - } +export async function canExecuteAddPluginCommand( + context: CommandContext, + services: IAddPluginCommandServices, +): Promise { + if (!context.args[0]) { + services.$errors.failWithHelp("You must specify plugin name."); + } - const installedPlugins = await this.$pluginsService.getAllInstalledPlugins( - this.$projectData + const installedPlugins = + await services.$pluginsService.getAllInstalledPlugins( + services.$projectData, ); - const pluginName = args[0].toLowerCase(); - if ( - _.some( - installedPlugins, - (plugin: IPluginData) => plugin.name.toLowerCase() === pluginName - ) - ) { - this.$errors.fail(`Plugin "${pluginName}" is already installed.`); - } - - return true; + const pluginName = context.args[0].toLowerCase(); + if ( + _.some( + installedPlugins, + (plugin: IPluginData) => plugin.name.toLowerCase() === pluginName, + ) + ) { + services.$errors.fail(`Plugin "${pluginName}" is already installed.`); } + + return true; } -injector.registerCommand(["plugin|add", "plugin|install"], AddPluginCommand); +export const addPluginCommandDefinition = defineCommand({ + name: ["plugin|add", "plugin|install"], + description: "Installs the specified plugin and its dependencies.", + arguments: "any", + setup: setupAddPluginCommand, + canExecute: canExecuteAddPluginCommand, + run(context, services): Promise { + return services.$pluginsService.add(context.args[0], services.$projectData); + }, +}); + +registerCommand(addPluginCommandDefinition); diff --git a/lib/commands/plugin/build-plugin.ts b/lib/commands/plugin/build-plugin.ts index 09c2800f99..8ca801c147 100644 --- a/lib/commands/plugin/build-plugin.ts +++ b/lib/commands/plugin/build-plugin.ts @@ -1,101 +1,138 @@ import { EOL } from "os"; import * as path from "path"; import * as constants from "../../constants"; -import { IOptions } from "../../declarations"; import { IAndroidPluginBuildService, IPluginBuildOptions, } from "../../definitions/android-plugin-migrator"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; import { IErrors, IFileSystem } from "../../common/declarations"; -import { injector } from "../../common/yok"; +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../common/define-command"; +import { inject } from "../../common/di"; +import { registerCommand } from "../../common/services/command-definition-adapter"; import { ITempService } from "../../definitions/temp-service"; -export class BuildPluginCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - public pluginProjectPath: string; - - constructor( - private $androidPluginBuildService: IAndroidPluginBuildService, - private $errors: IErrors, - private $logger: ILogger, - private $fs: IFileSystem, - private $options: IOptions, - private $tempService: ITempService +const buildPluginCommandOptions = { + path: stringOption(), + gradlePath: stringOption(), + gradleArgs: stringOption(), +} satisfies CommandOptionsSchema; + +export type BuildPluginCommandContext = CommandContext< + typeof buildPluginCommandOptions +>; + +export interface IBuildPluginCommandServices { + pluginProjectPath: string; + $androidPluginBuildService: IAndroidPluginBuildService; + $errors: IErrors; + $logger: ILogger; + $fs: IFileSystem; + $tempService: ITempService; +} + +export function setupBuildPluginCommand( + context: BuildPluginCommandContext, +): IBuildPluginCommandServices { + return { + pluginProjectPath: path.resolve(context.options.path || "."), + $androidPluginBuildService: inject( + "androidPluginBuildService", + ), + $errors: inject("errors"), + $logger: inject("logger"), + $fs: inject("fs"), + $tempService: inject("tempService"), + }; +} + +export async function canExecuteBuildPluginCommand( + context: BuildPluginCommandContext, + services: IBuildPluginCommandServices, +): Promise { + if ( + !services.$fs.exists( + path.join( + services.pluginProjectPath, + constants.PLATFORMS_DIR_NAME, + "android", + ), + ) ) { - this.pluginProjectPath = path.resolve(this.$options.path || "."); + services.$errors.fail( + "No plugin found at the current directory, or the plugin does not need to have its platforms/android components built into an `.aar`.", + ); } - public async execute(args: string[]): Promise { - const platformsAndroidPath = path.join( - this.pluginProjectPath, - constants.PLATFORMS_DIR_NAME, - "android" - ); - let pluginName = ""; + return true; +} - const pluginPackageJsonPath = path.join( - this.pluginProjectPath, - constants.PACKAGE_JSON_FILE_NAME - ); +export async function runBuildPluginCommand( + context: BuildPluginCommandContext, + services: IBuildPluginCommandServices, +): Promise { + const platformsAndroidPath = path.join( + services.pluginProjectPath, + constants.PLATFORMS_DIR_NAME, + "android", + ); + let pluginName = ""; + + const pluginPackageJsonPath = path.join( + services.pluginProjectPath, + constants.PACKAGE_JSON_FILE_NAME, + ); - if (this.$fs.exists(pluginPackageJsonPath)) { - const packageJsonContents = this.$fs.readJson(pluginPackageJsonPath); + if (services.$fs.exists(pluginPackageJsonPath)) { + const packageJsonContents = services.$fs.readJson(pluginPackageJsonPath); - if (packageJsonContents && packageJsonContents["name"]) { - pluginName = packageJsonContents["name"]; - } + if (packageJsonContents && packageJsonContents["name"]) { + pluginName = packageJsonContents["name"]; } + } - const tempAndroidProject = await this.$tempService.mkdirSync( - "android-project" - ); + const tempAndroidProject = + await services.$tempService.mkdirSync("android-project"); - const options: IPluginBuildOptions = { - gradlePath: this.$options.gradlePath, - gradleArgs: this.$options.gradleArgs, - aarOutputDir: platformsAndroidPath, - platformsAndroidDirPath: platformsAndroidPath, - pluginName: pluginName, - tempPluginDirPath: tempAndroidProject, - }; - - const androidPluginBuildResult = await this.$androidPluginBuildService.buildAar( - options - ); + const options: IPluginBuildOptions = { + gradlePath: context.options.gradlePath, + gradleArgs: context.options.gradleArgs, + aarOutputDir: platformsAndroidPath, + platformsAndroidDirPath: platformsAndroidPath, + pluginName: pluginName, + tempPluginDirPath: tempAndroidProject, + }; - if (androidPluginBuildResult) { - this.$logger.info( - `${pluginName} successfully built aar at ${platformsAndroidPath}.${EOL}Temporary Android project can be found at ${tempAndroidProject}.` - ); - } + const androidPluginBuildResult = + await services.$androidPluginBuildService.buildAar(options); - const migratedIncludeGradle = this.$androidPluginBuildService.migrateIncludeGradle( - options + if (androidPluginBuildResult) { + services.$logger.info( + `${pluginName} successfully built aar at ${platformsAndroidPath}.${EOL}Temporary Android project can be found at ${tempAndroidProject}.`, ); - - if (migratedIncludeGradle) { - this.$logger.info(`${pluginName} include gradle updated.`); - } } - public async canExecute(args: string[]): Promise { - if ( - !this.$fs.exists( - path.join( - this.pluginProjectPath, - constants.PLATFORMS_DIR_NAME, - "android" - ) - ) - ) { - this.$errors.fail( - "No plugin found at the current directory, or the plugin does not need to have its platforms/android components built into an `.aar`." - ); - } + const migratedIncludeGradle = + services.$androidPluginBuildService.migrateIncludeGradle(options); - return true; + if (migratedIncludeGradle) { + services.$logger.info(`${pluginName} include gradle updated.`); } } -injector.registerCommand("plugin|build", BuildPluginCommand); +export const buildPluginCommandDefinition = defineCommand({ + name: "plugin|build", + description: + "Builds the Android parts of a NativeScript plugin into an `.aar`.", + options: buildPluginCommandOptions, + arguments: "any", + setup: setupBuildPluginCommand, + canExecute: canExecuteBuildPluginCommand, + run: runBuildPluginCommand, +}); + +registerCommand(buildPluginCommandDefinition); diff --git a/lib/commands/plugin/create-plugin.ts b/lib/commands/plugin/create-plugin.ts index 2e6f9ea2d4..228af8bcd5 100644 --- a/lib/commands/plugin/create-plugin.ts +++ b/lib/commands/plugin/create-plugin.ts @@ -1,221 +1,279 @@ import * as path from "path"; import { isInteractive } from "../../common/helpers"; -import { IOptions, INodePackageManager } from "../../declarations"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; +import { INodePackageManager } from "../../declarations"; import { IErrors, IFileSystem, IChildProcess } from "../../common/declarations"; -import { injector } from "../../common/yok"; +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../common/define-command"; +import { inject } from "../../common/di"; +import { registerCommand } from "../../common/services/command-definition-adapter"; import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service"; -export class CreatePluginCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - public userMessage = - "What is your GitHub username?\n(will be used to update the Github URLs in the plugin's package.json)"; - public nameMessage = - "What will be the name of your plugin?\n(use lowercase characters and dashes only)"; - public includeTypeScriptDemoMessage = - 'Do you want to include a "TypeScript NativeScript" application linked with your plugin to make development easier?'; - public includeAngularDemoMessage = - 'Do you want to include an "Angular NativeScript" application linked with your plugin to make development easier?'; - public pathAlreadyExistsMessageTemplate = - "Path already exists and is not empty %s"; - constructor( - private $options: IOptions, - private $errors: IErrors, - private $terminalSpinnerService: ITerminalSpinnerService, - private $logger: ILogger, - private $pacoteService: IPacoteService, - private $fs: IFileSystem, - private $childProcess: IChildProcess, - private $prompter: IPrompter, - private $packageManager: INodePackageManager - ) {} - - public async execute(args: string[]): Promise { - const pluginRepoName = args[0]; - const pathToProject = this.$options.path; - const selectedTemplate = this.$options.template; - const selectedPath = path.resolve(pathToProject || "."); - const projectDir = path.join(selectedPath, pluginRepoName); - - // Must be out of try catch block, because will throw error if folder alredy exists and we don't want to delete it. - this.ensurePackageDir(projectDir); - - try { - await this.downloadPackage(selectedTemplate, projectDir); - await this.setupSeed(projectDir, pluginRepoName); - } catch (err) { - // The call to this.ensurePackageDir() above will throw error if folder alredy exists, so it is safe to delete here. - this.$fs.deleteDirectory(projectDir); - throw err; - } +export const USER_MESSAGE = + "What is your GitHub username?\n(will be used to update the Github URLs in the plugin's package.json)"; +export const NAME_MESSAGE = + "What will be the name of your plugin?\n(use lowercase characters and dashes only)"; +export const INCLUDE_TYPESCRIPT_DEMO_MESSAGE = + 'Do you want to include a "TypeScript NativeScript" application linked with your plugin to make development easier?'; +export const INCLUDE_ANGULAR_DEMO_MESSAGE = + 'Do you want to include an "Angular NativeScript" application linked with your plugin to make development easier?'; +export const PATH_ALREADY_EXISTS_MESSAGE_TEMPLATE = + "Path already exists and is not empty %s"; - this.$logger.printMarkdown( - "Solution for `%s` was successfully created.", - pluginRepoName - ); - } +const createPluginCommandOptions = { + path: stringOption(), + template: stringOption(), + username: stringOption(), + pluginName: stringOption(), + includeTypeScriptDemo: stringOption(), + includeAngularDemo: stringOption(), +} satisfies CommandOptionsSchema; - public async canExecute(args: string[]): Promise { - if (!args[0]) { - this.$errors.failWithHelp("You must specify the plugin repository name."); - } +export type CreatePluginCommandContext = CommandContext< + typeof createPluginCommandOptions +>; - return true; - } +export interface ICreatePluginCommandServices { + $errors: IErrors; + $terminalSpinnerService: ITerminalSpinnerService; + $logger: ILogger; + $pacoteService: IPacoteService; + $fs: IFileSystem; + $childProcess: IChildProcess; + $prompter: IPrompter; + $packageManager: INodePackageManager; +} - private async setupSeed( - projectDir: string, - pluginRepoName: string - ): Promise { - this.$logger.printMarkdown( - "Executing initial plugin configuration script..." - ); +export function setupCreatePluginCommand(): ICreatePluginCommandServices { + return { + $errors: inject("errors"), + $terminalSpinnerService: inject( + "terminalSpinnerService", + ), + $logger: inject("logger"), + $pacoteService: inject("pacoteService"), + $fs: inject("fs"), + $childProcess: inject("childProcess"), + $prompter: inject("prompter"), + $packageManager: inject("packageManager"), + }; +} - const config = this.$options; - const spinner = this.$terminalSpinnerService.createSpinner(); - const cwd = path.join(projectDir, "src"); - try { - spinner.start(); - const npmOptions: any = { silent: true }; - await this.$packageManager.install(cwd, cwd, npmOptions); - } finally { - spinner.stop(); - } +function ensurePackageDir( + services: ICreatePluginCommandServices, + projectDir: string, +): void { + services.$fs.createDirectory(projectDir); - const gitHubUsername = await this.getGitHubUsername(config.username); - const pluginNameSource = await this.getPluginNameSource( - config.pluginName, - pluginRepoName - ); - const includeTypescriptDemo = await this.getShouldIncludeDemoResult( - config.includeTypeScriptDemo, - this.includeTypeScriptDemoMessage + if (services.$fs.exists(projectDir) && !services.$fs.isEmptyDir(projectDir)) { + services.$errors.fail(PATH_ALREADY_EXISTS_MESSAGE_TEMPLATE, projectDir); + } +} + +async function downloadPackage( + services: ICreatePluginCommandServices, + selectedTemplate: string, + projectDir: string, +): Promise { + if (selectedTemplate) { + services.$logger.printMarkdown( + "Make sure your custom template is compatible with the Plugin Seed at https://github.com/NativeScript/nativescript-plugin-seed/", ); - const includeAngularDemo = await this.getShouldIncludeDemoResult( - config.includeAngularDemo, - this.includeAngularDemoMessage + } else { + services.$logger.printMarkdown( + "Downloading the latest version of NativeScript Plugin Seed...", ); + } - if ( - !isInteractive() && - (!config.username || - !config.pluginName || - !config.includeAngularDemo || - !config.includeTypeScriptDemo) - ) { - this.$logger.printMarkdown( - "Using default values for plugin creation options since your shell is not interactive." - ); - } + const spinner = services.$terminalSpinnerService.createSpinner(); + const packageToInstall = + selectedTemplate || + "https://github.com/NativeScript/nativescript-plugin-seed/archive/master.tar.gz"; + try { + spinner.start(); + await services.$pacoteService.extractPackage(packageToInstall, projectDir); + } finally { + spinner.stop(); + } +} - // run postclone script manually and kill it if it takes more than 10 sec - const pathToPostCloneScript = path.join("scripts", "postclone"); - const params = [ - pathToPostCloneScript, - `gitHubUsername=${gitHubUsername}`, - `pluginName=${pluginNameSource}`, - "initGit=y", - `includeTypeScriptDemo=${includeTypescriptDemo}`, - `includeAngularDemo=${includeAngularDemo}`, - ]; - - const outputScript = await this.$childProcess.spawnFromEvent( - process.execPath, - params, - "close", - { stdio: "inherit", cwd, timeout: 10000 } - ); - if (outputScript && outputScript.stdout) { - this.$logger.printMarkdown(outputScript.stdout); +async function getGitHubUsername( + services: ICreatePluginCommandServices, + gitHubUsername: string, +): Promise { + if (!gitHubUsername) { + gitHubUsername = "NativeScriptDeveloper"; + if (isInteractive()) { + gitHubUsername = await services.$prompter.getString(USER_MESSAGE, { + allowEmpty: false, + defaultAction: () => { + return gitHubUsername; + }, + }); } } - private ensurePackageDir(projectDir: string): void { - this.$fs.createDirectory(projectDir); + return gitHubUsername; +} - if (this.$fs.exists(projectDir) && !this.$fs.isEmptyDir(projectDir)) { - this.$errors.fail(this.pathAlreadyExistsMessageTemplate, projectDir); +async function getPluginNameSource( + services: ICreatePluginCommandServices, + pluginNameSource: string, + pluginRepoName: string, +): Promise { + if (!pluginNameSource) { + // remove nativescript- prefix for naming plugin files + const prefix = "nativescript-"; + pluginNameSource = pluginRepoName.toLowerCase().startsWith(prefix) + ? pluginRepoName.slice(prefix.length, pluginRepoName.length) + : pluginRepoName; + if (isInteractive()) { + pluginNameSource = await services.$prompter.getString(NAME_MESSAGE, { + allowEmpty: false, + defaultAction: () => { + return pluginNameSource; + }, + }); } } - private async downloadPackage( - selectedTemplate: string, - projectDir: string - ): Promise { - if (selectedTemplate) { - this.$logger.printMarkdown( - "Make sure your custom template is compatible with the Plugin Seed at https://github.com/NativeScript/nativescript-plugin-seed/" - ); - } else { - this.$logger.printMarkdown( - "Downloading the latest version of NativeScript Plugin Seed..." - ); - } + return pluginNameSource; +} - const spinner = this.$terminalSpinnerService.createSpinner(); - const packageToInstall = - selectedTemplate || - "https://github.com/NativeScript/nativescript-plugin-seed/archive/master.tar.gz"; - try { - spinner.start(); - await this.$pacoteService.extractPackage(packageToInstall, projectDir); - } finally { - spinner.stop(); - } +async function getShouldIncludeDemoResult( + services: ICreatePluginCommandServices, + includeDemoOption: string, + message: string, +): Promise { + let shouldIncludeDemo = !!includeDemoOption; + if (!includeDemoOption && isInteractive()) { + shouldIncludeDemo = await services.$prompter.confirm(message, () => { + return true; + }); } - private async getGitHubUsername(gitHubUsername: string): Promise { - if (!gitHubUsername) { - gitHubUsername = "NativeScriptDeveloper"; - if (isInteractive()) { - gitHubUsername = await this.$prompter.getString(this.userMessage, { - allowEmpty: false, - defaultAction: () => { - return gitHubUsername; - }, - }); - } - } + return shouldIncludeDemo ? "y" : "n"; +} + +async function setupSeed( + context: CreatePluginCommandContext, + services: ICreatePluginCommandServices, + projectDir: string, + pluginRepoName: string, +): Promise { + services.$logger.printMarkdown( + "Executing initial plugin configuration script...", + ); - return gitHubUsername; + const config = context.options; + const spinner = services.$terminalSpinnerService.createSpinner(); + const cwd = path.join(projectDir, "src"); + try { + spinner.start(); + const npmOptions: any = { silent: true }; + await services.$packageManager.install(cwd, cwd, npmOptions); + } finally { + spinner.stop(); } - private async getPluginNameSource( - pluginNameSource: string, - pluginRepoName: string - ): Promise { - if (!pluginNameSource) { - // remove nativescript- prefix for naming plugin files - const prefix = "nativescript-"; - pluginNameSource = pluginRepoName.toLowerCase().startsWith(prefix) - ? pluginRepoName.slice(prefix.length, pluginRepoName.length) - : pluginRepoName; - if (isInteractive()) { - pluginNameSource = await this.$prompter.getString(this.nameMessage, { - allowEmpty: false, - defaultAction: () => { - return pluginNameSource; - }, - }); - } - } + const gitHubUsername = await getGitHubUsername(services, config.username); + const pluginNameSource = await getPluginNameSource( + services, + config.pluginName, + pluginRepoName, + ); + const includeTypescriptDemo = await getShouldIncludeDemoResult( + services, + config.includeTypeScriptDemo, + INCLUDE_TYPESCRIPT_DEMO_MESSAGE, + ); + const includeAngularDemo = await getShouldIncludeDemoResult( + services, + config.includeAngularDemo, + INCLUDE_ANGULAR_DEMO_MESSAGE, + ); - return pluginNameSource; + if ( + !isInteractive() && + (!config.username || + !config.pluginName || + !config.includeAngularDemo || + !config.includeTypeScriptDemo) + ) { + services.$logger.printMarkdown( + "Using default values for plugin creation options since your shell is not interactive.", + ); } - private async getShouldIncludeDemoResult( - includeDemoOption: string, - message: string - ): Promise { - let shouldIncludeDemo = !!includeDemoOption; - if (!includeDemoOption && isInteractive()) { - shouldIncludeDemo = await this.$prompter.confirm(message, () => { - return true; - }); - } + // run postclone script manually and kill it if it takes more than 10 sec + const pathToPostCloneScript = path.join("scripts", "postclone"); + const params = [ + pathToPostCloneScript, + `gitHubUsername=${gitHubUsername}`, + `pluginName=${pluginNameSource}`, + "initGit=y", + `includeTypeScriptDemo=${includeTypescriptDemo}`, + `includeAngularDemo=${includeAngularDemo}`, + ]; - return shouldIncludeDemo ? "y" : "n"; + const outputScript = await services.$childProcess.spawnFromEvent( + process.execPath, + params, + "close", + { stdio: "inherit", cwd, timeout: 10000 }, + ); + if (outputScript && outputScript.stdout) { + services.$logger.printMarkdown(outputScript.stdout); } } -injector.registerCommand(["plugin|create"], CreatePluginCommand); +export async function runCreatePluginCommand( + context: CreatePluginCommandContext, + services: ICreatePluginCommandServices, +): Promise { + const pluginRepoName = context.args[0]; + const pathToProject = context.options.path; + const selectedTemplate = context.options.template; + const selectedPath = path.resolve(pathToProject || "."); + const projectDir = path.join(selectedPath, pluginRepoName); + + // Must be out of try catch block, because will throw error if folder alredy exists and we don't want to delete it. + ensurePackageDir(services, projectDir); + + try { + await downloadPackage(services, selectedTemplate, projectDir); + await setupSeed(context, services, projectDir, pluginRepoName); + } catch (err) { + // The call to ensurePackageDir() above will throw error if folder alredy exists, so it is safe to delete here. + services.$fs.deleteDirectory(projectDir); + throw err; + } + + services.$logger.printMarkdown( + "Solution for `%s` was successfully created.", + pluginRepoName, + ); +} + +export const createPluginCommandDefinition = defineCommand({ + name: "plugin|create", + description: "Creates a new project for a NativeScript plugin.", + options: createPluginCommandOptions, + arguments: "any", + setup: setupCreatePluginCommand, + canExecute(context, services): boolean { + if (!context.args[0]) { + services.$errors.failWithHelp( + "You must specify the plugin repository name.", + ); + } + + return true; + }, + run: runCreatePluginCommand, +}); + +registerCommand(createPluginCommandDefinition); diff --git a/lib/commands/plugin/list-plugins.ts b/lib/commands/plugin/list-plugins.ts index 365396657c..f691ce02a1 100644 --- a/lib/commands/plugin/list-plugins.ts +++ b/lib/commands/plugin/list-plugins.ts @@ -5,73 +5,86 @@ import { IPackageJsonDepedenciesResult, IBasePluginData, } from "../../definitions/plugins"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; -import { injector } from "../../common/yok"; +import { defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; +import { registerCommand } from "../../common/services/command-definition-adapter"; import { color } from "../../color"; -export class ListPluginsCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export interface IListPluginsCommandServices { + $pluginsService: IPluginsService; + $projectData: IProjectData; + $logger: ILogger; +} - constructor( - private $pluginsService: IPluginsService, - private $projectData: IProjectData, - private $logger: ILogger - ) { - this.$projectData.initializeProjectData(); - } +export function setupListPluginsCommand(): IListPluginsCommandServices { + const services = { + $pluginsService: inject("pluginsService"), + $projectData: inject("projectData"), + $logger: inject("logger"), + }; + services.$projectData.initializeProjectData(); - public async execute(args: string[]): Promise { - const installedPlugins: IPackageJsonDepedenciesResult = this.$pluginsService.getDependenciesFromPackageJson( - this.$projectData.projectDir - ); + return services; +} + +function createTableCells(items: IBasePluginData[]): string[][] { + return items.map((item) => [item.name, item.version]); +} + +export const listPluginsCommandDefinition = defineCommand({ + name: "plugin|*list", + description: "Lists all installed plugins.", + arguments: "none", + setup: setupListPluginsCommand, + async run(context, services): Promise { + const installedPlugins: IPackageJsonDepedenciesResult = + services.$pluginsService.getDependenciesFromPackageJson( + services.$projectData.projectDir, + ); const headers: string[] = ["Plugin", "Version"]; - const dependenciesData: string[][] = this.createTableCells( - installedPlugins.dependencies + const dependenciesData: string[][] = createTableCells( + installedPlugins.dependencies, ); const dependenciesTable: any = createTable(headers, dependenciesData); - this.$logger.info("Dependencies:"); - this.$logger.info(dependenciesTable.toString()); + services.$logger.info("Dependencies:"); + services.$logger.info(dependenciesTable.toString()); if ( installedPlugins.devDependencies && installedPlugins.devDependencies.length ) { - const devDependenciesData: string[][] = this.createTableCells( - installedPlugins.devDependencies + const devDependenciesData: string[][] = createTableCells( + installedPlugins.devDependencies, ); const devDependenciesTable: any = createTable( headers, - devDependenciesData + devDependenciesData, ); - this.$logger.info("Dev Dependencies:"); - this.$logger.info(devDependenciesTable.toString()); + services.$logger.info("Dev Dependencies:"); + services.$logger.info(devDependenciesTable.toString()); } else { - this.$logger.info("There are no dev dependencies."); + services.$logger.info("There are no dev dependencies."); } const viewDependenciesCommand: string = color.cyan( - "npm view grep dependencies" + "npm view grep dependencies", ); const viewDevDependenciesCommand: string = color.cyan( - "npm view grep devDependencies" + "npm view grep devDependencies", ); - this.$logger.warn("NOTE:"); - this.$logger.warn( - `If you want to check the dependencies of installed plugin use ${viewDependenciesCommand}` + services.$logger.warn("NOTE:"); + services.$logger.warn( + `If you want to check the dependencies of installed plugin use ${viewDependenciesCommand}`, ); - this.$logger.warn( - `If you want to check the dev dependencies of installed plugin use ${viewDevDependenciesCommand}` + services.$logger.warn( + `If you want to check the dev dependencies of installed plugin use ${viewDevDependenciesCommand}`, ); - } - - private createTableCells(items: IBasePluginData[]): string[][] { - return items.map((item) => [item.name, item.version]); - } -} + }, +}); -injector.registerCommand("plugin|*list", ListPluginsCommand); +registerCommand(listPluginsCommandDefinition); diff --git a/lib/commands/plugin/remove-plugin.ts b/lib/commands/plugin/remove-plugin.ts index 29f5cdfae9..6593ffb6a4 100644 --- a/lib/commands/plugin/remove-plugin.ts +++ b/lib/commands/plugin/remove-plugin.ts @@ -1,50 +1,71 @@ import * as _ from "lodash"; import { IProjectData } from "../../definitions/project"; import { IPluginsService } from "../../definitions/plugins"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; import { IErrors } from "../../common/declarations"; -import { injector } from "../../common/yok"; - -export class RemovePluginCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - private $pluginsService: IPluginsService, - private $errors: IErrors, - private $logger: ILogger, - private $projectData: IProjectData - ) { - this.$projectData.initializeProjectData(); - } +import { CommandContext, defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; +import { registerCommand } from "../../common/services/command-definition-adapter"; - public async execute(args: string[]): Promise { - return this.$pluginsService.remove(args[0], this.$projectData); - } +export interface IRemovePluginCommandServices { + $pluginsService: IPluginsService; + $errors: IErrors; + $logger: ILogger; + $projectData: IProjectData; +} - public async canExecute(args: string[]): Promise { - if (!args[0]) { - this.$errors.failWithHelp("You must specify plugin name."); - } +export function setupRemovePluginCommand(): IRemovePluginCommandServices { + const services = { + $pluginsService: inject("pluginsService"), + $errors: inject("errors"), + $logger: inject("logger"), + $projectData: inject("projectData"), + }; + services.$projectData.initializeProjectData(); - let pluginNames: string[] = []; - try { - // try installing the plugins, so we can get information from node_modules about their native code, libs, etc. - const installedPlugins = await this.$pluginsService.getAllInstalledPlugins( - this.$projectData + return services; +} + +export async function canExecuteRemovePluginCommand( + context: CommandContext, + services: IRemovePluginCommandServices, +): Promise { + if (!context.args[0]) { + services.$errors.failWithHelp("You must specify plugin name."); + } + + let pluginNames: string[] = []; + try { + // try installing the plugins, so we can get information from node_modules about their native code, libs, etc. + const installedPlugins = + await services.$pluginsService.getAllInstalledPlugins( + services.$projectData, ); - pluginNames = installedPlugins.map((pl) => pl.name); - } catch (err) { - this.$logger.trace("Error while installing plugins. Error is:", err); - pluginNames = _.keys(this.$projectData.dependencies); - } - - const pluginName = args[0].toLowerCase(); - if (!_.some(pluginNames, (name) => name.toLowerCase() === pluginName)) { - this.$errors.fail(`Plugin "${pluginName}" is not installed.`); - } - - return true; + pluginNames = installedPlugins.map((pl) => pl.name); + } catch (err) { + services.$logger.trace("Error while installing plugins. Error is:", err); + pluginNames = _.keys(services.$projectData.dependencies); + } + + const pluginName = context.args[0].toLowerCase(); + if (!_.some(pluginNames, (name) => name.toLowerCase() === pluginName)) { + services.$errors.fail(`Plugin "${pluginName}" is not installed.`); } + + return true; } -injector.registerCommand("plugin|remove", RemovePluginCommand); +export const removePluginCommandDefinition = defineCommand({ + name: "plugin|remove", + description: "Uninstalls the specified plugin and its dependencies.", + arguments: "any", + setup: setupRemovePluginCommand, + canExecute: canExecuteRemovePluginCommand, + run(context, services): Promise { + return services.$pluginsService.remove( + context.args[0], + services.$projectData, + ); + }, +}); + +registerCommand(removePluginCommandDefinition); diff --git a/lib/commands/plugin/update-plugin.ts b/lib/commands/plugin/update-plugin.ts index 64d736068e..f1e297492c 100644 --- a/lib/commands/plugin/update-plugin.ts +++ b/lib/commands/plugin/update-plugin.ts @@ -1,58 +1,80 @@ import * as _ from "lodash"; import { IProjectData } from "../../definitions/project"; import { IPluginsService } from "../../definitions/plugins"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; import { IErrors } from "../../common/declarations"; -import { injector } from "../../common/yok"; +import { CommandContext, defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; +import { registerCommand } from "../../common/services/command-definition-adapter"; -export class UpdatePluginCommand implements ICommand { - constructor( - private $pluginsService: IPluginsService, - private $projectData: IProjectData, - private $errors: IErrors - ) { - this.$projectData.initializeProjectData(); - } +export interface IUpdatePluginCommandServices { + $pluginsService: IPluginsService; + $projectData: IProjectData; + $errors: IErrors; +} - public async execute(args: string[]): Promise { - let pluginNames = args; +export function setupUpdatePluginCommand(): IUpdatePluginCommandServices { + const services = { + $pluginsService: inject("pluginsService"), + $projectData: inject("projectData"), + $errors: inject("errors"), + }; + services.$projectData.initializeProjectData(); - if (!pluginNames || args.length === 0) { - const installedPlugins = await this.$pluginsService.getAllInstalledPlugins( - this.$projectData - ); - pluginNames = installedPlugins.map((p) => p.name); - } + return services; +} - for (const pluginName of pluginNames) { - await this.$pluginsService.remove(pluginName, this.$projectData); - await this.$pluginsService.add(pluginName, this.$projectData); - } +export async function canExecuteUpdatePluginCommand( + context: CommandContext, + services: IUpdatePluginCommandServices, +): Promise { + const args = context.args; + if (!args || args.length === 0) { + return true; } - public async canExecute(args: string[]): Promise { - if (!args || args.length === 0) { - return true; - } - - const installedPlugins = await this.$pluginsService.getAllInstalledPlugins( - this.$projectData - ); - const installedPluginNames: string[] = installedPlugins.map( - (pl) => pl.name + const installedPlugins = + await services.$pluginsService.getAllInstalledPlugins( + services.$projectData, ); + const installedPluginNames: string[] = installedPlugins.map((pl) => pl.name); - const pluginName = args[0].toLowerCase(); - if ( - !_.some(installedPluginNames, (name) => name.toLowerCase() === pluginName) - ) { - this.$errors.fail(`Plugin "${pluginName}" is not installed.`); - } + const pluginName = args[0].toLowerCase(); + if ( + !_.some(installedPluginNames, (name) => name.toLowerCase() === pluginName) + ) { + services.$errors.fail(`Plugin "${pluginName}" is not installed.`); + } - return true; + return true; +} + +export async function runUpdatePluginCommand( + context: CommandContext, + services: IUpdatePluginCommandServices, +): Promise { + let pluginNames = context.args; + + if (!pluginNames || context.args.length === 0) { + const installedPlugins = + await services.$pluginsService.getAllInstalledPlugins( + services.$projectData, + ); + pluginNames = installedPlugins.map((p) => p.name); } - public allowedParameters: ICommandParameter[] = []; + for (const pluginName of pluginNames) { + await services.$pluginsService.remove(pluginName, services.$projectData); + await services.$pluginsService.add(pluginName, services.$projectData); + } } -injector.registerCommand("plugin|update", UpdatePluginCommand); +export const updatePluginCommandDefinition = defineCommand({ + name: "plugin|update", + description: "Uninstalls and installs the specified plugin(s).", + arguments: "any", + setup: setupUpdatePluginCommand, + canExecute: canExecuteUpdatePluginCommand, + run: runUpdatePluginCommand, +}); + +registerCommand(updatePluginCommandDefinition); diff --git a/lib/commands/post-install.ts b/lib/commands/post-install.ts index 6062467680..d1567cdf71 100644 --- a/lib/commands/post-install.ts +++ b/lib/commands/post-install.ts @@ -1,74 +1,97 @@ -import { doesCurrentNpmCommandMatch } from "../common/helpers"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; +import { color } from "../color"; import { + IAnalyticsService, IFileSystem, IHelpService, - ISettingsService, - IAnalyticsService, IHostInfo, + ISettingsService, } from "../common/declarations"; -import { injector } from "../common/yok"; -import { color } from "../color"; +import { CommandContext, defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { doesCurrentNpmCommandMatch } from "../common/helpers"; +import { registerCommand } from "../common/services/command-definition-adapter"; -export class PostInstallCliCommand implements ICommand { - constructor( - private $fs: IFileSystem, - private $commandsService: ICommandsService, - private $helpService: IHelpService, - private $settingsService: ISettingsService, - private $analyticsService: IAnalyticsService, - private $logger: ILogger, - private $hostInfo: IHostInfo, - ) {} +export interface IPostInstallCliCommandServices { + $fs: IFileSystem; + $commandsService: ICommandsService; + $helpService: IHelpService; + $settingsService: ISettingsService; + $analyticsService: IAnalyticsService; + $logger: ILogger; + $hostInfo: IHostInfo; +} - public disableAnalytics = true; - public allowedParameters: ICommandParameter[] = []; +export function setupPostInstallCliCommand(): IPostInstallCliCommandServices { + return { + $fs: inject("fs"), + $commandsService: inject("commandsService"), + $helpService: inject("helpService"), + $settingsService: inject("settingsService"), + $analyticsService: inject("analyticsService"), + $logger: inject("logger"), + $hostInfo: inject("hostInfo"), + }; +} - public async execute(args: string[]): Promise { - const isRunningWithSudoUser = !!process.env.SUDO_USER; +export async function runPostInstallCliCommand( + context: CommandContext, + services: IPostInstallCliCommandServices, +): Promise { + const isRunningWithSudoUser = !!process.env.SUDO_USER; - if (!this.$hostInfo.isWindows) { - // when running under 'sudo' we create a working dir with wrong owner (root) and - // it is no longer accessible for the user initiating the installation - // patch the owner here - if (isRunningWithSudoUser) { - // TODO: Check if this is the correct place, probably we should set this at the end of the command. - await this.$fs.setCurrentUserAsOwner( - this.$settingsService.getProfileDir(), - process.env.SUDO_USER, - ); - } + if (!services.$hostInfo.isWindows) { + // when running under 'sudo' we create a working dir with wrong owner (root) and + // it is no longer accessible for the user initiating the installation + // patch the owner here + if (isRunningWithSudoUser) { + // TODO: Check if this is the correct place, probably we should set this at the end of the command. + await services.$fs.setCurrentUserAsOwner( + services.$settingsService.getProfileDir(), + process.env.SUDO_USER, + ); } + } - const canExecutePostInstallTask = - !isRunningWithSudoUser || doesCurrentNpmCommandMatch([/^--unsafe-perm$/]); + const canExecutePostInstallTask = + !isRunningWithSudoUser || doesCurrentNpmCommandMatch([/^--unsafe-perm$/]); - if (canExecutePostInstallTask) { - await this.$helpService.generateHtmlPages(); + if (canExecutePostInstallTask) { + await services.$helpService.generateHtmlPages(); - // Explicitly ask for confirmation of usage-reporting: - await this.$analyticsService.checkConsent(); - await this.$commandsService.tryExecuteCommand("autocomplete", []); - } + // Explicitly ask for confirmation of usage-reporting: + await services.$analyticsService.checkConsent(); + await services.$commandsService.tryExecuteCommand("autocomplete", []); } +} - public async postCommandAction(args: string[]): Promise { - this.$logger.info(""); - this.$logger.info( - color.styleText( - ["green", "bold"], - "You have successfully installed the NativeScript CLI!", - ), - ); - this.$logger.info(""); - this.$logger.info("Your next step is to create a new project:"); - this.$logger.info(color.styleText(["green", "bold"], "ns create")); +export function reportSuccessfulInstallation( + services: IPostInstallCliCommandServices, +): void { + services.$logger.info(""); + services.$logger.info( + color.styleText( + ["green", "bold"], + "You have successfully installed the NativeScript CLI!", + ), + ); + services.$logger.info(""); + services.$logger.info("Your next step is to create a new project:"); + services.$logger.info(color.styleText(["green", "bold"], "ns create")); - this.$logger.info(""); - this.$logger.printMarkdown( - "If you have any questions, check Stack Overflow: `https://stackoverflow.com/questions/tagged/nativescript` and our public Discord channel: `https://nativescript.org/discord`", - ); - } + services.$logger.info(""); + services.$logger.printMarkdown( + "If you have any questions, check Stack Overflow: `https://stackoverflow.com/questions/tagged/nativescript` and our public Discord channel: `https://nativescript.org/discord`", + ); } -injector.registerCommand("post-install-cli", PostInstallCliCommand); +export const postInstallCliCommandDefinition = defineCommand({ + name: "post-install-cli", + description: "Completes the CLI installation.", + disableAnalytics: true, + setup: setupPostInstallCliCommand, + run: runPostInstallCliCommand, + postRun: (context, result, services) => + reportSuccessfulInstallation(services), +}); + +registerCommand(postInstallCliCommandDefinition); diff --git a/lib/commands/prepare.ts b/lib/commands/prepare.ts index 03323b9e6c..da029636b2 100644 --- a/lib/commands/prepare.ts +++ b/lib/commands/prepare.ts @@ -1,89 +1,99 @@ -import { ValidatePlatformCommandBase } from "./command-base"; +import { + canExecuteCommandBase, + injectPlatformCommandServices, + IPlatformCommandServices, + platformArgument, + validatePlatformArgument, + validatePlatformOptions, +} from "./command-base"; import { PrepareController } from "../controllers/prepare-controller"; import { PrepareDataService } from "../services/prepare-data-service"; -import { IProjectData } from "../definitions/project"; -import { IOptions, IPlatformValidationService } from "../declarations"; -import { IPlatformsDataService } from "../definitions/platform"; import { IMigrateController } from "../definitions/migrate"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { OptionType } from "../common/enums"; -import { injector } from "../common/yok"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommandDefinition } from "../common/services/command-definition-adapter"; -export class PrepareCommand - extends ValidatePlatformCommandBase - implements ICommand -{ - public allowedParameters = [this.$platformCommandParameter]; +export const prepareCommandOptions = { + watch: booleanOption({ default: false }), + hmr: booleanOption({ default: false }), + skipNative: booleanOption({ default: false }), + force: booleanOption(), +} satisfies CommandOptionsSchema; - public dashedOptions = { - watch: { - type: OptionType.Boolean, - default: false, - hasSensitiveValue: false, - }, - hmr: { type: OptionType.Boolean, default: false, hasSensitiveValue: false }, - skipNative: { - type: OptionType.Boolean, - default: false, - hasSensitiveValue: false, - }, +export type PrepareCommandContext = CommandContext< + typeof prepareCommandOptions +>; + +export interface IPrepareCommandServices extends IPlatformCommandServices { + $prepareController: PrepareController; + $prepareDataService: PrepareDataService; + $migrateController: IMigrateController; +} + +export function setupPrepareCommand(): IPrepareCommandServices { + const services = { + ...injectPlatformCommandServices(), + $prepareController: inject("prepareController"), + $prepareDataService: inject("prepareDataService"), + $migrateController: inject("migrateController"), }; + services.$projectData.initializeProjectData(); + + return services; +} - constructor( - public $options: IOptions, - public $prepareController: PrepareController, - public $platformValidationService: IPlatformValidationService, - public $projectData: IProjectData, - public $platformCommandParameter: ICommandParameter, - public $platformsDataService: IPlatformsDataService, - public $prepareDataService: PrepareDataService, - public $migrateController: IMigrateController, - ) { - super( - $options, - $platformsDataService, - $platformValidationService, - $projectData, - ); - this.$projectData.initializeProjectData(); +export async function canExecutePrepareCommand( + context: PrepareCommandContext, + services: IPrepareCommandServices, +): Promise { + const platform = context.args[0]; + if (!platform) { + // The declared argument validates only a platform that was passed; an + // absent one is rejected by the same check. + validatePlatformArgument(context.injector, platform); } - public async execute(args: string[]): Promise { - const platform = args[0]; + const result = await validatePlatformOptions(services, platform); - const prepareData = this.$prepareDataService.getPrepareData( - this.$projectData.projectDir, - platform, - this.$options, - ); - await this.$prepareController.prepare(prepareData); + if (!context.options.force) { + await services.$migrateController.validate({ + projectDir: services.$projectData.projectDir, + platforms: [platform], + }); } - public async canExecute(args: string[]): Promise { - const platform = args[0]; - const result = - (await this.$platformCommandParameter.validate(platform)) && - (await this.$platformValidationService.validateOptions( - this.$options.provision, - this.$options.teamId, - this.$projectData, - platform, - )); - - if (!this.$options.force) { - await this.$migrateController.validate({ - projectDir: this.$projectData.projectDir, - platforms: [platform], - }); - } + if (!result) { + return false; + } - if (!result) { - return false; - } + return canExecuteCommandBase(services, platform); +} - const canExecuteOutput = await super.canExecuteCommandBase(platform); - return canExecuteOutput; - } +export async function runPrepareCommand( + context: PrepareCommandContext, + services: IPrepareCommandServices, +): Promise { + const prepareData = services.$prepareDataService.getPrepareData( + services.$projectData.projectDir, + context.args[0], + services.$options, + ); + await services.$prepareController.prepare(prepareData); } -injector.registerCommand("prepare", PrepareCommand); +export const prepareCommandDefinition = defineCommand({ + name: "prepare", + description: "Copies common and platform-specific content to the platform.", + options: prepareCommandOptions, + arguments: [platformArgument], + setup: setupPrepareCommand, + canExecute: canExecutePrepareCommand, + run: runPrepareCommand, +}); + +registerCommandDefinition(prepareCommandDefinition); diff --git a/lib/commands/preview.ts b/lib/commands/preview.ts index e6dc122ce8..68337f7b89 100644 --- a/lib/commands/preview.ts +++ b/lib/commands/preview.ts @@ -1,106 +1,138 @@ -import { ICommandParameter, ICommand } from "../common/definitions/commands"; -import { IChildProcess, IErrors } from "../common/declarations"; -import { injector } from "../common/yok"; -import { IOptions, IPackageManager } from "../declarations"; -import { IProjectData } from "../definitions/project"; import { resolvePackagePath } from "@rigor789/resolve-package-path"; -import { PackageManagers } from "../constants"; -import { color } from "../color"; import * as path from "path"; +import { color } from "../color"; +import { IChildProcess, IErrors } from "../common/declarations"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; +import { PackageManagers } from "../constants"; +import { IPackageManager } from "../declarations"; +import { IProjectData } from "../definitions/project"; const PREVIEW_CLI_PACKAGE = "@nativescript/preview-cli"; -export class PreviewCommand implements ICommand { - allowedParameters: ICommandParameter[] = []; - allowUnknownOptions = true; - - constructor( - private $logger: ILogger, - private $errors: IErrors, - private $projectData: IProjectData, - private $packageManager: IPackageManager, - private $childProcess: IChildProcess, - private $options: IOptions, - ) {} +const previewCommandOptions = { + disableNpmInstall: booleanOption(), +} satisfies CommandOptionsSchema; - private getPreviewCLIPath(): string { - return resolvePackagePath(PREVIEW_CLI_PACKAGE, { - paths: [this.$projectData.projectDir], - }); - } +export type PreviewCommandContext = CommandContext< + typeof previewCommandOptions +>; - async execute(args: string[]): Promise { - if (!this.$options.disableNpmInstall) { - // ensure latest is installed - await this.$packageManager.install( - `${PREVIEW_CLI_PACKAGE}@latest`, - this.$projectData.projectDir, - { - "save-dev": true, - "save-exact": true, - } as any, - ); - } +export interface IPreviewCommandServices { + $childProcess: IChildProcess; + $errors: IErrors; + $logger: ILogger; + $packageManager: IPackageManager; + $projectData: IProjectData; +} - const previewCLIPath = this.getPreviewCLIPath(); +export function setupPreviewCommand(): IPreviewCommandServices { + return { + $childProcess: inject("childProcess"), + $errors: inject("errors"), + $logger: inject("logger"), + $packageManager: inject("packageManager"), + $projectData: inject("projectData"), + }; +} - if (!previewCLIPath) { - const packageManagerName = - await this.$packageManager.getPackageManagerName(); - let installCommand = ""; +function getPreviewCLIPath(services: IPreviewCommandServices): string { + return resolvePackagePath(PREVIEW_CLI_PACKAGE, { + paths: [services.$projectData.projectDir], + }); +} - switch (packageManagerName) { - case PackageManagers.yarn: - case PackageManagers.yarn2: - installCommand = "yarn add -D @nativescript/preview-cli"; - break; - case PackageManagers.pnpm: - installCommand = "pnpm install --save-dev @nativescript/preview-cli"; - break; - case PackageManagers.bun: - installCommand = "bun add --dev @nativescript/preview-cli"; - case PackageManagers.npm: - default: - installCommand = "npm install --save-dev @nativescript/preview-cli"; - break; - } - this.$logger.info( - [ - `Uhh ohh, no Preview CLI found.`, - "", - `This should not happen under regular circumstances, but seems like it did somehow... :(`, - `Good news though, you can install the Preview CLI by running`, - "", - " " + color.green(installCommand), - "", - "Once installed, run this command again and everything should work!", - "If it still fails, you can invoke the preview-cli directly as a last resort with", - "", - color.cyan(" ./node_modules/.bin/preview-cli"), - "", - "And if you are still having issues, try again - or reach out on Discord/open an issue on GitHub.", - ].join("\n"), - ); +export async function runPreviewCommand( + context: PreviewCommandContext, + services: IPreviewCommandServices, +): Promise { + if (!context.options.disableNpmInstall) { + // ensure latest is installed + await services.$packageManager.install( + `${PREVIEW_CLI_PACKAGE}@latest`, + services.$projectData.projectDir, + { + "save-dev": true, + "save-exact": true, + } as any, + ); + } - this.$errors.fail("Running preview failed."); - } + const previewCLIPath = getPreviewCLIPath(services); - const previewCLIBinPath = path.resolve(previewCLIPath, "./dist/index.js"); + if (!previewCLIPath) { + const packageManagerName = + await services.$packageManager.getPackageManagerName(); + let installCommand = ""; - const commandIndex = process.argv.indexOf("preview"); - const commandArgs = process.argv.slice(commandIndex + 1); - this.$childProcess.spawn( - process.execPath, - [previewCLIBinPath, ...commandArgs], - { - stdio: "inherit", - }, + switch (packageManagerName) { + case PackageManagers.yarn: + case PackageManagers.yarn2: + installCommand = "yarn add -D @nativescript/preview-cli"; + break; + case PackageManagers.pnpm: + installCommand = "pnpm install --save-dev @nativescript/preview-cli"; + break; + case PackageManagers.bun: + installCommand = "bun add --dev @nativescript/preview-cli"; + case PackageManagers.npm: + default: + installCommand = "npm install --save-dev @nativescript/preview-cli"; + break; + } + services.$logger.info( + [ + `Uhh ohh, no Preview CLI found.`, + "", + `This should not happen under regular circumstances, but seems like it did somehow... :(`, + `Good news though, you can install the Preview CLI by running`, + "", + " " + color.green(installCommand), + "", + "Once installed, run this command again and everything should work!", + "If it still fails, you can invoke the preview-cli directly as a last resort with", + "", + color.cyan(" ./node_modules/.bin/preview-cli"), + "", + "And if you are still having issues, try again - or reach out on Discord/open an issue on GitHub.", + ].join("\n"), ); - } - async canExecute(args: string[]): Promise { - return true; + services.$errors.fail("Running preview failed."); } + + const previewCLIBinPath = path.resolve(previewCLIPath, "./dist/index.js"); + + // The preview CLI takes the command line verbatim, including flags this CLI + // does not know, so the raw process arguments are what it gets rather than + // anything the command layer parsed. + const commandIndex = process.argv.indexOf("preview"); + const commandArgs = process.argv.slice(commandIndex + 1); + services.$childProcess.spawn( + process.execPath, + [previewCLIBinPath, ...commandArgs], + { + stdio: "inherit", + }, + ); } -injector.registerCommand("preview", PreviewCommand); +export const previewCommandDefinition = defineCommand({ + name: "preview", + description: "Runs your project with the NativeScript Preview CLI.", + options: previewCommandOptions, + // Arguments have never been rejected here, only ignored: they reach the + // preview CLI through the raw argv instead. + arguments: "any", + allowUnknownOptions: true, + setup: setupPreviewCommand, + run: runPreviewCommand, +}); + +registerCommand(previewCommandDefinition); diff --git a/lib/commands/remove-platform.ts b/lib/commands/remove-platform.ts index db5c5cb427..ba93db67bb 100644 --- a/lib/commands/remove-platform.ts +++ b/lib/commands/remove-platform.ts @@ -4,42 +4,73 @@ import { IPlatformCommandHelper, IPlatformValidationService, } from "../declarations"; -import { injector } from "../common/yok"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { IErrors } from "../common/declarations"; +import { CommandContext, defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; -export class RemovePlatformCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export interface IRemovePlatformCommandServices { + $errors: IErrors; + $platformCommandHelper: IPlatformCommandHelper; + $platformValidationService: IPlatformValidationService; + $projectData: IProjectData; +} - constructor( - private $errors: IErrors, - private $platformCommandHelper: IPlatformCommandHelper, - private $platformValidationService: IPlatformValidationService, - private $projectData: IProjectData - ) { - this.$projectData.initializeProjectData(); - } +export function setupRemovePlatformCommand(): IRemovePlatformCommandServices { + const services = { + $errors: inject("errors"), + $platformCommandHelper: inject( + "platformCommandHelper", + ), + $platformValidationService: inject( + "platformValidationService", + ), + $projectData: inject("projectData"), + }; + services.$projectData.initializeProjectData(); - public execute(args: string[]): Promise { - return this.$platformCommandHelper.removePlatforms(args, this.$projectData); - } + return services; +} - public async canExecute(args: string[]): Promise { - if (!args || args.length === 0) { - this.$errors.failWithHelp( - "No platform specified. Please specify a platform to remove." - ); - } - - _.each(args, (platform) => { - this.$platformValidationService.validatePlatform( - platform, - this.$projectData - ); - }); - - return true; +export async function canExecuteRemovePlatformCommand( + context: CommandContext, + services: IRemovePlatformCommandServices, +): Promise { + const args = context.args; + if (!args || args.length === 0) { + services.$errors.failWithHelp( + "No platform specified. Please specify a platform to remove.", + ); } + + _.each(args, (platform) => { + services.$platformValidationService.validatePlatform( + platform, + services.$projectData, + ); + }); + + return true; +} + +export function runRemovePlatformCommand( + context: CommandContext, + services: IRemovePlatformCommandServices, +): Promise { + return services.$platformCommandHelper.removePlatforms( + context.args, + services.$projectData, + ); } -injector.registerCommand("platform|remove", RemovePlatformCommand); +export const removePlatformCommandDefinition = defineCommand({ + name: "platform|remove", + description: + "Removes the selected platform from the platforms that the project currently targets.", + arguments: "any", + setup: setupRemovePlatformCommand, + canExecute: canExecuteRemovePlatformCommand, + run: runRemovePlatformCommand, +}); + +registerCommand(removePlatformCommandDefinition); diff --git a/lib/commands/resources/resources-update.ts b/lib/commands/resources/resources-update.ts index 5e87439003..5d4ab4cf1c 100644 --- a/lib/commands/resources/resources-update.ts +++ b/lib/commands/resources/resources-update.ts @@ -1,52 +1,74 @@ import { IProjectData } from "../../definitions/project"; import { IAndroidResourcesMigrationService } from "../../declarations"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; import { IErrors } from "../../common/declarations"; -import { injector } from "../../common/yok"; +import { CommandContext, defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; +import { registerCommand } from "../../common/services/command-definition-adapter"; -export class ResourcesUpdateCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export interface IResourcesUpdateCommandServices { + $projectData: IProjectData; + $errors: IErrors; + $androidResourcesMigrationService: IAndroidResourcesMigrationService; +} - constructor( - private $projectData: IProjectData, - private $errors: IErrors, - private $androidResourcesMigrationService: IAndroidResourcesMigrationService - ) { - this.$projectData.initializeProjectData(); - } +export function setupResourcesUpdateCommand(): IResourcesUpdateCommandServices { + const services = { + $projectData: inject("projectData"), + $errors: inject("errors"), + $androidResourcesMigrationService: + inject( + "androidResourcesMigrationService", + ), + }; + services.$projectData.initializeProjectData(); - public async execute(args: string[]): Promise { - await this.$androidResourcesMigrationService.migrate( - this.$projectData.getAppResourcesDirectoryPath() - ); + return services; +} + +export async function canExecuteResourcesUpdateCommand( + context: CommandContext, + services: IResourcesUpdateCommandServices, +): Promise { + let args = context.args; + if (!args || args.length === 0) { + // Command defaults to migrating the Android App_Resources, unless explicitly specified. + // The default reaches this check only; the migration itself ignores the arguments. + args = ["android"]; } - public async canExecute(args: string[]): Promise { - if (!args || args.length === 0) { - // Command defaults to migrating the Android App_Resources, unless explicitly specified - args = ["android"]; + for (const platform of args) { + if (!services.$androidResourcesMigrationService.canMigrate(platform)) { + services.$errors.fail( + `The ${platform} does not need to have its resources updated.`, + ); } - for (const platform of args) { - if (!this.$androidResourcesMigrationService.canMigrate(platform)) { - this.$errors.fail( - `The ${platform} does not need to have its resources updated.` - ); - } - - if ( - this.$androidResourcesMigrationService.hasMigrated( - this.$projectData.getAppResourcesDirectoryPath() - ) - ) { - this.$errors.fail( - "The App_Resources have already been updated for the Android platform." - ); - } + if ( + services.$androidResourcesMigrationService.hasMigrated( + services.$projectData.getAppResourcesDirectoryPath(), + ) + ) { + services.$errors.fail( + "The App_Resources have already been updated for the Android platform.", + ); } - - return true; } + + return true; } -injector.registerCommand("resources|update", ResourcesUpdateCommand); +export const resourcesUpdateCommandDefinition = defineCommand({ + name: "resources|update", + description: + "Updates the App_Resources directory to the structure the current Android runtime expects.", + arguments: "any", + setup: setupResourcesUpdateCommand, + canExecute: canExecuteResourcesUpdateCommand, + async run(context, services): Promise { + await services.$androidResourcesMigrationService.migrate( + services.$projectData.getAppResourcesDirectoryPath(), + ); + }, +}); + +registerCommand(resourcesUpdateCommandDefinition); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index 8b7e789c1b..1aa2af0adc 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -1,13 +1,19 @@ import { ERROR_NO_VALID_SUBCOMMAND_FORMAT } from "../common/constants"; import { IErrors, IHostInfo } from "../common/declarations"; -import { cache } from "../common/decorators"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { IKeyCommandHelper, IKeyCommandPlatform, } from "../common/definitions/key-commands"; -import { IInjector } from "../common/definitions/yok"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject, InjectionToken } from "../common/di"; import { hasValidAndroidSigning } from "../common/helpers"; +import { registerCommandDefinition } from "../common/services/command-definition-adapter"; import { injector } from "../common/yok"; import { ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, @@ -17,209 +23,232 @@ import { IOptions, IPlatformValidationService } from "../declarations"; import { IMigrateController } from "../definitions/migrate"; import { IProjectData, IProjectDataService } from "../definitions/project"; -export class RunCommandBase implements ICommand { - private liveSyncCommandHelperAdditionalOptions: ILiveSyncCommandHelperAdditionalOptions = - {}; - - public platform: string; - constructor( - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - private $errors: IErrors, - private $hostInfo: IHostInfo, - private $liveSyncCommandHelper: ILiveSyncCommandHelper, - private $migrateController: IMigrateController, - private $options: IOptions, - private $projectData: IProjectData, - private $keyCommandHelper: IKeyCommandHelper - ) {} - - public allowedParameters: ICommandParameter[] = []; - public async execute(args: string[]): Promise { - await this.$liveSyncCommandHelper.executeCommandLiveSync( - this.platform, - this.liveSyncCommandHelperAdditionalOptions - ); - - if (process.env.NS_IS_INTERACTIVE) { - this.$keyCommandHelper.attachKeyCommands( - this.platform as IKeyCommandPlatform, - "run" - ); - } - } - - public async canExecute(args: string[]): Promise { - if (args.length) { - this.$errors.failWithHelp(ERROR_NO_VALID_SUBCOMMAND_FORMAT, "run"); - } - - this.platform = args[0] || this.platform; - if (!this.platform && !this.$hostInfo.isDarwin) { - this.platform = this.$devicePlatformsConstants.Android; - } +/** + * Which `$devicePlatformsConstants` entry this registration runs. `run|*all` + * has none and registers without providing it. + */ +const RUN_PLATFORM = new InjectionToken<"iOS" | "Android" | "visionOS">( + "runCommandPlatform", +); + +const runCommandOptions = { + force: booleanOption(), + release: booleanOption(), + aab: booleanOption(), + keyStorePath: stringOption(), + keyStorePassword: stringOption(), + keyStoreAlias: stringOption(), + keyStoreAliasPassword: stringOption(), +} satisfies CommandOptionsSchema; + +export type RunCommandContext = CommandContext; + +export interface IRunCommandServices { + /** + * Undefined for `run|*all`, which targets every platform. `canExecute` + * narrows it to Android off macOS, and `run` reads whatever it settled on. + */ + platform: string; + $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; + $errors: IErrors; + $hostInfo: IHostInfo; + $keyCommandHelper: IKeyCommandHelper; + $liveSyncCommandHelper: ILiveSyncCommandHelper; + $migrateController: IMigrateController; + $options: IOptions; + $platformValidationService: IPlatformValidationService; + $projectData: IProjectData; + $projectDataService: IProjectDataService; +} - this.$projectData.initializeProjectData(); - const platforms = this.platform - ? [this.platform] - : [ - this.$devicePlatformsConstants.Android, - this.$devicePlatformsConstants.iOS, - ]; - - if (!this.$options.force) { - await this.$migrateController.validate({ - projectDir: this.$projectData.projectDir, - platforms, - }); - } +export function setupRunCommand(): IRunCommandServices { + return { + platform: undefined, + $devicePlatformsConstants: inject( + "devicePlatformsConstants", + ), + $errors: inject("errors"), + $hostInfo: inject("hostInfo"), + $keyCommandHelper: inject("keyCommandHelper"), + $liveSyncCommandHelper: inject( + "liveSyncCommandHelper", + ), + $migrateController: inject("migrateController"), + $options: inject("options"), + $platformValidationService: inject( + "platformValidationService", + ), + $projectData: inject("projectData"), + $projectDataService: inject("projectDataService"), + }; +} - await this.$liveSyncCommandHelper.validatePlatform(this.platform); +function setupRunPlatformCommand(): IRunCommandServices { + const services = setupRunCommand(); + services.platform = services.$devicePlatformsConstants[inject(RUN_PLATFORM)]; - return true; - } + return services; } -injector.registerCommand("run|*all", RunCommandBase); +export async function canExecuteRunCommand( + context: RunCommandContext, + services: IRunCommandServices, +): Promise { + if (context.args.length) { + services.$errors.failWithHelp(ERROR_NO_VALID_SUBCOMMAND_FORMAT, "run"); + } -export class RunIosCommand implements ICommand { - @cache() - protected get runCommand(): RunCommandBase { - const runCommand = this.$injector.resolve(RunCommandBase); - runCommand.platform = this.platform; - return runCommand; + if (!services.platform && !services.$hostInfo.isDarwin) { + services.platform = services.$devicePlatformsConstants.Android; } - public allowedParameters: ICommandParameter[] = []; - public get platform(): string { - return this.$devicePlatformsConstants.iOS; + services.$projectData.initializeProjectData(); + const platforms = services.platform + ? [services.platform] + : [ + services.$devicePlatformsConstants.Android, + services.$devicePlatformsConstants.iOS, + ]; + + if (!context.options.force) { + await services.$migrateController.validate({ + projectDir: services.$projectData.projectDir, + platforms, + }); } - constructor( - protected $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - protected $errors: IErrors, - protected $injector: IInjector, - protected $options: IOptions, - protected $platformValidationService: IPlatformValidationService, - protected $projectDataService: IProjectDataService - ) {} - - public async execute(args: string[]): Promise { - return this.runCommand.execute(args); + await services.$liveSyncCommandHelper.validatePlatform(services.platform); + + return true; +} + +export async function runRunCommand( + context: RunCommandContext, + services: IRunCommandServices, +): Promise { + await services.$liveSyncCommandHelper.executeCommandLiveSync( + services.platform, + {}, + ); + + if (process.env.NS_IS_INTERACTIVE) { + services.$keyCommandHelper.attachKeyCommands( + services.platform, + "run", + ); } +} - public async canExecute(args: string[]): Promise { - const projectData = this.$projectDataService.getProjectData(); +export const runCommandDefinition = defineCommand({ + name: "run|*all", + description: "Runs your project on all connected devices and emulators.", + options: runCommandOptions, + // The base rejects arguments itself, with the sub-command message. + arguments: "any", + setup: setupRunCommand, + canExecute: canExecuteRunCommand, + run: runRunCommand, +}); + +registerCommandDefinition(runCommandDefinition); + +export const runApplePlatformCommandDefinition = defineCommand({ + name: "run|ios", + description: "Runs your project on a connected Apple device or simulator.", + options: runCommandOptions, + arguments: "any", + setup: setupRunPlatformCommand, + async canExecute( + context: RunCommandContext, + services: IRunCommandServices, + ): Promise { + const projectData = services.$projectDataService.getProjectData(); if ( - !this.$platformValidationService.isPlatformSupportedForOS( - this.platform, - projectData + !services.$platformValidationService.isPlatformSupportedForOS( + services.platform, + projectData, ) ) { - this.$errors.fail( - `Applications for platform ${this.platform} can not be built on this OS` + services.$errors.fail( + `Applications for platform ${services.platform} can not be built on this OS`, ); } const result = - (await this.runCommand.canExecute(args)) && - (await this.$platformValidationService.validateOptions( - this.$options.provision, - this.$options.teamId, + (await canExecuteRunCommand(context, services)) && + (await services.$platformValidationService.validateOptions( + services.$options.provision, + services.$options.teamId, projectData, - this.platform.toLowerCase() + services.platform.toLowerCase(), )); return result; - } -} - -injector.registerCommand("run|ios", RunIosCommand); - -export class RunAndroidCommand implements ICommand { - @cache() - private get runCommand(): RunCommandBase { - const runCommand = this.$injector.resolve(RunCommandBase); - runCommand.platform = this.platform; - return runCommand; - } - - public allowedParameters: ICommandParameter[] = []; - public get platform(): string { - return this.$devicePlatformsConstants.Android; - } - - constructor( - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - private $errors: IErrors, - private $injector: IInjector, - private $options: IOptions, - private $platformValidationService: IPlatformValidationService, - private $projectData: IProjectData - ) {} - - public async execute(args: string[]): Promise { - return this.runCommand.execute(args); - } - - public async canExecute(args: string[]): Promise { - await this.runCommand.canExecute(args); + }, + run: runRunCommand, +}); + +export const runAndroidCommandDefinition = defineCommand({ + name: "run|android", + description: "Runs your project on a connected Android device or emulator.", + options: runCommandOptions, + arguments: "any", + setup: setupRunPlatformCommand, + async canExecute( + context: RunCommandContext, + services: IRunCommandServices, + ): Promise { + // The base verdict is dropped rather than combined with the checks below; + // the base only ever returns true or throws, so the Android command has + // always relied on it for its side effects alone. + await canExecuteRunCommand(context, services); if ( - !this.$platformValidationService.isPlatformSupportedForOS( - this.$devicePlatformsConstants.Android, - this.$projectData + !services.$platformValidationService.isPlatformSupportedForOS( + services.$devicePlatformsConstants.Android, + services.$projectData, ) ) { - this.$errors.fail( - `Applications for platform ${this.$devicePlatformsConstants.Android} can not be built on this OS` + services.$errors.fail( + `Applications for platform ${services.$devicePlatformsConstants.Android} can not be built on this OS`, ); } if ( - (this.$options.release || this.$options.aab) && - !hasValidAndroidSigning(this.$options) + (context.options.release || context.options.aab) && + !hasValidAndroidSigning(context.options) ) { - if (this.$options.release) { - this.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + if (context.options.release) { + services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - this.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + services.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } - return this.$platformValidationService.validateOptions( - this.$options.provision, - this.$options.teamId, - this.$projectData, - this.$devicePlatformsConstants.Android.toLowerCase() + return services.$platformValidationService.validateOptions( + services.$options.provision, + services.$options.teamId, + services.$projectData, + services.$devicePlatformsConstants.Android.toLowerCase(), ); - } -} - -injector.registerCommand("run|android", RunAndroidCommand); - -export class RunVisionOSCommand extends RunIosCommand { - public get platform(): string { - return this.$devicePlatformsConstants.visionOS; - } - - constructor( - protected $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - protected $errors: IErrors, - protected $injector: IInjector, - protected $options: IOptions, - protected $platformValidationService: IPlatformValidationService, - protected $projectDataService: IProjectDataService - ) { - super( - $devicePlatformsConstants, - $errors, - $injector, - $options, - $platformValidationService, - $projectDataService - ); - } + }, + run: runRunCommand, +}); + +const runApplePlatforms: [string, "iOS" | "visionOS"][] = [ + ["run|ios", "iOS"], + ["run|vision", "visionOS"], + ["run|visionos", "visionOS"], +]; + +for (const [name, platform] of runApplePlatforms) { + registerCommandDefinition( + { ...runApplePlatformCommandDefinition, name }, + injector.createChild([{ provide: RUN_PLATFORM, useValue: platform }]), + ); } -injector.registerCommand("run|vision", RunVisionOSCommand); -injector.registerCommand("run|visionos", RunVisionOSCommand); +registerCommandDefinition( + runAndroidCommandDefinition, + injector.createChild([{ provide: RUN_PLATFORM, useValue: "Android" }]), +); diff --git a/lib/commands/setup.ts b/lib/commands/setup.ts index 5bb22dd6c2..73b1495405 100644 --- a/lib/commands/setup.ts +++ b/lib/commands/setup.ts @@ -1,14 +1,19 @@ -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { IDoctorService } from "../common/declarations"; -import { injector } from "../common/yok"; +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; -export class SetupCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export const setupCommandDefinition = defineCommand({ + name: "setup|*", + description: + "Run the setup script to try to automatically configure your environment.", + arguments: "none", + setup: () => ({ + $doctorService: inject("doctorService"), + }), + run(context, services): Promise { + return services.$doctorService.runSetupScript(); + }, +}); - constructor(private $doctorService: IDoctorService) {} - - public execute(args: string[]): Promise { - return this.$doctorService.runSetupScript(); - } -} -injector.registerCommand("setup|*", SetupCommand); +registerCommand(setupCommandDefinition); diff --git a/lib/commands/start.ts b/lib/commands/start.ts index 4fc1ec3d6b..499c57d93b 100644 --- a/lib/commands/start.ts +++ b/lib/commands/start.ts @@ -1,19 +1,22 @@ -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { printHeader } from "../common/header"; -import { injector } from "../common/yok"; +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; import { IStartService } from "../definitions/start-service"; -export class StartCommand implements ICommand { - constructor(private $startService: IStartService) {} - async execute(args: string[]): Promise { +export const startCommandDefinition = defineCommand({ + name: "start", + description: "Starts the NativeScript interactive command line.", + arguments: "any", + setup: () => ({ + $startService: inject("startService"), + }), + async run(context, services): Promise { printHeader(); - this.$startService.start(); + // Left unawaited: the command returns while the service keeps running. + services.$startService.start(); return; - } - allowedParameters: ICommandParameter[]; - async canExecute?(args: string[]): Promise { - return true; - } -} + }, +}); -injector.registerCommand("start", StartCommand); +registerCommand(startCommandDefinition); diff --git a/lib/commands/test-init.ts b/lib/commands/test-init.ts index 8cf06ebd4e..219581d633 100644 --- a/lib/commands/test-init.ts +++ b/lib/commands/test-init.ts @@ -8,7 +8,13 @@ import { } from "../definitions/project"; import { INodePackageManager, IOptions } from "../declarations"; import { IPluginsService } from "../definitions/plugins"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; +import { + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; import { IDictionary, IErrors, @@ -16,122 +22,149 @@ import { IResourceLoader, IDependencyInformation, } from "../common/declarations"; -import { injector } from "../common/yok"; import { color } from "../color"; -class TestInitCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +const karmaConfigAdditionalFrameworks: IDictionary = { + mocha: ["chai"], +}; + +const testInitCommandOptions = { + framework: stringOption(), +} satisfies CommandOptionsSchema; + +interface ITestInitCommandServices { + $errors: IErrors; + $fs: IFileSystem; + $logger: ILogger; + $options: IOptions; + $packageManager: INodePackageManager; + $pluginsService: IPluginsService; + $projectData: IProjectData; + $prompter: IPrompter; + $resources: IResourceLoader; + $testInitializationService: ITestInitializationService; +} - private karmaConfigAdditionalFrameworks: IDictionary = { - mocha: ["chai"], +function setupTestInitCommand(): ITestInitCommandServices { + const services = { + $errors: inject("errors"), + $fs: inject("fs"), + $logger: inject("logger"), + $options: inject("options"), + $packageManager: inject("packageManager"), + $pluginsService: inject("pluginsService"), + $projectData: inject("projectData"), + $prompter: inject("prompter"), + $resources: inject("resources"), + $testInitializationService: inject( + "testInitializationService", + ), }; + services.$projectData.initializeProjectData(); - /** - * Android blocks cleartext traffic by default (API 28+), which would - * reject the runner's ws:// connection to the host. Scope the exception - * to the emulator loopback alias and adb-reverse loopback only. - */ - private ensureAndroidNetworkSecurityConfig(bufferedLogs: string[]): void { - const manifestPath = path.join( - this.$projectData.appResourcesDirectoryPath, - "Android", - "src", - "main", - "AndroidManifest.xml", - ); - if (!this.$fs.exists(manifestPath)) { - bufferedLogs.push( - color.yellow( - "Could not locate App_Resources/Android/src/main/AndroidManifest.xml. For Android test runs, allow cleartext traffic to 10.0.2.2 and 127.0.0.1 via a network security config.", - ), - ); - return; - } - - const manifestContent = this.$fs.readText(manifestPath); - if (manifestContent.indexOf("networkSecurityConfig") !== -1) { - bufferedLogs.push( - color.yellow( - "AndroidManifest.xml already sets android:networkSecurityConfig — make sure it permits cleartext traffic to 10.0.2.2 and 127.0.0.1 for test runs.", - ), - ); - return; - } + return services; +} - const xmlDirectory = path.join( - this.$projectData.appResourcesDirectoryPath, - "Android", - "src", - "main", - "res", - "xml", +/** + * Android blocks cleartext traffic by default (API 28+), which would + * reject the runner's ws:// connection to the host. Scope the exception + * to the emulator loopback alias and adb-reverse loopback only. + */ +function ensureAndroidNetworkSecurityConfig( + services: ITestInitCommandServices, + bufferedLogs: string[], +): void { + const manifestPath = path.join( + services.$projectData.appResourcesDirectoryPath, + "Android", + "src", + "main", + "AndroidManifest.xml", + ); + if (!services.$fs.exists(manifestPath)) { + bufferedLogs.push( + color.yellow( + "Could not locate App_Resources/Android/src/main/AndroidManifest.xml. For Android test runs, allow cleartext traffic to 10.0.2.2 and 127.0.0.1 via a network security config.", + ), ); - this.$fs.ensureDirectoryExists(xmlDirectory); - const securityConfigPath = path.join(xmlDirectory, "network_security.xml"); - if (!this.$fs.exists(securityConfigPath)) { - this.$fs.copyFile( - this.$resources.resolvePath("test/network_security.xml"), - securityConfigPath, - ); - bufferedLogs.push( - `Added ${color.yellow("App_Resources/Android/src/main/res/xml/network_security.xml")}`, - ); - } + return; + } - this.$fs.writeFile( - manifestPath, - manifestContent.replace( - / { - const projectDir = this.$projectData.projectDir; +export const testInitCommandDefinition = defineCommand({ + name: "test|init", + description: "Configures your project for unit testing.", + options: testInitCommandOptions, + arguments: "none", + setup: setupTestInitCommand, + async run(context, services: ITestInitCommandServices): Promise { + const projectDir = services.$projectData.projectDir; const frameworkToInstall = - this.$options.framework || - (await this.$prompter.promptForChoice( + context.options.framework || + (await services.$prompter.promptForChoice( "Select testing framework:", TESTING_FRAMEWORKS, )); if (TESTING_FRAMEWORKS.indexOf(frameworkToInstall) === -1) { - this.$errors.failWithHelp( + services.$errors.failWithHelp( `Unknown or unsupported unit testing framework: ${frameworkToInstall}.`, ); } const projectFilesExtension = - this.$projectData.projectType === ProjectTypes.TsFlavorName || - this.$projectData.projectType === ProjectTypes.NgFlavorName + services.$projectData.projectType === ProjectTypes.TsFlavorName || + services.$projectData.projectType === ProjectTypes.NgFlavorName ? ".ts" : ".js"; let modulesToInstall: IDependencyInformation[] = []; try { modulesToInstall = - this.$testInitializationService.getDependencies(frameworkToInstall); + services.$testInitializationService.getDependencies(frameworkToInstall); } catch (err) { - this.$errors.fail( + services.$errors.fail( `Unable to install the unit testing dependencies. Error: '${err.message}'`, ); } @@ -145,26 +178,28 @@ class TestInitCommand implements ICommand { for (const mod of modulesToInstall) { let moduleToInstall = mod.name; moduleToInstall += `@${mod.version}`; - await this.$packageManager.install(moduleToInstall, projectDir, { + await services.$packageManager.install(moduleToInstall, projectDir, { // Packages with native code must land in "dependencies" — the CLI // integrates plugin platform files (pods, aars) only from there. ...(mod.saveInDependencies ? { save: true } : { "save-dev": true }), "save-exact": true, optional: false, - disableNpmInstall: this.$options.disableNpmInstall, - frameworkPath: this.$options.frameworkPath, - ignoreScripts: this.$options.ignoreScripts, - path: this.$options.path, + disableNpmInstall: services.$options.disableNpmInstall, + frameworkPath: services.$options.frameworkPath, + ignoreScripts: services.$options.ignoreScripts, + path: services.$options.path, }); const modulePath = path.join(projectDir, "node_modules", mod.name); const modulePackageJsonPath = path.join(modulePath, "package.json"); - const modulePackageJsonContent = this.$fs.readJson(modulePackageJsonPath); + const modulePackageJsonContent = services.$fs.readJson( + modulePackageJsonPath, + ); const modulePeerDependencies = modulePackageJsonContent.peerDependencies || {}; const modulePeerDependenciesMeta = modulePackageJsonContent.peerDependenciesMeta || {}; - const projectPackageJson = this.$fs.readJson( + const projectPackageJson = services.$fs.readJson( path.join(projectDir, "package.json"), ); const installedProjectDependencies = { @@ -201,20 +236,20 @@ class TestInitCommand implements ICommand { // catch errors when a peerDependency is already installed // e.g karma is installed; karma-jasmine depends on karma and will try to install it again try { - await this.$packageManager.install( + await services.$packageManager.install( `${peerDependency}@${dependencyVersion}`, projectDir, { "save-dev": true, "save-exact": true, disableNpmInstall: false, - frameworkPath: this.$options.frameworkPath, - ignoreScripts: this.$options.ignoreScripts, - path: this.$options.path, + frameworkPath: services.$options.frameworkPath, + ignoreScripts: services.$options.ignoreScripts, + path: services.$options.path, }, ); } catch (e) { - this.$logger.error(e.message); + services.$logger.error(e.message); } } } @@ -224,27 +259,27 @@ class TestInitCommand implements ICommand { if (!isVitest) { // The Karma client only exists in the v4 line — v5+ is Vitest-only, so // an unpinned install would break these setups once v5 is `latest`. - await this.$pluginsService.add( + await services.$pluginsService.add( "@nativescript/unit-test-runner@^4.0.0", - this.$projectData, + services.$projectData, ); } - this.$logger.clearScreen(); + services.$logger.clearScreen(); const bufferedLogs = []; - const testsDir = path.join(this.$projectData.appDirectoryPath, "tests"); + const testsDir = path.join(services.$projectData.appDirectoryPath, "tests"); const projectTestsDir = path.relative( - this.$projectData.projectDir, + services.$projectData.projectDir, testsDir, ); const relativeTestsDir = path.relative( - this.$projectData.appDirectoryPath, + services.$projectData.appDirectoryPath, testsDir, ); let shouldCreateSampleTests = true; - if (this.$fs.exists(testsDir)) { + if (services.$fs.exists(testsDir)) { const specFilenamePattern = `.spec${projectFilesExtension}`; bufferedLogs.push( color.yellow( @@ -258,37 +293,38 @@ class TestInitCommand implements ICommand { shouldCreateSampleTests = false; } - this.$fs.ensureDirectoryExists(testsDir); + services.$fs.ensureDirectoryExists(testsDir); if (isVitest) { - const vitestConfigResourcePath = this.$resources.resolvePath( + const vitestConfigResourcePath = services.$resources.resolvePath( "test/vitest.config.mts", ); - this.$fs.copyFile( + services.$fs.copyFile( vitestConfigResourcePath, path.join(projectDir, "vitest.config.mts"), ); bufferedLogs.push(`Added/replaced ${color.yellow("vitest.config.mts")}`); - this.ensureAndroidNetworkSecurityConfig(bufferedLogs); + ensureAndroidNetworkSecurityConfig(services, bufferedLogs); } else { const frameworks = [frameworkToInstall] - .concat(this.karmaConfigAdditionalFrameworks[frameworkToInstall] || []) + .concat(karmaConfigAdditionalFrameworks[frameworkToInstall] || []) .map((fw) => `'${fw}'`) .join(", "); const testFiles = `'${fromWindowsRelativePathToUnix( relativeTestsDir, )}/**/*${projectFilesExtension}'`; - const karmaConfTemplate = this.$resources.readText("test/karma.conf.js"); + const karmaConfTemplate = + services.$resources.readText("test/karma.conf.js"); const karmaConf = _.template(karmaConfTemplate)({ frameworks, testFiles, - basePath: this.$projectData.getAppDirectoryRelativePath(), + basePath: services.$projectData.getAppDirectoryRelativePath(), }); - this.$fs.writeFile(path.join(projectDir, "karma.conf.js"), karmaConf); + services.$fs.writeFile(path.join(projectDir, "karma.conf.js"), karmaConf); } - const exampleFilePath = this.$resources.resolvePath( + const exampleFilePath = services.$resources.resolvePath( `test/example.${frameworkToInstall}${projectFilesExtension}`, ); const targetExampleTestPath = path.join( @@ -296,8 +332,8 @@ class TestInitCommand implements ICommand { `example.spec${projectFilesExtension}`, ); - if (shouldCreateSampleTests && this.$fs.exists(exampleFilePath)) { - this.$fs.copyFile(exampleFilePath, targetExampleTestPath); + if (shouldCreateSampleTests && services.$fs.exists(exampleFilePath)) { + services.$fs.copyFile(exampleFilePath, targetExampleTestPath); const targetExampleTestRelativePath = path.relative( projectDir, targetExampleTestPath, @@ -308,18 +344,18 @@ class TestInitCommand implements ICommand { } // test main entry - const testMainResourcesPath = this.$resources.resolvePath( + const testMainResourcesPath = services.$resources.resolvePath( isVitest ? `test/test-main.vitest${projectFilesExtension}` : `test/test-main${projectFilesExtension}`, ); const testMainPath = path.join( - this.$projectData.appDirectoryPath, + services.$projectData.appDirectoryPath, `test${projectFilesExtension}`, ); - if (!this.$fs.exists(testMainPath)) { - this.$fs.copyFile(testMainResourcesPath, testMainPath); + if (!services.$fs.exists(testMainPath)) { + services.$fs.copyFile(testMainResourcesPath, testMainPath); const testMainRelativePath = path.relative(projectDir, testMainPath); bufferedLogs.push( `Main test entrypoint created: ${color.yellow(testMainRelativePath)}`, @@ -327,14 +363,14 @@ class TestInitCommand implements ICommand { } if (!isVitest || projectFilesExtension === ".ts") { - const testTsConfigTemplate = this.$resources.readText( + const testTsConfigTemplate = services.$resources.readText( "test/tsconfig.spec.json", ); const testTsConfig = _.template(testTsConfigTemplate)({ - basePath: this.$projectData.getAppDirectoryRelativePath(), + basePath: services.$projectData.getAppDirectoryRelativePath(), }); - this.$fs.writeFile( + services.$fs.writeFile( path.join(projectDir, "tsconfig.spec.json"), testTsConfig, ); @@ -381,7 +417,7 @@ class TestInitCommand implements ICommand { "", ]; - this.$logger.info( + services.$logger.info( [ [ color.green(`Tests using`), @@ -394,7 +430,7 @@ class TestInitCommand implements ICommand { ...closingNotes, ].join("\n"), ); - } -} + }, +}); -injector.registerCommand("test|init", TestInitCommand); +registerCommand(testInitCommandDefinition); diff --git a/lib/commands/test.ts b/lib/commands/test.ts index 646af87e3e..831ad1c9ad 100644 --- a/lib/commands/test.ts +++ b/lib/commands/test.ts @@ -1,290 +1,324 @@ +import { + IAnalyticsService, + IDictionary, + IErrors, +} from "../common/declarations"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject, InjectionToken } from "../common/di"; +import { ErrorCodes } from "../common/enums"; import { hasValidAndroidSigning } from "../common/helpers"; +import { registerCommand } from "../common/services/command-definition-adapter"; +import { getInjector } from "../common/yok"; import { - ANDROID_RELEASE_BUILD_ERROR_MESSAGE, ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, + ANDROID_RELEASE_BUILD_ERROR_MESSAGE, } from "../constants"; +import { IOptions } from "../declarations"; +import { ICleanupService } from "../definitions/cleanup-service"; +import { IMigrateController } from "../definitions/migrate"; +import { IPlatformEnvironmentRequirements } from "../definitions/platform"; import { IProjectData, ITestExecutionService, IVitestExecutionService, } from "../definitions/project"; -import { IOptions } from "../declarations"; -import { IPlatformEnvironmentRequirements } from "../definitions/platform"; -import { IMigrateController } from "../definitions/migrate"; -import { ICommandParameter, ICommand } from "../common/definitions/commands"; -import { - IAnalyticsService, - IErrors, - IDictionary, -} from "../common/declarations"; -import { ErrorCodes, OptionType } from "../common/enums"; -import { ICleanupService } from "../definitions/cleanup-service"; -import { injector } from "../common/yok"; -abstract class TestCommandBase { - public allowedParameters: ICommandParameter[] = []; - public dashedOptions = { - hmr: { type: OptionType.Boolean, default: false, hasSensitiveValue: false }, - }; +/** The platform spelling the test services receive, verbatim. */ +const TEST_PLATFORM = new InjectionToken<"android" | "iOS" | "visionOS">( + "testCommandPlatform", +); - protected abstract platform: string; - protected abstract $projectData: IProjectData; - protected abstract $testExecutionService: ITestExecutionService; - protected abstract $vitestExecutionService: IVitestExecutionService; - protected abstract $analyticsService: IAnalyticsService; - protected abstract $options: IOptions; - protected abstract $platformEnvironmentRequirements: IPlatformEnvironmentRequirements; - protected abstract $errors: IErrors; - protected abstract $cleanupService: ICleanupService; - protected abstract $liveSyncCommandHelper: ILiveSyncCommandHelper; - protected abstract $devicesService: Mobile.IDevicesService; - protected abstract $migrateController: IMigrateController; - protected abstract $logger: ILogger; +const testCommandOptions = { + // The CLI-wide default is true; unit testing has always opted out of it. + hmr: booleanOption({ default: false }), + force: booleanOption(), + watch: booleanOption(), + justlaunch: booleanOption(), + debugBrk: booleanOption(), + device: stringOption(), + emulator: booleanOption(), + forDevice: booleanOption(), + sdk: stringOption(), + release: booleanOption(), + aab: booleanOption(), + keyStorePath: stringOption(), + keyStorePassword: stringOption(), + keyStoreAlias: stringOption(), + keyStoreAliasPassword: stringOption(), +} satisfies CommandOptionsSchema; - public async execute(args: string[]): Promise { - if (this.$vitestExecutionService.isVitestProject(this.$projectData)) { - await this.$vitestExecutionService.startTestRun( - this.platform, - this.$projectData, - ); - process.exit(0); - } +export type TestCommandContext = CommandContext; - this.$logger.warn( - "Karma-based unit testing is deprecated and will be removed in a future release. " + - "Re-initialize your tests with '$ ns test init --framework vitest' to migrate.", - ); +export interface ITestCommandServices { + platform: string; + $analyticsService: IAnalyticsService; + $cleanupService: ICleanupService; + $devicesService: Mobile.IDevicesService; + $errors: IErrors; + $liveSyncCommandHelper: ILiveSyncCommandHelper; + $logger: ILogger; + $migrateController: IMigrateController; + $options: IOptions; + $platformEnvironmentRequirements: IPlatformEnvironmentRequirements; + $projectData: IProjectData; + $testExecutionService: ITestExecutionService; + $vitestExecutionService: IVitestExecutionService; +} - let devices = []; - if (this.$options.debugBrk) { - await this.$devicesService.initialize({ - platform: this.platform, - deviceId: this.$options.device, - emulator: this.$options.emulator, - skipInferPlatform: !this.platform, - sdk: this.$options.sdk, - }); +export function setupTestCommand(): ITestCommandServices { + return { + platform: inject(TEST_PLATFORM), + $analyticsService: inject("analyticsService"), + $cleanupService: inject("cleanupService"), + $devicesService: inject("devicesService"), + $errors: inject("errors"), + $liveSyncCommandHelper: inject( + "liveSyncCommandHelper", + ), + $logger: inject("logger"), + $migrateController: inject("migrateController"), + $options: inject("options"), + $platformEnvironmentRequirements: inject( + "platformEnvironmentRequirements", + ), + $projectData: inject("projectData"), + $testExecutionService: inject( + "testExecutionService", + ), + $vitestExecutionService: inject( + "vitestExecutionService", + ), + }; +} - const selectedDeviceForDebug = - await this.$devicesService.pickSingleDevice({ - onlyEmulators: this.$options.emulator, - onlyDevices: this.$options.forDevice, - deviceId: this.$options.device, - }); - devices = [selectedDeviceForDebug]; - // const debugData = this.getDebugData(platform, projectData, deployOptions, { device: selectedDeviceForDebug.deviceInfo.identifier }); - // await this.$debugService.debug(debugData, this.$options); - } else { - devices = await this.$liveSyncCommandHelper.getDeviceInstances( - this.platform, +export async function canExecuteTestCommand( + context: TestCommandContext, + services: ITestCommandServices, +): Promise { + if (!context.options.force) { + if (context.options.hmr) { + // With HMR we are not restarting after LiveSync which is causing a 30 seconds app start on Android + // because the Runtime does not watch for the `/data/local/tmp-livesync-in-progress` file deletion. + // The App is closing itself after each test execution and the bug will be reproducible on each LiveSync. + services.$errors.fail( + "The `--hmr` option is not supported for this command.", ); } - if (!this.$options.env) { - this.$options.env = {}; - } - this.$options.env.unitTesting = true; + await services.$migrateController.validate({ + projectDir: services.$projectData.projectDir, + platforms: [services.platform], + }); + } - const liveSyncInfo = this.$liveSyncCommandHelper.getLiveSyncData( - this.$projectData.projectDir, - ); + services.$projectData.initializeProjectData(); + services.$analyticsService.setShouldDispose( + context.options.justlaunch || !context.options.watch, + ); + services.$cleanupService.setShouldDispose( + context.options.justlaunch || !context.options.watch, + ); - const deviceDebugMap: IDictionary = {}; - devices.forEach( - (device) => - (deviceDebugMap[device.deviceInfo.identifier] = this.$options.debugBrk), + const output = + await services.$platformEnvironmentRequirements.checkEnvironmentRequirements( + { + platform: services.platform, + projectDir: services.$projectData.projectDir, + options: services.$options, + }, ); - const deviceDescriptors = - await this.$liveSyncCommandHelper.createDeviceDescriptors( - devices, - this.platform, - { deviceDebugMap }, - ); - - await this.$testExecutionService.startKarmaServer( - this.platform, - liveSyncInfo, - deviceDescriptors, + if (services.$vitestExecutionService.isVitestProject(services.$projectData)) { + const canStartTestRun = services.$vitestExecutionService.canStartTestRun( + services.$projectData, ); - // if we got here, it means karma exited with exit code 0 (success) - process.exit(0); - } - - async canExecute(args: string[]): Promise { - if (!this.$options.force) { - if (this.$options.hmr) { - // With HMR we are not restarting after LiveSync which is causing a 30 seconds app start on Android - // because the Runtime does not watch for the `/data/local/tmp-livesync-in-progress` file deletion. - // The App is closing itself after each test execution and the bug will be reproducible on each LiveSync. - this.$errors.fail( - "The `--hmr` option is not supported for this command.", - ); - } - - await this.$migrateController.validate({ - projectDir: this.$projectData.projectDir, - platforms: [this.platform], + if (!canStartTestRun) { + services.$errors.fail({ + formatStr: + "Error: In order to run unit tests, your project must already be configured by running $ ns test init.", + errorCode: ErrorCodes.TESTS_INIT_REQUIRED, }); } + return output.canExecute && canStartTestRun; + } - this.$projectData.initializeProjectData(); - this.$analyticsService.setShouldDispose( - this.$options.justlaunch || !this.$options.watch, + const canStartKarmaServer = + await services.$testExecutionService.canStartKarmaServer( + services.$projectData, ); - this.$cleanupService.setShouldDispose( - this.$options.justlaunch || !this.$options.watch, + if (!canStartKarmaServer) { + services.$errors.fail({ + formatStr: + "Error: In order to run unit tests, your project must already be configured by running $ ns test init.", + errorCode: ErrorCodes.TESTS_INIT_REQUIRED, + }); + } + + return output.canExecute && canStartKarmaServer; +} + +export async function runTestCommand( + context: TestCommandContext, + services: ITestCommandServices, +): Promise { + if (services.$vitestExecutionService.isVitestProject(services.$projectData)) { + await services.$vitestExecutionService.startTestRun( + services.platform, + services.$projectData, ); + process.exit(0); + } - const output = - await this.$platformEnvironmentRequirements.checkEnvironmentRequirements({ - platform: this.platform, - projectDir: this.$projectData.projectDir, - options: this.$options, - }); + services.$logger.warn( + "Karma-based unit testing is deprecated and will be removed in a future release. " + + "Re-initialize your tests with '$ ns test init --framework vitest' to migrate.", + ); - if (this.$vitestExecutionService.isVitestProject(this.$projectData)) { - const canStartTestRun = this.$vitestExecutionService.canStartTestRun( - this.$projectData, - ); - if (!canStartTestRun) { - this.$errors.fail({ - formatStr: - "Error: In order to run unit tests, your project must already be configured by running $ ns test init.", - errorCode: ErrorCodes.TESTS_INIT_REQUIRED, - }); - } - return output.canExecute && canStartTestRun; - } + let devices = []; + if (context.options.debugBrk) { + await services.$devicesService.initialize({ + platform: services.platform, + deviceId: context.options.device, + emulator: context.options.emulator, + skipInferPlatform: !services.platform, + sdk: context.options.sdk, + }); - const canStartKarmaServer = - await this.$testExecutionService.canStartKarmaServer(this.$projectData); - if (!canStartKarmaServer) { - this.$errors.fail({ - formatStr: - "Error: In order to run unit tests, your project must already be configured by running $ ns test init.", - errorCode: ErrorCodes.TESTS_INIT_REQUIRED, + const selectedDeviceForDebug = + await services.$devicesService.pickSingleDevice({ + onlyEmulators: context.options.emulator, + onlyDevices: context.options.forDevice, + deviceId: context.options.device, }); - } + devices = [selectedDeviceForDebug]; + // const debugData = this.getDebugData(platform, projectData, deployOptions, { device: selectedDeviceForDebug.deviceInfo.identifier }); + // await this.$debugService.debug(debugData, this.$options); + } else { + devices = await services.$liveSyncCommandHelper.getDeviceInstances( + services.platform, + ); + } - return output.canExecute && canStartKarmaServer; + // The bundler reads unitTesting off the shared options service, so the flag + // is set there rather than on the command's own snapshot. + if (!services.$options.env) { + services.$options.env = {}; } -} + services.$options.env.unitTesting = true; -class TestAndroidCommand extends TestCommandBase implements ICommand { - protected platform = "android"; + const liveSyncInfo = services.$liveSyncCommandHelper.getLiveSyncData( + services.$projectData.projectDir, + ); - constructor( - protected $projectData: IProjectData, - protected $testExecutionService: ITestExecutionService, - protected $vitestExecutionService: IVitestExecutionService, - protected $analyticsService: IAnalyticsService, - protected $options: IOptions, - protected $platformEnvironmentRequirements: IPlatformEnvironmentRequirements, - protected $errors: IErrors, - protected $cleanupService: ICleanupService, - protected $liveSyncCommandHelper: ILiveSyncCommandHelper, - protected $devicesService: Mobile.IDevicesService, - protected $migrateController: IMigrateController, - protected $logger: ILogger, - ) { - super(); - } + const deviceDebugMap: IDictionary = {}; + devices.forEach( + (device) => + (deviceDebugMap[device.deviceInfo.identifier] = context.options.debugBrk), + ); - public async execute(args: string[]): Promise { - await super.execute(args); - } + const deviceDescriptors = + await services.$liveSyncCommandHelper.createDeviceDescriptors( + devices, + services.platform, + { deviceDebugMap }, + ); + + await services.$testExecutionService.startKarmaServer( + services.platform, + liveSyncInfo, + deviceDescriptors, + ); + // if we got here, it means karma exited with exit code 0 (success) + process.exit(0); +} - async canExecute(args: string[]): Promise { - const canExecuteBase = await super.canExecute(args); +export const testCommandDefinition = defineCommand({ + name: "test|ios", + description: "Runs the tests in your project on connected Apple devices.", + options: testCommandOptions, + // Arguments have never been rejected here, only ignored. + arguments: "any", + setup: setupTestCommand, + canExecute: canExecuteTestCommand, + run: runTestCommand, +}); + +export const testAndroidCommandDefinition = defineCommand({ + name: "test|android", + description: + "Runs the tests in your project on connected Android devices or Android emulators.", + options: testCommandOptions, + arguments: "any", + setup: setupTestCommand, + async canExecute( + context: TestCommandContext, + services: ITestCommandServices, + ): Promise { + const canExecuteBase = await canExecuteTestCommand(context, services); if (canExecuteBase) { if ( - (this.$options.release || this.$options.aab) && - !hasValidAndroidSigning(this.$options) + (context.options.release || context.options.aab) && + !hasValidAndroidSigning(context.options) ) { - if (this.$options.release) { - this.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + if (context.options.release) { + services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - this.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + services.$errors.failWithHelp( + ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, + ); } } } return canExecuteBase; - } -} - -class TestIosCommand extends TestCommandBase implements ICommand { - protected platform = "iOS"; - - constructor( - protected $projectData: IProjectData, - protected $testExecutionService: ITestExecutionService, - protected $vitestExecutionService: IVitestExecutionService, - protected $analyticsService: IAnalyticsService, - protected $options: IOptions, - protected $platformEnvironmentRequirements: IPlatformEnvironmentRequirements, - protected $errors: IErrors, - protected $cleanupService: ICleanupService, - protected $liveSyncCommandHelper: ILiveSyncCommandHelper, - protected $devicesService: Mobile.IDevicesService, - protected $migrateController: IMigrateController, - protected $logger: ILogger, - ) { - super(); - } -} - -class TestVisionOSCommand extends TestIosCommand { - protected platform = "visionOS"; - - // The injector discovers dependencies by parsing constructor source text, - // so an inherited constructor would resolve to zero dependencies. - constructor( - protected $projectData: IProjectData, - protected $testExecutionService: ITestExecutionService, - protected $vitestExecutionService: IVitestExecutionService, - protected $analyticsService: IAnalyticsService, - protected $options: IOptions, - protected $platformEnvironmentRequirements: IPlatformEnvironmentRequirements, - protected $errors: IErrors, - protected $cleanupService: ICleanupService, - protected $liveSyncCommandHelper: ILiveSyncCommandHelper, - protected $devicesService: Mobile.IDevicesService, - protected $migrateController: IMigrateController, - protected $logger: ILogger, - ) { - super( - $projectData, - $testExecutionService, - $vitestExecutionService, - $analyticsService, - $options, - $platformEnvironmentRequirements, - $errors, - $cleanupService, - $liveSyncCommandHelper, - $devicesService, - $migrateController, - $logger, - ); - } + }, + run: runTestCommand, +}); - async canExecute(args: string[]): Promise { - this.$projectData.initializeProjectData(); +export const testVisionOSCommandDefinition = defineCommand({ + name: ["test|vision", "test|visionos"], + description: + "Runs the tests in your project in the visionOS Simulator or on connected Apple Vision Pro devices.", + options: testCommandOptions, + arguments: "any", + setup: setupTestCommand, + async canExecute( + context: TestCommandContext, + services: ITestCommandServices, + ): Promise { + services.$projectData.initializeProjectData(); // The Karma runner (v4 line) never supported visionOS — only the Vitest // path can drive it. - if (!this.$vitestExecutionService.isVitestProject(this.$projectData)) { - this.$errors.fail( + if ( + !services.$vitestExecutionService.isVitestProject(services.$projectData) + ) { + services.$errors.fail( "visionOS unit testing requires the Vitest runner. Run '$ ns test init --framework vitest' to configure your project.", ); } - return super.canExecute(args); - } -} + return canExecuteTestCommand(context, services); + }, + run: runTestCommand, +}); + +registerCommand( + testCommandDefinition, + getInjector().createChild([{ provide: TEST_PLATFORM, useValue: "iOS" }]), +); + +registerCommand( + testAndroidCommandDefinition, + getInjector().createChild([{ provide: TEST_PLATFORM, useValue: "android" }]), +); -injector.registerCommand("test|android", TestAndroidCommand); -injector.registerCommand("test|ios", TestIosCommand); -injector.registerCommand("test|vision", TestVisionOSCommand); -injector.registerCommand("test|visionos", TestVisionOSCommand); +registerCommand( + testVisionOSCommandDefinition, + getInjector().createChild([{ provide: TEST_PLATFORM, useValue: "visionOS" }]), +); diff --git a/lib/commands/typings.ts b/lib/commands/typings.ts index d2e296fa91..821559a248 100644 --- a/lib/commands/typings.ts +++ b/lib/commands/typings.ts @@ -4,237 +4,296 @@ import * as path from "path"; import { PromptObject } from "prompts"; import { color } from "../color"; import { IChildProcess, IFileSystem, IHostInfo } from "../common/declarations"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { injector } from "../common/yok"; +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; import { IOptions, IStaticConfig } from "../declarations"; import { IProjectData } from "../definitions/project"; -export class TypingsCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - constructor( - private $logger: ILogger, - private $options: IOptions, - private $fs: IFileSystem, - private $projectData: IProjectData, - private $mobileHelper: Mobile.IMobileHelper, - private $childProcess: IChildProcess, - private $hostInfo: IHostInfo, - private $staticConfig: IStaticConfig, - private $prompter: IPrompter, - ) {} - - public async execute(args: string[]): Promise { - const platform = args[0]; - let result; - if (this.$mobileHelper.isAndroidPlatform(platform)) { - result = await this.handleAndroidTypings(); - } else if (this.$mobileHelper.isiOSPlatform(platform)) { - result = await this.handleiOSTypings(); - } - let typingsFolder = "./typings"; - if (this.$options.copyTo) { - this.$fs.copyFile( - path.resolve(this.$projectData.projectDir, "typings"), - this.$options.copyTo, - ); - typingsFolder = this.$options.copyTo; - } +const typingsCommandOptions = { + aar: stringOption(), + copyTo: stringOption(), + filter: stringOption(), + jar: stringOption(), +} satisfies CommandOptionsSchema; - if (result !== false) { - this.$logger.info( - "Typings have been generated in the following directory:", - typingsFolder, - ); - } +export type TypingsCommandContext = CommandContext< + typeof typingsCommandOptions +>; + +export interface ITypingsCommandServices { + $childProcess: IChildProcess; + $fs: IFileSystem; + $hostInfo: IHostInfo; + $logger: ILogger; + $mobileHelper: Mobile.IMobileHelper; + $options: IOptions; + $projectData: IProjectData; + $prompter: IPrompter; + $staticConfig: IStaticConfig; +} + +export function setupTypingsCommand(): ITypingsCommandServices { + return { + $childProcess: inject("childProcess"), + $fs: inject("fs"), + $hostInfo: inject("hostInfo"), + $logger: inject("logger"), + $mobileHelper: inject("mobileHelper"), + $options: inject("options"), + $projectData: inject("projectData"), + $prompter: inject("prompter"), + $staticConfig: inject("staticConfig"), + }; +} + +async function resolveGradleDependencies( + services: ITypingsCommandServices, + target: string, +) { + const gradleHome = path.resolve( + process.env.GRADLE_USER_HOME ?? path.join(homedir(), `/.gradle`), + ); + const gradleFiles = path.resolve(gradleHome, "caches/modules-2/files-2.1/"); + + if (!services.$fs.exists(gradleFiles)) { + services.$logger.warn("No gradle files found"); + return; } - public async canExecute(args: string[]): Promise { - const platform = args[0]; - this.$mobileHelper.validatePlatformName(platform); - return true; + const pattern = `${target.replaceAll(":", "/")}/**/*.{jar,aar}`; + + const items = []; + for await (const item of glob(pattern, { + cwd: gradleFiles, + })) { + const [group, artifact, version, sha1, file] = item.split(path.sep); + items.push({ + id: sha1 + version, + group, + artifact, + version, + sha1, + file, + path: path.resolve(gradleFiles, item), + }); } - private async resolveGradleDependencies(target: string) { - const gradleHome = path.resolve( - process.env.GRADLE_USER_HOME ?? path.join(homedir(), `/.gradle`), - ); - const gradleFiles = path.resolve(gradleHome, "caches/modules-2/files-2.1/"); + if (items.length === 0) { + services.$logger.warn("No files found"); + return []; + } - if (!this.$fs.exists(gradleFiles)) { - this.$logger.warn("No gradle files found"); - return; - } + services.$logger.clearScreen(); - const pattern = `${target.replaceAll(":", "/")}/**/*.{jar,aar}`; - - const items = []; - for await (const item of glob(pattern, { - cwd: gradleFiles, - })) { - const [group, artifact, version, sha1, file] = item.split(path.sep); - items.push({ - id: sha1 + version, - group, - artifact, - version, - sha1, - file, - path: path.resolve(gradleFiles, item), - }); - } + const choices = await services.$prompter.promptForChoice( + `Select dependencies to generate typings for (${color.greenBright( + target, + )})`, + items + .sort((a, b) => { + if (a.artifact < b.artifact) return -1; + if (a.artifact > b.artifact) return 1; - if (items.length === 0) { - this.$logger.warn("No files found"); - return []; - } + return a.version.localeCompare(b.version, undefined, { + numeric: true, + sensitivity: "base", + }); + }) + .map((item) => { + return { + title: `${color.white(item.group)}:${color.greenBright( + item.artifact, + )}:${color.yellow(item.version)} - ${color.styleText( + ["cyanBright", "bold"], + item.file, + )}`, + value: item.id, + }; + }), + true, + { + optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions + } as Partial, + ); - this.$logger.clearScreen(); - - const choices = await this.$prompter.promptForChoice( - `Select dependencies to generate typings for (${color.greenBright( - target, - )})`, - items - .sort((a, b) => { - if (a.artifact < b.artifact) return -1; - if (a.artifact > b.artifact) return 1; - - return a.version.localeCompare(b.version, undefined, { - numeric: true, - sensitivity: "base", - }); - }) - .map((item) => { - return { - title: `${color.white(item.group)}:${color.greenBright( - item.artifact, - )}:${color.yellow(item.version)} - ${color.styleText( - ["cyanBright", "bold"], - item.file, - )}`, - value: item.id, - }; - }), - true, - { - optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions - } as Partial, - ); + services.$logger.clearScreen(); - this.$logger.clearScreen(); + return items + .filter((item) => choices.includes(item.id)) + .map((item) => item.path); +} - return items - .filter((item) => choices.includes(item.id)) - .map((item) => item.path); - } +async function handleAndroidTypings( + context: TypingsCommandContext, + services: ITypingsCommandServices, +) { + // The gradle targets are positional arguments this command reads off the + // raw argv rather than declaring, so that they keep working alongside the + // --jar and --aar flags. + const targets = services.$options.argv._.slice(2) ?? []; + const paths: string[] = []; - private async handleAndroidTypings() { - const targets = this.$options.argv._.slice(2) ?? []; - const paths: string[] = []; - - if (targets.length) { - for (const target of targets) { - try { - paths.push(...(await this.resolveGradleDependencies(target))); - } catch (err) { - this.$logger.trace( - `Failed to resolve gradle dependencies for target "${target}"`, - err, - ); - } + if (targets.length) { + for (const target of targets) { + try { + paths.push(...(await resolveGradleDependencies(services, target))); + } catch (err) { + services.$logger.trace( + `Failed to resolve gradle dependencies for target "${target}"`, + err, + ); } } + } - if (!paths.length && !(this.$options.jar || this.$options.aar)) { - this.$logger.warn( - [ - "No .jar or .aar file specified. Please specify at least one of the following:", - " - path to .jar file with --jar ", - " - path to .aar file with --aar ", - ].join("\n"), - ); - return false; - } - - this.$fs.ensureDirectoryExists( - path.resolve(this.$projectData.projectDir, "typings", "android"), + if (!paths.length && !(context.options.jar || context.options.aar)) { + services.$logger.warn( + [ + "No .jar or .aar file specified. Please specify at least one of the following:", + " - path to .jar file with --jar ", + " - path to .aar file with --aar ", + ].join("\n"), ); + return false; + } - const dtsGeneratorPath = path.resolve( - this.$projectData.platformsDir, - "android", - "build-tools", - "dts-generator.jar", + services.$fs.ensureDirectoryExists( + path.resolve(services.$projectData.projectDir, "typings", "android"), + ); + + const dtsGeneratorPath = path.resolve( + services.$projectData.platformsDir, + "android", + "build-tools", + "dts-generator.jar", + ); + if (!services.$fs.exists(dtsGeneratorPath)) { + services.$logger.warn( + "No platforms folder found, preparing project now...", + ); + await services.$childProcess.spawnFromEvent( + services.$hostInfo.isWindows ? "ns.cmd" : "ns", + ["prepare", "android"], + "exit", + { stdio: "inherit", shell: services.$hostInfo.isWindows }, ); - if (!this.$fs.exists(dtsGeneratorPath)) { - this.$logger.warn("No platforms folder found, preparing project now..."); - await this.$childProcess.spawnFromEvent( - this.$hostInfo.isWindows ? "ns.cmd" : "ns", - ["prepare", "android"], - "exit", - { stdio: "inherit", shell: this.$hostInfo.isWindows }, - ); + } + + const asArray = (input: string | string[]) => { + if (!input) { + return []; } - const asArray = (input: string | string[]) => { - if (!input) { - return []; - } + if (typeof input === "string") { + return [input]; + } - if (typeof input === "string") { - return [input]; - } + return input; + }; - return input; - }; + const inputs: string[] = [ + ...asArray(context.options.jar), + ...asArray(context.options.aar), + ...paths, + ]; - const inputs: string[] = [ - ...asArray(this.$options.jar), - ...asArray(this.$options.aar), - ...paths, - ]; + await services.$childProcess.spawnFromEvent( + "java", + [ + "-jar", + dtsGeneratorPath, + "-input", + ...inputs, + "-output", + path.resolve(services.$projectData.projectDir, "typings", "android"), + ], + "exit", + { stdio: "inherit" }, + ); +} - await this.$childProcess.spawnFromEvent( - "java", - [ - "-jar", - dtsGeneratorPath, - "-input", - ...inputs, - "-output", - path.resolve(this.$projectData.projectDir, "typings", "android"), - ], - "exit", - { stdio: "inherit" }, - ); +async function handleiOSTypings( + context: TypingsCommandContext, + services: ITypingsCommandServices, +) { + if (context.options.filter !== undefined) { + services.$logger.warn("--filter flag is not supported yet."); } - private async handleiOSTypings() { - if (this.$options.filter !== undefined) { - this.$logger.warn("--filter flag is not supported yet."); - } + services.$fs.ensureDirectoryExists( + path.resolve(services.$projectData.projectDir, "typings", "ios"), + ); + + await services.$childProcess.spawnFromEvent( + "node", + [services.$staticConfig.cliBinPath, "build", "ios"], + "exit", + { + env: { + ...process.env, + TNS_TYPESCRIPT_DECLARATIONS_PATH: path.resolve( + services.$projectData.projectDir, + "typings", + "ios", + ), + }, + stdio: "inherit", + }, + ); +} + +export function canExecuteTypingsCommand( + context: TypingsCommandContext, + services: ITypingsCommandServices, +): boolean { + services.$mobileHelper.validatePlatformName(context.args[0]); + return true; +} - this.$fs.ensureDirectoryExists( - path.resolve(this.$projectData.projectDir, "typings", "ios"), +export async function runTypingsCommand( + context: TypingsCommandContext, + services: ITypingsCommandServices, +): Promise { + const platform = context.args[0]; + let result; + if (services.$mobileHelper.isAndroidPlatform(platform)) { + result = await handleAndroidTypings(context, services); + } else if (services.$mobileHelper.isiOSPlatform(platform)) { + result = await handleiOSTypings(context, services); + } + let typingsFolder = "./typings"; + if (context.options.copyTo) { + services.$fs.copyFile( + path.resolve(services.$projectData.projectDir, "typings"), + context.options.copyTo, ); + typingsFolder = context.options.copyTo; + } - await this.$childProcess.spawnFromEvent( - "node", - [this.$staticConfig.cliBinPath, "build", "ios"], - "exit", - { - env: { - ...process.env, - TNS_TYPESCRIPT_DECLARATIONS_PATH: path.resolve( - this.$projectData.projectDir, - "typings", - "ios", - ), - }, - stdio: "inherit", - }, + if (result !== false) { + services.$logger.info( + "Typings have been generated in the following directory:", + typingsFolder, ); } } -injector.registerCommand("typings", TypingsCommand); +export const typingsCommandDefinition = defineCommand({ + name: "typings", + description: "Generates typings for the native platform APIs.", + options: typingsCommandOptions, + // Only the first argument is read; the rest are gradle targets this command + // takes off the raw argv, so the policy must not reject them. + arguments: "any", + setup: setupTypingsCommand, + canExecute: canExecuteTypingsCommand, + run: runTypingsCommand, +}); + +registerCommand(typingsCommandDefinition); diff --git a/lib/commands/update-platform.ts b/lib/commands/update-platform.ts index da127b4ac5..493185ea21 100644 --- a/lib/commands/update-platform.ts +++ b/lib/commands/update-platform.ts @@ -9,65 +9,101 @@ import { IPlatformEnvironmentRequirements, ICheckEnvironmentRequirementsInput, } from "../definitions/platform"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { IErrors } from "../common/declarations"; -import { injector } from "../common/yok"; +import { CommandContext, defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; -export class UpdatePlatformCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export interface IUpdatePlatformCommandServices { + $errors: IErrors; + $options: IOptions; + $platformEnvironmentRequirements: IPlatformEnvironmentRequirements; + $platformCommandHelper: IPlatformCommandHelper; + $platformValidationService: IPlatformValidationService; + $projectData: IProjectData; +} - constructor( - private $errors: IErrors, - private $options: IOptions, - private $platformEnvironmentRequirements: IPlatformEnvironmentRequirements, - private $platformCommandHelper: IPlatformCommandHelper, - private $platformValidationService: IPlatformValidationService, - private $projectData: IProjectData - ) { - this.$projectData.initializeProjectData(); - } +export function setupUpdatePlatformCommand(): IUpdatePlatformCommandServices { + const services = { + $errors: inject("errors"), + $options: inject("options"), + $platformEnvironmentRequirements: inject( + "platformEnvironmentRequirements", + ), + $platformCommandHelper: inject( + "platformCommandHelper", + ), + $platformValidationService: inject( + "platformValidationService", + ), + $projectData: inject("projectData"), + }; + services.$projectData.initializeProjectData(); - public async execute(args: string[]): Promise { - await this.$platformCommandHelper.updatePlatforms(args, this.$projectData); - } + return services; +} - public async canExecute(args: string[]): Promise { - if (!args || args.length === 0) { - this.$errors.failWithHelp( - "No platform specified. Please specify platforms to update." - ); - } +export async function canExecuteUpdatePlatformCommand( + context: CommandContext, + services: IUpdatePlatformCommandServices, +): Promise { + const args = context.args; + if (!args || args.length === 0) { + services.$errors.failWithHelp( + "No platform specified. Please specify platforms to update.", + ); + } - _.each(args, (arg) => { - const platform = arg.split("@")[0]; - this.$platformValidationService.validatePlatform( - platform, - this.$projectData - ); - }); + _.each(args, (arg) => { + const platform = arg.split("@")[0]; + services.$platformValidationService.validatePlatform( + platform, + services.$projectData, + ); + }); - for (const arg of args) { - const [platform, versionToBeInstalled] = arg.split("@"); - const checkEnvironmentRequirementsInput: ICheckEnvironmentRequirementsInput = { + for (const arg of args) { + const [platform, versionToBeInstalled] = arg.split("@"); + const checkEnvironmentRequirementsInput: ICheckEnvironmentRequirementsInput = + { platform, - options: this.$options, + options: services.$options, }; - // If version is not specified, we know the command will install the latest compatible Android runtime. - // The latest compatible Android runtime supports Java version, so we do not need to pass it here. - // Passing projectDir to the @nativescript/doctor validation will cause it to check the runtime from the current package.json - // So in this case, where we do not want to validate the runtime, just do not pass both projectDir and runtimeVersion. - if (versionToBeInstalled) { - checkEnvironmentRequirementsInput.projectDir = this.$projectData.projectDir; - checkEnvironmentRequirementsInput.runtimeVersion = versionToBeInstalled; - } - - await this.$platformEnvironmentRequirements.checkEnvironmentRequirements( - checkEnvironmentRequirementsInput - ); + // If version is not specified, we know the command will install the latest compatible Android runtime. + // The latest compatible Android runtime supports Java version, so we do not need to pass it here. + // Passing projectDir to the @nativescript/doctor validation will cause it to check the runtime from the current package.json + // So in this case, where we do not want to validate the runtime, just do not pass both projectDir and runtimeVersion. + if (versionToBeInstalled) { + checkEnvironmentRequirementsInput.projectDir = + services.$projectData.projectDir; + checkEnvironmentRequirementsInput.runtimeVersion = versionToBeInstalled; } - return true; + await services.$platformEnvironmentRequirements.checkEnvironmentRequirements( + checkEnvironmentRequirementsInput, + ); } + + return true; } -injector.registerCommand("platform|update", UpdatePlatformCommand); +export async function runUpdatePlatformCommand( + context: CommandContext, + services: IUpdatePlatformCommandServices, +): Promise { + await services.$platformCommandHelper.updatePlatforms( + context.args, + services.$projectData, + ); +} + +export const updatePlatformCommandDefinition = defineCommand({ + name: "platform|update", + description: "Updates the NativeScript runtime for the specified platform.", + arguments: "any", + setup: setupUpdatePlatformCommand, + canExecute: canExecuteUpdatePlatformCommand, + run: runUpdatePlatformCommand, +}); + +registerCommand(updatePlatformCommandDefinition); diff --git a/lib/commands/update.ts b/lib/commands/update.ts index 890b0d66bc..22c0aea933 100644 --- a/lib/commands/update.ts +++ b/lib/commands/update.ts @@ -1,75 +1,113 @@ import { IProjectData } from "../definitions/project"; import { IMigrateController } from "../definitions/migrate"; -import { IOptions } from "../declarations"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { IErrors } from "../common/declarations"; -import { injector } from "../common/yok"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; -export class UpdateCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - public static readonly SHOULD_MIGRATE_PROJECT_MESSAGE = - 'This project is not compatible with the current NativeScript version and cannot be updated. Use "ns migrate" to make your project compatible.'; - public static readonly PROJECT_UP_TO_DATE_MESSAGE = - "This project is up to date."; +export const SHOULD_MIGRATE_PROJECT_MESSAGE = + 'This project is not compatible with the current NativeScript version and cannot be updated. Use "ns migrate" to make your project compatible.'; +export const PROJECT_UP_TO_DATE_MESSAGE = "This project is up to date."; - constructor( - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - private $updateController: IUpdateController, - private $migrateController: IMigrateController, - private $options: IOptions, - private $errors: IErrors, - private $logger: ILogger, - private $projectData: IProjectData, - private $markingModeService: IMarkingModeService - ) { - this.$projectData.initializeProjectData(); - } +const updateCommandOptions = { + markingMode: booleanOption(), + frameworkPath: stringOption(), +} satisfies CommandOptionsSchema; - public async execute(args: string[]): Promise { - if (this.$options.markingMode) { - // ns update --markingMode - await this.$markingModeService.handleMarkingModeFullDeprecation({ - projectDir: this.$projectData.projectDir, - forceSwitch: true, - }); - return; - } +export type UpdateCommandContext = CommandContext; - if ( - !(await this.$updateController.shouldUpdate({ - projectDir: this.$projectData.projectDir, - version: args[0], - })) - ) { - this.$logger.printMarkdown( - `__${UpdateCommand.PROJECT_UP_TO_DATE_MESSAGE}__` - ); - return; - } +export interface IUpdateCommandServices { + $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; + $updateController: IUpdateController; + $migrateController: IMigrateController; + $errors: IErrors; + $logger: ILogger; + $projectData: IProjectData; + $markingModeService: IMarkingModeService; +} - await this.$updateController.update({ - projectDir: this.$projectData.projectDir, - version: args[0], - frameworkPath: this.$options.frameworkPath, - }); +export function setupUpdateCommand(): IUpdateCommandServices { + const services = { + $devicePlatformsConstants: inject( + "devicePlatformsConstants", + ), + $updateController: inject("updateController"), + $migrateController: inject("migrateController"), + $errors: inject("errors"), + $logger: inject("logger"), + $projectData: inject("projectData"), + $markingModeService: inject("markingModeService"), + }; + services.$projectData.initializeProjectData(); + + return services; +} + +export async function canExecuteUpdateCommand( + context: UpdateCommandContext, + services: IUpdateCommandServices, +): Promise { + const shouldMigrate = await services.$migrateController.shouldMigrate({ + projectDir: services.$projectData.projectDir, + platforms: [ + services.$devicePlatformsConstants.Android, + services.$devicePlatformsConstants.iOS, + ], + loose: true, + }); + + if (shouldMigrate) { + services.$errors.fail(SHOULD_MIGRATE_PROJECT_MESSAGE); } - public async canExecute(args: string[]): Promise { - const shouldMigrate = await this.$migrateController.shouldMigrate({ - projectDir: this.$projectData.projectDir, - platforms: [ - this.$devicePlatformsConstants.Android, - this.$devicePlatformsConstants.iOS, - ], - loose: true, - }); + return context.args.length < 2 && services.$projectData.projectDir !== ""; +} - if (shouldMigrate) { - this.$errors.fail(UpdateCommand.SHOULD_MIGRATE_PROJECT_MESSAGE); - } +export async function runUpdateCommand( + context: UpdateCommandContext, + services: IUpdateCommandServices, +): Promise { + if (context.options.markingMode) { + // ns update --markingMode + await services.$markingModeService.handleMarkingModeFullDeprecation({ + projectDir: services.$projectData.projectDir, + forceSwitch: true, + }); + return; + } - return args.length < 2 && this.$projectData.projectDir !== ""; + if ( + !(await services.$updateController.shouldUpdate({ + projectDir: services.$projectData.projectDir, + version: context.args[0], + })) + ) { + services.$logger.printMarkdown(`__${PROJECT_UP_TO_DATE_MESSAGE}__`); + return; } + + await services.$updateController.update({ + projectDir: services.$projectData.projectDir, + version: context.args[0], + frameworkPath: context.options.frameworkPath, + }); } -injector.registerCommand("update", UpdateCommand); +export const updateCommandDefinition = defineCommand({ + name: "update", + description: + "Updates the project with the latest versions of its NativeScript dependencies.", + options: updateCommandOptions, + arguments: "any", + setup: setupUpdateCommand, + canExecute: canExecuteUpdateCommand, + run: runUpdateCommand, +}); + +registerCommand(updateCommandDefinition); diff --git a/lib/commands/widget.ts b/lib/commands/widget.ts index 5d511d9a6e..949fd6ab48 100644 --- a/lib/commands/widget.ts +++ b/lib/commands/widget.ts @@ -1,63 +1,24 @@ import { IProjectConfigService, IProjectData } from "../definitions/project"; import * as fs from "fs"; import * as prompts from "prompts"; -import { ICommandParameter, ICommand } from "../common/definitions/commands"; import { IErrors } from "../common/declarations"; import * as path from "path"; import * as plist from "plist"; -import { injector } from "../common/yok"; +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; +import { registerCommand } from "../common/services/command-definition-adapter"; import { capitalizeFirstLetter } from "../common/utils"; import { EOL } from "os"; -export class WidgetCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - +class IOSWidgetGenerator { constructor( protected $projectData: IProjectData, protected $projectConfigService: IProjectConfigService, protected $logger: ILogger, protected $errors: IErrors, - ) { - this.$projectData.initializeProjectData(); - } - - public async execute(args: string[]): Promise { - this.failWithUsage(); - - return Promise.resolve(); - } - - protected failWithUsage(): void { - this.$errors.failWithHelp("Usage: ns widget ios"); - } - public async canExecute(args: string[]): Promise { - this.failWithUsage(); - return false; - } - - protected getIosSourcePathBase() { - const resources = this.$projectData.getAppResourcesDirectoryPath(); - return path.join(resources, "iOS", "src"); - } -} -export class WidgetIOSCommand extends WidgetCommand { - constructor( - $projectData: IProjectData, - $projectConfigService: IProjectConfigService, - $logger: ILogger, - $errors: IErrors, - ) { - super($projectData, $projectConfigService, $logger, $errors); - } - public async canExecute(args: string[]): Promise { - return true; - } + ) {} - public async execute(args: string[]): Promise { - this.startPrompt(args); - } - - private async startPrompt(args: string[]) { + public async startPrompt(args: string[]) { let result = await prompts.prompt({ type: "text", name: "name", @@ -934,6 +895,37 @@ declare class AppleWidgetUtils extends NSObject { } } +interface IWidgetCommandServices { + generator: IOSWidgetGenerator; +} + // No flat "widget": the subcommand registration below synthesizes the parent -// dispatcher, and WidgetCommand serves as WidgetIOSCommand's base class. -injector.registerCommand(["widget|ios"], WidgetIOSCommand); +// dispatcher. +export const widgetIOSCommandDefinition = defineCommand({ + name: "widget|ios", + description: "Generates an iOS widget extension for the project.", + arguments: "any", + setup(): IWidgetCommandServices { + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + return { + generator: new IOSWidgetGenerator( + $projectData, + inject("projectConfigService"), + inject("logger"), + inject("errors"), + ), + }; + }, + canExecute(): boolean { + return true; + }, + run(context, services: IWidgetCommandServices): void { + // Not awaited: the command has always reported completion before the + // prompts it opens are answered. + services.generator.startPrompt(context.args); + }, +}); + +registerCommand(widgetIOSCommandDefinition); diff --git a/lib/common/command-params.ts b/lib/common/command-params.ts index 94ae68ec4b..a98cae3c02 100644 --- a/lib/common/command-params.ts +++ b/lib/common/command-params.ts @@ -5,6 +5,10 @@ import { import { IInjector } from "./definitions/yok"; import { injector } from "./yok"; +/** + * @deprecated Positional arguments of a defineCommand definition are declared with + * `arguments`. Kept for commands still implementing ICommand. + */ export class StringCommandParameter implements ICommandParameter { public mandatory = false; public errorMessage: string; @@ -25,6 +29,10 @@ export class StringCommandParameter implements ICommandParameter { } injector.register("stringParameter", StringCommandParameter); +/** + * @deprecated Use a required `arguments` spec with an errorMessage instead. Kept for + * commands still implementing ICommand. + */ export class StringParameterBuilder implements IStringParameterBuilder { constructor(private $injector: IInjector) {} diff --git a/lib/common/commands/analytics.ts b/lib/common/commands/analytics.ts index 95d91db0fa..a3ab0e1e01 100644 --- a/lib/common/commands/analytics.ts +++ b/lib/common/commands/analytics.ts @@ -1,103 +1,132 @@ -import { IOptions } from "../../declarations"; -import { ICommandParameter, ICommand } from "../definitions/commands"; -import { IErrors, IAnalyticsService } from "../declarations"; -import { injector } from "../yok"; +import { IAnalyticsService } from "../declarations"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, +} from "../define-command"; +import { inject, InjectionToken } from "../di"; +import { registerCommand } from "../services/command-definition-adapter"; +import { getInjector } from "../yok"; -export class AnalyticsCommandParameter implements ICommandParameter { - constructor(private $errors: IErrors) {} - mandatory = false; - async validate(validationValue: string): Promise { - const val = validationValue || ""; - switch (val.toLowerCase()) { - case "enable": - case "disable": - case "status": - case "": - return true; - default: - this.$errors.failWithHelp( - `The value '${validationValue}' is not valid. Valid values are 'enable', 'disable' and 'status'.` - ); - } - } +/** Which reporting the registration configures. */ +interface IAnalyticsSetting { + /** The static config property naming the setting the CLI stores it under. */ + staticConfigKey: keyof Pick< + Config.IStaticConfig, + "TRACK_FEATURE_USAGE_SETTING_NAME" | "ERROR_REPORT_SETTING_NAME" + >; + humanReadableSettingName: string; } -class AnalyticsCommand implements ICommand { - constructor( - protected $analyticsService: IAnalyticsService, - private $logger: ILogger, - private $errors: IErrors, - private $options: IOptions, - private settingName: string, - private humanReadableSettingName: string - ) {} +const ANALYTICS_SETTING = new InjectionToken( + "analyticsSetting", +); - public allowedParameters = [new AnalyticsCommandParameter(this.$errors)]; - public disableAnalytics = true; +export const analyticsCommandOptions = { + json: booleanOption(), +} satisfies CommandOptionsSchema; - public async execute(args: string[]): Promise { - const arg = args[0] || ""; - switch (arg.toLowerCase()) { - case "enable": - await this.$analyticsService.setStatus(this.settingName, true); - // TODO(Analytics): await this.$analyticsService.track(this.settingName, "enabled"); - this.$logger.info(`${this.humanReadableSettingName} is now enabled.`); - break; - case "disable": - // TODO(Analytics): await this.$analyticsService.track(this.settingName, "disabled"); - await this.$analyticsService.setStatus(this.settingName, false); - this.$logger.info(`${this.humanReadableSettingName} is now disabled.`); - break; - case "status": - case "": - this.$logger.info( - await this.$analyticsService.getStatusMessage( - this.settingName, - this.$options.json, - this.humanReadableSettingName - ) - ); - break; - } - } +export type AnalyticsCommandContext = CommandContext< + typeof analyticsCommandOptions +>; + +export interface IAnalyticsCommandServices { + settingName: string; + humanReadableSettingName: string; + $analyticsService: IAnalyticsService; + $logger: ILogger; +} + +export function setupAnalyticsCommand(): IAnalyticsCommandServices { + const setting = inject(ANALYTICS_SETTING); + const $staticConfig = inject("staticConfig"); + + return { + settingName: $staticConfig[setting.staticConfigKey], + humanReadableSettingName: setting.humanReadableSettingName, + $analyticsService: inject("analyticsService"), + $logger: inject("logger"), + }; } -export class UsageReportingCommand extends AnalyticsCommand { - constructor( - protected $analyticsService: IAnalyticsService, - $logger: ILogger, - $errors: IErrors, - $options: IOptions, - $staticConfig: Config.IStaticConfig - ) { - super( - $analyticsService, - $logger, - $errors, - $options, - $staticConfig.TRACK_FEATURE_USAGE_SETTING_NAME, - "Usage reporting" - ); +export function validateAnalyticsState(value: string): boolean | string { + switch ((value || "").toLowerCase()) { + case "enable": + case "disable": + case "status": + case "": + return true; + default: + return `The value '${value}' is not valid. Valid values are 'enable', 'disable' and 'status'.`; } } -injector.registerCommand("usage-reporting", UsageReportingCommand); -export class ErrorReportingCommand extends AnalyticsCommand { - constructor( - protected $analyticsService: IAnalyticsService, - $logger: ILogger, - $errors: IErrors, - $options: IOptions, - $staticConfig: Config.IStaticConfig - ) { - super( - $analyticsService, - $logger, - $errors, - $options, - $staticConfig.ERROR_REPORT_SETTING_NAME, - "Error reporting" - ); +export async function runAnalyticsCommand( + context: AnalyticsCommandContext, + services: IAnalyticsCommandServices, +): Promise { + const arg = context.args[0] || ""; + switch (arg.toLowerCase()) { + case "enable": + await services.$analyticsService.setStatus(services.settingName, true); + // TODO(Analytics): await this.$analyticsService.track(this.settingName, "enabled"); + services.$logger.info( + `${services.humanReadableSettingName} is now enabled.`, + ); + break; + case "disable": + // TODO(Analytics): await this.$analyticsService.track(this.settingName, "disabled"); + await services.$analyticsService.setStatus(services.settingName, false); + services.$logger.info( + `${services.humanReadableSettingName} is now disabled.`, + ); + break; + case "status": + case "": + services.$logger.info( + await services.$analyticsService.getStatusMessage( + services.settingName, + context.options.json, + services.humanReadableSettingName, + ), + ); + break; } } -injector.registerCommand("error-reporting", ErrorReportingCommand); + +export const analyticsCommandDefinition = defineCommand({ + name: "usage-reporting", + description: "Configures anonymous reporting for the CLI.", + options: analyticsCommandOptions, + arguments: [{ name: "state", validate: validateAnalyticsState }], + disableAnalytics: true, + setup: setupAnalyticsCommand, + run: runAnalyticsCommand, +}); + +const analyticsCommands: [string, IAnalyticsSetting][] = [ + [ + "usage-reporting", + { + staticConfigKey: "TRACK_FEATURE_USAGE_SETTING_NAME", + humanReadableSettingName: "Usage reporting", + }, + ], + [ + "error-reporting", + { + staticConfigKey: "ERROR_REPORT_SETTING_NAME", + humanReadableSettingName: "Error reporting", + }, + ], +]; + +for (const [name, setting] of analyticsCommands) { + registerCommand( + { ...analyticsCommandDefinition, name }, + getInjector().createChild([ + { provide: ANALYTICS_SETTING, useValue: setting }, + ]), + ); +} diff --git a/lib/common/commands/autocompletion.ts b/lib/common/commands/autocompletion.ts index 926c1a613c..4a916995d4 100644 --- a/lib/common/commands/autocompletion.ts +++ b/lib/common/commands/autocompletion.ts @@ -1,102 +1,108 @@ import * as helpers from "../helpers"; -import { ICommandParameter, ICommand } from "../definitions/commands"; import { IAutoCompletionService } from "../declarations"; -import { injector } from "../yok"; +import { defineCommand } from "../define-command"; +import { inject } from "../di"; +import { registerCommand } from "../services/command-definition-adapter"; -export class AutoCompleteCommand implements ICommand { - constructor( - private $autoCompletionService: IAutoCompletionService, - private $logger: ILogger, - private $prompter: IPrompter - ) {} +export interface IAutoCompleteCommandServices { + $autoCompletionService: IAutoCompletionService; + $logger: ILogger; +} - public disableAnalytics = true; - public allowedParameters: ICommandParameter[] = []; +export function injectAutoCompleteCommandServices(): IAutoCompleteCommandServices { + return { + $autoCompletionService: inject( + "autoCompletionService", + ), + $logger: inject("logger"), + }; +} - public async execute(args: string[]): Promise { +export const autoCompleteCommandDefinition = defineCommand({ + name: "autocomplete|*default", + description: "Prompts to enable command-line completion for the CLI.", + arguments: "none", + disableAnalytics: true, + setup: () => ({ + ...injectAutoCompleteCommandServices(), + $prompter: inject("prompter"), + }), + async run(context, services): Promise { if (helpers.isInteractive()) { - if (this.$autoCompletionService.isAutoCompletionEnabled()) { - if (this.$autoCompletionService.isObsoleteAutoCompletionEnabled()) { + if (services.$autoCompletionService.isAutoCompletionEnabled()) { + if (services.$autoCompletionService.isObsoleteAutoCompletionEnabled()) { // obsolete autocompletion is enabled, update it to the new one: - await this.$autoCompletionService.enableAutoCompletion(); + await services.$autoCompletionService.enableAutoCompletion(); } else { - this.$logger.info("Autocompletion is already enabled"); + services.$logger.info("Autocompletion is already enabled"); } } else { - this.$logger.info( - "If you are using bash or zsh, you can enable command-line completion." + services.$logger.info( + "If you are using bash or zsh, you can enable command-line completion.", ); const message = "Do you want to enable it now?"; - const autoCompetionStatus = await this.$prompter.confirm( + const autoCompetionStatus = await services.$prompter.confirm( message, - () => true + () => true, ); if (autoCompetionStatus) { - await this.$autoCompletionService.enableAutoCompletion(); + await services.$autoCompletionService.enableAutoCompletion(); } else { // make sure we've removed all autocompletion code from all shell profiles - this.$autoCompletionService.disableAutoCompletion(); + services.$autoCompletionService.disableAutoCompletion(); } } } - } -} -injector.registerCommand("autocomplete|*default", AutoCompleteCommand); - -export class DisableAutoCompleteCommand implements ICommand { - constructor( - private $autoCompletionService: IAutoCompletionService, - private $logger: ILogger - ) {} + }, +}); - public disableAnalytics = true; - public allowedParameters: ICommandParameter[] = []; - - public async execute(args: string[]): Promise { - if (this.$autoCompletionService.isAutoCompletionEnabled()) { - this.$autoCompletionService.disableAutoCompletion(); +export const disableAutoCompleteCommandDefinition = defineCommand({ + name: "autocomplete|disable", + description: "Disables command-line completion for the CLI.", + arguments: "none", + disableAnalytics: true, + setup: injectAutoCompleteCommandServices, + async run(context, services): Promise { + if (services.$autoCompletionService.isAutoCompletionEnabled()) { + services.$autoCompletionService.disableAutoCompletion(); } else { - this.$logger.info("Autocompletion is already disabled."); + services.$logger.info("Autocompletion is already disabled."); } - } -} -injector.registerCommand("autocomplete|disable", DisableAutoCompleteCommand); - -export class EnableAutoCompleteCommand implements ICommand { - constructor( - private $autoCompletionService: IAutoCompletionService, - private $logger: ILogger - ) {} + }, +}); - public disableAnalytics = true; - public allowedParameters: ICommandParameter[] = []; - - public async execute(args: string[]): Promise { - if (this.$autoCompletionService.isAutoCompletionEnabled()) { - this.$logger.info("Autocompletion is already enabled."); +export const enableAutoCompleteCommandDefinition = defineCommand({ + name: "autocomplete|enable", + description: "Enables command-line completion for the CLI.", + arguments: "none", + disableAnalytics: true, + setup: injectAutoCompleteCommandServices, + async run(context, services): Promise { + if (services.$autoCompletionService.isAutoCompletionEnabled()) { + services.$logger.info("Autocompletion is already enabled."); } else { - await this.$autoCompletionService.enableAutoCompletion(); + await services.$autoCompletionService.enableAutoCompletion(); } - } -} -injector.registerCommand("autocomplete|enable", EnableAutoCompleteCommand); - -export class AutoCompleteStatusCommand implements ICommand { - constructor( - private $autoCompletionService: IAutoCompletionService, - private $logger: ILogger - ) {} + }, +}); - public disableAnalytics = true; - public allowedParameters: ICommandParameter[] = []; - - public async execute(args: string[]): Promise { - if (this.$autoCompletionService.isAutoCompletionEnabled()) { - this.$logger.info("Autocompletion is enabled."); +export const autoCompleteStatusCommandDefinition = defineCommand({ + name: "autocomplete|status", + description: "Prints whether command-line completion is enabled.", + arguments: "none", + disableAnalytics: true, + setup: injectAutoCompleteCommandServices, + async run(context, services): Promise { + if (services.$autoCompletionService.isAutoCompletionEnabled()) { + services.$logger.info("Autocompletion is enabled."); } else { - this.$logger.info("Autocompletion is disabled."); + services.$logger.info("Autocompletion is disabled."); } - } -} -injector.registerCommand("autocomplete|status", AutoCompleteStatusCommand); + }, +}); + +registerCommand(autoCompleteCommandDefinition); +registerCommand(disableAutoCompleteCommandDefinition); +registerCommand(enableAutoCompleteCommandDefinition); +registerCommand(autoCompleteStatusCommandDefinition); diff --git a/lib/common/commands/device/device-log-stream.ts b/lib/common/commands/device/device-log-stream.ts index 313e607314..971d1d524a 100644 --- a/lib/common/commands/device/device-log-stream.ts +++ b/lib/common/commands/device/device-log-stream.ts @@ -1,50 +1,79 @@ -import { IOptions } from "../../../declarations"; -import { ICommandParameter, ICommand } from "../../definitions/commands"; import { ICleanupService } from "../../../definitions/cleanup-service"; import { IErrors } from "../../declarations"; -import { injector } from "../../yok"; - -export class OpenDeviceLogStreamCommand implements ICommand { - private static NOT_SPECIFIED_DEVICE_ERROR_MESSAGE = - "More than one device found. Specify device explicitly."; - - constructor( - private $devicesService: Mobile.IDevicesService, - private $errors: IErrors, - private $commandsService: ICommandsService, - private $options: IOptions, - private $deviceLogProvider: Mobile.IDeviceLogProvider, - private $loggingLevels: Mobile.ILoggingLevels, - $iOSSimulatorLogProvider: Mobile.IiOSSimulatorLogProvider, - $cleanupService: ICleanupService - ) { - $iOSSimulatorLogProvider.setShouldDispose(false); - $cleanupService.setShouldDispose(false); - } +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; +import { registerCommand } from "../../services/command-definition-adapter"; + +const NOT_SPECIFIED_DEVICE_ERROR_MESSAGE = + "More than one device found. Specify device explicitly."; - allowedParameters: ICommandParameter[] = []; +const openDeviceLogStreamCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; - public async execute(args: string[]): Promise { - this.$deviceLogProvider.setLogLevel(this.$loggingLevels.full); +export type OpenDeviceLogStreamCommandContext = CommandContext< + typeof openDeviceLogStreamCommandOptions +>; - await this.$devicesService.initialize({ - deviceId: this.$options.device, - skipInferPlatform: true, - }); +export interface IOpenDeviceLogStreamCommandServices { + $commandsService: ICommandsService; + $deviceLogProvider: Mobile.IDeviceLogProvider; + $devicesService: Mobile.IDevicesService; + $errors: IErrors; + $loggingLevels: Mobile.ILoggingLevels; +} - if (this.$devicesService.deviceCount > 1) { - await this.$commandsService.tryExecuteCommand("device", []); - this.$errors.failWithHelp( - OpenDeviceLogStreamCommand.NOT_SPECIFIED_DEVICE_ERROR_MESSAGE - ); - } +export function setupOpenDeviceLogStreamCommand(): IOpenDeviceLogStreamCommandServices { + // The log stream is the command's whole output, so neither the simulator log + // provider nor the cleanup process may be torn down while it is open. The + // legacy command did this from its constructor, which ran before anything + // looked at the command line. + inject( + "iOSSimulatorLogProvider", + ).setShouldDispose(false); + inject("cleanupService").setShouldDispose(false); + + return { + $commandsService: inject("commandsService"), + $deviceLogProvider: inject("deviceLogProvider"), + $devicesService: inject("devicesService"), + $errors: inject("errors"), + $loggingLevels: inject("loggingLevels"), + }; +} - const action = (device: Mobile.IiOSDevice) => device.openDeviceLogStream(); - await this.$devicesService.execute(action); +export async function runOpenDeviceLogStreamCommand( + context: OpenDeviceLogStreamCommandContext, + services: IOpenDeviceLogStreamCommandServices, +): Promise { + services.$deviceLogProvider.setLogLevel(services.$loggingLevels.full); + + await services.$devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + + if (services.$devicesService.deviceCount > 1) { + await services.$commandsService.tryExecuteCommand("device", []); + services.$errors.failWithHelp(NOT_SPECIFIED_DEVICE_ERROR_MESSAGE); } + + const action = (device: Mobile.IiOSDevice) => device.openDeviceLogStream(); + await services.$devicesService.execute(action); } -injector.registerCommand( - ["device|log", "devices|log"], - OpenDeviceLogStreamCommand -); +export const openDeviceLogStreamCommandDefinition = defineCommand({ + name: ["device|log", "devices|log"], + description: "Opens the device log stream for a connected device.", + options: openDeviceLogStreamCommandOptions, + arguments: "none", + setup: setupOpenDeviceLogStreamCommand, + run: runOpenDeviceLogStreamCommand, +}); + +registerCommand(openDeviceLogStreamCommandDefinition); diff --git a/lib/common/commands/device/get-file.ts b/lib/common/commands/device/get-file.ts index ebe632ae52..46827575a0 100644 --- a/lib/common/commands/device/get-file.ts +++ b/lib/common/commands/device/get-file.ts @@ -1,60 +1,82 @@ import { IProjectData } from "../../../definitions/project"; -import { IOptions } from "../../../declarations"; -import { ICommandParameter, ICommand } from "../../definitions/commands"; import { IErrors } from "../../declarations"; -import { injector } from "../../yok"; - -export class GetFileCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $stringParameter: ICommandParameter, - private $projectData: IProjectData, - private $errors: IErrors, - private $options: IOptions - ) {} - - public allowedParameters: ICommandParameter[] = [ - this.$stringParameter, - this.$stringParameter, - ]; - - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, - skipInferPlatform: true, - }); - let appIdentifier = args[1]; - - if (!appIdentifier) { - try { - this.$projectData.initializeProjectData(); - } catch (err) { - // ignore the error - } - if (!this.$projectData.projectIdentifiers) { - this.$errors.fail( - "Please enter application identifier or execute this command in project." - ); - } - } +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; +import { registerCommand } from "../../services/command-definition-adapter"; + +const getFileCommandOptions = { + device: stringOption(), + file: stringOption(), +} satisfies CommandOptionsSchema; + +export type GetFileCommandContext = CommandContext< + typeof getFileCommandOptions +>; + +export interface IGetFileCommandServices { + $devicesService: Mobile.IDevicesService; + $errors: IErrors; + $projectData: IProjectData; +} + +export function setupGetFileCommand(): IGetFileCommandServices { + return { + $devicesService: inject("devicesService"), + $errors: inject("errors"), + $projectData: inject("projectData"), + }; +} + +export async function runGetFileCommand( + context: GetFileCommandContext, + services: IGetFileCommandServices, +): Promise { + await services.$devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + let appIdentifier = context.args[1]; - const action = async (device: Mobile.IDevice) => { - appIdentifier = - appIdentifier || - this.$projectData.projectIdentifiers[ - device.deviceInfo.platform.toLowerCase() - ]; - await device.fileSystem.getFile( - args[0], - appIdentifier, - this.$options.file + if (!appIdentifier) { + try { + services.$projectData.initializeProjectData(); + } catch (err) { + // ignore the error + } + if (!services.$projectData.projectIdentifiers) { + services.$errors.fail( + "Please enter application identifier or execute this command in project.", ); - }; - await this.$devicesService.execute(action); + } } + + const action = async (device: Mobile.IDevice) => { + appIdentifier = + appIdentifier || + services.$projectData.projectIdentifiers[ + device.deviceInfo.platform.toLowerCase() + ]; + await device.fileSystem.getFile( + context.args[0], + appIdentifier, + context.options.file, + ); + }; + await services.$devicesService.execute(action); } -injector.registerCommand( - ["device|get-file", "devices|get-file"], - GetFileCommand -); +export const getFileCommandDefinition = defineCommand({ + name: ["device|get-file", "devices|get-file"], + description: "Downloads a file from a connected device.", + options: getFileCommandOptions, + arguments: [{ name: "path" }, { name: "appId" }], + setup: setupGetFileCommand, + run: runGetFileCommand, +}); + +registerCommand(getFileCommandDefinition); diff --git a/lib/common/commands/device/list-applications.ts b/lib/common/commands/device/list-applications.ts index 567c0987ba..fc11a67a7b 100644 --- a/lib/common/commands/device/list-applications.ts +++ b/lib/common/commands/device/list-applications.ts @@ -1,45 +1,69 @@ +import * as _ from "lodash"; import { EOL } from "os"; import * as util from "util"; -import * as _ from "lodash"; -import { IOptions } from "../../../declarations"; -import { ICommandParameter, ICommand } from "../../definitions/commands"; -import { injector } from "../../yok"; - -export class ListApplicationsCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $logger: ILogger, - private $options: IOptions - ) {} - - allowedParameters: ICommandParameter[] = []; - - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, - skipInferPlatform: true, - }); - const output: string[] = []; - - const action = async (device: Mobile.IDevice) => { - const applications = await device.applicationManager.getInstalledApplications(); - output.push( - util.format( - "%s=====Installed applications on device with UDID '%s' are:", - EOL, - device.deviceInfo.identifier - ) - ); - _.each(applications, (applicationId: string) => - output.push(applicationId) - ); - }; - await this.$devicesService.execute(action); - - this.$logger.info(output.join(EOL)); - } +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; +import { registerCommand } from "../../services/command-definition-adapter"; + +const listApplicationsCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; + +export type ListApplicationsCommandContext = CommandContext< + typeof listApplicationsCommandOptions +>; + +export interface IListApplicationsCommandServices { + $devicesService: Mobile.IDevicesService; + $logger: ILogger; +} + +export function setupListApplicationsCommand(): IListApplicationsCommandServices { + return { + $devicesService: inject("devicesService"), + $logger: inject("logger"), + }; } -injector.registerCommand( - ["device|list-applications", "devices|list-applications"], - ListApplicationsCommand -); + +export async function runListApplicationsCommand( + context: ListApplicationsCommandContext, + services: IListApplicationsCommandServices, +): Promise { + await services.$devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + const output: string[] = []; + + const action = async (device: Mobile.IDevice) => { + const applications = + await device.applicationManager.getInstalledApplications(); + output.push( + util.format( + "%s=====Installed applications on device with UDID '%s' are:", + EOL, + device.deviceInfo.identifier, + ), + ); + _.each(applications, (applicationId: string) => output.push(applicationId)); + }; + await services.$devicesService.execute(action); + + services.$logger.info(output.join(EOL)); +} + +export const listApplicationsCommandDefinition = defineCommand({ + name: ["device|list-applications", "devices|list-applications"], + description: "Lists the installed applications on all connected devices.", + options: listApplicationsCommandOptions, + arguments: "none", + setup: setupListApplicationsCommand, + run: runListApplicationsCommand, +}); + +registerCommand(listApplicationsCommandDefinition); diff --git a/lib/common/commands/device/list-devices.ts b/lib/common/commands/device/list-devices.ts index d4b5dfb9a7..e3c322f3ad 100644 --- a/lib/common/commands/device/list-devices.ts +++ b/lib/common/commands/device/list-devices.ts @@ -1,185 +1,222 @@ -import { createTable, formatListOfNames } from "../../helpers"; +import { color } from "../../../color"; import { DeviceConnectionType } from "../../../constants"; -import { IOptions } from "../../../declarations"; -import { ICommand, ICommandParameter } from "../../definitions/commands"; import { IErrors } from "../../declarations"; -import { IInjector } from "../../definitions/yok"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, +} from "../../define-command"; +import { inject, InjectionToken } from "../../di"; +import { createTable, formatListOfNames } from "../../helpers"; +import { registerCommandDefinition } from "../../services/command-definition-adapter"; import { injector } from "../../yok"; -import { color } from "../../../color"; -export class ListDevicesCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $errors: IErrors, - private $emulatorHelper: Mobile.IEmulatorHelper, - private $logger: ILogger, - private $stringParameter: ICommandParameter, - private $mobileHelper: Mobile.IMobileHelper, - private $options: IOptions - ) {} - - public allowedParameters = [this.$stringParameter]; - - public async execute(args: string[]): Promise { - const devices: { - available?: any[]; - devices: any[]; - } = { - devices: [], - }; +/** Which `$devicePlatformsConstants` entry this registration lists. */ +const LIST_DEVICES_PLATFORM = new InjectionToken<"iOS" | "Android">( + "listDevicesCommandPlatform", +); - if (this.$options.availableDevices) { - const platform = this.$mobileHelper.normalizePlatformName(args[0]); - if (!platform && args[0]) { - this.$errors.fail( - `${ - args[0] - } is not a valid device platform. The valid platforms are ${formatListOfNames( - this.$mobileHelper.platformNames - )}` - ); - } - - const availableEmulatorsOutput = await this.$devicesService.getEmulatorImages( - { platform } - ); - const emulators = this.$emulatorHelper.getEmulatorsFromAvailableEmulatorsOutput( - availableEmulatorsOutput - ); - devices.available = emulators; +const listDevicesCommandOptions = { + availableDevices: booleanOption(), + json: booleanOption(), +} satisfies CommandOptionsSchema; + +export type ListDevicesCommandContext = CommandContext< + typeof listDevicesCommandOptions +>; + +export interface IListDevicesCommandServices { + $devicesService: Mobile.IDevicesService; + $emulatorHelper: Mobile.IEmulatorHelper; + $errors: IErrors; + $logger: ILogger; + $mobileHelper: Mobile.IMobileHelper; +} - if (!this.$options.json) { - this.$logger.info(color.bold("\n Available emulators")); - this.printEmulators(emulators); - } - } +export function setupListDevicesCommand(): IListDevicesCommandServices { + return { + $devicesService: inject("devicesService"), + $emulatorHelper: inject("emulatorHelper"), + $errors: inject("errors"), + $logger: inject("logger"), + $mobileHelper: inject("mobileHelper"), + }; +} - let index = 1; - await this.$devicesService.initialize({ - platform: args[0], - deviceId: null, - skipInferPlatform: true, - skipDeviceDetectionInterval: true, - skipEmulatorStart: true, - fullDiscovery: true, - }); - - if (!this.$options.json) { - this.$logger.info(color.bold("\n Connected devices & emulators")); - } +function printEmulators( + services: IListDevicesCommandServices, + emulators: Mobile.IDeviceInfo[], +): void { + const table: any = createTable( + [ + "Device Name", + "Platform", + "Version", + "Device Identifier", + "Image Identifier", + // "Error Help", + ], + [], + ); + for (const info of emulators) { + table.push([ + info.displayName, + info.platform, + info.version, + info.identifier || "", + info.imageIdentifier || "", + // info.errorHelp || "", + ]); + } - const table: any = createTable( - [ - "#", - "Device Name", - "Platform", - "Device Identifier", - "Type", - "Status", - "Connection Type", - ], - [] - ); - let action: (_device: Mobile.IDevice) => Promise; - if (this.$options.json) { - action = async (device) => { - devices.devices.push(device.deviceInfo); - }; - } else { - action = async (device) => { - table.push([ - (index++).toString(), - device.deviceInfo.displayName || "", - device.deviceInfo.platform || "", - device.deviceInfo.identifier || "", - device.deviceInfo.type || "", - device.deviceInfo.status || "", - device.deviceInfo.connectionTypes - .map((type) => DeviceConnectionType[type]) - .join(", "), - ]); - }; + services.$logger.info(table.toString()); +} + +export async function runListDevicesCommand( + context: ListDevicesCommandContext, + services: IListDevicesCommandServices, + platformFilter: string, +): Promise { + const devices: { + available?: any[]; + devices: any[]; + } = { + devices: [], + }; + + if (context.options.availableDevices) { + const platform = + services.$mobileHelper.normalizePlatformName(platformFilter); + if (!platform && platformFilter) { + services.$errors.fail( + `${platformFilter} is not a valid device platform. The valid platforms are ${formatListOfNames( + services.$mobileHelper.platformNames, + )}`, + ); } - await this.$devicesService.execute(action, undefined, { - allowNoDevices: true, - }); + const availableEmulatorsOutput = + await services.$devicesService.getEmulatorImages({ platform }); + const emulators = + services.$emulatorHelper.getEmulatorsFromAvailableEmulatorsOutput( + availableEmulatorsOutput, + ); + devices.available = emulators; - if (this.$options.json) { - return this.$logger.info(JSON.stringify(devices, null, 2)); + if (!context.options.json) { + services.$logger.info(color.bold("\n Available emulators")); + printEmulators(services, emulators); } + } - if (table.length) { - this.$logger.info(table.toString()); - } + let index = 1; + await services.$devicesService.initialize({ + platform: platformFilter, + deviceId: null, + skipInferPlatform: true, + skipDeviceDetectionInterval: true, + skipEmulatorStart: true, + fullDiscovery: true, + }); + + if (!context.options.json) { + services.$logger.info(color.bold("\n Connected devices & emulators")); } - private printEmulators(emulators: Mobile.IDeviceInfo[]) { - const table: any = createTable( - [ - "Device Name", - "Platform", - "Version", - "Device Identifier", - "Image Identifier", - // "Error Help", - ], - [] - ); - for (const info of emulators) { + const table: any = createTable( + [ + "#", + "Device Name", + "Platform", + "Device Identifier", + "Type", + "Status", + "Connection Type", + ], + [], + ); + let action: (_device: Mobile.IDevice) => Promise; + if (context.options.json) { + action = async (device) => { + devices.devices.push(device.deviceInfo); + }; + } else { + action = async (device) => { table.push([ - info.displayName, - info.platform, - info.version, - info.identifier || "", - info.imageIdentifier || "", - // info.errorHelp || "", + (index++).toString(), + device.deviceInfo.displayName || "", + device.deviceInfo.platform || "", + device.deviceInfo.identifier || "", + device.deviceInfo.type || "", + device.deviceInfo.status || "", + device.deviceInfo.connectionTypes + .map((type) => DeviceConnectionType[type]) + .join(", "), ]); - } - - this.$logger.info(table.toString()); + }; } -} -injector.registerCommand(["device|*list", "devices|*list"], ListDevicesCommand); + await services.$devicesService.execute(action, undefined, { + allowNoDevices: true, + }); -class ListAndroidDevicesCommand implements ICommand { - constructor( - private $injector: IInjector, - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants - ) {} - - public allowedParameters: ICommandParameter[] = []; + if (context.options.json) { + return services.$logger.info(JSON.stringify(devices, null, 2)); + } - public async execute(args: string[]): Promise { - const listDevicesCommand: ICommand = this.$injector.resolve( - ListDevicesCommand - ); - const platform = this.$devicePlatformsConstants.Android; - await listDevicesCommand.execute([platform]); + if (table.length) { + services.$logger.info(table.toString()); } } -injector.registerCommand( - ["device|android", "devices|android"], - ListAndroidDevicesCommand -); - -class ListiOSDevicesCommand implements ICommand { - constructor( - private $injector: IInjector, - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants - ) {} - - public allowedParameters: ICommandParameter[] = []; +export const listDevicesCommandDefinition = defineCommand({ + name: ["device|*list", "devices|*list"], + description: "Lists the connected devices and emulators.", + options: listDevicesCommandOptions, + arguments: [{ name: "platform" }], + setup: setupListDevicesCommand, + run(context, services): Promise { + return runListDevicesCommand(context, services, context.args[0]); + }, +}); + +registerCommandDefinition(listDevicesCommandDefinition); + +interface IListPlatformDevicesCommandServices extends IListDevicesCommandServices { + platform: string; +} - public async execute(args: string[]): Promise { - const listDevicesCommand: ICommand = this.$injector.resolve( - ListDevicesCommand +export const listPlatformDevicesCommandDefinition = defineCommand({ + name: ["device|android", "devices|android"], + description: "Lists the connected devices and emulators for one platform.", + options: listDevicesCommandOptions, + arguments: "none", + setup(): IListPlatformDevicesCommandServices { + const $devicePlatformsConstants = inject( + "devicePlatformsConstants", ); - const platform = this.$devicePlatformsConstants.iOS; - await listDevicesCommand.execute([platform]); - } -} -injector.registerCommand(["device|ios", "devices|ios"], ListiOSDevicesCommand); + return { + ...setupListDevicesCommand(), + platform: $devicePlatformsConstants[inject(LIST_DEVICES_PLATFORM)], + }; + }, + run(context, services): Promise { + return runListDevicesCommand(context, services, services.platform); + }, +}); + +const listDevicesPlatforms: [string[], "iOS" | "Android"][] = [ + [["device|android", "devices|android"], "Android"], + [["device|ios", "devices|ios"], "iOS"], +]; + +for (const [name, platform] of listDevicesPlatforms) { + registerCommandDefinition( + { ...listPlatformDevicesCommandDefinition, name }, + injector.createChild([ + { provide: LIST_DEVICES_PLATFORM, useValue: platform }, + ]), + ); +} diff --git a/lib/common/commands/device/list-files.ts b/lib/common/commands/device/list-files.ts index 1b603306ae..ac5c2b9d8f 100644 --- a/lib/common/commands/device/list-files.ts +++ b/lib/common/commands/device/list-files.ts @@ -1,57 +1,78 @@ import { IProjectData } from "../../../definitions/project"; -import { IOptions } from "../../../declarations"; -import { ICommandParameter, ICommand } from "../../definitions/commands"; import { IErrors } from "../../declarations"; -import { injector } from "../../yok"; - -export class ListFilesCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $stringParameter: ICommandParameter, - private $options: IOptions, - private $projectData: IProjectData, - private $errors: IErrors - ) {} - - public allowedParameters: ICommandParameter[] = [ - this.$stringParameter, - this.$stringParameter, - ]; - - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, - skipInferPlatform: true, - }); - const pathToList = args[0]; - let appIdentifier = args[1]; - - if (!appIdentifier) { - try { - this.$projectData.initializeProjectData(); - } catch (err) { - // ignore the error - } - if (!this.$projectData.projectIdentifiers) { - this.$errors.fail( - "Please enter application identifier or execute this command in project." - ); - } - } +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; +import { registerCommand } from "../../services/command-definition-adapter"; + +const listFilesCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; + +export type ListFilesCommandContext = CommandContext< + typeof listFilesCommandOptions +>; + +export interface IListFilesCommandServices { + $devicesService: Mobile.IDevicesService; + $errors: IErrors; + $projectData: IProjectData; +} + +export function setupListFilesCommand(): IListFilesCommandServices { + return { + $devicesService: inject("devicesService"), + $errors: inject("errors"), + $projectData: inject("projectData"), + }; +} + +export async function runListFilesCommand( + context: ListFilesCommandContext, + services: IListFilesCommandServices, +): Promise { + await services.$devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + const pathToList = context.args[0]; + let appIdentifier = context.args[1]; - const action = async (device: Mobile.IDevice) => { - appIdentifier = - appIdentifier || - this.$projectData.projectIdentifiers[ - device.deviceInfo.platform.toLowerCase() - ]; - await device.fileSystem.listFiles(pathToList, appIdentifier); - }; - await this.$devicesService.execute(action); + if (!appIdentifier) { + try { + services.$projectData.initializeProjectData(); + } catch (err) { + // ignore the error + } + if (!services.$projectData.projectIdentifiers) { + services.$errors.fail( + "Please enter application identifier or execute this command in project.", + ); + } } + + const action = async (device: Mobile.IDevice) => { + appIdentifier = + appIdentifier || + services.$projectData.projectIdentifiers[ + device.deviceInfo.platform.toLowerCase() + ]; + await device.fileSystem.listFiles(pathToList, appIdentifier); + }; + await services.$devicesService.execute(action); } -injector.registerCommand( - ["device|list-files", "devices|list-files"], - ListFilesCommand -); +export const listFilesCommandDefinition = defineCommand({ + name: ["device|list-files", "devices|list-files"], + description: "Lists the files in a directory on a connected device.", + options: listFilesCommandOptions, + arguments: [{ name: "path" }, { name: "appId" }], + setup: setupListFilesCommand, + run: runListFilesCommand, +}); + +registerCommand(listFilesCommandDefinition); diff --git a/lib/common/commands/device/put-file.ts b/lib/common/commands/device/put-file.ts index 280afb9f57..1023c0876d 100644 --- a/lib/common/commands/device/put-file.ts +++ b/lib/common/commands/device/put-file.ts @@ -1,56 +1,81 @@ import { IProjectData } from "../../../definitions/project"; -import { IOptions } from "../../../declarations"; -import { ICommand, ICommandParameter } from "../../definitions/commands"; import { IErrors } from "../../declarations"; -import { injector } from "../../yok"; - -export class PutFileCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $stringParameter: ICommandParameter, - private $options: IOptions, - private $projectData: IProjectData, - private $errors: IErrors - ) {} - - allowedParameters: ICommandParameter[] = [ - this.$stringParameter, - this.$stringParameter, - this.$stringParameter, - ]; - - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, - skipInferPlatform: true, - }); - let appIdentifier = args[2]; - - if (!appIdentifier) { - try { - this.$projectData.initializeProjectData(); - } catch (err) { - // ignore the error - } - if (!this.$projectData.projectIdentifiers) { - this.$errors.fail( - "Please enter application identifier or execute this command in project." - ); - } - } +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; +import { registerCommand } from "../../services/command-definition-adapter"; + +const putFileCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; + +export type PutFileCommandContext = CommandContext< + typeof putFileCommandOptions +>; + +export interface IPutFileCommandServices { + $devicesService: Mobile.IDevicesService; + $errors: IErrors; + $projectData: IProjectData; +} + +export function setupPutFileCommand(): IPutFileCommandServices { + return { + $devicesService: inject("devicesService"), + $errors: inject("errors"), + $projectData: inject("projectData"), + }; +} - const action = async (device: Mobile.IDevice) => { - appIdentifier = - appIdentifier || - this.$projectData.projectIdentifiers[ - device.deviceInfo.platform.toLowerCase() - ]; - await device.fileSystem.putFile(args[0], args[1], appIdentifier); - }; - await this.$devicesService.execute(action); +export async function runPutFileCommand( + context: PutFileCommandContext, + services: IPutFileCommandServices, +): Promise { + await services.$devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + let appIdentifier = context.args[2]; + + if (!appIdentifier) { + try { + services.$projectData.initializeProjectData(); + } catch (err) { + // ignore the error + } + if (!services.$projectData.projectIdentifiers) { + services.$errors.fail( + "Please enter application identifier or execute this command in project.", + ); + } } + + const action = async (device: Mobile.IDevice) => { + appIdentifier = + appIdentifier || + services.$projectData.projectIdentifiers[ + device.deviceInfo.platform.toLowerCase() + ]; + await device.fileSystem.putFile( + context.args[0], + context.args[1], + appIdentifier, + ); + }; + await services.$devicesService.execute(action); } -injector.registerCommand( - ["device|put-file", "devices|put-file"], - PutFileCommand -); + +export const putFileCommandDefinition = defineCommand({ + name: ["device|put-file", "devices|put-file"], + description: "Uploads a file to a connected device.", + options: putFileCommandOptions, + arguments: [{ name: "localPath" }, { name: "devicePath" }, { name: "appId" }], + setup: setupPutFileCommand, + run: runPutFileCommand, +}); + +registerCommand(putFileCommandDefinition); diff --git a/lib/common/commands/device/run-application.ts b/lib/common/commands/device/run-application.ts index 10a56a2952..c4b9370959 100644 --- a/lib/common/commands/device/run-application.ts +++ b/lib/common/commands/device/run-application.ts @@ -1,47 +1,68 @@ -import { IOptions } from "../../../declarations"; -import { ICommand, ICommandParameter } from "../../definitions/commands"; import { IErrors } from "../../declarations"; -import { injector } from "../../yok"; - -export class RunApplicationOnDeviceCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $errors: IErrors, - private $stringParameter: ICommandParameter, - private $staticConfig: Config.IStaticConfig, - private $options: IOptions - ) {} - - public allowedParameters: ICommandParameter[] = [ - this.$stringParameter, - this.$stringParameter, - ]; - - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, - skipInferPlatform: true, - }); - - if (this.$devicesService.deviceCount > 1) { - this.$errors.failWithHelp( - "More than one device found. Specify device explicitly with --device option. To discover device ID, use $%s device command.", - this.$staticConfig.CLIENT_NAME.toLowerCase() - ); - } - - await this.$devicesService.execute( - async (device: Mobile.IDevice) => - await device.applicationManager.startApplication({ - appId: args[0], - projectName: args[1], - projectDir: null, - }) +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; +import { registerCommand } from "../../services/command-definition-adapter"; + +const runApplicationOnDeviceCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; + +export type RunApplicationOnDeviceCommandContext = CommandContext< + typeof runApplicationOnDeviceCommandOptions +>; + +export interface IRunApplicationOnDeviceCommandServices { + $devicesService: Mobile.IDevicesService; + $errors: IErrors; + $staticConfig: Config.IStaticConfig; +} + +export function setupRunApplicationOnDeviceCommand(): IRunApplicationOnDeviceCommandServices { + return { + $devicesService: inject("devicesService"), + $errors: inject("errors"), + $staticConfig: inject("staticConfig"), + }; +} + +export async function runRunApplicationOnDeviceCommand( + context: RunApplicationOnDeviceCommandContext, + services: IRunApplicationOnDeviceCommandServices, +): Promise { + await services.$devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + + if (services.$devicesService.deviceCount > 1) { + services.$errors.failWithHelp( + "More than one device found. Specify device explicitly with --device option. To discover device ID, use $%s device command.", + services.$staticConfig.CLIENT_NAME.toLowerCase(), ); } + + await services.$devicesService.execute( + async (device: Mobile.IDevice) => + await device.applicationManager.startApplication({ + appId: context.args[0], + projectName: context.args[1], + projectDir: null, + }), + ); } -injector.registerCommand( - ["device|run", "devices|run"], - RunApplicationOnDeviceCommand -); +export const runApplicationOnDeviceCommandDefinition = defineCommand({ + name: ["device|run", "devices|run"], + description: "Runs the selected application on a connected device.", + options: runApplicationOnDeviceCommandOptions, + arguments: [{ name: "appId" }, { name: "projectName" }], + setup: setupRunApplicationOnDeviceCommand, + run: runRunApplicationOnDeviceCommand, +}); + +registerCommand(runApplicationOnDeviceCommandDefinition); diff --git a/lib/common/commands/device/stop-application.ts b/lib/common/commands/device/stop-application.ts index 9e0106f72f..e3d8a8de30 100644 --- a/lib/common/commands/device/stop-application.ts +++ b/lib/common/commands/device/stop-application.ts @@ -1,38 +1,56 @@ -import { IOptions } from "../../../declarations"; -import { injector } from "../../yok"; -import { ICommandParameter, ICommand } from "../../definitions/commands"; - -export class StopApplicationOnDeviceCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $stringParameter: ICommandParameter, - private $options: IOptions - ) {} - - allowedParameters: ICommandParameter[] = [ - this.$stringParameter, - this.$stringParameter, - this.$stringParameter, - ]; - - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, - skipInferPlatform: true, - platform: args[1], - }); +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; +import { registerCommand } from "../../services/command-definition-adapter"; + +const stopApplicationOnDeviceCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; + +export type StopApplicationOnDeviceCommandContext = CommandContext< + typeof stopApplicationOnDeviceCommandOptions +>; + +export interface IStopApplicationOnDeviceCommandServices { + $devicesService: Mobile.IDevicesService; +} - const action = (device: Mobile.IDevice) => - device.applicationManager.stopApplication({ - appId: args[0], - projectName: args[2], - projectDir: null, - }); - await this.$devicesService.execute(action); - } +export function setupStopApplicationOnDeviceCommand(): IStopApplicationOnDeviceCommandServices { + return { + $devicesService: inject("devicesService"), + }; } -injector.registerCommand( - ["device|stop", "devices|stop"], - StopApplicationOnDeviceCommand -); +export async function runStopApplicationOnDeviceCommand( + context: StopApplicationOnDeviceCommandContext, + services: IStopApplicationOnDeviceCommandServices, +): Promise { + await services.$devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + platform: context.args[1], + }); + + const action = (device: Mobile.IDevice) => + device.applicationManager.stopApplication({ + appId: context.args[0], + projectName: context.args[2], + projectDir: null, + }); + await services.$devicesService.execute(action); +} + +export const stopApplicationOnDeviceCommandDefinition = defineCommand({ + name: ["device|stop", "devices|stop"], + description: "Stops the selected application on a connected device.", + options: stopApplicationOnDeviceCommandOptions, + arguments: [{ name: "appId" }, { name: "platform" }, { name: "projectName" }], + setup: setupStopApplicationOnDeviceCommand, + run: runStopApplicationOnDeviceCommand, +}); + +registerCommand(stopApplicationOnDeviceCommandDefinition); diff --git a/lib/common/commands/device/uninstall-application.ts b/lib/common/commands/device/uninstall-application.ts index af79be554f..92747dc5f0 100644 --- a/lib/common/commands/device/uninstall-application.ts +++ b/lib/common/commands/device/uninstall-application.ts @@ -1,28 +1,51 @@ -import { IOptions } from "../../../declarations"; -import { ICommandParameter, ICommand } from "../../definitions/commands"; -import { injector } from "../../yok"; - -export class UninstallApplicationCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $stringParameter: ICommandParameter, - private $options: IOptions - ) {} - - allowedParameters: ICommandParameter[] = [this.$stringParameter]; - - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, - skipInferPlatform: true, - }); - - const action = (device: Mobile.IDevice) => - device.applicationManager.uninstallApplication(args[0]); - await this.$devicesService.execute(action); - } +import { + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; +import { registerCommand } from "../../services/command-definition-adapter"; + +const uninstallApplicationCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; + +export type UninstallApplicationCommandContext = CommandContext< + typeof uninstallApplicationCommandOptions +>; + +export interface IUninstallApplicationCommandServices { + $devicesService: Mobile.IDevicesService; +} + +export function setupUninstallApplicationCommand(): IUninstallApplicationCommandServices { + return { + $devicesService: inject("devicesService"), + }; } -injector.registerCommand( - ["device|uninstall", "devices|uninstall"], - UninstallApplicationCommand -); + +export async function runUninstallApplicationCommand( + context: UninstallApplicationCommandContext, + services: IUninstallApplicationCommandServices, +): Promise { + await services.$devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + + const action = (device: Mobile.IDevice) => + device.applicationManager.uninstallApplication(context.args[0]); + await services.$devicesService.execute(action); +} + +export const uninstallApplicationCommandDefinition = defineCommand({ + name: ["device|uninstall", "devices|uninstall"], + description: "Uninstalls an application from all connected devices.", + options: uninstallApplicationCommandOptions, + arguments: [{ name: "appId" }], + setup: setupUninstallApplicationCommand, + run: runUninstallApplicationCommand, +}); + +registerCommand(uninstallApplicationCommandDefinition); diff --git a/lib/common/commands/doctor.ts b/lib/common/commands/doctor.ts index 40b4fecbc7..6ede1f9c5a 100644 --- a/lib/common/commands/doctor.ts +++ b/lib/common/commands/doctor.ts @@ -1,62 +1,57 @@ -import { injector } from "../yok"; -import { ICommand, ICommandParameter } from "../definitions/commands"; import { IDoctorService, IProjectHelper } from "../declarations"; +import { defineCommand } from "../define-command"; +import { inject, InjectionToken } from "../di"; +import { registerCommand } from "../services/command-definition-adapter"; +import { getInjector } from "../yok"; import { PlatformTypes } from "../../constants"; -export class DoctorCommand implements ICommand { - constructor( - private $doctorService: IDoctorService, - private $projectHelper: IProjectHelper - ) {} +/** Which platform this registration checks; absent for the whole environment. */ +const DOCTOR_PLATFORM = new InjectionToken( + "doctorCommandPlatform", +); - public allowedParameters: ICommandParameter[] = []; - - public execute(args: string[]): Promise { - return this.$doctorService.printWarnings({ - trackResult: false, - projectDir: this.$projectHelper.projectDir, - forceCheck: true, - }); - } +export interface IDoctorCommandServices { + platform: PlatformTypes; + $doctorService: IDoctorService; + $projectHelper: IProjectHelper; } -injector.registerCommand("doctor|*all", DoctorCommand); -export class DoctorIosCommand implements ICommand { - constructor( - private $doctorService: IDoctorService, - private $projectHelper: IProjectHelper - ) {} - - public allowedParameters: ICommandParameter[] = []; - - public execute(args: string[]): Promise { - return this.$doctorService.printWarnings({ - trackResult: false, - projectDir: this.$projectHelper.projectDir, - forceCheck: true, - platform: PlatformTypes.ios, - }); - } +export function setupDoctorCommand(): IDoctorCommandServices { + return { + platform: inject(DOCTOR_PLATFORM, { optional: true }), + $doctorService: inject("doctorService"), + $projectHelper: inject("projectHelper"), + }; } -injector.registerCommand("doctor|ios", DoctorIosCommand); - -export class DoctorAndroidCommand implements ICommand { - constructor( - private $doctorService: IDoctorService, - private $projectHelper: IProjectHelper - ) {} - - public allowedParameters: ICommandParameter[] = []; - - public execute(args: string[]): Promise { - return this.$doctorService.printWarnings({ +export const doctorCommandDefinition = defineCommand({ + name: "doctor|*all", + description: + "Checks the local environment for configuration issues, and prints what it finds.", + arguments: "none", + setup: setupDoctorCommand, + run(context, services): Promise { + return services.$doctorService.printWarnings({ trackResult: false, - projectDir: this.$projectHelper.projectDir, + projectDir: services.$projectHelper.projectDir, forceCheck: true, - platform: PlatformTypes.android, + ...(services.platform ? { platform: services.platform } : {}), }); - } + }, +}); + +const doctorPlatforms: [string, PlatformTypes][] = [ + ["doctor|ios", PlatformTypes.ios], + ["doctor|android", PlatformTypes.android], +]; + +registerCommand(doctorCommandDefinition); + +for (const [name, platform] of doctorPlatforms) { + registerCommand( + { ...doctorCommandDefinition, name }, + getInjector().createChild([ + { provide: DOCTOR_PLATFORM, useValue: platform }, + ]), + ); } - -injector.registerCommand("doctor|android", DoctorAndroidCommand); diff --git a/lib/common/commands/generate-messages.ts b/lib/common/commands/generate-messages.ts index cbab874bdb..66860086a4 100644 --- a/lib/common/commands/generate-messages.ts +++ b/lib/common/commands/generate-messages.ts @@ -1,50 +1,61 @@ import * as path from "path"; -import { IOptions } from "../../declarations"; -import { ICommand, ICommandParameter } from "../definitions/commands"; import { IFileSystem, IServiceContractGenerator } from "../declarations"; -import { injector } from "../yok"; +import { + booleanOption, + CommandOptionsSchema, + defineCommand, +} from "../define-command"; +import { inject } from "../di"; +import { registerCommand } from "../services/command-definition-adapter"; -export class GenerateMessages implements ICommand { - private static MESSAGES_DEFINITIONS_FILE_NAME = "messages.interface.d.ts"; - private static MESSAGES_IMPLEMENTATION_FILE_NAME = "messages.ts"; +const MESSAGES_DEFINITIONS_FILE_NAME = "messages.interface.d.ts"; +const MESSAGES_IMPLEMENTATION_FILE_NAME = "messages.ts"; - constructor( - private $fs: IFileSystem, - private $messageContractGenerator: IServiceContractGenerator, - private $options: IOptions - ) {} +const generateMessagesCommandOptions = { + default: booleanOption(), +} satisfies CommandOptionsSchema; - allowedParameters: ICommandParameter[] = []; - - async execute(args: string[]): Promise { - const result = await this.$messageContractGenerator.generate(); +export const generateMessagesCommandDefinition = defineCommand({ + name: "dev-generate-messages", + description: "Regenerates the CLI's message contracts.", + options: generateMessagesCommandOptions, + arguments: "none", + setup: () => ({ + $fs: inject("fs"), + $messageContractGenerator: inject( + "messageContractGenerator", + ), + }), + async run(context, services): Promise { + const result = await services.$messageContractGenerator.generate(); const innerMessagesDirectory = path.join(__dirname, "../messages"); const outerMessagesDirectory = path.join(__dirname, "../.."); let interfaceFilePath: string; let implementationFilePath: string; - if (this.$options.default) { + if (context.options.default) { interfaceFilePath = path.join( innerMessagesDirectory, - GenerateMessages.MESSAGES_DEFINITIONS_FILE_NAME + MESSAGES_DEFINITIONS_FILE_NAME, ); implementationFilePath = path.join( innerMessagesDirectory, - GenerateMessages.MESSAGES_IMPLEMENTATION_FILE_NAME + MESSAGES_IMPLEMENTATION_FILE_NAME, ); } else { interfaceFilePath = path.join( outerMessagesDirectory, - GenerateMessages.MESSAGES_DEFINITIONS_FILE_NAME + MESSAGES_DEFINITIONS_FILE_NAME, ); implementationFilePath = path.join( outerMessagesDirectory, - GenerateMessages.MESSAGES_IMPLEMENTATION_FILE_NAME + MESSAGES_IMPLEMENTATION_FILE_NAME, ); } - this.$fs.writeFile(interfaceFilePath, result.interfaceFile); - this.$fs.writeFile(implementationFilePath, result.implementationFile); - } -} -injector.registerCommand("dev-generate-messages", GenerateMessages); + services.$fs.writeFile(interfaceFilePath, result.interfaceFile); + services.$fs.writeFile(implementationFilePath, result.implementationFile); + }, +}); + +registerCommand(generateMessagesCommandDefinition); diff --git a/lib/common/commands/help.ts b/lib/common/commands/help.ts index 0a4fe24503..17f535a42d 100644 --- a/lib/common/commands/help.ts +++ b/lib/common/commands/help.ts @@ -1,47 +1,72 @@ import * as _ from "lodash"; -import { IOptions } from "../../declarations"; -import { IInjector } from "../definitions/yok"; -import { injector } from "../yok"; -import { ICommand, ICommandParameter } from "../definitions/commands"; +import { CommandRegistry } from "../contracts/command-registry"; import { IHelpService } from "../declarations"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, +} from "../define-command"; +import { inject } from "../di"; +import { registerCommand } from "../services/command-definition-adapter"; -export class HelpCommand implements ICommand { - constructor( - private $injector: IInjector, - private $helpService: IHelpService, - private $options: IOptions - ) {} +export const helpCommandOptions = { + help: booleanOption(), +} satisfies CommandOptionsSchema; - public enableHooks = false; - public async canExecute(args: string[]): Promise { - return true; - } +export type HelpCommandContext = CommandContext; + +export interface IHelpCommandServices { + $commandRegistry: CommandRegistry; + $helpService: IHelpService; +} - public allowedParameters: ICommandParameter[] = []; +export function setupHelpCommand(): IHelpCommandServices { + return { + $commandRegistry: inject(CommandRegistry), + $helpService: inject("helpService"), + }; +} - public async execute(args: string[]): Promise { - let commandName = (args[0] || "").toLowerCase(); - let commandArguments = _.tail(args); - const hierarchicalCommand = this.$injector.buildHierarchicalCommand( +export async function runHelpCommand( + context: HelpCommandContext, + services: IHelpCommandServices, +): Promise { + const args = context.args; + let commandName = (args[0] || "").toLowerCase(); + let commandArguments = _.tail(args); + const hierarchicalCommand = + services.$commandRegistry.buildHierarchicalCommand( args[0], - commandArguments + commandArguments, ); - if (hierarchicalCommand) { - commandName = hierarchicalCommand.commandName; - commandArguments = hierarchicalCommand.remainingArguments; - } + if (hierarchicalCommand) { + commandName = hierarchicalCommand.commandName; + commandArguments = hierarchicalCommand.remainingArguments; + } - const commandData: ICommandData = { - commandName, - commandArguments, - }; + const commandData: ICommandData = { + commandName, + commandArguments, + }; - if (this.$options.help) { - await this.$helpService.showCommandLineHelp(commandData); - } else { - await this.$helpService.openHelpForCommandInBrowser(commandData); - } + if (context.options.help) { + await services.$helpService.showCommandLineHelp(commandData); + } else { + await services.$helpService.openHelpForCommandInBrowser(commandData); } } -injector.registerCommand(["help", "/?"], HelpCommand); +export const helpCommandDefinition = defineCommand({ + name: ["help", "/?"], + description: "Shows the help for a command.", + options: helpCommandOptions, + // The command names whatever command it explains, so every argument after + // the first is that command's own. + arguments: "any", + enableHooks: false, + setup: setupHelpCommand, + run: runHelpCommand, +}); + +registerCommand(helpCommandDefinition); diff --git a/lib/common/commands/package-manager-get.ts b/lib/common/commands/package-manager-get.ts index a258fcb110..883c4aa1a1 100644 --- a/lib/common/commands/package-manager-get.ts +++ b/lib/common/commands/package-manager-get.ts @@ -1,32 +1,34 @@ -import { injector } from "../yok"; -import { IUserSettingsService, IErrors } from "../declarations"; -import { ICommand, ICommandParameter } from "../definitions/commands"; +import { IUserSettingsService } from "../declarations"; +import { defineCommand } from "../define-command"; +import { inject } from "../di"; +import { registerCommand } from "../services/command-definition-adapter"; -export class PackageManagerGetCommand implements ICommand { - constructor( - private $errors: IErrors, - private $logger: ILogger, - private $userSettingsService: IUserSettingsService - ) {} - - public allowedParameters: ICommandParameter[] = []; +export interface IPackageManagerGetCommandServices { + $logger: ILogger; + $userSettingsService: IUserSettingsService; +} - public async execute(args: string[]): Promise { - if (args && args.length) { - this.$errors.failWithHelp( - `The arguments '${args.join( - " " - )}' are not valid for the 'package-manager get' command.` - ); - } +export function setupPackageManagerGetCommand(): IPackageManagerGetCommandServices { + return { + $logger: inject("logger"), + $userSettingsService: inject("userSettingsService"), + }; +} - const result = await this.$userSettingsService.getSettingValue( - "packageManager" +export const packageManagerGetCommandDefinition = defineCommand({ + name: "package-manager|*get", + description: "Prints the value of the current package manager.", + setup: setupPackageManagerGetCommand, + async run( + context, + services: IPackageManagerGetCommandServices, + ): Promise { + const result = + await services.$userSettingsService.getSettingValue("packageManager"); + services.$logger.printMarkdown( + `Your current package manager is \`${result || "npm"}\`.`, ); - this.$logger.printMarkdown( - `Your current package manager is \`${result || "npm"}\`.` - ); - } -} + }, +}); -injector.registerCommand("package-manager|*get", PackageManagerGetCommand); +registerCommand(packageManagerGetCommandDefinition); diff --git a/lib/common/commands/package-manager-set.ts b/lib/common/commands/package-manager-set.ts index 7f9c1924e0..0e3e113641 100644 --- a/lib/common/commands/package-manager-set.ts +++ b/lib/common/commands/package-manager-set.ts @@ -1,41 +1,54 @@ import { PackageManagers } from "../../constants"; -import { ICommand, ICommandParameter } from "../definitions/commands"; -import { IUserSettingsService, IErrors } from "../declarations"; -import { injector } from "../yok"; +import { IErrors, IUserSettingsService } from "../declarations"; +import { defineCommand } from "../define-command"; +import { inject } from "../di"; +import { registerCommand } from "../services/command-definition-adapter"; -export class PackageManagerCommand implements ICommand { - constructor( - private $userSettingsService: IUserSettingsService, - private $errors: IErrors, - private $logger: ILogger, - private $stringParameter: ICommandParameter - ) {} +export interface IPackageManagerSetCommandServices { + $userSettingsService: IUserSettingsService; + $errors: IErrors; + $logger: ILogger; +} - public allowedParameters: ICommandParameter[] = [this.$stringParameter]; +export function setupPackageManagerSetCommand(): IPackageManagerSetCommandServices { + return { + $userSettingsService: inject("userSettingsService"), + $errors: inject("errors"), + $logger: inject("logger"), + }; +} - public async execute(args: string[]): Promise { - const packageManagerName = args[0]; +export const packageManagerSetCommandDefinition = defineCommand({ + name: "package-manager|set", + description: "Sets the package manager the CLI installs dependencies with.", + arguments: [{ name: "packageManager" }], + setup: setupPackageManagerSetCommand, + async run( + context, + services: IPackageManagerSetCommandServices, + ): Promise { + const packageManagerName = context.args[0]; const supportedPackageManagers = Object.keys(PackageManagers); if (supportedPackageManagers.indexOf(packageManagerName) === -1) { - this.$errors.fail( + services.$errors.fail( `${packageManagerName} is not a valid package manager. Supported values are: ${supportedPackageManagers.join( - ", " - )}.` + ", ", + )}.`, ); } - await this.$userSettingsService.saveSetting( + await services.$userSettingsService.saveSetting( "packageManager", - packageManagerName + packageManagerName, ); - this.$logger.printMarkdown( - `Please ensure you have the directory containing \`${packageManagerName}\` executable available in your PATH.` + services.$logger.printMarkdown( + `Please ensure you have the directory containing \`${packageManagerName}\` executable available in your PATH.`, ); - this.$logger.printMarkdown( - `You've successfully set \`${packageManagerName}\` as your package manager.` + services.$logger.printMarkdown( + `You've successfully set \`${packageManagerName}\` as your package manager.`, ); - } -} + }, +}); -injector.registerCommand("package-manager|set", PackageManagerCommand); +registerCommand(packageManagerSetCommandDefinition); diff --git a/lib/common/commands/post-install.ts b/lib/common/commands/post-install.ts index f66d6ef343..75e19de477 100644 --- a/lib/common/commands/post-install.ts +++ b/lib/common/commands/post-install.ts @@ -1,17 +1,21 @@ -import { injector } from "../yok"; -import { ICommand, ICommandParameter } from "../definitions/commands"; import { IErrors } from "../declarations"; +import { defineCommand } from "../define-command"; +import { inject } from "../di"; +import { registerCommand } from "../services/command-definition-adapter"; -export class PostInstallCommand implements ICommand { - constructor(protected $errors: IErrors) {} - - public disableAnalytics = true; - public allowedParameters: ICommandParameter[] = []; - - public async execute(args: string[]): Promise { - this.$errors.fail( - "This command is deprecated. Use `ns dev-post-install-cli` instead" +export const postInstallCommandDefinition = defineCommand({ + name: "dev-post-install", + description: "Deprecated; use `ns dev-post-install-cli`.", + arguments: "none", + disableAnalytics: true, + setup: () => ({ + $errors: inject("errors"), + }), + async run(context, services): Promise { + services.$errors.fail( + "This command is deprecated. Use `ns dev-post-install-cli` instead", ); - } -} -injector.registerCommand("dev-post-install", PostInstallCommand); + }, +}); + +registerCommand(postInstallCommandDefinition); diff --git a/lib/common/commands/preuninstall.ts b/lib/common/commands/preuninstall.ts index 4d086a0bee..4a61c9e4e3 100644 --- a/lib/common/commands/preuninstall.ts +++ b/lib/common/commands/preuninstall.ts @@ -5,31 +5,63 @@ import { AnalyticsEventLabelDelimiter, } from "../../constants"; import { IPackageInstallationManager } from "../../declarations"; -import { ICommand, ICommandParameter } from "../definitions/commands"; import { IAnalyticsService, IFileSystem, ISettingsService, } from "../declarations"; -import { injector } from "../yok"; +import { defineCommand } from "../define-command"; +import { inject } from "../di"; +import { registerCommand } from "../services/command-definition-adapter"; import { IExtensibilityService } from "../definitions/extensibility"; -export class PreUninstallCommand implements ICommand { - // disabled for now (6/24/2020) - // private static FEEDBACK_FORM_URL = "https://www.nativescript.org/uninstall-feedback"; +// disabled for now (6/24/2020) +// const FEEDBACK_FORM_URL = "https://www.nativescript.org/uninstall-feedback"; + +export interface IPreUninstallCommandServices { + $analyticsService: IAnalyticsService; + $extensibilityService: IExtensibilityService; + $fs: IFileSystem; + $packageInstallationManager: IPackageInstallationManager; + $settingsService: ISettingsService; +} - public allowedParameters: ICommandParameter[] = []; +export function setupPreUninstallCommand(): IPreUninstallCommandServices { + return { + $analyticsService: inject("analyticsService"), + $extensibilityService: inject( + "extensibilityService", + ), + $fs: inject("fs"), + $packageInstallationManager: inject( + "packageInstallationManager", + ), + $settingsService: inject("settingsService"), + }; +} - constructor( - private $analyticsService: IAnalyticsService, - private $extensibilityService: IExtensibilityService, - private $fs: IFileSystem, - // private $opener: IOpener, - private $packageInstallationManager: IPackageInstallationManager, - private $settingsService: ISettingsService - ) {} +async function handleFeedbackForm(): Promise { + // disabled for now (6/24/2020) + // if (isInteractive()) { + // $opener.open(FEEDBACK_FORM_URL); + // } + return Promise.resolve(); +} - public async execute(args: string[]): Promise { +async function handleIntentionalUninstall( + services: IPreUninstallCommandServices, +): Promise { + services.$extensibilityService.removeAllExtensions(); + services.$packageInstallationManager.clearInspectorCache(); + await handleFeedbackForm(); +} + +export const preUninstallCommandDefinition = defineCommand({ + name: "dev-preuninstall", + description: "Runs the CLI's own uninstall bookkeeping.", + arguments: "none", + setup: setupPreUninstallCommand, + async run(context, services): Promise { const isIntentionalUninstall = doesCurrentNpmCommandMatch([ /^uninstall$/, /^remove$/, @@ -39,34 +71,24 @@ export class PreUninstallCommand implements ICommand { /^unlink$/, ]); - await this.$analyticsService.trackEventActionInGoogleAnalytics({ + await services.$analyticsService.trackEventActionInGoogleAnalytics({ action: TrackActionNames.UninstallCLI, additionalData: `isIntentionalUninstall${AnalyticsEventLabelDelimiter}${isIntentionalUninstall}${AnalyticsEventLabelDelimiter}isInteractive${AnalyticsEventLabelDelimiter}${!!isInteractive()}`, }); if (isIntentionalUninstall) { - await this.handleIntentionalUninstall(); + await handleIntentionalUninstall(services); } - this.$fs.deleteFile( - path.join(this.$settingsService.getProfileDir(), "KillSwitches", "cli") + services.$fs.deleteFile( + path.join( + services.$settingsService.getProfileDir(), + "KillSwitches", + "cli", + ), ); - await this.$analyticsService.finishTracking(); - } - - private async handleIntentionalUninstall(): Promise { - this.$extensibilityService.removeAllExtensions(); - this.$packageInstallationManager.clearInspectorCache(); - await this.handleFeedbackForm(); - } - - private async handleFeedbackForm(): Promise { - // disabled for now (6/24/2020) - // if (isInteractive()) { - // this.$opener.open(PreUninstallCommand.FEEDBACK_FORM_URL); - // } - return Promise.resolve(); - } -} + await services.$analyticsService.finishTracking(); + }, +}); -injector.registerCommand("dev-preuninstall", PreUninstallCommand); +registerCommand(preUninstallCommandDefinition); diff --git a/lib/common/commands/proxy/proxy-base.ts b/lib/common/commands/proxy/proxy-base.ts index e989dce6fa..e9fbd68597 100644 --- a/lib/common/commands/proxy/proxy-base.ts +++ b/lib/common/commands/proxy/proxy-base.ts @@ -1,28 +1,31 @@ -import { ICommandParameter, ICommand } from "../../definitions/commands"; import { IAnalyticsService, IProxyService } from "../../declarations"; +import { inject } from "../../di"; -export abstract class ProxyCommandBase implements ICommand { - public disableAnalytics = true; - public allowedParameters: ICommandParameter[] = []; - - constructor( - protected $analyticsService: IAnalyticsService, - protected $logger: ILogger, - protected $proxyService: IProxyService, - private commandName: string - ) {} +export interface IProxyCommandServices { + $analyticsService: IAnalyticsService; + $logger: ILogger; + $proxyService: IProxyService; +} - public abstract execute(args: string[]): Promise; +export function injectProxyCommandServices(): IProxyCommandServices { + return { + $analyticsService: inject("analyticsService"), + $logger: inject("logger"), + $proxyService: inject("proxyService"), + }; +} - protected async tryTrackUsage() { - try { - // TODO(Analytics): Check why we have set the `disableAnalytics` to true and we track the command as separate one - // instead of tracking it through the commandsService. - this.$logger.trace(this.commandName); - // await this.$analyticsService.trackFeature(this.commandName); - } catch (ex) { - this.$logger.trace("Error in trying to track proxy command usage:"); - this.$logger.trace(ex); - } +export async function tryTrackProxyCommandUsage( + services: IProxyCommandServices, + commandName: string, +): Promise { + try { + // TODO(Analytics): Check why we have set the `disableAnalytics` to true and we track the command as separate one + // instead of tracking it through the commandsService. + services.$logger.trace(commandName); + // await services.$analyticsService.trackFeature(commandName); + } catch (ex) { + services.$logger.trace("Error in trying to track proxy command usage:"); + services.$logger.trace(ex); } } diff --git a/lib/common/commands/proxy/proxy-clear.ts b/lib/common/commands/proxy/proxy-clear.ts index aa1981cbc9..b3eec7343b 100644 --- a/lib/common/commands/proxy/proxy-clear.ts +++ b/lib/common/commands/proxy/proxy-clear.ts @@ -1,22 +1,24 @@ -import { ProxyCommandBase } from "./proxy-base"; -import { IAnalyticsService, IProxyService } from "../../declarations"; -import { injector } from "../../yok"; -const proxyClearCommandName = "proxy|clear"; +import { defineCommand } from "../../define-command"; +import { registerCommand } from "../../services/command-definition-adapter"; +import { + injectProxyCommandServices, + IProxyCommandServices, + tryTrackProxyCommandUsage, +} from "./proxy-base"; -export class ProxyClearCommand extends ProxyCommandBase { - constructor( - protected $analyticsService: IAnalyticsService, - protected $logger: ILogger, - protected $proxyService: IProxyService - ) { - super($analyticsService, $logger, $proxyService, proxyClearCommandName); - } +const proxyClearCommandName = "proxy|clear"; - public async execute(args: string[]): Promise { - await this.$proxyService.clearCache(); - this.$logger.info("Successfully cleared proxy."); - await this.tryTrackUsage(); - } -} +export const proxyClearCommandDefinition = defineCommand({ + name: proxyClearCommandName, + description: "Clears the currently configured proxy settings.", + arguments: "none", + disableAnalytics: true, + setup: injectProxyCommandServices, + async run(context, services: IProxyCommandServices): Promise { + await services.$proxyService.clearCache(); + services.$logger.info("Successfully cleared proxy."); + await tryTrackProxyCommandUsage(services, proxyClearCommandName); + }, +}); -injector.registerCommand(proxyClearCommandName, ProxyClearCommand); +registerCommand(proxyClearCommandDefinition); diff --git a/lib/common/commands/proxy/proxy-get.ts b/lib/common/commands/proxy/proxy-get.ts index 143b38384b..2682b3e869 100644 --- a/lib/common/commands/proxy/proxy-get.ts +++ b/lib/common/commands/proxy/proxy-get.ts @@ -1,22 +1,23 @@ -import { ProxyCommandBase } from "./proxy-base"; -import { IAnalyticsService, IProxyService } from "../../declarations"; -import { injector } from "../../yok"; +import { defineCommand } from "../../define-command"; +import { registerCommand } from "../../services/command-definition-adapter"; +import { + injectProxyCommandServices, + IProxyCommandServices, + tryTrackProxyCommandUsage, +} from "./proxy-base"; const proxyGetCommandName = "proxy|*get"; -export class ProxyGetCommand extends ProxyCommandBase { - constructor( - protected $analyticsService: IAnalyticsService, - protected $logger: ILogger, - protected $proxyService: IProxyService - ) { - super($analyticsService, $logger, $proxyService, proxyGetCommandName); - } +export const proxyGetCommandDefinition = defineCommand({ + name: proxyGetCommandName, + description: "Prints the current proxy settings.", + arguments: "none", + disableAnalytics: true, + setup: injectProxyCommandServices, + async run(context, services: IProxyCommandServices): Promise { + services.$logger.info(await services.$proxyService.getInfo()); + await tryTrackProxyCommandUsage(services, proxyGetCommandName); + }, +}); - public async execute(args: string[]): Promise { - this.$logger.info(await this.$proxyService.getInfo()); - await this.tryTrackUsage(); - } -} - -injector.registerCommand(proxyGetCommandName, ProxyGetCommand); +registerCommand(proxyGetCommandDefinition); diff --git a/lib/common/commands/proxy/proxy-set.ts b/lib/common/commands/proxy/proxy-set.ts index ee9b1f8a38..3206b57607 100644 --- a/lib/common/commands/proxy/proxy-set.ts +++ b/lib/common/commands/proxy/proxy-set.ts @@ -1,188 +1,214 @@ -import * as commandParams from "../../command-params"; -import { isInteractive } from "../../helpers"; -import { ProxyCommandBase } from "./proxy-base"; -import { HttpProtocolToPort } from "../../constants"; +import { EOL, platform } from "os"; import { parse } from "url"; -import { platform, EOL } from "os"; -import { IOptions } from "../../../declarations"; +import { HttpProtocolToPort } from "../../constants"; import { IErrors, IHostInfo, - IAnalyticsService, - IProxyService, IProxyLibSettings, IPrompterQuestion, } from "../../declarations"; -import { IInjector } from "../../definitions/yok"; -import { injector } from "../../yok"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, +} from "../../define-command"; +import { inject } from "../../di"; +import { isInteractive } from "../../helpers"; +import { registerCommand } from "../../services/command-definition-adapter"; +import { + injectProxyCommandServices, + IProxyCommandServices, + tryTrackProxyCommandUsage, +} from "./proxy-base"; const { getCredentialsFromAuth } = require("proxy-lib/lib/utils"); const proxySetCommandName = "proxy|set"; -export class ProxySetCommand extends ProxyCommandBase { - public allowedParameters = [ - new commandParams.StringCommandParameter(this.$injector), - new commandParams.StringCommandParameter(this.$injector), - new commandParams.StringCommandParameter(this.$injector), - ]; - - constructor( - private $errors: IErrors, - private $injector: IInjector, - private $prompter: IPrompter, - private $hostInfo: IHostInfo, - private $staticConfig: Config.IStaticConfig, - protected $analyticsService: IAnalyticsService, - protected $logger: ILogger, - protected $options: IOptions, - protected $proxyService: IProxyService - ) { - super($analyticsService, $logger, $proxyService, proxySetCommandName); - } +const proxySetCommandOptions = { + insecure: booleanOption(), +} satisfies CommandOptionsSchema; - public async execute(args: string[]): Promise { - let urlString = args[0]; - let username = args[1]; - let password = args[2]; - - const noUrl = !urlString; - if (noUrl) { - if (!isInteractive()) { - this.$errors.failWithHelp( - "Console is not interactive - you need to supply all command parameters." - ); - } else { - urlString = await this.$prompter.getString("Url", { - allowEmpty: false, - }); - } - } +export type ProxySetCommandContext = CommandContext< + typeof proxySetCommandOptions +>; - let urlObj = parse(urlString); - if ((!urlObj.protocol || !urlObj.hostname) && !isInteractive()) { - this.$errors.fail( - "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname." - ); - } +export interface IProxySetCommandServices extends IProxyCommandServices { + $errors: IErrors; + $hostInfo: IHostInfo; + $prompter: IPrompter; + $staticConfig: Config.IStaticConfig; +} - while (!urlObj.protocol || !urlObj.hostname) { - this.$logger.warn( - "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname." - ); - urlString = await this.$prompter.getString("Url", { allowEmpty: false }); - urlObj = parse(urlString); - } +export function setupProxySetCommand(): IProxySetCommandServices { + return { + ...injectProxyCommandServices(), + $errors: inject("errors"), + $hostInfo: inject("hostInfo"), + $prompter: inject("prompter"), + $staticConfig: inject("staticConfig"), + }; +} - let port = - (urlObj.port && +urlObj.port) || HttpProtocolToPort[urlObj.protocol]; - const noPort = !port || !this.isValidPort(port); - const authCredentials = getCredentialsFromAuth(urlObj.auth || ""); - if ( - (username && - authCredentials.username && - username !== authCredentials.username) || - (password && - authCredentials.password && - password !== authCredentials.password) - ) { - this.$errors.fail( - "The credentials you have provided in the url address mismatch those passed as command line arguments." - ); - } - username = username || authCredentials.username; - password = password || authCredentials.password; +function isPasswordRequired(username: string, password: string): boolean { + return !!(username && !password); +} - if (!isInteractive()) { - if (noPort) { - this.$errors.fail( - `The port you have specified (${port || "none"}) is not valid.` - ); - } else if (this.isPasswordRequired(username, password)) { - this.$errors.failWithHelp( - "Console is not interactive - you need to supply all command parameters." - ); - } - } +function isValidPort(port: number): boolean { + return !isNaN(port) && port > 0 && port < 65536; +} - if (noPort) { - if (port) { - this.$logger.warn(this.getInvalidPortMessage(port)); - } +function getInvalidPortMessage(port: number): string { + return `Specified port ${port} is not valid. Please enter a value between 1 and 65535.`; +} - port = await this.getPortFromUserInput(); - } +async function getPortFromUserInput( + services: IProxySetCommandServices, +): Promise { + const schemaName = "port"; + const schema: IPrompterQuestion = { + message: "Port", + type: "text", + name: schemaName, + validate: (value: any) => { + return !value || !isValidPort(value) + ? getInvalidPortMessage(value) + : true; + }, + }; + + const prompterResult = await services.$prompter.get([schema]); + return parseInt(prompterResult[schemaName]); +} + +export async function runProxySetCommand( + context: ProxySetCommandContext, + services: IProxySetCommandServices, +): Promise { + let urlString = context.args[0]; + let username = context.args[1]; + let password = context.args[2]; - if (!username) { - this.$logger.info( - "In case your proxy requires authentication, please specify username and password. If authentication is not required, just leave it empty." + const noUrl = !urlString; + if (noUrl) { + if (!isInteractive()) { + services.$errors.failWithHelp( + "Console is not interactive - you need to supply all command parameters.", ); - username = await this.$prompter.getString("Username", { - defaultAction: () => "", + } else { + urlString = await services.$prompter.getString("Url", { + allowEmpty: false, }); } + } - if (this.isPasswordRequired(username, password)) { - password = await this.$prompter.getPassword("Password"); - } + let urlObj = parse(urlString); + if ((!urlObj.protocol || !urlObj.hostname) && !isInteractive()) { + services.$errors.fail( + "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname.", + ); + } - const settings: IProxyLibSettings = { - proxyUrl: urlString, - username, - password, - rejectUnauthorized: !this.$options.insecure, - }; + while (!urlObj.protocol || !urlObj.hostname) { + services.$logger.warn( + "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname.", + ); + urlString = await services.$prompter.getString("Url", { + allowEmpty: false, + }); + urlObj = parse(urlString); + } - if (!this.$hostInfo.isWindows) { - this.$logger.warn( - `Note that storing credentials is not supported on ${platform()} yet.` + let port = + (urlObj.port && +urlObj.port) || HttpProtocolToPort[urlObj.protocol]; + const noPort = !port || !isValidPort(port); + const authCredentials = getCredentialsFromAuth(urlObj.auth || ""); + if ( + (username && + authCredentials.username && + username !== authCredentials.username) || + (password && + authCredentials.password && + password !== authCredentials.password) + ) { + services.$errors.fail( + "The credentials you have provided in the url address mismatch those passed as command line arguments.", + ); + } + username = username || authCredentials.username; + password = password || authCredentials.password; + + if (!isInteractive()) { + if (noPort) { + services.$errors.fail( + `The port you have specified (${port || "none"}) is not valid.`, + ); + } else if (isPasswordRequired(username, password)) { + services.$errors.failWithHelp( + "Console is not interactive - you need to supply all command parameters.", ); } + } - const clientName = this.$staticConfig.CLIENT_NAME.toLowerCase(); - const messageNote = - (clientName === "tns" - ? "Note that 'npm' and 'Gradle' need to be configured separately to work with a proxy." - : "Note that `npm` needs to be configured separately to work with a proxy.") + - EOL; - - this.$logger.warn( - `${messageNote}Run '${clientName} proxy set --help' for more information.` - ); + if (noPort) { + if (port) { + services.$logger.warn(getInvalidPortMessage(port)); + } - await this.$proxyService.setCache(settings); - this.$logger.info(`Successfully setup proxy.${EOL}`); - this.$logger.info(await this.$proxyService.getInfo()); - await this.tryTrackUsage(); + port = await getPortFromUserInput(services); } - private isPasswordRequired(username: string, password: string): boolean { - return !!(username && !password); + if (!username) { + services.$logger.info( + "In case your proxy requires authentication, please specify username and password. If authentication is not required, just leave it empty.", + ); + username = await services.$prompter.getString("Username", { + defaultAction: () => "", + }); } - private isValidPort(port: number): boolean { - return !isNaN(port) && port > 0 && port < 65536; + if (isPasswordRequired(username, password)) { + password = await services.$prompter.getPassword("Password"); } - private async getPortFromUserInput(): Promise { - const schemaName = "port"; - const schema: IPrompterQuestion = { - message: "Port", - type: "text", - name: schemaName, - validate: (value: any) => { - return !value || !this.isValidPort(value) - ? this.getInvalidPortMessage(value) - : true; - }, - }; - - const prompterResult = await this.$prompter.get([schema]); - return parseInt(prompterResult[schemaName]); - } + const settings: IProxyLibSettings = { + proxyUrl: urlString, + username, + password, + rejectUnauthorized: !context.options.insecure, + }; - private getInvalidPortMessage(port: number): string { - return `Specified port ${port} is not valid. Please enter a value between 1 and 65535.`; + if (!services.$hostInfo.isWindows) { + services.$logger.warn( + `Note that storing credentials is not supported on ${platform()} yet.`, + ); } + + const clientName = services.$staticConfig.CLIENT_NAME.toLowerCase(); + const messageNote = + (clientName === "tns" + ? "Note that 'npm' and 'Gradle' need to be configured separately to work with a proxy." + : "Note that `npm` needs to be configured separately to work with a proxy.") + + EOL; + + services.$logger.warn( + `${messageNote}Run '${clientName} proxy set --help' for more information.`, + ); + + await services.$proxyService.setCache(settings); + services.$logger.info(`Successfully setup proxy.${EOL}`); + services.$logger.info(await services.$proxyService.getInfo()); + await tryTrackProxyCommandUsage(services, proxySetCommandName); } -injector.registerCommand(proxySetCommandName, ProxySetCommand); +export const proxySetCommandDefinition = defineCommand({ + name: proxySetCommandName, + description: "Configures a proxy for the CLI to use.", + options: proxySetCommandOptions, + arguments: [{ name: "url" }, { name: "username" }, { name: "password" }], + disableAnalytics: true, + setup: setupProxySetCommand, + run: runProxySetCommand, +}); + +registerCommand(proxySetCommandDefinition); diff --git a/lib/common/declarations.d.ts b/lib/common/declarations.d.ts index df4a19a1fb..902025248f 100644 --- a/lib/common/declarations.d.ts +++ b/lib/common/declarations.d.ts @@ -271,6 +271,7 @@ interface IHttpRequestError extends Error { interface ICommandOptions { disableAnalytics?: boolean; enableHooks?: boolean; + /** @deprecated Declared here, referenced nowhere. */ disableCommandHelpSuggestion?: boolean; } diff --git a/lib/common/definitions/commands.d.ts b/lib/common/definitions/commands.d.ts index 9750ebe2be..49e8e2a3b9 100644 --- a/lib/common/definitions/commands.d.ts +++ b/lib/common/definitions/commands.d.ts @@ -4,6 +4,7 @@ interface ICommand extends ICommandOptions { execute(args: string[]): Promise; allowedParameters: ICommandParameter[]; + /** @deprecated Read by the command dispatcher, set by nothing. */ isDisabled?: boolean; // Implement this method in cases when you want to have your own logic for validation. In case you do not implement it, @@ -12,6 +13,7 @@ interface ICommand extends ICommandOptions { // but at least one of them is required. Used in prop|add, prop|set, etc. commands as their logic is complicated and // default validation in CommandsService is not applicable. canExecute?(args: string[]): Promise; + /** @deprecated Declared here, referenced nowhere. */ completionData?: string[]; dashedOptions?: IDictionary; isHierarchicalCommand?: boolean; diff --git a/lib/common/test/unit-tests/preuninstall.ts b/lib/common/test/unit-tests/preuninstall.ts index 249d1c24f0..1dc569db3c 100644 --- a/lib/common/test/unit-tests/preuninstall.ts +++ b/lib/common/test/unit-tests/preuninstall.ts @@ -1,6 +1,7 @@ import { assert } from "chai"; import { Yok } from "../../yok"; -import { PreUninstallCommand } from "../../commands/preuninstall"; +import { preUninstallCommandDefinition } from "../../commands/preuninstall"; +import { registerCommand } from "../../services/command-definition-adapter"; import * as path from "path"; import { IPackageInstallationManager } from "../../../declarations"; import { IInjector } from "../../definitions/yok"; @@ -37,12 +38,12 @@ describe("preuninstall", () => { testInjector.register("analyticsService", { trackEventActionInGoogleAnalytics: async ( - data: IEventActionData + data: IEventActionData, ): Promise => undefined, finishTracking: async (): Promise => undefined, }); - testInjector.registerCommand("dev-preuninstall", PreUninstallCommand); + registerCommand(preUninstallCommandDefinition, testInjector); return testInjector; }; @@ -56,9 +57,8 @@ describe("preuninstall", () => { deletedFiles.push(pathToFile); }; - const preUninstallCommand: ICommand = testInjector.resolveCommand( - "dev-preuninstall" - ); + const preUninstallCommand: ICommand = + testInjector.resolveCommand("dev-preuninstall"); await preUninstallCommand.execute([]); assert.deepStrictEqual(deletedFiles, [ path.join(profileDir, "KillSwitches", "cli"), @@ -94,12 +94,11 @@ describe("preuninstall", () => { ]; const testInjector = createTestInjector(); - const analyticsService = testInjector.resolve( - "analyticsService" - ); + const analyticsService = + testInjector.resolve("analyticsService"); let trackedData: IEventActionData[] = []; analyticsService.trackEventActionInGoogleAnalytics = async ( - data: IEventActionData + data: IEventActionData, ): Promise => { trackedData.push(data); }; @@ -109,9 +108,8 @@ describe("preuninstall", () => { isFinishTrackingCalled = true; }; - const preUninstallCommand: ICommand = testInjector.resolveCommand( - "dev-preuninstall" - ); + const preUninstallCommand: ICommand = + testInjector.resolveCommand("dev-preuninstall"); for (const testCase of testData) { helpers.isInteractive = () => testCase.isInteractive; helpers.doesCurrentNpmCommandMatch = () => @@ -126,7 +124,7 @@ describe("preuninstall", () => { ]); assert.isTrue( isFinishTrackingCalled, - "At the end of the command, finishTracking must be called" + "At the end of the command, finishTracking must be called", ); trackedData = []; } @@ -144,24 +142,24 @@ describe("preuninstall", () => { }; const extensibilityService = testInjector.resolve( - "extensibilityService" + "extensibilityService", ); let isRemoveAllExtensionsCalled = false; extensibilityService.removeAllExtensions = () => { isRemoveAllExtensionsCalled = true; }; - const packageInstallationManager = testInjector.resolve< - IPackageInstallationManager - >("packageInstallationManager"); + const packageInstallationManager = + testInjector.resolve( + "packageInstallationManager", + ); let isClearInspectorCacheCalled = false; packageInstallationManager.clearInspectorCache = () => { isClearInspectorCacheCalled = true; }; - const preUninstallCommand: ICommand = testInjector.resolveCommand( - "dev-preuninstall" - ); + const preUninstallCommand: ICommand = + testInjector.resolveCommand("dev-preuninstall"); await preUninstallCommand.execute([]); assert.deepStrictEqual(deletedFiles, [ path.join(profileDir, "KillSwitches", "cli"), @@ -169,11 +167,11 @@ describe("preuninstall", () => { assert.isTrue( isRemoveAllExtensionsCalled, - "When uninstall is called, `removeAllExtensions` method must be called" + "When uninstall is called, `removeAllExtensions` method must be called", ); assert.isTrue( isClearInspectorCacheCalled, - "When uninstall is called, `clearInspectorCache` method must be called" + "When uninstall is called, `clearInspectorCache` method must be called", ); }); diff --git a/lib/key-commands/bootstrap.ts b/lib/key-commands/bootstrap.ts index a10d2bd7b8..bf3761c463 100644 --- a/lib/key-commands/bootstrap.ts +++ b/lib/key-commands/bootstrap.ts @@ -1,4 +1,5 @@ import { SpecialKeys } from "../common/definitions/key-commands"; +import { registerBuiltInCommand } from "../common/services/command-definition-adapter"; import { injector } from "../common/yok"; const path = "./key-commands/index"; @@ -17,7 +18,20 @@ injector.requireKeyCommand("n", path); injector.requireKeyCommand(SpecialKeys.QuestionMark, path); injector.requireKeyCommand(SpecialKeys.CtrlC, path); -injector.requireCommand("open|ios", path); -injector.requireCommand("open|android", path); -injector.requireCommand("open|visionos", path); -injector.requireCommand("open|vision", path); + +registerBuiltInCommand( + "open|ios", + () => require("../commands/open").iosOpenCommand, +); +registerBuiltInCommand( + "open|android", + () => require("../commands/open").androidOpenCommand, +); +registerBuiltInCommand( + "open|visionos", + () => require("../commands/open").visionOpenCommand, +); +registerBuiltInCommand( + "open|vision", + () => require("../commands/open").visionOpenCommand, +); diff --git a/lib/key-commands/index.ts b/lib/key-commands/index.ts index 904e3bf3b8..e09ffc84cd 100644 --- a/lib/key-commands/index.ts +++ b/lib/key-commands/index.ts @@ -1,8 +1,10 @@ -import * as fs from "fs"; -import { platform as currentPlatform } from "os"; -import * as path from "path"; import { color } from "../color"; -import { PrepareCommand } from "../commands/prepare"; +import { + getAndroidStudioPath, + openAndroidStudioProject, + openVisionOSProject, + openXcodeProject, +} from "../commands/open"; import { IChildProcess, IXcodeSelectService } from "../common/declarations"; import { ICommand } from "../common/definitions/commands"; import { @@ -47,95 +49,24 @@ export class ShiftA implements IKeyCommand { private $logger: ILogger, private $liveSyncCommandHelper: ILiveSyncCommandHelper, private $childProcess: IChildProcess, - private $projectData: IProjectData + private $projectData: IProjectData, ) {} getAndroidStudioPath(): string | null { - const os = currentPlatform(); - - if (os === "darwin") { - const possibleStudioPaths = [ - "/Applications/Android Studio.app", - `${process.env.HOME}/Applications/Android Studio.app`, - ]; - - return possibleStudioPaths.find((p) => fs.existsSync(p)) || null; - } else if (os === "win32") { - const studioPath = path.join( - "C:", - "Program Files", - "Android", - "Android Studio", - "bin", - "studio64.exe" - ); - return fs.existsSync(studioPath) ? studioPath : null; - } else if (os === "linux") { - const studioPath = "/usr/local/android-studio/bin/studio.sh"; - return fs.existsSync(studioPath) ? studioPath : null; - } - - return null; + return getAndroidStudioPath(); } async execute(): Promise { - this.$liveSyncCommandHelper.validatePlatform(this.platform); - this.$projectData.initializeProjectData(); - const androidDir = `${this.$projectData.platformsDir}/android`; - - if (!fs.existsSync(androidDir)) { - const prepareCommand = injector.resolveCommand( - "prepare" - ) as PrepareCommand; - await prepareCommand.execute([this.platform]); - if (this.isInteractive) { - process.stdin.resume(); - } - } - - let studioPath = null; - - studioPath = process.env.NATIVESCRIPT_ANDROID_STUDIO_PATH; - - if (!studioPath) { - studioPath = this.getAndroidStudioPath(); - - if (!studioPath) { - this.$logger.error( - "Android Studio is not installed, or is not in a standard location. Use NATIVESCRIPT_ANDROID_STUDIO_PATH." - ); - return; - } - } - - const os = currentPlatform(); - if (os === "darwin") { - this.$childProcess.exec(`open -a "${studioPath}" ${androidDir}`); - } else if (os === "win32") { - const child = this.$childProcess.spawn(studioPath, [androidDir], { - detached: true, - stdio: "ignore", - }); - child.unref(); - } else if (os === "linux") { - this.$childProcess.exec(`${studioPath} ${androidDir}`); - } - } -} -export class OpenAndroidCommand extends ShiftA { - constructor( - $logger: ILogger, - $liveSyncCommandHelper: ILiveSyncCommandHelper, - $childProcess: IChildProcess, - $projectData: IProjectData, - private $options: IOptions - ) { - super($logger, $liveSyncCommandHelper, $childProcess, $projectData); - this.isInteractive = false; - } - async execute(): Promise { - this.$options.watch = false; - super.execute(); + await openAndroidStudioProject( + { + $logger: this.$logger, + $liveSyncCommandHelper: this.$liveSyncCommandHelper, + $childProcess: this.$childProcess, + $projectData: this.$projectData, + }, + this.platform, + this.isInteractive, + ); } } @@ -170,72 +101,22 @@ export class ShiftI implements IKeyCommand { private $childProcess: IChildProcess, private $projectData: IProjectData, private $xcodeSelectService: IXcodeSelectService, - private $xcodebuildArgsService: IXcodebuildArgsService + private $xcodebuildArgsService: IXcodebuildArgsService, ) {} async execute(): Promise { - const os = currentPlatform(); - if (os === "darwin") { - this.$projectData.initializeProjectData(); - const iosDir = path.resolve(this.$projectData.platformsDir, "ios"); - - if (!fs.existsSync(iosDir)) { - const prepareCommand = injector.resolveCommand( - "prepare" - ) as PrepareCommand; - - await prepareCommand.execute(["ios"]); - if (this.isInteractive) { - process.stdin.resume(); - } - } - const platformData = this.$iOSProjectService.getPlatformData( - this.$projectData - ); - const xcprojectFile = this.$xcodebuildArgsService.getXcodeProjectArgs( - platformData, - this.$projectData - )[1]; - - if (fs.existsSync(xcprojectFile)) { - this.$xcodeSelectService - .getDeveloperDirectoryPath() - .then(() => this.$childProcess.exec(`open ${xcprojectFile}`, {})) - .catch((e) => { - this.$logger.error(e.message); - }); - } else { - this.$logger.error(`Unable to open project file: ${xcprojectFile}`); - } - } else { - this.$logger.error("Opening a project in XCode requires macOS."); - } - } -} - -export class OpenIOSCommand extends ShiftI { - constructor( - $iOSProjectService: IOSProjectService, - $logger: ILogger, - $childProcess: IChildProcess, - $projectData: IProjectData, - $xcodeSelectService: IXcodeSelectService, - $xcodebuildArgsService: IXcodebuildArgsService, - private $options: IOptions - ) { - super( - $iOSProjectService, - $logger, - $childProcess, - $projectData, - $xcodeSelectService, - $xcodebuildArgsService + await openXcodeProject( + { + $iOSProjectService: this.$iOSProjectService, + $logger: this.$logger, + $childProcess: this.$childProcess, + $projectData: this.$projectData, + $xcodeSelectService: this.$xcodeSelectService, + $xcodebuildArgsService: this.$xcodebuildArgsService, + }, + "ios", + this.isInteractive, ); - this.isInteractive = false; - } - async execute(): Promise { - this.$options.watch = false; - super.execute(); } } @@ -271,78 +152,22 @@ export class ShiftV implements IKeyCommand { private $projectData: IProjectData, private $xcodeSelectService: IXcodeSelectService, private $xcodebuildArgsService: IXcodebuildArgsService, - protected $options: IOptions + protected $options: IOptions, ) {} async execute(): Promise { - this.$options.platformOverride = "visionOS"; - const os = currentPlatform(); - if (os === "darwin") { - this.$projectData.initializeProjectData(); - const visionOSDir = path.resolve( - this.$projectData.platformsDir, - "visionos" - ); - - if (!fs.existsSync(visionOSDir)) { - const prepareCommand = injector.resolveCommand( - "prepare" - ) as PrepareCommand; - - await prepareCommand.execute(["visionos"]); - if (this.isInteractive) { - process.stdin.resume(); - } - } - const platformData = this.$iOSProjectService.getPlatformData( - this.$projectData - ); - const xcprojectFile = this.$xcodebuildArgsService.getXcodeProjectArgs( - platformData, - this.$projectData - )[1]; - - if (fs.existsSync(xcprojectFile)) { - this.$xcodeSelectService - .getDeveloperDirectoryPath() - .then(() => this.$childProcess.exec(`open ${xcprojectFile}`, {})) - .catch((e) => { - this.$logger.error(e.message); - }); - } else { - this.$logger.error(`Unable to open project file: ${xcprojectFile}`); - } - } else { - this.$logger.error("Opening a project in XCode requires macOS."); - } - this.$options.platformOverride = null; - } -} - -export class OpenVisionOSCommand extends ShiftV { - constructor( - $iOSProjectService: IOSProjectService, - $logger: ILogger, - $childProcess: IChildProcess, - $projectData: IProjectData, - $xcodeSelectService: IXcodeSelectService, - $xcodebuildArgsService: IXcodebuildArgsService, - protected $options: IOptions - ) { - super( - $iOSProjectService, - $logger, - $childProcess, - $projectData, - $xcodeSelectService, - $xcodebuildArgsService, - $options + await openVisionOSProject( + { + $iOSProjectService: this.$iOSProjectService, + $logger: this.$logger, + $childProcess: this.$childProcess, + $projectData: this.$projectData, + $xcodeSelectService: this.$xcodeSelectService, + $xcodebuildArgsService: this.$xcodebuildArgsService, + }, + this.$options, + this.isInteractive, ); - this.isInteractive = false; - } - async execute(): Promise { - this.$options.watch = false; - super.execute(); } } @@ -356,16 +181,15 @@ export class R implements IKeyCommand { constructor(private $liveSyncCommandHelper: ILiveSyncCommandHelper) {} async execute(platform: string): Promise { - const devices = await this.$liveSyncCommandHelper.getDeviceInstances( - platform - ); + const devices = + await this.$liveSyncCommandHelper.getDeviceInstances(platform); await this.$liveSyncCommandHelper.executeLiveSyncOperation( devices, platform, { restartLiveSync: true, - } as ILiveSyncCommandHelperAdditionalOptions + } as ILiveSyncCommandHelperAdditionalOptions, ); } } @@ -380,9 +204,8 @@ export class ShiftR implements IKeyCommand { constructor(private $liveSyncCommandHelper: ILiveSyncCommandHelper) {} async execute(platform: string): Promise { - const devices = await this.$liveSyncCommandHelper.getDeviceInstances( - platform - ); + const devices = + await this.$liveSyncCommandHelper.getDeviceInstances(platform); await this.$liveSyncCommandHelper.executeLiveSyncOperation( devices, platform, @@ -390,7 +213,7 @@ export class ShiftR implements IKeyCommand { skipNativePrepare: false, forceRebuildNativeApp: true, restartLiveSync: true, - } as ILiveSyncCommandHelperAdditionalOptions + } as ILiveSyncCommandHelperAdditionalOptions, ); } } @@ -422,7 +245,7 @@ export class W implements IKeyCommand { process.stdout.write( paused ? color.gray("Paused watching file changes... Press 'w' to resume.") - : color.bgGreen("Resumed watching file changes") + : color.bgGreen("Resumed watching file changes"), ); } catch (e) {} } @@ -437,7 +260,7 @@ export class C implements IKeyCommand { constructor( private $childProcess: IChildProcess, - private $liveSyncCommandHelper: ILiveSyncCommandHelper + private $liveSyncCommandHelper: ILiveSyncCommandHelper, ) {} async execute(): Promise { @@ -515,8 +338,3 @@ injector.registerKeyCommand("A", ShiftA); injector.registerKeyCommand("n", N); injector.registerKeyCommand(SpecialKeys.QuestionMark, QuestionMark); injector.registerKeyCommand(SpecialKeys.CtrlC, CtrlC); - -injector.registerCommand("open|ios", OpenIOSCommand); -injector.registerCommand("open|visionos", OpenVisionOSCommand); -injector.registerCommand("open|vision", OpenVisionOSCommand); -injector.registerCommand("open|android", OpenAndroidCommand); diff --git a/lib/platform-command-param.ts b/lib/platform-command-param.ts index 2b8f7ef2ef..2b61cc847c 100644 --- a/lib/platform-command-param.ts +++ b/lib/platform-command-param.ts @@ -3,10 +3,14 @@ import { IPlatformValidationService } from "./declarations"; import { injector } from "./common/yok"; import { ICommandParameter } from "./common/definitions/commands"; +/** + * @deprecated Use the platformArgument spec from lib/commands/command-base. Kept for + * commands still implementing ICommand. + */ export class PlatformCommandParameter implements ICommandParameter { constructor( private $platformValidationService: IPlatformValidationService, - private $projectData: IProjectData + private $projectData: IProjectData, ) {} mandatory = true; async validate(value: string): Promise { diff --git a/test/commands/post-install.ts b/test/commands/post-install.ts index fb9464d082..f7dd021f95 100644 --- a/test/commands/post-install.ts +++ b/test/commands/post-install.ts @@ -1,6 +1,7 @@ import { Yok } from "../../lib/common/yok"; import { assert } from "chai"; -import { PostInstallCliCommand } from "../../lib/commands/post-install"; +import { postInstallCliCommandDefinition } from "../../lib/commands/post-install"; +import { registerCommand } from "../../lib/common/services/command-definition-adapter"; import { SettingsService } from "../../lib/common/test/unit-tests/stubs"; import { IInjector } from "../../lib/common/definitions/yok"; import { IHelpService, IAnalyticsService } from "../../lib/common/declarations"; @@ -17,7 +18,7 @@ const createTestInjector = (): IInjector => { testInjector.register("commandsService", { tryExecuteCommand: async ( commandName: string, - commandArguments: string[] + commandArguments: string[], ): Promise => undefined, }); @@ -44,7 +45,7 @@ const createTestInjector = (): IInjector => { testInjector.register("settingsService", SettingsService); - testInjector.registerCommand("post-install-cli", PostInstallCliCommand); + registerCommand(postInstallCliCommandDefinition, testInjector); testInjector.register("hostInfo", {}); @@ -71,17 +72,15 @@ describe("post-install command", () => { isGenerateHtmlPagesCalled = true; }; - const analyticsService = testInjector.resolve( - "analyticsService" - ); + const analyticsService = + testInjector.resolve("analyticsService"); let isCheckConsentCalled = false; analyticsService.checkConsent = async (): Promise => { isCheckConsentCalled = true; }; - const commandsService = testInjector.resolve( - "commandsService" - ); + const commandsService = + testInjector.resolve("commandsService"); let isTryExecuteCommandCalled = false; commandsService.tryExecuteCommand = async (): Promise => { isTryExecuteCommandCalled = true; @@ -98,17 +97,17 @@ describe("post-install command", () => { assert.equal( isGenerateHtmlPagesCalled, opts.shouldCallMethod, - `post-install-cli command must ${hasNotInMsg} call helpService.generateHtmlPages` + `post-install-cli command must ${hasNotInMsg} call helpService.generateHtmlPages`, ); assert.equal( isCheckConsentCalled, opts.shouldCallMethod, - `post-install-cli command must ${hasNotInMsg} call analyticsService.checkConsent` + `post-install-cli command must ${hasNotInMsg} call analyticsService.checkConsent`, ); assert.equal( isTryExecuteCommandCalled, opts.shouldCallMethod, - `post-install-cli command must ${hasNotInMsg} call commandsService.tryExecuteCommand` + `post-install-cli command must ${hasNotInMsg} call commandsService.tryExecuteCommand`, ); }; diff --git a/test/platform-commands.ts b/test/platform-commands.ts index 8efe669bd9..6dcc2739a1 100644 --- a/test/platform-commands.ts +++ b/test/platform-commands.ts @@ -1,9 +1,10 @@ import * as yok from "../lib/common/yok"; import * as stubs from "./stubs"; -import * as PlatformAddCommandLib from "../lib/commands/add-platform"; -import * as PlatformRemoveCommandLib from "../lib/commands/remove-platform"; -import * as PlatformUpdateCommandLib from "../lib/commands/update-platform"; -import * as PlatformCleanCommandLib from "../lib/commands/platform-clean"; +import { addPlatformCommandDefinition } from "../lib/commands/add-platform"; +import { removePlatformCommandDefinition } from "../lib/commands/remove-platform"; +import { updatePlatformCommandDefinition } from "../lib/commands/update-platform"; +import { platformCleanCommandDefinition } from "../lib/commands/platform-clean"; +import { registerCommand } from "../lib/common/services/command-definition-adapter"; import * as StaticConfigLib from "../lib/config"; import * as CommandsServiceLib from "../lib/common/services/commands-service"; import * as optionsLib from "../lib/options"; @@ -43,7 +44,7 @@ class PlatformData implements IPlatformData { platformNameLowerCase = "android"; platformProjectService: IPlatformProjectService = { validate: async ( - projectData: IProjectData + projectData: IProjectData, ): Promise => { return { checkEnvironmentRequirementsOutput: { @@ -84,7 +85,7 @@ class ErrorsNoFailStub implements IErrors { async beginCommand( action: () => Promise, - printHelpCommand: () => Promise + printHelpCommand: () => Promise, ): Promise { let result = false; try { @@ -111,7 +112,7 @@ class ErrorsNoFailStub implements IErrors { parsed: any, knownOpts: any, shorthands: any, - clientName?: string + clientName?: string, ): void { /* intentionally left blank */ } @@ -145,7 +146,7 @@ function createTestInjector() { testInjector.register("logger", stubs.LoggerStub); testInjector.register( "packageInstallationManager", - stubs.PackageInstallationManagerStub + stubs.PackageInstallationManagerStub, ); testInjector.register("projectData", stubs.ProjectDataStub); testInjector.register("platformsDataService", PlatformsDataService); @@ -154,22 +155,10 @@ function createTestInjector() { testInjector.register("prompter", {}); testInjector.register("sysInfo", {}); testInjector.register("commands-service", CommandsServiceLib.CommandsService); - testInjector.registerCommand( - "platform|add", - PlatformAddCommandLib.AddPlatformCommand - ); - testInjector.registerCommand( - "platform|remove", - PlatformRemoveCommandLib.RemovePlatformCommand - ); - testInjector.registerCommand( - "platform|update", - PlatformUpdateCommandLib.UpdatePlatformCommand - ); - testInjector.registerCommand( - "platform|clean", - PlatformCleanCommandLib.CleanCommand - ); + registerCommand(addPlatformCommandDefinition, testInjector); + registerCommand(removePlatformCommandDefinition, testInjector); + registerCommand(updatePlatformCommandDefinition, testInjector); + registerCommand(platformCleanCommandDefinition, testInjector); testInjector.register("resources", {}); testInjector.register("commandsService", { tryExecuteCommand: () => { @@ -188,13 +177,13 @@ function createTestInjector() { }); testInjector.register( "projectFilesManager", - ProjectFilesManagerLib.ProjectFilesManager + ProjectFilesManagerLib.ProjectFilesManager, ); testInjector.register("hooksService", stubs.HooksServiceStub); testInjector.register( "localToDevicePathDataFactory", - LocalToDevicePathDataFactory + LocalToDevicePathDataFactory, ); testInjector.register("mobileHelper", MobileHelper); testInjector.register("projectFilesProvider", ProjectFilesProvider); @@ -204,7 +193,7 @@ function createTestInjector() { testInjector.register("childProcess", ChildProcessLib.ChildProcess); testInjector.register( "projectChangesService", - ProjectChangesLib.ProjectChangesService + ProjectChangesLib.ProjectChangesService, ); testInjector.register("analyticsService", { track: async () => async (): Promise => undefined, @@ -229,7 +218,7 @@ function createTestInjector() { checkEnvironmentRequirements: async ( platform?: string, projectDir?: string, - runtimeVersion?: string + runtimeVersion?: string, ): Promise => { return { canExecute: true, @@ -241,7 +230,7 @@ function createTestInjector() { extractPackage: async ( packageName: string, destinationDirectory: string, - options?: IPacoteExtractOptions + options?: IPacoteExtractOptions, ): Promise => undefined, }); testInjector.register("optionsTracker", { @@ -280,7 +269,7 @@ describe("Platform Service Tests", () => { it("is not executed when platform is not passed", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -296,7 +285,7 @@ describe("Platform Service Tests", () => { it("is not executed when platform is not valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { if (commandName !== "help") { @@ -316,7 +305,7 @@ describe("Platform Service Tests", () => { it("is executed when platform is valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -332,7 +321,7 @@ describe("Platform Service Tests", () => { it("is executed when all platforms are valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -351,7 +340,7 @@ describe("Platform Service Tests", () => { it("is not executed when at least one platform is not valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -372,7 +361,7 @@ describe("Platform Service Tests", () => { it("is not executed when platform is not passed", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -388,7 +377,7 @@ describe("Platform Service Tests", () => { it("is not executed when platform is not valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -406,7 +395,7 @@ describe("Platform Service Tests", () => { it("is executed when platform is valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -421,7 +410,7 @@ describe("Platform Service Tests", () => { it("is executed when all platforms are valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -440,7 +429,7 @@ describe("Platform Service Tests", () => { it("is not executed when at least one platform is not valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -467,7 +456,7 @@ describe("Platform Service Tests", () => { it("is not executed when platform is not passed", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -483,7 +472,7 @@ describe("Platform Service Tests", () => { it("is not executed when platform is not valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -502,7 +491,7 @@ describe("Platform Service Tests", () => { let commandsExecutedCount = 0; isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -525,7 +514,7 @@ describe("Platform Service Tests", () => { it("is not executed when platform is not added", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -542,7 +531,7 @@ describe("Platform Service Tests", () => { let commandsExecutedCount = 0; isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -569,7 +558,7 @@ describe("Platform Service Tests", () => { it("is not executed when at least one platform is not added", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -592,7 +581,7 @@ describe("Platform Service Tests", () => { it("is not executed when at least one platform is not valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -630,7 +619,7 @@ describe("Platform Service Tests", () => { assert.deepStrictEqual( platformActions, expectedPlatformActions, - "Expected `remove ios`, `add ios` calls to the platformService." + "Expected `remove ios`, `add ios` calls to the platformService.", ); }); }); @@ -639,7 +628,7 @@ describe("Platform Service Tests", () => { it("is not executed when platform is not passed", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -655,7 +644,7 @@ describe("Platform Service Tests", () => { it("is not executed when platform is not valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -673,7 +662,7 @@ describe("Platform Service Tests", () => { it("is executed when platform is valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -689,7 +678,7 @@ describe("Platform Service Tests", () => { it("is executed when all platforms are valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; @@ -708,7 +697,7 @@ describe("Platform Service Tests", () => { it("is not executed when at least one platform is not valid", async () => { isCommandExecuted = false; commandsService.executeCommandUnchecked = async ( - commandName: string + commandName: string, ): Promise => { if (commandName !== "help") { isCommandExecuted = true; diff --git a/test/plugin-create.ts b/test/plugin-create.ts index fdb9e68a93..399a8927a3 100644 --- a/test/plugin-create.ts +++ b/test/plugin-create.ts @@ -1,6 +1,15 @@ import { Yok } from "../lib/common/yok"; import * as stubs from "./stubs"; -import { CreatePluginCommand } from "../lib/commands/plugin/create-plugin"; +import { + createPluginCommandDefinition, + INCLUDE_ANGULAR_DEMO_MESSAGE, + INCLUDE_TYPESCRIPT_DEMO_MESSAGE, + NAME_MESSAGE, + PATH_ALREADY_EXISTS_MESSAGE_TEMPLATE, + USER_MESSAGE, +} from "../lib/commands/plugin/create-plugin"; +import { registerCommand } from "../lib/common/services/command-definition-adapter"; +import { ICommand } from "../lib/common/definitions/commands"; import { assert } from "chai"; import * as helpers from "../lib/common/helpers"; import * as sinon from "sinon"; @@ -63,7 +72,7 @@ function createTestInjector() { }, }); - testInjector.register("createCommand", CreatePluginCommand); + registerCommand(createPluginCommandDefinition, testInjector); return testInjector; } @@ -71,14 +80,14 @@ function createTestInjector() { describe("Plugin create command tests", () => { let testInjector: IInjector; let options: IOptions; - let createPluginCommand: CreatePluginCommand; + let createPluginCommand: ICommand; beforeEach(() => { // @ts-expect-error helpers.isInteractive = () => true; testInjector = createTestInjector(); options = testInjector.resolve("$options"); - createPluginCommand = testInjector.resolve("$createCommand"); + createPluginCommand = testInjector.resolveCommand("plugin|create"); }); afterEach(() => { @@ -121,12 +130,11 @@ describe("Plugin create command tests", () => { const prompter = testInjector.resolve("$prompter"); const strings: IDictionary = {}; const confirmQuestions: IDictionary = {}; - strings[createPluginCommand.userMessage] = dummyUser; - strings[createPluginCommand.nameMessage] = dummyName; - confirmQuestions[createPluginCommand.includeTypeScriptDemoMessage] = - createDemoProjectAnswer; - confirmQuestions[createPluginCommand.includeAngularDemoMessage] = + strings[USER_MESSAGE] = dummyUser; + strings[NAME_MESSAGE] = dummyName; + confirmQuestions[INCLUDE_TYPESCRIPT_DEMO_MESSAGE] = createDemoProjectAnswer; + confirmQuestions[INCLUDE_ANGULAR_DEMO_MESSAGE] = createDemoProjectAnswer; prompter.expect({ strings: strings, @@ -141,11 +149,10 @@ describe("Plugin create command tests", () => { const prompter = testInjector.resolve("$prompter"); const strings: IDictionary = {}; const confirmQuestions: IDictionary = {}; - strings[createPluginCommand.nameMessage] = dummyName; - confirmQuestions[createPluginCommand.includeTypeScriptDemoMessage] = - createDemoProjectAnswer; - confirmQuestions[createPluginCommand.includeAngularDemoMessage] = + strings[NAME_MESSAGE] = dummyName; + confirmQuestions[INCLUDE_TYPESCRIPT_DEMO_MESSAGE] = createDemoProjectAnswer; + confirmQuestions[INCLUDE_ANGULAR_DEMO_MESSAGE] = createDemoProjectAnswer; prompter.expect({ strings: strings, @@ -161,11 +168,10 @@ describe("Plugin create command tests", () => { const strings: IDictionary = {}; const confirmQuestions: IDictionary = {}; - strings[createPluginCommand.userMessage] = dummyUser; - confirmQuestions[createPluginCommand.includeTypeScriptDemoMessage] = - createDemoProjectAnswer; - confirmQuestions[createPluginCommand.includeAngularDemoMessage] = + strings[USER_MESSAGE] = dummyUser; + confirmQuestions[INCLUDE_TYPESCRIPT_DEMO_MESSAGE] = createDemoProjectAnswer; + confirmQuestions[INCLUDE_ANGULAR_DEMO_MESSAGE] = createDemoProjectAnswer; prompter.expect({ strings, @@ -180,10 +186,9 @@ describe("Plugin create command tests", () => { const prompter = testInjector.resolve("$prompter"); const strings: IDictionary = {}; const confirmQuestions: IDictionary = {}; - strings[createPluginCommand.userMessage] = dummyUser; - strings[createPluginCommand.nameMessage] = dummyName; - confirmQuestions[createPluginCommand.includeAngularDemoMessage] = - createDemoProjectAnswer; + strings[USER_MESSAGE] = dummyUser; + strings[NAME_MESSAGE] = dummyName; + confirmQuestions[INCLUDE_ANGULAR_DEMO_MESSAGE] = createDemoProjectAnswer; prompter.expect({ strings: strings, @@ -199,9 +204,9 @@ describe("Plugin create command tests", () => { const strings: IDictionary = {}; const confirmQuestions: IDictionary = {}; - strings[createPluginCommand.userMessage] = dummyUser; - strings[createPluginCommand.nameMessage] = dummyName; - confirmQuestions[createPluginCommand.includeTypeScriptDemoMessage] = + strings[USER_MESSAGE] = dummyUser; + strings[NAME_MESSAGE] = dummyName; + confirmQuestions[INCLUDE_TYPESCRIPT_DEMO_MESSAGE] = createDemoProjectAnswer; prompter.expect({ @@ -275,10 +280,7 @@ describe("Plugin create command tests", () => { await assert.isRejected( executePromise, - util.format( - createPluginCommand.pathAlreadyExistsMessageTemplate, - projectPath, - ), + util.format(PATH_ALREADY_EXISTS_MESSAGE_TEMPLATE, projectPath), ); assert(fsSpy.notCalled); }); diff --git a/test/plugins-service.ts b/test/plugins-service.ts index 85bc40641d..5fe354efea 100644 --- a/test/plugins-service.ts +++ b/test/plugins-service.ts @@ -19,7 +19,8 @@ import { ProjectDataService } from "../lib/services/project-data-service"; import { ProjectFilesManager } from "../lib/common/services/project-files-manager"; import { ResourceLoader } from "../lib/common/resource-loader"; import { PluginsService } from "../lib/services/plugins-service"; -import { AddPluginCommand } from "../lib/commands/plugin/add-plugin"; +import { addPluginCommandDefinition } from "../lib/commands/plugin/add-plugin"; +import { registerCommand } from "../lib/common/services/command-definition-adapter"; import { MessagesService } from "../lib/common/services/messages-service"; import { NodeModulesBuilder } from "../lib/tools/node-modules/node-modules-builder"; import { AndroidProjectService } from "../lib/services/android-project-service"; @@ -246,7 +247,12 @@ function createProjectFile(testInjector: IInjector): string { const fs = testInjector.resolve("fs") as FileSystem; const tempFolder = mkdtempSync(path.join(tmpdir(), "pluginsService-")); const options = testInjector.resolve("options"); - options.path = tempFolder; + // An own property rather than options.path: the accessor writes into argv, + // which the command line re-parse that precedes a command replaces. + Object.defineProperty(options, "path", { + value: tempFolder, + configurable: true, + }); const packageJsonData = { name: "testModuleName", @@ -321,8 +327,7 @@ describe("Plugins service", () => { const commands = ["add", "install"]; beforeEach(() => { testInjector = createTestInjector(); - testInjector.registerCommand("plugin|add", AddPluginCommand); - testInjector.registerCommand("plugin|install", AddPluginCommand); + registerCommand(addPluginCommandDefinition, testInjector); }); _.each(commands, (command) => { diff --git a/test/project-commands.ts b/test/project-commands.ts index bb61c67771..6fd748279e 100644 --- a/test/project-commands.ts +++ b/test/project-commands.ts @@ -1,6 +1,7 @@ import { Yok } from "../lib/common/yok"; import * as stubs from "./stubs"; -import { CreateProjectCommand } from "../lib/commands/create-project"; +import { createProjectCommandDefinition } from "../lib/commands/create-project"; +import { registerCommand } from "../lib/common/services/command-definition-adapter"; import { StringCommandParameter } from "../lib/common/command-params"; import { setIsInteractive } from "../lib/common/helpers"; import * as constants from "../lib/constants"; @@ -168,7 +169,7 @@ function createTestInjector() { ng: false, template: undefined, }); - testInjector.register("createCommand", CreateProjectCommand); + registerCommand(createProjectCommandDefinition, testInjector); testInjector.register("stringParameter", StringCommandParameter); testInjector.register("prompter", PrompterStub); @@ -226,7 +227,7 @@ describe("Project commands tests", () => { createProjectCalledWithForce = false; selectedTemplateName = undefined; options = testInjector.resolve("$options"); - createProjectCommand = testInjector.resolve("$createCommand"); + createProjectCommand = testInjector.resolveCommand("create"); }); afterEach(() => { diff --git a/test/tns-appstore-upload.ts b/test/tns-appstore-upload.ts index 2fac17b6ab..72e1ba0d6d 100644 --- a/test/tns-appstore-upload.ts +++ b/test/tns-appstore-upload.ts @@ -1,4 +1,6 @@ -import { PublishIOS } from "../lib/commands/appstore-upload"; +import { publishIOSCommandDefinition } from "../lib/commands/appstore-upload"; +import { registerCommand } from "../lib/common/services/command-definition-adapter"; +import { Injector } from "../lib/common/di"; import { PrompterStub, LoggerStub, @@ -48,9 +50,6 @@ class AppStore { projectRoot: "/Users/person/git/MyProject", }; this.initInjector({ - commands: { - appstore: PublishIOS, - }, services: { errors: {}, fs: {}, @@ -95,20 +94,19 @@ class AppStore { this.command = this.injector.resolveCommand("appstore"); } - initInjector(services?: { - commands?: { [service: string]: any }; - services?: { [service: string]: any }; - }) { + initInjector(services?: { services?: { [service: string]: any } }) { this.injector = new yok.Yok(); if (services) { - for (const cmd in services.commands) { - this.injector.registerCommand(cmd, services.commands[cmd]); - } for (const serv in services.services) { this.injector.register(serv, services.services[serv]); } } + registerCommand( + { ...publishIOSCommandDefinition, name: "appstore" }, + (this.injector), + ); + this.injector.register("projectDataService", ProjectDataServiceStub); } diff --git a/test/update.ts b/test/update.ts index 3842ae534c..c20cf94922 100644 --- a/test/update.ts +++ b/test/update.ts @@ -1,6 +1,8 @@ import * as stubs from "./stubs"; import * as yok from "../lib/common/yok"; -import { UpdateCommand } from "../lib/commands/update"; +import { updateCommandDefinition } from "../lib/commands/update"; +import { registerCommand } from "../lib/common/services/command-definition-adapter"; +import { ICommand } from "../lib/common/definitions/commands"; import { assert } from "chai"; import { Options } from "../lib/options"; import { StaticConfig } from "../lib/config"; @@ -42,6 +44,8 @@ function createTestInjector(projectDir: string = projectFolder): IInjector { }, }); + registerCommand(updateCommandDefinition, testInjector); + return testInjector; } @@ -49,7 +53,7 @@ describe("update command method tests", () => { describe("canExecute", () => { it("returns false if too many arguments", async () => { const testInjector = createTestInjector(); - const updateCommand = testInjector.resolve(UpdateCommand); + const updateCommand: ICommand = testInjector.resolveCommand("update"); const canExecuteOutput = await updateCommand.canExecute([ "333", "111", @@ -61,7 +65,7 @@ describe("update command method tests", () => { it("returns false when projectDir is an empty string", async () => { const testInjector = createTestInjector(""); - const updateCommand = testInjector.resolve(UpdateCommand); + const updateCommand: ICommand = testInjector.resolveCommand("update"); const canExecuteOutput = await updateCommand.canExecute([]); return assert.equal(canExecuteOutput, false); @@ -69,7 +73,7 @@ describe("update command method tests", () => { it("returns true when the setup is correct", async () => { const testInjector = createTestInjector(); - const updateCommand = testInjector.resolve(UpdateCommand); + const updateCommand: ICommand = testInjector.resolveCommand("update"); const canExecuteOutput = await updateCommand.canExecute(["3.3.0"]); return assert.equal(canExecuteOutput, true); From 13db77e1fb45766ae5e389ff2d53965a0d8c13ef Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 16:44:51 -0300 Subject: [PATCH 03/19] feat(commands): register commands lazily against a checked definition type registerCommand takes one shape and registers in one call, off the global binding and into the loading context's injector; built-in commands load on first use through a shared helper; getInjector becomes getRootInjector. Package-manager commands register from their real path, dev-post-install is reachable again, and a mistyped subcommand shows help in the terminal. --- defining-commands.md | 147 +++++- extensions.md | 2 +- lib/bootstrap.ts | 457 +++++++++++++---- lib/commands/add-platform.ts | 3 - lib/commands/apple-login.ts | 3 - lib/commands/appstore-list.ts | 3 - lib/commands/appstore-upload.ts | 3 - lib/commands/build.ts | 215 ++++---- lib/commands/clean.ts | 3 - lib/commands/config.ts | 5 - lib/commands/create-project.ts | 3 - lib/commands/debug.ts | 166 +++--- lib/commands/deploy.ts | 3 - lib/commands/embedding/embed.ts | 3 - .../extensibility/install-extension.ts | 3 - lib/commands/extensibility/list-extensions.ts | 3 - .../extensibility/uninstall-extension.ts | 3 - lib/commands/fonts.ts | 3 - lib/commands/generate-assets.ts | 71 ++- lib/commands/generate-help.ts | 3 - lib/commands/generate.ts | 3 - lib/commands/hooks/hooks-lock.ts | 4 - lib/commands/hooks/hooks.ts | 4 - lib/commands/info.ts | 3 - lib/commands/install.ts | 3 - lib/commands/list-platforms.ts | 3 - lib/commands/migrate.ts | 3 - lib/commands/native-add.ts | 98 ++-- lib/commands/platform-clean.ts | 3 - lib/commands/plugin/add-plugin.ts | 3 - lib/commands/plugin/build-plugin.ts | 3 - lib/commands/plugin/create-plugin.ts | 3 - lib/commands/plugin/list-plugins.ts | 3 - lib/commands/plugin/remove-plugin.ts | 3 - lib/commands/plugin/update-plugin.ts | 3 - lib/commands/post-install.ts | 3 - lib/commands/prepare.ts | 3 - lib/commands/preview.ts | 3 - lib/commands/remove-platform.ts | 3 - lib/commands/resources/resources-update.ts | 3 - lib/commands/run.ts | 129 +++-- lib/commands/setup.ts | 3 - lib/commands/start.ts | 3 - lib/commands/test-init.ts | 3 - lib/commands/test.ts | 35 +- lib/commands/typings.ts | 3 - lib/commands/update-platform.ts | 3 - lib/commands/update.ts | 3 - lib/commands/widget.ts | 3 - lib/common/bootstrap.ts | 222 ++++++-- lib/common/commands/analytics.ts | 70 +-- lib/common/commands/autocompletion.ts | 6 - .../commands/device/device-log-stream.ts | 3 - lib/common/commands/device/get-file.ts | 3 - .../commands/device/list-applications.ts | 3 - lib/common/commands/device/list-devices.ts | 75 ++- lib/common/commands/device/list-files.ts | 3 - lib/common/commands/device/put-file.ts | 3 - lib/common/commands/device/run-application.ts | 3 - .../commands/device/stop-application.ts | 3 - .../commands/device/uninstall-application.ts | 3 - lib/common/commands/doctor.ts | 73 ++- lib/common/commands/generate-messages.ts | 3 - lib/common/commands/help.ts | 3 - lib/common/commands/package-manager-get.ts | 3 - lib/common/commands/package-manager-set.ts | 3 - lib/common/commands/post-install.ts | 3 - lib/common/commands/preuninstall.ts | 3 - lib/common/commands/proxy/proxy-clear.ts | 3 - lib/common/commands/proxy/proxy-get.ts | 3 - lib/common/commands/proxy/proxy-set.ts | 3 - lib/common/contracts/command-registry.ts | 40 +- lib/common/contracts/index.ts | 6 +- lib/common/define-command.ts | 38 +- lib/common/deprecation.ts | 2 +- lib/common/di/index.ts | 2 +- lib/common/di/inject.ts | 9 + lib/common/helpers.ts | 2 +- .../services/command-definition-adapter.ts | 180 ++++++- lib/common/test/unit-tests/preuninstall.ts | 5 +- lib/common/yok.ts | 34 +- lib/services/extensibility-service.ts | 47 +- test/command-registration.ts | 18 + test/commands/post-install.ts | 5 +- test/compat/injector-facade-surface.ts | 10 +- test/compat/legacy-hooks.ts | 6 +- test/define-command.ts | 478 ++++++++++++++---- test/deprecation.ts | 8 +- test/extension-manifests.ts | 57 ++- test/platform-commands.ts | 17 +- test/plugin-create.ts | 5 +- test/plugins-service.ts | 5 +- test/project-commands.ts | 5 +- test/tns-appstore-upload.ts | 6 +- test/type-fixtures/define-command-types.ts | 58 +++ test/update.ts | 5 +- 96 files changed, 1980 insertions(+), 1009 deletions(-) diff --git a/defining-commands.md b/defining-commands.md index dcbf1ba012..0d510e1c00 100644 --- a/defining-commands.md +++ b/defining-commands.md @@ -485,48 +485,159 @@ leaves the CLI's defaults in place. Registering a definition ------------------------ -Inside the CLI, a definition is registered with `registerCommandDefinition`: +Inside the CLI, a definition is registered with `registerCommand`: ```ts -import { registerCommandDefinition } from "../common/services/command-definition-adapter"; -import addWidgetCommand from "./add-widget"; +import { registerCommand } from "../common/services/command-definition-adapter"; -registerCommandDefinition(addWidgetCommand); +registerCommand({ + name: "widget|add", + options: { force: booleanOption({ default: false }) }, + run: async (ctx) => { … }, +}); +``` + +It takes either a `DefinedCommand` — the result of `defineCommand`, marker and +all — or the definition itself, which it defines on your behalf, so registering +a command is one call. Either way the definition is validated before it reaches +the registry. It claims every name the definition declares, through the +`CommandRegistry` the target injector provides, and returns a +`DeferredCommandResult` — see *The owner is ambient* below. The command instance +is built by a factory on first resolution and cached. + +Pass providers as the second argument to scope the command to a child injector +of the one it registers against — how a definition is parameterized per +registration: + +```ts +for (const [name, platform] of buildCommandPlatforms) { + registerCommand({ ...buildCommandDefinition, name }, [ + { provide: BUILD_PLATFORM, useValue: platform }, + ]); +} +``` + +That is how one definition serves several commands that differ only in data — +the platform each one targets — instead of one command subclassing another. + +**Which injector it registers against is not a parameter.** It is the injector +of the current injection context — see *The owner is ambient* below — and the +CLI's own injector outside one. To register against some other injector, run +the call in its context: + +```ts +runInInjectionContext(someInjector, () => registerCommand(definition)); ``` -It takes a `DefinedCommand` — the result of `defineCommand`, marker and all — -and rejects a bare object of the right shape, so a definition can never reach -the registry without having been validated. It registers under every name the -definition declares, through the `CommandRegistry` the target injector provides; -pass a second argument to target a different injector (tests do this). The -command instance is built by a factory on first resolution and cached. +A test registering into its own container does that too, which is the same +path the CLI itself takes. -`registerCommandDefinition` lives in +`registerCommand` lives in `lib/common/services/command-definition-adapter` rather than in `nativescript/contracts`, because it reaches into the CLI runtime — the side-effect-free contracts entry point deliberately does not pull it in. `defineCommand`, the option helpers and all the types are exported from both `nativescript/contracts` and `lib/common/define-command`. -Extensions do not need `registerCommandDefinition` at all: a +Extensions do not need `registerCommand` at all: a `nativescript.commands` manifest entry may point straight at a module that exports a definition, and the CLI adapts and registers it lazily under the manifest key (see [extensions.md](extensions.md)). +### Registering lazily + +`registerCommand` needs the definition in hand, which means loading the module +that holds it. `registerLazyCommand` claims the name instead, and loads the +module the first time that one command is resolved: + +```ts +import { registerLazyCommand } from "../common/services/command-definition-adapter"; + +registerLazyCommand( + "run|ios", + () => require("./commands/run").iosRunCommand, +); +``` + +The name routes immediately — including through the `run` dispatcher the CLI +synthesizes for it — so listing commands, resolving a sibling, or printing help +for the parent never loads `run.js`. The loader runs on the resolution of +`run|ios` alone, and what it returns is registered under the name that was +claimed. + +**The type argument is mandatory.** `require()` is typed `any`, so nothing can +be inferred from the loader: without the type argument the name would be +checked against nothing at all. Leave it off and the `name` parameter says so: + +``` +error TS2345: Argument of type '"run|ios"' is not assignable to parameter of type +'"Pass the definition type: registerLazyCommand(...)"' +``` + +With the type argument, the name is checked against the one the definition +declares — every one of them, for a definition that declares aliases: + +``` +error TS2345: Argument of type '"run|iosss"' is not assignable to parameter of +type '"run|ios"' +``` + +and a type argument that is not a definition is rejected against the +constraint. The loader is re-checked at runtime as well, because the guarantee +is only as good as the type the call site passed. + +**The loader must be synchronous.** `CommandsService` reads the resolved +command's `dashedOptions` before it validates the command line, so a command +that is still being imported has no options to validate against — a dynamic +`import()` here would report every flag as unknown. `require` is the tool for +this job. + +**Providers are optional and cost nothing until the command runs.** The child +injector is built inside the loader, so a name that is never resolved never +creates one: + +```ts +registerLazyCommand( + "x", + () => require("./commands/x").cmd, + [{ provide: SOME_TOKEN, useValue: "value" }], +); +``` + +**The owner is ambient.** Every registration has an owner, which attributes +conflicts and load failures and makes re-registering the same name under the +same owner a no-op instead of a conflict. It is not a parameter: the helper +targets the injector of the current injection context when there is one, and +reads `COMMAND_OWNER` off it. Outside a context it targets the CLI's own +injector, and the CLI is the owner. An extension's module is loaded inside a +context whose injector provides `COMMAND_OWNER`, so a command the module +registers on its own is attributed to the extension without the module naming +itself — through `registerCommand` just as much as through this helper. + +`registerCommand` therefore returns a `DeferredCommandResult` too: every +registration is arbitrated against the names already claimed, rather than +overwriting one. + +**Conflicts are returned, not thrown.** The result is the same +`DeferredCommandResult` the extension manifest path gets — `{ registered: +true }`, or `registered: false` with a `rejection` to branch on. The CLI's own +bootstrap wraps the call and throws, because a name it cannot claim is a +mistake in `bootstrap.ts`; a host loading someone else's command usually wants +to warn and carry on. `describeRejection(rejection)` renders one for a human. + ### One definition, several registrations A family of commands that differ only in a value — `run|android` and `run|ios`, -say — is one definition registered several times, each against a child injector -that provides the value: +say — is one definition registered several times, each with providers that +carry the value: ```ts const PLATFORM = new InjectionToken("commandPlatform"); for (const platform of ["android", "ios"]) { - registerCommandDefinition( - { ...definition, name: `run|${platform}` }, - injector.createChild([{ provide: PLATFORM, useValue: platform }]), - ); + registerCommand({ ...definition, name: `run|${platform}` }, [ + { provide: PLATFORM, useValue: platform }, + ]); } ``` diff --git a/extensions.md b/extensions.md index 93f882f1fb..ae440e5ba7 100644 --- a/extensions.md +++ b/extensions.md @@ -218,7 +218,7 @@ rejected with a warning. **The manifest key decides how a command is invoked.** It has to: the CLI routes `ns hello world` to your module before that module has been loaded, so the key is the only name it can know. A `name` inside the definition is metadata — it is -what `registerCommandDefinition` uses when a module registers itself, and it is +what `registerCommand` uses when a module registers itself, and it is useful documentation, but a manifest entry overrides it. If the two disagree the CLI warns, naming both, and runs the command under the manifest key. diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index a293bde809..d552ebf26d 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -1,7 +1,14 @@ import { injector } from "./common/yok"; +import { registerBuiltInCommand } from "./common/services/command-definition-adapter"; +import type { fontsCommandDefinition } from "./commands/fonts"; require("./common/bootstrap"); +/** + * The CLI owns every name it registers here, so a refusal is a mistake in this + * file rather than a condition to report and carry on from, the way a + * conflicting extension is. + */ injector.requirePublicClass("logger", "./common/logger/logger"); injector.require("config", "./config"); injector.require("options", "./options"); @@ -168,62 +175,199 @@ injector.require( "./services/analytics/google-analytics-provider", ); injector.require("platformCommandParameter", "./platform-command-param"); -injector.requireCommand("create", "./commands/create-project"); -injector.requireCommand("clean", "./commands/clean"); -injector.requireCommand("config|*list", "./commands/config"); -injector.requireCommand("config|get", "./commands/config"); -injector.requireCommand("config|set", "./commands/config"); -injector.requireCommand("generate", "./commands/generate"); -injector.requireCommand("platform|*list", "./commands/list-platforms"); -injector.requireCommand("platform|add", "./commands/add-platform"); -injector.requireCommand("platform|remove", "./commands/remove-platform"); -injector.requireCommand("platform|update", "./commands/update-platform"); -injector.requireCommand("run|*all", "./commands/run"); -injector.requireCommand("run|ios", "./commands/run"); -injector.requireCommand("run|android", "./commands/run"); -injector.requireCommand("run|vision", "./commands/run"); -injector.requireCommand("run|visionos", "./commands/run"); -injector.requireCommand("typings", "./commands/typings"); - -injector.requireCommand("preview", "./commands/preview"); - -injector.requireCommand("debug|ios", "./commands/debug"); -injector.requireCommand("debug|android", "./commands/debug"); -injector.requireCommand("debug|vision", "./commands/debug"); -injector.requireCommand("debug|visionos", "./commands/debug"); -injector.requireCommand("fonts", "./commands/fonts"); - -injector.requireCommand("prepare", "./commands/prepare"); -injector.requireCommand("build|ios", "./commands/build"); -injector.requireCommand("build|android", "./commands/build"); -injector.requireCommand("build|vision", "./commands/build"); -injector.requireCommand("build|visionos", "./commands/build"); -injector.requireCommand("deploy", "./commands/deploy"); - -injector.requireCommand("embed", "./commands/embedding/embed"); +registerBuiltInCommand< + typeof import("./commands/create-project").createProjectCommandDefinition +>( + "create", + () => require("./commands/create-project").createProjectCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/clean").cleanCommandDefinition +>("clean", () => require("./commands/clean").cleanCommandDefinition); +registerBuiltInCommand< + typeof import("./commands/config").configListCommandDefinition +>( + "config|*list", + () => require("./commands/config").configListCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/config").configGetCommandDefinition +>("config|get", () => require("./commands/config").configGetCommandDefinition); +registerBuiltInCommand< + typeof import("./commands/config").configSetCommandDefinition +>("config|set", () => require("./commands/config").configSetCommandDefinition); +registerBuiltInCommand< + typeof import("./commands/generate").generateCommandDefinition +>("generate", () => require("./commands/generate").generateCommandDefinition); +registerBuiltInCommand< + typeof import("./commands/list-platforms").listPlatformsCommandDefinition +>( + "platform|*list", + () => require("./commands/list-platforms").listPlatformsCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/add-platform").addPlatformCommandDefinition +>( + "platform|add", + () => require("./commands/add-platform").addPlatformCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/remove-platform").removePlatformCommandDefinition +>( + "platform|remove", + () => require("./commands/remove-platform").removePlatformCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/update-platform").updatePlatformCommandDefinition +>( + "platform|update", + () => require("./commands/update-platform").updatePlatformCommandDefinition, +); +registerBuiltInCommand( + "run|*all", + () => require("./commands/run").runCommandDefinition, +); +registerBuiltInCommand( + "run|ios", + () => require("./commands/run").iosRunCommand, +); +registerBuiltInCommand( + "run|android", + () => require("./commands/run").androidRunCommand, +); +registerBuiltInCommand( + "run|vision", + () => require("./commands/run").visionRunCommand, +); +registerBuiltInCommand( + "run|visionos", + () => require("./commands/run").visionRunCommand, +); +registerBuiltInCommand< + typeof import("./commands/typings").typingsCommandDefinition +>("typings", () => require("./commands/typings").typingsCommandDefinition); + +registerBuiltInCommand< + typeof import("./commands/preview").previewCommandDefinition +>("preview", () => require("./commands/preview").previewCommandDefinition); + +registerBuiltInCommand( + "debug|ios", + () => require("./commands/debug").iosDebugCommand, +); +registerBuiltInCommand( + "debug|android", + () => require("./commands/debug").androidDebugCommand, +); +registerBuiltInCommand( + "debug|vision", + () => require("./commands/debug").visionDebugCommand, +); +registerBuiltInCommand( + "debug|visionos", + () => require("./commands/debug").visionDebugCommand, +); +registerBuiltInCommand( + "fonts", + () => require("./commands/fonts").fontsCommandDefinition, +); + +registerBuiltInCommand< + typeof import("./commands/prepare").prepareCommandDefinition +>("prepare", () => require("./commands/prepare").prepareCommandDefinition); +registerBuiltInCommand( + "build|ios", + () => require("./commands/build").iosBuildCommand, +); +registerBuiltInCommand( + "build|android", + () => require("./commands/build").androidBuildCommand, +); +registerBuiltInCommand( + "build|vision", + () => require("./commands/build").visionBuildCommand, +); +registerBuiltInCommand( + "build|visionos", + () => require("./commands/build").visionBuildCommand, +); +registerBuiltInCommand< + typeof import("./commands/deploy").deployCommandDefinition +>("deploy", () => require("./commands/deploy").deployCommandDefinition); + +registerBuiltInCommand< + typeof import("./commands/embedding/embed").embedCommandDefinition +>("embed", () => require("./commands/embedding/embed").embedCommandDefinition); injector.require("testExecutionService", "./services/test-execution-service"); injector.require( "vitestExecutionService", "./services/vitest-execution-service", ); -injector.requireCommand("test|android", "./commands/test"); -injector.requireCommand("test|ios", "./commands/test"); -injector.requireCommand("test|vision", "./commands/test"); -injector.requireCommand("test|visionos", "./commands/test"); -injector.requireCommand("test|init", "./commands/test-init"); -injector.requireCommand("dev-generate-help", "./commands/generate-help"); - -injector.requireCommand("appstore|*list", "./commands/appstore-list"); -injector.requireCommand("appstore|upload", "./commands/appstore-upload"); -injector.requireCommand("publish|ios", "./commands/appstore-upload"); -injector.requireCommand("apple-login", "./commands/apple-login"); +registerBuiltInCommand< + typeof import("./commands/test").testAndroidCommandDefinition +>( + "test|android", + () => require("./commands/test").testAndroidCommandDefinition, +); +registerBuiltInCommand( + "test|ios", + () => require("./commands/test").testCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/test").testVisionOSCommandDefinition +>( + "test|vision", + () => require("./commands/test").testVisionOSCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/test").testVisionOSCommandDefinition +>( + "test|visionos", + () => require("./commands/test").testVisionOSCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/test-init").testInitCommandDefinition +>("test|init", () => require("./commands/test-init").testInitCommandDefinition); +registerBuiltInCommand< + typeof import("./commands/generate-help").generateHelpCommandDefinition +>( + "dev-generate-help", + () => require("./commands/generate-help").generateHelpCommandDefinition, +); + +registerBuiltInCommand< + typeof import("./commands/appstore-list").listiOSAppsCommandDefinition +>( + "appstore|*list", + () => require("./commands/appstore-list").listiOSAppsCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/appstore-upload").publishIOSCommandDefinition +>( + "appstore|upload", + () => require("./commands/appstore-upload").publishIOSCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/appstore-upload").publishIOSCommandDefinition +>( + "publish|ios", + () => require("./commands/appstore-upload").publishIOSCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/apple-login").appleLoginCommandDefinition +>( + "apple-login", + () => require("./commands/apple-login").appleLoginCommandDefinition, +); injector.require( "itmsTransporterService", "./services/itmstransporter-service", ); -injector.requireCommand("setup|*", "./commands/setup"); +registerBuiltInCommand< + typeof import("./commands/setup").setupCommandDefinition +>("setup|*", () => require("./commands/setup").setupCommandDefinition); injector.requirePublic("packageManager", "./package-manager"); injector.requirePublic("npm", "./node-package-manager"); @@ -231,13 +375,21 @@ injector.requirePublic("yarn", "./yarn-package-manager"); injector.requirePublic("yarn2", "./yarn2-package-manager"); injector.requirePublic("pnpm", "./pnpm-package-manager"); injector.requirePublic("bun", "./bun-package-manager"); -injector.requireCommand( +registerBuiltInCommand< + typeof import("./common/commands/package-manager-get").packageManagerGetCommandDefinition +>( "package-manager|*get", - "./commands/package-manager-get", + () => + require("./common/commands/package-manager-get") + .packageManagerGetCommandDefinition, ); -injector.requireCommand( +registerBuiltInCommand< + typeof import("./common/commands/package-manager-set").packageManagerSetCommandDefinition +>( "package-manager|set", - "./commands/package-manager-set", + () => + require("./common/commands/package-manager-set") + .packageManagerSetCommandDefinition, ); injector.require( @@ -258,44 +410,112 @@ injector.require( "./services/plugin-variables-service", ); injector.require("pluginsService", "./services/plugins-service"); -injector.requireCommand("plugin|*list", "./commands/plugin/list-plugins"); -injector.requireCommand("plugin|add", "./commands/plugin/add-plugin"); -injector.requireCommand("plugin|install", "./commands/plugin/add-plugin"); -injector.requireCommand("plugin|remove", "./commands/plugin/remove-plugin"); -injector.requireCommand("plugin|update", "./commands/plugin/update-plugin"); -injector.requireCommand("plugin|build", "./commands/plugin/build-plugin"); -injector.requireCommand("plugin|create", "./commands/plugin/create-plugin"); - -injector.requireCommand( - ["hooks|*list", "hooks|install"], - "./commands/hooks/hooks", -); -injector.requireCommand( - ["hooks|lock", "hooks|verify"], - "./commands/hooks/hooks-lock", +registerBuiltInCommand< + typeof import("./commands/plugin/list-plugins").listPluginsCommandDefinition +>( + "plugin|*list", + () => require("./commands/plugin/list-plugins").listPluginsCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/plugin/add-plugin").addPluginCommandDefinition +>( + "plugin|add", + () => require("./commands/plugin/add-plugin").addPluginCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/plugin/add-plugin").addPluginCommandDefinition +>( + "plugin|install", + () => require("./commands/plugin/add-plugin").addPluginCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/plugin/remove-plugin").removePluginCommandDefinition +>( + "plugin|remove", + () => + require("./commands/plugin/remove-plugin").removePluginCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/plugin/update-plugin").updatePluginCommandDefinition +>( + "plugin|update", + () => + require("./commands/plugin/update-plugin").updatePluginCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/plugin/build-plugin").buildPluginCommandDefinition +>( + "plugin|build", + () => require("./commands/plugin/build-plugin").buildPluginCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/plugin/create-plugin").createPluginCommandDefinition +>( + "plugin|create", + () => + require("./commands/plugin/create-plugin").createPluginCommandDefinition, +); + +registerBuiltInCommand< + typeof import("./commands/hooks/hooks").hooksListCommandDefinition +>( + "hooks|*list", + () => require("./commands/hooks/hooks").hooksListCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/hooks/hooks").hooksInstallCommandDefinition +>( + "hooks|install", + () => require("./commands/hooks/hooks").hooksInstallCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/hooks/hooks-lock").hooksLockCommandDefinition +>( + "hooks|lock", + () => require("./commands/hooks/hooks-lock").hooksLockCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/hooks/hooks-lock").hooksVerifyCommandDefinition +>( + "hooks|verify", + () => require("./commands/hooks/hooks-lock").hooksVerifyCommandDefinition, ); injector.require("doctorService", "./services/doctor-service"); injector.require("xcprojService", "./services/xcproj-service"); injector.require("versionsService", "./services/versions-service"); -injector.requireCommand("install", "./commands/install"); +registerBuiltInCommand< + typeof import("./commands/install").installCommandDefinition +>("install", () => require("./commands/install").installCommandDefinition); injector.require("infoService", "./services/info-service"); -injector.requireCommand("info", "./commands/info"); +registerBuiltInCommand( + "info", + () => require("./commands/info").infoCommandDefinition, +); injector.require( "androidResourcesMigrationService", "./services/android-resources-migration-service", ); -injector.requireCommand( +registerBuiltInCommand< + typeof import("./commands/resources/resources-update").resourcesUpdateCommandDefinition +>( "resources|update", - "./commands/resources/resources-update", + () => + require("./commands/resources/resources-update") + .resourcesUpdateCommandDefinition, ); injector.require("androidToolsInfo", "./android-tools-info"); injector.require("devicePathProvider", "./device-path-provider"); -injector.requireCommand("platform|clean", "./commands/platform-clean"); +registerBuiltInCommand< + typeof import("./commands/platform-clean").platformCleanCommandDefinition +>( + "platform|clean", + () => require("./commands/platform-clean").platformCleanCommandDefinition, +); injector.require( "androidBundleValidatorHelper", @@ -338,9 +558,18 @@ injector.require( ); injector.require("messages", "./common/messages/messages"); -injector.requireCommand("post-install-cli", "./commands/post-install"); -injector.requireCommand("migrate", "./commands/migrate"); -injector.requireCommand("update", "./commands/update"); +registerBuiltInCommand< + typeof import("./commands/post-install").postInstallCliCommandDefinition +>( + "post-install-cli", + () => require("./commands/post-install").postInstallCliCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/migrate").migrateCommandDefinition +>("migrate", () => require("./commands/migrate").migrateCommandDefinition); +registerBuiltInCommand< + typeof import("./commands/update").updateCommandDefinition +>("update", () => require("./commands/update").updateCommandDefinition); injector.require("iOSLogFilter", "./services/ios-log-filter"); injector.require("logSourceMapService", "./services/log-source-map-service"); @@ -353,17 +582,29 @@ injector.require("staticConfig", "./config"); injector.require("requireService", "./services/require-service"); -injector.requireCommand( +registerBuiltInCommand< + typeof import("./commands/extensibility/list-extensions").listExtensionsCommandDefinition +>( "extension|*list", - "./commands/extensibility/list-extensions", + () => + require("./commands/extensibility/list-extensions") + .listExtensionsCommandDefinition, ); -injector.requireCommand( +registerBuiltInCommand< + typeof import("./commands/extensibility/install-extension").installExtensionCommandDefinition +>( "extension|install", - "./commands/extensibility/install-extension", + () => + require("./commands/extensibility/install-extension") + .installExtensionCommandDefinition, ); -injector.requireCommand( +registerBuiltInCommand< + typeof import("./commands/extensibility/uninstall-extension").uninstallExtensionCommandDefinition +>( "extension|uninstall", - "./commands/extensibility/uninstall-extension", + () => + require("./commands/extensibility/uninstall-extension") + .uninstallExtensionCommandDefinition, ); injector.requirePublicClass( "extensibilityService", @@ -384,13 +625,17 @@ injector.require( "./services/platform-environment-requirements", ); -injector.requireCommand( +registerBuiltInCommand< + typeof import("./commands/generate-assets").generateIconsCommand +>( "resources|generate|icons", - "./commands/generate-assets", + () => require("./commands/generate-assets").generateIconsCommand, ); -injector.requireCommand( +registerBuiltInCommand< + typeof import("./commands/generate-assets").generateSplashesCommand +>( "resources|generate|splashes", - "./commands/generate-assets", + () => require("./commands/generate-assets").generateSplashesCommand, ); injector.requirePublic( "assetsGenerationService", @@ -462,17 +707,41 @@ injector.require("sharedEventBus", "./shared-event-bus"); injector.require("keyCommandHelper", "./helpers/key-command-helper"); -injector.requireCommand("start", "./commands/start"); +registerBuiltInCommand< + typeof import("./commands/start").startCommandDefinition +>("start", () => require("./commands/start").startCommandDefinition); injector.require("startService", "./services/start-service"); -injector.requireCommand( - [ - "native|add", - "native|add|java", - "native|add|kotlin", - "native|add|swift", - "native|add|objective-c", - ], - "./commands/native-add", -); -injector.requireCommand(["widget|ios"], "./commands/widget"); +registerBuiltInCommand< + typeof import("./commands/native-add").nativeAddCommandDefinition +>( + "native|add", + () => require("./commands/native-add").nativeAddCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/native-add").javaNativeAddCommand +>( + "native|add|java", + () => require("./commands/native-add").javaNativeAddCommand, +); +registerBuiltInCommand< + typeof import("./commands/native-add").kotlinNativeAddCommand +>( + "native|add|kotlin", + () => require("./commands/native-add").kotlinNativeAddCommand, +); +registerBuiltInCommand< + typeof import("./commands/native-add").swiftNativeAddCommand +>( + "native|add|swift", + () => require("./commands/native-add").swiftNativeAddCommand, +); +registerBuiltInCommand< + typeof import("./commands/native-add").objectiveCNativeAddCommand +>( + "native|add|objective-c", + () => require("./commands/native-add").objectiveCNativeAddCommand, +); +registerBuiltInCommand< + typeof import("./commands/widget").widgetIOSCommandDefinition +>("widget|ios", () => require("./commands/widget").widgetIOSCommandDefinition); require("./key-commands/bootstrap"); diff --git a/lib/commands/add-platform.ts b/lib/commands/add-platform.ts index ca982bafb4..79faa9f01e 100644 --- a/lib/commands/add-platform.ts +++ b/lib/commands/add-platform.ts @@ -12,7 +12,6 @@ import { stringOption, } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; const addPlatformCommandOptions = { frameworkPath: stringOption(), @@ -98,5 +97,3 @@ export const addPlatformCommandDefinition = defineCommand({ canExecute: canExecuteAddPlatformCommand, run: runAddPlatformCommand, }); - -registerCommand(addPlatformCommandDefinition); diff --git a/lib/commands/apple-login.ts b/lib/commands/apple-login.ts index cfc21e17bf..fc0a523fda 100644 --- a/lib/commands/apple-login.ts +++ b/lib/commands/apple-login.ts @@ -1,7 +1,6 @@ import { IErrors } from "../common/declarations"; import { CommandContext, defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; import { IApplePortalSessionService } from "../services/apple-portal/definitions"; export type AppleLoginCommandContext = CommandContext; @@ -61,5 +60,3 @@ export const appleLoginCommandDefinition = defineCommand({ setup: setupAppleLoginCommand, run: runAppleLoginCommand, }); - -registerCommand(appleLoginCommandDefinition); diff --git a/lib/commands/appstore-list.ts b/lib/commands/appstore-list.ts index 529e6a29b5..2417008c28 100644 --- a/lib/commands/appstore-list.ts +++ b/lib/commands/appstore-list.ts @@ -7,7 +7,6 @@ import { } from "../common/define-command"; import { inject } from "../common/di"; import { createTable } from "../common/helpers"; -import { registerCommand } from "../common/services/command-definition-adapter"; import { IPlatformValidationService } from "../declarations"; import { IProjectData } from "../definitions/project"; import { @@ -130,5 +129,3 @@ export const listiOSAppsCommandDefinition = defineCommand({ setup: setupListiOSAppsCommand, run: runListiOSAppsCommand, }); - -registerCommand(listiOSAppsCommandDefinition); diff --git a/lib/commands/appstore-upload.ts b/lib/commands/appstore-upload.ts index 118edbe57c..81eadc6281 100644 --- a/lib/commands/appstore-upload.ts +++ b/lib/commands/appstore-upload.ts @@ -9,7 +9,6 @@ import { stringOption, } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; import { BuildController } from "../controllers/build-controller"; import { IOSBuildData } from "../data/build-data"; import { @@ -200,5 +199,3 @@ export const publishIOSCommandDefinition = defineCommand({ canExecute: canExecutePublishIOSCommand, run: runPublishIOSCommand, }); - -registerCommand(publishIOSCommandDefinition); diff --git a/lib/commands/build.ts b/lib/commands/build.ts index 913c514914..8552f26e93 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -15,21 +15,18 @@ import { IMigrateController } from "../definitions/migrate"; import { IErrors } from "../common/declarations"; import { booleanOption, + CommandName, CommandOptionsSchema, defineCommand, stringOption, } from "../common/define-command"; -import { inject, InjectionToken } from "../common/di"; -import { registerCommandDefinition } from "../common/services/command-definition-adapter"; -import { injector } from "../common/yok"; +import { inject } from "../common/di"; /** - * Which `$devicePlatformsConstants` entry this registration builds for. The - * constants stay the source of truth for the platform spelling. + * Which `$devicePlatformsConstants` entry a command builds for. The constants + * stay the source of truth for the platform spelling. */ -const BUILD_PLATFORM = new InjectionToken<"iOS" | "Android" | "visionOS">( - "buildCommandPlatform", -); +type BuildPlatform = "iOS" | "Android" | "visionOS"; const buildCommandOptions = { watch: booleanOption({ default: false }), @@ -54,109 +51,113 @@ interface IBuildCommandServices extends IPlatformCommandServices { $androidBundleValidatorHelper: IAndroidBundleValidatorHelper; } -export const buildCommandDefinition = defineCommand({ - name: "build", - description: "Builds the project for the selected target platform.", - options: buildCommandOptions, - arguments: "none", - setup(): IBuildCommandServices { - const devicePlatformsConstants = inject( - "devicePlatformsConstants", - ); - const platform = devicePlatformsConstants[inject(BUILD_PLATFORM)]; - const isAndroid = devicePlatformsConstants.isAndroid(platform); - const services = { - ...injectPlatformCommandServices(), - platform, - isAndroid, - $errors: inject("errors"), - $logger: inject("logger"), - $buildController: inject("buildController"), - $buildDataService: inject("buildDataService"), - $migrateController: inject("migrateController"), - // Only the android build checks the runtime version. - $androidBundleValidatorHelper: isAndroid - ? inject("androidBundleValidatorHelper") - : null, - }; - services.$projectData.initializeProjectData(); - - return services; - }, - async canExecute(context, services): Promise { - const { platform } = services; - - if (!context.options.force) { - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, - platforms: [platform], - }); - } - - if (services.isAndroid) { - services.$androidBundleValidatorHelper.validateRuntimeVersion( - services.$projectData, +const defineBuildCommand = ( + name: TName, + buildPlatform: BuildPlatform, +) => + defineCommand({ + name, + description: "Builds the project for the selected target platform.", + options: buildCommandOptions, + arguments: "none", + setup(): IBuildCommandServices { + const devicePlatformsConstants = inject( + "devicePlatformsConstants", ); - } else if ( - !services.$platformValidationService.isPlatformSupportedForOS( + const platform = devicePlatformsConstants[buildPlatform]; + const isAndroid = devicePlatformsConstants.isAndroid(platform); + const services = { + ...injectPlatformCommandServices(), platform, - services.$projectData, - ) - ) { - services.$errors.fail( - `Applications for platform ${platform} can not be built on this OS`, - ); - } - - if (!(await canExecuteCommandBase(services, platform))) { - return false; - } - - if ( - services.isAndroid && - context.options.release && - !hasValidAndroidSigning(context.options) - ) { - services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); - } - - return validatePlatformOptions(services, platform); - }, - async run(context, services): Promise { - const buildData = services.$buildDataService.getBuildData( - services.$projectData.projectDir, - services.platform.toLowerCase(), - services.$options, - ); - const outputPath = - await services.$buildController.prepareAndBuild(buildData); - - if (services.isAndroid && context.options.aab) { - services.$logger.info( - AndroidAppBundleMessages.ANDROID_APP_BUNDLE_DOCS_MESSAGE, + isAndroid, + $errors: inject("errors"), + $logger: inject("logger"), + $buildController: inject("buildController"), + $buildDataService: inject("buildDataService"), + $migrateController: inject("migrateController"), + // Only the android build checks the runtime version. + $androidBundleValidatorHelper: isAndroid + ? inject( + "androidBundleValidatorHelper", + ) + : null, + }; + services.$projectData.initializeProjectData(); + + return services; + }, + async canExecute(context, services): Promise { + const { platform } = services; + + if (!context.options.force) { + await services.$migrateController.validate({ + projectDir: services.$projectData.projectDir, + platforms: [platform], + }); + } + + if (services.isAndroid) { + services.$androidBundleValidatorHelper.validateRuntimeVersion( + services.$projectData, + ); + } else if ( + !services.$platformValidationService.isPlatformSupportedForOS( + platform, + services.$projectData, + ) + ) { + services.$errors.fail( + `Applications for platform ${platform} can not be built on this OS`, + ); + } + + if (!(await canExecuteCommandBase(services, platform))) { + return false; + } + + if ( + services.isAndroid && + context.options.release && + !hasValidAndroidSigning(context.options) + ) { + services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + } + + return validatePlatformOptions(services, platform); + }, + async run(context, services): Promise { + const buildData = services.$buildDataService.getBuildData( + services.$projectData.projectDir, + services.platform.toLowerCase(), + services.$options, ); + const outputPath = + await services.$buildController.prepareAndBuild(buildData); - if (context.options.release) { + if (services.isAndroid && context.options.aab) { services.$logger.info( - AndroidAppBundleMessages.ANDROID_APP_BUNDLE_PUBLISH_DOCS_MESSAGE, + AndroidAppBundleMessages.ANDROID_APP_BUNDLE_DOCS_MESSAGE, ); + + if (context.options.release) { + services.$logger.info( + AndroidAppBundleMessages.ANDROID_APP_BUNDLE_PUBLISH_DOCS_MESSAGE, + ); + } } - } - - return outputPath; - }, -}); - -const buildCommandPlatforms: [string, "iOS" | "Android" | "visionOS"][] = [ - ["build|ios", "iOS"], - ["build|android", "Android"], - ["build|vision", "visionOS"], - ["build|visionos", "visionOS"], -]; - -for (const [name, platform] of buildCommandPlatforms) { - registerCommandDefinition( - { ...buildCommandDefinition, name }, - injector.createChild([{ provide: BUILD_PLATFORM, useValue: platform }]), - ); -} + + return outputPath; + }, + }); + +export const iosBuildCommand = defineBuildCommand("build|ios", "iOS"); + +export const androidBuildCommand = defineBuildCommand( + "build|android", + "Android", +); + +export const visionBuildCommand = defineBuildCommand( + ["build|vision", "build|visionos"], + "visionOS", +); diff --git a/lib/commands/clean.ts b/lib/commands/clean.ts index f45b1e6116..b193930020 100644 --- a/lib/commands/clean.ts +++ b/lib/commands/clean.ts @@ -12,7 +12,6 @@ import { } from "../common/define-command"; import { inject } from "../common/di"; import { isInteractive } from "../common/helpers"; -import { registerCommand } from "../common/services/command-definition-adapter"; import * as constants from "../constants"; import { IStaticConfig } from "../declarations"; import { @@ -433,5 +432,3 @@ export const cleanCommandDefinition = defineCommand({ setup: setupCleanCommand, run: runCleanCommand, }); - -registerCommand(cleanCommandDefinition); diff --git a/lib/commands/config.ts b/lib/commands/config.ts index 7b4df4f3be..b3d21b8eef 100644 --- a/lib/commands/config.ts +++ b/lib/commands/config.ts @@ -3,7 +3,6 @@ import { SupportedConfigValues } from "../tools/config-manipulation/config-trans import { IErrors } from "../common/declarations"; import { CommandContext, defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; import { color } from "../color"; export interface IConfigCommandServices { @@ -141,7 +140,3 @@ export const configSetCommandDefinition = defineCommand({ } }, }); - -registerCommand(configListCommandDefinition); -registerCommand(configGetCommandDefinition); -registerCommand(configSetCommandDefinition); diff --git a/lib/commands/create-project.ts b/lib/commands/create-project.ts index 0b4167198e..f3a72dca02 100644 --- a/lib/commands/create-project.ts +++ b/lib/commands/create-project.ts @@ -9,7 +9,6 @@ import { } from "../common/define-command"; import { inject } from "../common/di"; import { isInteractive } from "../common/helpers"; -import { registerCommand } from "../common/services/command-definition-adapter"; import * as constants from "../constants"; import { ICreateProjectData, IProjectService } from "../definitions/project"; @@ -530,5 +529,3 @@ export const createProjectCommandDefinition = defineCommand({ run: runCreateProjectCommand, postRun: reportCreatedProject, }); - -registerCommand(createProjectCommandDefinition); diff --git a/lib/commands/debug.ts b/lib/commands/debug.ts index f5cd2e237e..c0d13631bf 100644 --- a/lib/commands/debug.ts +++ b/lib/commands/debug.ts @@ -2,14 +2,13 @@ import { IErrors, ISysInfo } from "../common/declarations"; import { booleanOption, CommandContext, + CommandName, CommandOptionsSchema, defineCommand, stringOption, } from "../common/define-command"; -import { inject, InjectionToken } from "../common/di"; +import { inject } from "../common/di"; import { hasValidAndroidSigning } from "../common/helpers"; -import { registerCommandDefinition } from "../common/services/command-definition-adapter"; -import { injector } from "../common/yok"; import { ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE } from "../constants"; import { ICleanupService } from "../definitions/cleanup-service"; import { @@ -26,10 +25,8 @@ import { } from "./command-base"; import * as _ from "lodash"; -/** Which `$devicePlatformsConstants` entry this registration debugs. */ -const DEBUG_PLATFORM = new InjectionToken<"iOS" | "Android" | "visionOS">( - "debugCommandPlatform", -); +/** Which `$devicePlatformsConstants` entry a command debugs. */ +type DebugPlatform = "iOS" | "Android" | "visionOS"; const debugCommandOptions = { force: booleanOption(), @@ -61,14 +58,16 @@ export interface IDebugCommandServices extends IPlatformCommandServices { $migrateController: IMigrateController; } -export function setupDebugCommand(): IDebugCommandServices { +export function setupDebugCommand( + debugPlatform: DebugPlatform, +): IDebugCommandServices { const $devicePlatformsConstants = inject( "devicePlatformsConstants", ); return { ...injectPlatformCommandServices(), - platform: $devicePlatformsConstants[inject(DEBUG_PLATFORM)], + platform: $devicePlatformsConstants[debugPlatform], $cleanupService: inject("cleanupService"), $debugController: inject("debugController"), $debugDataService: inject("debugDataService"), @@ -170,24 +169,26 @@ interface IDebugApplePlatformCommandServices extends IDebugCommandServices { $sysInfo: ISysInfo; } -function setupDebugApplePlatformCommand(): IDebugApplePlatformCommandServices { - const services = { - ...setupDebugCommand(), - $sysInfo: inject("sysInfo"), +const setupDebugApplePlatformCommand = + (debugPlatform: "iOS" | "visionOS") => + (): IDebugApplePlatformCommandServices => { + const services = { + ...setupDebugCommand(debugPlatform), + $sysInfo: inject("sysInfo"), + }; + services.$projectData.initializeProjectData(); + + // Do not dispose ios-device-lib, so the process will remain alive and the debug application (NativeScript Inspector or Chrome DevTools) will be able to connect to the socket. + // In case we dispose ios-device-lib, the socket will be closed and the code will fail when the debug application tries to read/send data to device socket. + // That's why the `$ ns debug ios --justlaunch` command will not release the terminal. + // In case we do not set it to false, the dispose will be called once the command finishes its execution, which will prevent the debugging. + inject("iosDeviceOperations").setShouldDispose(false); + inject( + "iOSSimulatorLogProvider", + ).setShouldDispose(false); + + return services; }; - services.$projectData.initializeProjectData(); - - // Do not dispose ios-device-lib, so the process will remain alive and the debug application (NativeScript Inspector or Chrome DevTools) will be able to connect to the socket. - // In case we dispose ios-device-lib, the socket will be closed and the code will fail when the debug application tries to read/send data to device socket. - // That's why the `$ ns debug ios --justlaunch` command will not release the terminal. - // In case we do not set it to false, the dispose will be called once the command finishes its execution, which will prevent the debugging. - inject("iosDeviceOperations").setShouldDispose(false); - inject( - "iOSSimulatorLogProvider", - ).setShouldDispose(false); - - return services; -} function isValidTimeoutOption(timeout: string): boolean { if (!timeout) { @@ -206,58 +207,73 @@ function isValidTimeoutOption(timeout: string): boolean { return true; } -export const debugApplePlatformCommandDefinition = defineCommand({ - name: "debug|ios", - description: "Debugs your project on a connected Apple device or simulator.", - options: debugCommandOptions, - // Arguments have never been rejected here, only ignored. - arguments: "any", - setup: setupDebugApplePlatformCommand, - async canExecute( - context: DebugCommandContext, - services: IDebugApplePlatformCommandServices, - ): Promise { - if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.platform, - services.$projectData, - ) - ) { - services.$errors.fail( - `Applications for platform ${services.platform} can not be built on this OS`, - ); - } - - if (!isValidTimeoutOption(context.options.timeout)) { - services.$errors.fail( - `Timeout option specifies the seconds NativeScript CLI will wait to find the inspector socket port from device's logs. Must be a number.`, - ); - } - - if (context.options.inspector) { - const macOSWarning = await services.$sysInfo.getMacOSWarningMessage(); +const defineApplePlatformDebugCommand = ( + name: TName, + debugPlatform: "iOS" | "visionOS", +) => + defineCommand({ + name, + description: + "Debugs your project on a connected Apple device or simulator.", + options: debugCommandOptions, + // Arguments have never been rejected here, only ignored. + arguments: "any", + setup: setupDebugApplePlatformCommand(debugPlatform), + async canExecute( + context: DebugCommandContext, + services: IDebugApplePlatformCommandServices, + ): Promise { if ( - macOSWarning && - macOSWarning.severity === SystemWarningsSeverity.high + !services.$platformValidationService.isPlatformSupportedForOS( + services.platform, + services.$projectData, + ) ) { services.$errors.fail( - `You cannot use NativeScript Inspector on this OS. To use it, please update your OS.`, + `Applications for platform ${services.platform} can not be built on this OS`, ); } - } - return canExecuteDebugCommand(context, services); - }, - run: runDebugCommand, -}); + if (!isValidTimeoutOption(context.options.timeout)) { + services.$errors.fail( + `Timeout option specifies the seconds NativeScript CLI will wait to find the inspector socket port from device's logs. Must be a number.`, + ); + } + + if (context.options.inspector) { + const macOSWarning = await services.$sysInfo.getMacOSWarningMessage(); + if ( + macOSWarning && + macOSWarning.severity === SystemWarningsSeverity.high + ) { + services.$errors.fail( + `You cannot use NativeScript Inspector on this OS. To use it, please update your OS.`, + ); + } + } + + return canExecuteDebugCommand(context, services); + }, + run: runDebugCommand, + }); + +export const iosDebugCommand = defineApplePlatformDebugCommand( + "debug|ios", + "iOS", +); + +export const visionDebugCommand = defineApplePlatformDebugCommand( + ["debug|vision", "debug|visionos"], + "visionOS", +); -export const debugAndroidCommandDefinition = defineCommand({ +export const androidDebugCommand = defineCommand({ name: "debug|android", description: "Debugs your project on a connected Android device or emulator.", options: debugCommandOptions, arguments: "any", setup(): IDebugCommandServices { - const services = setupDebugCommand(); + const services = setupDebugCommand("Android"); services.$projectData.initializeProjectData(); return services; @@ -277,21 +293,3 @@ export const debugAndroidCommandDefinition = defineCommand({ }, run: runDebugCommand, }); - -const debugApplePlatforms: [string, "iOS" | "visionOS"][] = [ - ["debug|ios", "iOS"], - ["debug|vision", "visionOS"], - ["debug|visionos", "visionOS"], -]; - -for (const [name, platform] of debugApplePlatforms) { - registerCommandDefinition( - { ...debugApplePlatformCommandDefinition, name }, - injector.createChild([{ provide: DEBUG_PLATFORM, useValue: platform }]), - ); -} - -registerCommandDefinition( - debugAndroidCommandDefinition, - injector.createChild([{ provide: DEBUG_PLATFORM, useValue: "Android" }]), -); diff --git a/lib/commands/deploy.ts b/lib/commands/deploy.ts index de2520d08f..d96166600f 100644 --- a/lib/commands/deploy.ts +++ b/lib/commands/deploy.ts @@ -18,7 +18,6 @@ import { stringOption, } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommandDefinition } from "../common/services/command-definition-adapter"; const deployCommandOptions = { watch: booleanOption({ default: false }), @@ -83,5 +82,3 @@ export const deployCommandDefinition = defineCommand({ await services.$deployCommandHelper.deploy(context.args[0]); }, }); - -registerCommandDefinition(deployCommandDefinition); diff --git a/lib/commands/embedding/embed.ts b/lib/commands/embedding/embed.ts index ce520c8e05..d4489ddfaa 100644 --- a/lib/commands/embedding/embed.ts +++ b/lib/commands/embedding/embed.ts @@ -2,7 +2,6 @@ import { resolve } from "path"; import { color } from "../../color"; import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -import { registerCommandDefinition } from "../../common/services/command-definition-adapter"; import { IFileSystem } from "../../common/declarations"; import { IProjectConfigService } from "../../definitions/project"; import { platformArgument } from "../command-base"; @@ -97,5 +96,3 @@ export const embedCommandDefinition = defineCommand({ await runPrepareCommand(context, services); }, }); - -registerCommandDefinition(embedCommandDefinition); diff --git a/lib/commands/extensibility/install-extension.ts b/lib/commands/extensibility/install-extension.ts index a0dd0093ac..d69eca8202 100644 --- a/lib/commands/extensibility/install-extension.ts +++ b/lib/commands/extensibility/install-extension.ts @@ -1,7 +1,6 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; -import { registerCommand } from "../../common/services/command-definition-adapter"; export interface IInstallExtensionCommandServices { $extensibilityService: IExtensibilityService; @@ -48,5 +47,3 @@ export const installExtensionCommandDefinition = defineCommand({ ); }, }); - -registerCommand(installExtensionCommandDefinition); diff --git a/lib/commands/extensibility/list-extensions.ts b/lib/commands/extensibility/list-extensions.ts index 28b9b3c9dc..1ae4fb8383 100644 --- a/lib/commands/extensibility/list-extensions.ts +++ b/lib/commands/extensibility/list-extensions.ts @@ -3,7 +3,6 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; import * as helpers from "../../common/helpers"; -import { registerCommand } from "../../common/services/command-definition-adapter"; export interface IListExtensionsCommandServices { $extensibilityService: IExtensibilityService; @@ -39,5 +38,3 @@ export const listExtensionsCommandDefinition = defineCommand({ } }, }); - -registerCommand(listExtensionsCommandDefinition); diff --git a/lib/commands/extensibility/uninstall-extension.ts b/lib/commands/extensibility/uninstall-extension.ts index 0dce41187d..44b26b915e 100644 --- a/lib/commands/extensibility/uninstall-extension.ts +++ b/lib/commands/extensibility/uninstall-extension.ts @@ -1,7 +1,6 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; -import { registerCommand } from "../../common/services/command-definition-adapter"; export interface IUninstallExtensionCommandServices { $extensibilityService: IExtensibilityService; @@ -40,5 +39,3 @@ export const uninstallExtensionCommandDefinition = defineCommand({ ); }, }); - -registerCommand(uninstallExtensionCommandDefinition); diff --git a/lib/commands/fonts.ts b/lib/commands/fonts.ts index 4ba2752d50..7d4a13e4ba 100644 --- a/lib/commands/fonts.ts +++ b/lib/commands/fonts.ts @@ -2,7 +2,6 @@ import { IProjectConfigService, IProjectData } from "../definitions/project"; import { IFileSystem } from "../common/declarations"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; import * as constants from "../constants"; import * as fontFinder from "font-finder"; import { createTable } from "../common/helpers"; @@ -80,5 +79,3 @@ export const fontsCommandDefinition = defineCommand({ services.$logger.info(table.toString()); }, }); - -registerCommand(fontsCommandDefinition); diff --git a/lib/commands/generate-assets.ts b/lib/commands/generate-assets.ts index d9a3242ab8..a299ab1e4f 100644 --- a/lib/commands/generate-assets.ts +++ b/lib/commands/generate-assets.ts @@ -1,23 +1,20 @@ import { CommandContext, + CommandName, CommandOptionsSchema, defineCommand, stringOption, } from "../common/define-command"; -import { inject, InjectionToken } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; -import { getInjector } from "../common/yok"; +import { inject } from "../common/di"; import { IAssetsGenerationService, IResourceGenerationData, } from "../declarations"; import { IProjectData } from "../definitions/project"; -/** Which set of assets a registration generates from the source image. */ +/** Which set of assets a command generates from the source image. */ type GeneratedAssets = "icons" | "splashes"; -const GENERATED_ASSETS = new InjectionToken("generatedAssets"); - const generators: Record< GeneratedAssets, ( @@ -43,9 +40,11 @@ export interface IGenerateAssetsCommandServices { $projectData: IProjectData; } -export function setupGenerateAssetsCommand(): IGenerateAssetsCommandServices { +export function setupGenerateAssetsCommand( + assets: GeneratedAssets, +): IGenerateAssetsCommandServices { const services = { - assets: inject(GENERATED_ASSETS), + assets, $assetsGenerationService: inject( "assetsGenerationService", ), @@ -67,33 +66,33 @@ export function runGenerateAssetsCommand( }); } -export const generateAssetsCommandDefinition = defineCommand({ - name: "resources|generate|icons", - description: - "Generates icons and splash screens based on the provided image.", - options: generateAssetsCommandOptions, - arguments: [ - { - name: "imagePath", - required: true, - errorMessage: - "You have to provide path to image to generate other images based on it.", - }, - ], - setup: setupGenerateAssetsCommand, - run: runGenerateAssetsCommand, -}); +const defineGenerateAssetsCommand = ( + name: TName, + assets: GeneratedAssets, +) => + defineCommand({ + name, + description: + "Generates icons and splash screens based on the provided image.", + options: generateAssetsCommandOptions, + arguments: [ + { + name: "imagePath", + required: true, + errorMessage: + "You have to provide path to image to generate other images based on it.", + }, + ], + setup: () => setupGenerateAssetsCommand(assets), + run: runGenerateAssetsCommand, + }); -const generateAssetsCommands: [string, GeneratedAssets][] = [ - ["resources|generate|icons", "icons"], - ["resources|generate|splashes", "splashes"], -]; +export const generateIconsCommand = defineGenerateAssetsCommand( + "resources|generate|icons", + "icons", +); -for (const [name, assets] of generateAssetsCommands) { - registerCommand( - { ...generateAssetsCommandDefinition, name }, - getInjector().createChild([ - { provide: GENERATED_ASSETS, useValue: assets }, - ]), - ); -} +export const generateSplashesCommand = defineGenerateAssetsCommand( + "resources|generate|splashes", + "splashes", +); diff --git a/lib/commands/generate-help.ts b/lib/commands/generate-help.ts index 7c8c23f5f3..ca0c64ae79 100644 --- a/lib/commands/generate-help.ts +++ b/lib/commands/generate-help.ts @@ -1,7 +1,6 @@ import { IHelpService } from "../common/declarations"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; export const generateHelpCommandDefinition = defineCommand({ name: "dev-generate-help", @@ -14,5 +13,3 @@ export const generateHelpCommandDefinition = defineCommand({ return services.$helpService.generateHtmlPages(); }, }); - -registerCommand(generateHelpCommandDefinition); diff --git a/lib/commands/generate.ts b/lib/commands/generate.ts index 50ff0d539f..29caa6ece4 100644 --- a/lib/commands/generate.ts +++ b/lib/commands/generate.ts @@ -2,7 +2,6 @@ import { IErrors } from "../common/declarations"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; export const generateCommandDefinition = defineCommand({ name: "generate", @@ -24,8 +23,6 @@ export const generateCommandDefinition = defineCommand({ }, }); -registerCommand(generateCommandDefinition); - /** * Converts an array of command line arguments to options for the executed schematic. * @param rawArgs The command line arguments. They should be in the format 'key=value' for strings or 'key' for booleans. diff --git a/lib/commands/hooks/hooks-lock.ts b/lib/commands/hooks/hooks-lock.ts index e5727c3587..18142e8a92 100644 --- a/lib/commands/hooks/hooks-lock.ts +++ b/lib/commands/hooks/hooks-lock.ts @@ -1,6 +1,5 @@ import { IPluginData } from "../../definitions/plugins"; import { defineCommand } from "../../common/define-command"; -import { registerCommand } from "../../common/services/command-definition-adapter"; import path = require("path"); import * as crypto from "crypto"; import { @@ -103,6 +102,3 @@ export const hooksVerifyCommandDefinition = defineCommand({ } }, }); - -registerCommand(hooksLockCommandDefinition); -registerCommand(hooksVerifyCommandDefinition); diff --git a/lib/commands/hooks/hooks.ts b/lib/commands/hooks/hooks.ts index 3ed512273d..afe451f6d9 100644 --- a/lib/commands/hooks/hooks.ts +++ b/lib/commands/hooks/hooks.ts @@ -1,6 +1,5 @@ import { IPluginData } from "../../definitions/plugins"; import { CommandContext, defineCommand } from "../../common/define-command"; -import { registerCommand } from "../../common/services/command-definition-adapter"; import path = require("path"); import { HOOKS_DIR_NAME } from "../../constants"; import { createTable } from "../../common/helpers"; @@ -109,6 +108,3 @@ export const hooksListCommandDefinition = defineCommand({ return runHooksCommand(services, true); }, }); - -registerCommand(hooksInstallCommandDefinition); -registerCommand(hooksListCommandDefinition); diff --git a/lib/commands/info.ts b/lib/commands/info.ts index b745354004..008ad002e4 100644 --- a/lib/commands/info.ts +++ b/lib/commands/info.ts @@ -1,7 +1,6 @@ import { IInfoService } from "../declarations"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; export const infoCommandDefinition = defineCommand({ name: "info", @@ -14,5 +13,3 @@ export const infoCommandDefinition = defineCommand({ return services.$infoService.printComponentsInfo(); }, }); - -registerCommand(infoCommandDefinition); diff --git a/lib/commands/install.ts b/lib/commands/install.ts index bc733cdd2f..788a3e00b1 100644 --- a/lib/commands/install.ts +++ b/lib/commands/install.ts @@ -8,7 +8,6 @@ import { stringOption, } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; import { PlatformTypes } from "../constants"; import { INodePackageManager, @@ -148,5 +147,3 @@ export const installCommandDefinition = defineCommand({ setup: setupInstallCommand, run: runInstallCommand, }); - -registerCommand(installCommandDefinition); diff --git a/lib/commands/list-platforms.ts b/lib/commands/list-platforms.ts index be7b537b86..2250ddb5bd 100644 --- a/lib/commands/list-platforms.ts +++ b/lib/commands/list-platforms.ts @@ -3,7 +3,6 @@ import { IProjectData } from "../definitions/project"; import { IPlatformCommandHelper } from "../declarations"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; export interface IListPlatformsCommandServices { $platformCommandHelper: IPlatformCommandHelper; @@ -70,5 +69,3 @@ export const listPlatformsCommandDefinition = defineCommand({ } }, }); - -registerCommand(listPlatformsCommandDefinition); diff --git a/lib/commands/migrate.ts b/lib/commands/migrate.ts index 373916df10..909511e397 100644 --- a/lib/commands/migrate.ts +++ b/lib/commands/migrate.ts @@ -2,7 +2,6 @@ import { IProjectData } from "../definitions/project"; import { IMigrateController, IMigrationData } from "../definitions/migrate"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; export interface IMigrateCommandServices { $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; @@ -55,5 +54,3 @@ export const migrateCommandDefinition = defineCommand({ await services.$migrateController.migrate(migrationData); }, }); - -registerCommand(migrateCommandDefinition); diff --git a/lib/commands/native-add.ts b/lib/commands/native-add.ts index 9131eedad6..879c169ecb 100644 --- a/lib/commands/native-add.ts +++ b/lib/commands/native-add.ts @@ -2,24 +2,18 @@ import * as fs from "fs"; import { EOL } from "os"; import * as path from "path"; import { IErrors } from "../common/declarations"; -import { defineCommand } from "../common/define-command"; -import { inject, InjectionToken } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; +import { CommandName, defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; import { capitalizeFirstLetter } from "../common/utils"; -import { getInjector } from "../common/yok"; import { IProjectData } from "../definitions/project"; /** - * Which language a registration generates a source file for. It also decides - * the platform: java and kotlin write under App_Resources/Android, swift and + * Which language a command generates a source file for. It also decides the + * platform: java and kotlin write under App_Resources/Android, swift and * objective-c under App_Resources/iOS. */ type NativeAddLanguage = "java" | "kotlin" | "swift" | "objective-c"; -const NATIVE_ADD_LANGUAGE = new InjectionToken( - "nativeAddLanguage", -); - export interface INativeAddCommandServices { $projectData: IProjectData; $logger: ILogger; @@ -373,44 +367,50 @@ export const nativeAddCommandDefinition = defineCommand({ }, }); -export const nativeAddLanguageCommandDefinition = defineCommand({ - name: "native|add|swift", - description: "Adds a native source file to the application.", - // The one usage message answers both too few and too many arguments; a - // declared argument spec would report them with two different ones. - arguments: "any", - setup(): INativeAddLanguageCommandServices { - return { - ...setupNativeAddCommand(), - language: inject(NATIVE_ADD_LANGUAGE), - }; - }, - canExecute(context, services: INativeAddLanguageCommandServices): boolean { - if (context.args.length !== 1) { - failWithUsage(services); - } +const defineNativeAddLanguageCommand = ( + name: TName, + language: NativeAddLanguage, +) => + defineCommand({ + name, + description: "Adds a native source file to the application.", + // The one usage message answers both too few and too many arguments; a + // declared argument spec would report them with two different ones. + arguments: "any", + setup(): INativeAddLanguageCommandServices { + return { + ...setupNativeAddCommand(), + language, + }; + }, + canExecute(context, services: INativeAddLanguageCommandServices): boolean { + if (context.args.length !== 1) { + failWithUsage(services); + } - return true; - }, - run(context, services: INativeAddLanguageCommandServices): void { - generators[services.language](services, context.args[0]); - }, -}); + return true; + }, + run(context, services: INativeAddLanguageCommandServices): void { + generators[services.language](services, context.args[0]); + }, + }); -registerCommand(nativeAddCommandDefinition); - -const nativeAddLanguages: [string, NativeAddLanguage][] = [ - ["native|add|java", "java"], - ["native|add|kotlin", "kotlin"], - ["native|add|swift", "swift"], - ["native|add|objective-c", "objective-c"], -]; - -for (const [name, language] of nativeAddLanguages) { - registerCommand( - { ...nativeAddLanguageCommandDefinition, name }, - getInjector().createChild([ - { provide: NATIVE_ADD_LANGUAGE, useValue: language }, - ]), - ); -} +export const javaNativeAddCommand = defineNativeAddLanguageCommand( + "native|add|java", + "java", +); + +export const kotlinNativeAddCommand = defineNativeAddLanguageCommand( + "native|add|kotlin", + "kotlin", +); + +export const swiftNativeAddCommand = defineNativeAddLanguageCommand( + "native|add|swift", + "swift", +); + +export const objectiveCNativeAddCommand = defineNativeAddLanguageCommand( + "native|add|objective-c", + "objective-c", +); diff --git a/lib/commands/platform-clean.ts b/lib/commands/platform-clean.ts index 0793d425c1..887dc4bdd1 100644 --- a/lib/commands/platform-clean.ts +++ b/lib/commands/platform-clean.ts @@ -14,7 +14,6 @@ import { stringOption, } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; const platformCleanCommandOptions = { frameworkPath: stringOption(), @@ -115,5 +114,3 @@ export const platformCleanCommandDefinition = defineCommand({ canExecute: canExecutePlatformCleanCommand, run: runPlatformCleanCommand, }); - -registerCommand(platformCleanCommandDefinition); diff --git a/lib/commands/plugin/add-plugin.ts b/lib/commands/plugin/add-plugin.ts index ef82450934..fda3acf033 100644 --- a/lib/commands/plugin/add-plugin.ts +++ b/lib/commands/plugin/add-plugin.ts @@ -4,7 +4,6 @@ import { IPluginsService, IPluginData } from "../../definitions/plugins"; import { IErrors } from "../../common/declarations"; import { CommandContext, defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -import { registerCommand } from "../../common/services/command-definition-adapter"; export interface IAddPluginCommandServices { $pluginsService: IPluginsService; @@ -58,5 +57,3 @@ export const addPluginCommandDefinition = defineCommand({ return services.$pluginsService.add(context.args[0], services.$projectData); }, }); - -registerCommand(addPluginCommandDefinition); diff --git a/lib/commands/plugin/build-plugin.ts b/lib/commands/plugin/build-plugin.ts index 8ca801c147..0e5f94c9d1 100644 --- a/lib/commands/plugin/build-plugin.ts +++ b/lib/commands/plugin/build-plugin.ts @@ -13,7 +13,6 @@ import { stringOption, } from "../../common/define-command"; import { inject } from "../../common/di"; -import { registerCommand } from "../../common/services/command-definition-adapter"; import { ITempService } from "../../definitions/temp-service"; const buildPluginCommandOptions = { @@ -134,5 +133,3 @@ export const buildPluginCommandDefinition = defineCommand({ canExecute: canExecuteBuildPluginCommand, run: runBuildPluginCommand, }); - -registerCommand(buildPluginCommandDefinition); diff --git a/lib/commands/plugin/create-plugin.ts b/lib/commands/plugin/create-plugin.ts index 228af8bcd5..71bb45340a 100644 --- a/lib/commands/plugin/create-plugin.ts +++ b/lib/commands/plugin/create-plugin.ts @@ -9,7 +9,6 @@ import { stringOption, } from "../../common/define-command"; import { inject } from "../../common/di"; -import { registerCommand } from "../../common/services/command-definition-adapter"; import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service"; export const USER_MESSAGE = @@ -275,5 +274,3 @@ export const createPluginCommandDefinition = defineCommand({ }, run: runCreatePluginCommand, }); - -registerCommand(createPluginCommandDefinition); diff --git a/lib/commands/plugin/list-plugins.ts b/lib/commands/plugin/list-plugins.ts index f691ce02a1..771a3f595e 100644 --- a/lib/commands/plugin/list-plugins.ts +++ b/lib/commands/plugin/list-plugins.ts @@ -7,7 +7,6 @@ import { } from "../../definitions/plugins"; import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -import { registerCommand } from "../../common/services/command-definition-adapter"; import { color } from "../../color"; export interface IListPluginsCommandServices { @@ -86,5 +85,3 @@ export const listPluginsCommandDefinition = defineCommand({ ); }, }); - -registerCommand(listPluginsCommandDefinition); diff --git a/lib/commands/plugin/remove-plugin.ts b/lib/commands/plugin/remove-plugin.ts index 6593ffb6a4..4feb50305b 100644 --- a/lib/commands/plugin/remove-plugin.ts +++ b/lib/commands/plugin/remove-plugin.ts @@ -4,7 +4,6 @@ import { IPluginsService } from "../../definitions/plugins"; import { IErrors } from "../../common/declarations"; import { CommandContext, defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -import { registerCommand } from "../../common/services/command-definition-adapter"; export interface IRemovePluginCommandServices { $pluginsService: IPluginsService; @@ -67,5 +66,3 @@ export const removePluginCommandDefinition = defineCommand({ ); }, }); - -registerCommand(removePluginCommandDefinition); diff --git a/lib/commands/plugin/update-plugin.ts b/lib/commands/plugin/update-plugin.ts index f1e297492c..1180850f2f 100644 --- a/lib/commands/plugin/update-plugin.ts +++ b/lib/commands/plugin/update-plugin.ts @@ -4,7 +4,6 @@ import { IPluginsService } from "../../definitions/plugins"; import { IErrors } from "../../common/declarations"; import { CommandContext, defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -import { registerCommand } from "../../common/services/command-definition-adapter"; export interface IUpdatePluginCommandServices { $pluginsService: IPluginsService; @@ -76,5 +75,3 @@ export const updatePluginCommandDefinition = defineCommand({ canExecute: canExecuteUpdatePluginCommand, run: runUpdatePluginCommand, }); - -registerCommand(updatePluginCommandDefinition); diff --git a/lib/commands/post-install.ts b/lib/commands/post-install.ts index d1567cdf71..bcfdab2fd4 100644 --- a/lib/commands/post-install.ts +++ b/lib/commands/post-install.ts @@ -9,7 +9,6 @@ import { import { CommandContext, defineCommand } from "../common/define-command"; import { inject } from "../common/di"; import { doesCurrentNpmCommandMatch } from "../common/helpers"; -import { registerCommand } from "../common/services/command-definition-adapter"; export interface IPostInstallCliCommandServices { $fs: IFileSystem; @@ -93,5 +92,3 @@ export const postInstallCliCommandDefinition = defineCommand({ postRun: (context, result, services) => reportSuccessfulInstallation(services), }); - -registerCommand(postInstallCliCommandDefinition); diff --git a/lib/commands/prepare.ts b/lib/commands/prepare.ts index da029636b2..18bb43e76c 100644 --- a/lib/commands/prepare.ts +++ b/lib/commands/prepare.ts @@ -16,7 +16,6 @@ import { defineCommand, } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommandDefinition } from "../common/services/command-definition-adapter"; export const prepareCommandOptions = { watch: booleanOption({ default: false }), @@ -95,5 +94,3 @@ export const prepareCommandDefinition = defineCommand({ canExecute: canExecutePrepareCommand, run: runPrepareCommand, }); - -registerCommandDefinition(prepareCommandDefinition); diff --git a/lib/commands/preview.ts b/lib/commands/preview.ts index 68337f7b89..8e19715443 100644 --- a/lib/commands/preview.ts +++ b/lib/commands/preview.ts @@ -9,7 +9,6 @@ import { defineCommand, } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; import { PackageManagers } from "../constants"; import { IPackageManager } from "../declarations"; import { IProjectData } from "../definitions/project"; @@ -134,5 +133,3 @@ export const previewCommandDefinition = defineCommand({ setup: setupPreviewCommand, run: runPreviewCommand, }); - -registerCommand(previewCommandDefinition); diff --git a/lib/commands/remove-platform.ts b/lib/commands/remove-platform.ts index ba93db67bb..67e6805f64 100644 --- a/lib/commands/remove-platform.ts +++ b/lib/commands/remove-platform.ts @@ -7,7 +7,6 @@ import { import { IErrors } from "../common/declarations"; import { CommandContext, defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; export interface IRemovePlatformCommandServices { $errors: IErrors; @@ -72,5 +71,3 @@ export const removePlatformCommandDefinition = defineCommand({ canExecute: canExecuteRemovePlatformCommand, run: runRemovePlatformCommand, }); - -registerCommand(removePlatformCommandDefinition); diff --git a/lib/commands/resources/resources-update.ts b/lib/commands/resources/resources-update.ts index 5d4ab4cf1c..0be239f3da 100644 --- a/lib/commands/resources/resources-update.ts +++ b/lib/commands/resources/resources-update.ts @@ -3,7 +3,6 @@ import { IAndroidResourcesMigrationService } from "../../declarations"; import { IErrors } from "../../common/declarations"; import { CommandContext, defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -import { registerCommand } from "../../common/services/command-definition-adapter"; export interface IResourcesUpdateCommandServices { $projectData: IProjectData; @@ -70,5 +69,3 @@ export const resourcesUpdateCommandDefinition = defineCommand({ ); }, }); - -registerCommand(resourcesUpdateCommandDefinition); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index 1aa2af0adc..90c9961c71 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -7,14 +7,13 @@ import { import { booleanOption, CommandContext, + CommandName, CommandOptionsSchema, defineCommand, stringOption, } from "../common/define-command"; -import { inject, InjectionToken } from "../common/di"; +import { inject } from "../common/di"; import { hasValidAndroidSigning } from "../common/helpers"; -import { registerCommandDefinition } from "../common/services/command-definition-adapter"; -import { injector } from "../common/yok"; import { ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, ANDROID_RELEASE_BUILD_ERROR_MESSAGE, @@ -23,14 +22,6 @@ import { IOptions, IPlatformValidationService } from "../declarations"; import { IMigrateController } from "../definitions/migrate"; import { IProjectData, IProjectDataService } from "../definitions/project"; -/** - * Which `$devicePlatformsConstants` entry this registration runs. `run|*all` - * has none and registers without providing it. - */ -const RUN_PLATFORM = new InjectionToken<"iOS" | "Android" | "visionOS">( - "runCommandPlatform", -); - const runCommandOptions = { force: booleanOption(), release: booleanOption(), @@ -83,12 +74,15 @@ export function setupRunCommand(): IRunCommandServices { }; } -function setupRunPlatformCommand(): IRunCommandServices { - const services = setupRunCommand(); - services.platform = services.$devicePlatformsConstants[inject(RUN_PLATFORM)]; +type RunPlatform = "iOS" | "Android" | "visionOS"; - return services; -} +const setupPlatformRunCommand = + (platform: RunPlatform) => (): IRunCommandServices => { + const services = setupRunCommand(); + services.platform = services.$devicePlatformsConstants[platform]; + + return services; + }; export async function canExecuteRunCommand( context: RunCommandContext, @@ -150,50 +144,61 @@ export const runCommandDefinition = defineCommand({ run: runRunCommand, }); -registerCommandDefinition(runCommandDefinition); - -export const runApplePlatformCommandDefinition = defineCommand({ - name: "run|ios", - description: "Runs your project on a connected Apple device or simulator.", - options: runCommandOptions, - arguments: "any", - setup: setupRunPlatformCommand, - async canExecute( - context: RunCommandContext, - services: IRunCommandServices, - ): Promise { - const projectData = services.$projectDataService.getProjectData(); +async function canExecuteApplePlatformRunCommand( + context: RunCommandContext, + services: IRunCommandServices, +): Promise { + const projectData = services.$projectDataService.getProjectData(); + + if ( + !services.$platformValidationService.isPlatformSupportedForOS( + services.platform, + projectData, + ) + ) { + services.$errors.fail( + `Applications for platform ${services.platform} can not be built on this OS`, + ); + } - if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.platform, - projectData, - ) - ) { - services.$errors.fail( - `Applications for platform ${services.platform} can not be built on this OS`, - ); - } + const result = + (await canExecuteRunCommand(context, services)) && + (await services.$platformValidationService.validateOptions( + services.$options.provision, + services.$options.teamId, + projectData, + services.platform.toLowerCase(), + )); + return result; +} - const result = - (await canExecuteRunCommand(context, services)) && - (await services.$platformValidationService.validateOptions( - services.$options.provision, - services.$options.teamId, - projectData, - services.platform.toLowerCase(), - )); - return result; - }, - run: runRunCommand, -}); +const defineApplePlatformRunCommand = ( + name: TName, + platform: "iOS" | "visionOS", +) => + defineCommand({ + name, + description: "Runs your project on a connected Apple device or simulator.", + options: runCommandOptions, + arguments: "any", + setup: setupPlatformRunCommand(platform), + canExecute: canExecuteApplePlatformRunCommand, + run: runRunCommand, + }); + +export const iosRunCommand = defineApplePlatformRunCommand("run|ios", "iOS"); + +export const visionRunCommand = defineApplePlatformRunCommand( + ["run|vision", "run|visionos"], + "visionOS", +); -export const runAndroidCommandDefinition = defineCommand({ +export const androidRunCommand = defineCommand({ name: "run|android", description: "Runs your project on a connected Android device or emulator.", options: runCommandOptions, arguments: "any", - setup: setupRunPlatformCommand, + setup: setupPlatformRunCommand("Android"), async canExecute( context: RunCommandContext, services: IRunCommandServices, @@ -234,21 +239,3 @@ export const runAndroidCommandDefinition = defineCommand({ }, run: runRunCommand, }); - -const runApplePlatforms: [string, "iOS" | "visionOS"][] = [ - ["run|ios", "iOS"], - ["run|vision", "visionOS"], - ["run|visionos", "visionOS"], -]; - -for (const [name, platform] of runApplePlatforms) { - registerCommandDefinition( - { ...runApplePlatformCommandDefinition, name }, - injector.createChild([{ provide: RUN_PLATFORM, useValue: platform }]), - ); -} - -registerCommandDefinition( - runAndroidCommandDefinition, - injector.createChild([{ provide: RUN_PLATFORM, useValue: "Android" }]), -); diff --git a/lib/commands/setup.ts b/lib/commands/setup.ts index 73b1495405..69e21a161e 100644 --- a/lib/commands/setup.ts +++ b/lib/commands/setup.ts @@ -1,7 +1,6 @@ import { IDoctorService } from "../common/declarations"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; export const setupCommandDefinition = defineCommand({ name: "setup|*", @@ -15,5 +14,3 @@ export const setupCommandDefinition = defineCommand({ return services.$doctorService.runSetupScript(); }, }); - -registerCommand(setupCommandDefinition); diff --git a/lib/commands/start.ts b/lib/commands/start.ts index 499c57d93b..d1ac27f997 100644 --- a/lib/commands/start.ts +++ b/lib/commands/start.ts @@ -1,7 +1,6 @@ import { printHeader } from "../common/header"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; import { IStartService } from "../definitions/start-service"; export const startCommandDefinition = defineCommand({ @@ -18,5 +17,3 @@ export const startCommandDefinition = defineCommand({ return; }, }); - -registerCommand(startCommandDefinition); diff --git a/lib/commands/test-init.ts b/lib/commands/test-init.ts index 219581d633..0db996dc5f 100644 --- a/lib/commands/test-init.ts +++ b/lib/commands/test-init.ts @@ -14,7 +14,6 @@ import { stringOption, } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; import { IDictionary, IErrors, @@ -432,5 +431,3 @@ export const testInitCommandDefinition = defineCommand({ ); }, }); - -registerCommand(testInitCommandDefinition); diff --git a/lib/commands/test.ts b/lib/commands/test.ts index 831ad1c9ad..49c528f48f 100644 --- a/lib/commands/test.ts +++ b/lib/commands/test.ts @@ -10,11 +10,9 @@ import { defineCommand, stringOption, } from "../common/define-command"; -import { inject, InjectionToken } from "../common/di"; +import { inject } from "../common/di"; import { ErrorCodes } from "../common/enums"; import { hasValidAndroidSigning } from "../common/helpers"; -import { registerCommand } from "../common/services/command-definition-adapter"; -import { getInjector } from "../common/yok"; import { ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, ANDROID_RELEASE_BUILD_ERROR_MESSAGE, @@ -30,9 +28,7 @@ import { } from "../definitions/project"; /** The platform spelling the test services receive, verbatim. */ -const TEST_PLATFORM = new InjectionToken<"android" | "iOS" | "visionOS">( - "testCommandPlatform", -); +type TestPlatform = "android" | "iOS" | "visionOS"; const testCommandOptions = { // The CLI-wide default is true; unit testing has always opted out of it. @@ -71,9 +67,11 @@ export interface ITestCommandServices { $vitestExecutionService: IVitestExecutionService; } -export function setupTestCommand(): ITestCommandServices { +export function setupTestCommand( + testPlatform: TestPlatform, +): ITestCommandServices { return { - platform: inject(TEST_PLATFORM), + platform: testPlatform, $analyticsService: inject("analyticsService"), $cleanupService: inject("cleanupService"), $devicesService: inject("devicesService"), @@ -244,7 +242,7 @@ export const testCommandDefinition = defineCommand({ options: testCommandOptions, // Arguments have never been rejected here, only ignored. arguments: "any", - setup: setupTestCommand, + setup: () => setupTestCommand("iOS"), canExecute: canExecuteTestCommand, run: runTestCommand, }); @@ -255,7 +253,7 @@ export const testAndroidCommandDefinition = defineCommand({ "Runs the tests in your project on connected Android devices or Android emulators.", options: testCommandOptions, arguments: "any", - setup: setupTestCommand, + setup: () => setupTestCommand("android"), async canExecute( context: TestCommandContext, services: ITestCommandServices, @@ -287,7 +285,7 @@ export const testVisionOSCommandDefinition = defineCommand({ "Runs the tests in your project in the visionOS Simulator or on connected Apple Vision Pro devices.", options: testCommandOptions, arguments: "any", - setup: setupTestCommand, + setup: () => setupTestCommand("visionOS"), async canExecute( context: TestCommandContext, services: ITestCommandServices, @@ -307,18 +305,3 @@ export const testVisionOSCommandDefinition = defineCommand({ }, run: runTestCommand, }); - -registerCommand( - testCommandDefinition, - getInjector().createChild([{ provide: TEST_PLATFORM, useValue: "iOS" }]), -); - -registerCommand( - testAndroidCommandDefinition, - getInjector().createChild([{ provide: TEST_PLATFORM, useValue: "android" }]), -); - -registerCommand( - testVisionOSCommandDefinition, - getInjector().createChild([{ provide: TEST_PLATFORM, useValue: "visionOS" }]), -); diff --git a/lib/commands/typings.ts b/lib/commands/typings.ts index 821559a248..79152d97de 100644 --- a/lib/commands/typings.ts +++ b/lib/commands/typings.ts @@ -11,7 +11,6 @@ import { stringOption, } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; import { IOptions, IStaticConfig } from "../declarations"; import { IProjectData } from "../definitions/project"; @@ -295,5 +294,3 @@ export const typingsCommandDefinition = defineCommand({ canExecute: canExecuteTypingsCommand, run: runTypingsCommand, }); - -registerCommand(typingsCommandDefinition); diff --git a/lib/commands/update-platform.ts b/lib/commands/update-platform.ts index 493185ea21..85eb8e61f7 100644 --- a/lib/commands/update-platform.ts +++ b/lib/commands/update-platform.ts @@ -12,7 +12,6 @@ import { import { IErrors } from "../common/declarations"; import { CommandContext, defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; export interface IUpdatePlatformCommandServices { $errors: IErrors; @@ -105,5 +104,3 @@ export const updatePlatformCommandDefinition = defineCommand({ canExecute: canExecuteUpdatePlatformCommand, run: runUpdatePlatformCommand, }); - -registerCommand(updatePlatformCommandDefinition); diff --git a/lib/commands/update.ts b/lib/commands/update.ts index 22c0aea933..71524be88d 100644 --- a/lib/commands/update.ts +++ b/lib/commands/update.ts @@ -9,7 +9,6 @@ import { stringOption, } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; export const SHOULD_MIGRATE_PROJECT_MESSAGE = 'This project is not compatible with the current NativeScript version and cannot be updated. Use "ns migrate" to make your project compatible.'; @@ -109,5 +108,3 @@ export const updateCommandDefinition = defineCommand({ canExecute: canExecuteUpdateCommand, run: runUpdateCommand, }); - -registerCommand(updateCommandDefinition); diff --git a/lib/commands/widget.ts b/lib/commands/widget.ts index 949fd6ab48..033d6942ff 100644 --- a/lib/commands/widget.ts +++ b/lib/commands/widget.ts @@ -6,7 +6,6 @@ import * as path from "path"; import * as plist from "plist"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -import { registerCommand } from "../common/services/command-definition-adapter"; import { capitalizeFirstLetter } from "../common/utils"; import { EOL } from "os"; @@ -927,5 +926,3 @@ export const widgetIOSCommandDefinition = defineCommand({ services.generator.startPrompt(context.args); }, }); - -registerCommand(widgetIOSCommandDefinition); diff --git a/lib/common/bootstrap.ts b/lib/common/bootstrap.ts index 287586c134..4a401ec5ac 100644 --- a/lib/common/bootstrap.ts +++ b/lib/common/bootstrap.ts @@ -1,9 +1,15 @@ import { injector } from "./yok"; +import { registerBuiltInCommand } from "./services/command-definition-adapter"; import { ICliGlobal } from "./definitions/cli-global"; import * as _ from "lodash"; ((global))._ = _; ((global)).$injector = injector; +/** + * The CLI owns every name it registers here, so a refusal is a mistake in this + * file rather than a condition to report and carry on from, the way a + * conflicting extension is. + */ injector.require("errors", "./errors"); injector.requirePublic("fs", "./file-system"); injector.require("hostInfo", "./host-info"); @@ -29,43 +35,156 @@ injector.require("prompter", "./prompter"); injector.require("projectHelper", "./project-helper"); injector.require("pluginVariablesHelper", "./plugin-variables-helper"); -injector.requireCommand(["help", "/?"], "./commands/help"); -injector.requireCommand("usage-reporting", "./commands/analytics"); -injector.requireCommand("error-reporting", "./commands/analytics"); +registerBuiltInCommand( + "help", + () => require("./commands/help").helpCommandDefinition, +); +registerBuiltInCommand( + "/?", + () => require("./commands/help").helpCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/analytics").usageReportingCommand +>( + "usage-reporting", + () => require("./commands/analytics").usageReportingCommand, +); +registerBuiltInCommand< + typeof import("./commands/analytics").errorReportingCommand +>( + "error-reporting", + () => require("./commands/analytics").errorReportingCommand, +); -injector.requireCommand("dev-post-install", "./commands/post-install"); -injector.requireCommand("autocomplete|*default", "./commands/autocompletion"); -injector.requireCommand("autocomplete|enable", "./commands/autocompletion"); -injector.requireCommand("autocomplete|disable", "./commands/autocompletion"); -injector.requireCommand("autocomplete|status", "./commands/autocompletion"); +registerBuiltInCommand< + typeof import("./commands/post-install").postInstallCommandDefinition +>( + "dev-post-install", + () => require("./commands/post-install").postInstallCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/autocompletion").autoCompleteCommandDefinition +>( + "autocomplete|*default", + () => require("./commands/autocompletion").autoCompleteCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/autocompletion").enableAutoCompleteCommandDefinition +>( + "autocomplete|enable", + () => + require("./commands/autocompletion").enableAutoCompleteCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/autocompletion").disableAutoCompleteCommandDefinition +>( + "autocomplete|disable", + () => + require("./commands/autocompletion").disableAutoCompleteCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/autocompletion").autoCompleteStatusCommandDefinition +>( + "autocomplete|status", + () => + require("./commands/autocompletion").autoCompleteStatusCommandDefinition, +); -injector.requireCommand( - ["device|*list", "devices|*list"], - "./commands/device/list-devices", +registerBuiltInCommand< + typeof import("./commands/device/list-devices").listDevicesCommandDefinition +>( + "device|*list", + () => require("./commands/device/list-devices").listDevicesCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/device/list-devices").listDevicesCommandDefinition +>( + "devices|*list", + () => require("./commands/device/list-devices").listDevicesCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/device/list-devices").androidListDevicesCommand +>( + "device|android", + () => require("./commands/device/list-devices").androidListDevicesCommand, +); +registerBuiltInCommand< + typeof import("./commands/device/list-devices").androidListDevicesCommand +>( + "devices|android", + () => require("./commands/device/list-devices").androidListDevicesCommand, ); -injector.requireCommand( - ["device|android", "devices|android"], - "./commands/device/list-devices", +registerBuiltInCommand< + typeof import("./commands/device/list-devices").iosListDevicesCommand +>( + "device|ios", + () => require("./commands/device/list-devices").iosListDevicesCommand, ); -injector.requireCommand( - ["device|ios", "devices|ios"], - "./commands/device/list-devices", +registerBuiltInCommand< + typeof import("./commands/device/list-devices").iosListDevicesCommand +>( + "devices|ios", + () => require("./commands/device/list-devices").iosListDevicesCommand, ); -injector.requireCommand("device|log", "./commands/device/device-log-stream"); -injector.requireCommand("device|run", "./commands/device/run-application"); -injector.requireCommand("device|stop", "./commands/device/stop-application"); -injector.requireCommand( +registerBuiltInCommand< + typeof import("./commands/device/device-log-stream").openDeviceLogStreamCommandDefinition +>( + "device|log", + () => + require("./commands/device/device-log-stream") + .openDeviceLogStreamCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/device/run-application").runApplicationOnDeviceCommandDefinition +>( + "device|run", + () => + require("./commands/device/run-application") + .runApplicationOnDeviceCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/device/stop-application").stopApplicationOnDeviceCommandDefinition +>( + "device|stop", + () => + require("./commands/device/stop-application") + .stopApplicationOnDeviceCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/device/list-applications").listApplicationsCommandDefinition +>( "device|list-applications", - "./commands/device/list-applications", + () => + require("./commands/device/list-applications") + .listApplicationsCommandDefinition, ); -injector.requireCommand( +registerBuiltInCommand< + typeof import("./commands/device/uninstall-application").uninstallApplicationCommandDefinition +>( "device|uninstall", - "./commands/device/uninstall-application", + () => + require("./commands/device/uninstall-application") + .uninstallApplicationCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/device/list-files").listFilesCommandDefinition +>( + "device|list-files", + () => require("./commands/device/list-files").listFilesCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/device/get-file").getFileCommandDefinition +>( + "device|get-file", + () => require("./commands/device/get-file").getFileCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/device/put-file").putFileCommandDefinition +>( + "device|put-file", + () => require("./commands/device/put-file").putFileCommandDefinition, ); -injector.requireCommand("device|list-files", "./commands/device/list-files"); -injector.requireCommand("device|get-file", "./commands/device/get-file"); -injector.requireCommand("device|put-file", "./commands/device/put-file"); injector.require( "iosDeviceOperations", @@ -163,18 +282,49 @@ injector.require( "./services/message-contract-generator", ); injector.require("proxyService", "./services/proxy-service"); -injector.requireCommand("dev-preuninstall", "./commands/preuninstall"); -injector.requireCommand( +registerBuiltInCommand< + typeof import("./commands/preuninstall").preUninstallCommandDefinition +>( + "dev-preuninstall", + () => require("./commands/preuninstall").preUninstallCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/generate-messages").generateMessagesCommandDefinition +>( "dev-generate-messages", - "./commands/generate-messages", + () => + require("./commands/generate-messages").generateMessagesCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/doctor").doctorCommandDefinition +>("doctor|*all", () => require("./commands/doctor").doctorCommandDefinition); +registerBuiltInCommand( + "doctor|ios", + () => require("./commands/doctor").iosDoctorCommand, +); +registerBuiltInCommand( + "doctor|android", + () => require("./commands/doctor").androidDoctorCommand, ); -injector.requireCommand("doctor|*all", "./commands/doctor"); -injector.requireCommand("doctor|ios", "./commands/doctor"); -injector.requireCommand("doctor|android", "./commands/doctor"); -injector.requireCommand("proxy|*get", "./commands/proxy/proxy-get"); -injector.requireCommand("proxy|set", "./commands/proxy/proxy-set"); -injector.requireCommand("proxy|clear", "./commands/proxy/proxy-clear"); +registerBuiltInCommand< + typeof import("./commands/proxy/proxy-get").proxyGetCommandDefinition +>( + "proxy|*get", + () => require("./commands/proxy/proxy-get").proxyGetCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/proxy/proxy-set").proxySetCommandDefinition +>( + "proxy|set", + () => require("./commands/proxy/proxy-set").proxySetCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/proxy/proxy-clear").proxyClearCommandDefinition +>( + "proxy|clear", + () => require("./commands/proxy/proxy-clear").proxyClearCommandDefinition, +); injector.require("utils", "./utils"); injector.require("plistParser", "./plist-parser"); diff --git a/lib/common/commands/analytics.ts b/lib/common/commands/analytics.ts index a3ab0e1e01..36e39f76b8 100644 --- a/lib/common/commands/analytics.ts +++ b/lib/common/commands/analytics.ts @@ -2,14 +2,13 @@ import { IAnalyticsService } from "../declarations"; import { booleanOption, CommandContext, + CommandName, CommandOptionsSchema, defineCommand, } from "../define-command"; -import { inject, InjectionToken } from "../di"; -import { registerCommand } from "../services/command-definition-adapter"; -import { getInjector } from "../yok"; +import { inject } from "../di"; -/** Which reporting the registration configures. */ +/** Which reporting a command configures. */ interface IAnalyticsSetting { /** The static config property naming the setting the CLI stores it under. */ staticConfigKey: keyof Pick< @@ -19,10 +18,6 @@ interface IAnalyticsSetting { humanReadableSettingName: string; } -const ANALYTICS_SETTING = new InjectionToken( - "analyticsSetting", -); - export const analyticsCommandOptions = { json: booleanOption(), } satisfies CommandOptionsSchema; @@ -38,8 +33,9 @@ export interface IAnalyticsCommandServices { $logger: ILogger; } -export function setupAnalyticsCommand(): IAnalyticsCommandServices { - const setting = inject(ANALYTICS_SETTING); +export function setupAnalyticsCommand( + setting: IAnalyticsSetting, +): IAnalyticsCommandServices { const $staticConfig = inject("staticConfig"); return { @@ -95,38 +91,26 @@ export async function runAnalyticsCommand( } } -export const analyticsCommandDefinition = defineCommand({ - name: "usage-reporting", - description: "Configures anonymous reporting for the CLI.", - options: analyticsCommandOptions, - arguments: [{ name: "state", validate: validateAnalyticsState }], - disableAnalytics: true, - setup: setupAnalyticsCommand, - run: runAnalyticsCommand, -}); +const defineAnalyticsCommand = ( + name: TName, + setting: IAnalyticsSetting, +) => + defineCommand({ + name, + description: "Configures anonymous reporting for the CLI.", + options: analyticsCommandOptions, + arguments: [{ name: "state", validate: validateAnalyticsState }], + disableAnalytics: true, + setup: () => setupAnalyticsCommand(setting), + run: runAnalyticsCommand, + }); -const analyticsCommands: [string, IAnalyticsSetting][] = [ - [ - "usage-reporting", - { - staticConfigKey: "TRACK_FEATURE_USAGE_SETTING_NAME", - humanReadableSettingName: "Usage reporting", - }, - ], - [ - "error-reporting", - { - staticConfigKey: "ERROR_REPORT_SETTING_NAME", - humanReadableSettingName: "Error reporting", - }, - ], -]; +export const usageReportingCommand = defineAnalyticsCommand("usage-reporting", { + staticConfigKey: "TRACK_FEATURE_USAGE_SETTING_NAME", + humanReadableSettingName: "Usage reporting", +}); -for (const [name, setting] of analyticsCommands) { - registerCommand( - { ...analyticsCommandDefinition, name }, - getInjector().createChild([ - { provide: ANALYTICS_SETTING, useValue: setting }, - ]), - ); -} +export const errorReportingCommand = defineAnalyticsCommand("error-reporting", { + staticConfigKey: "ERROR_REPORT_SETTING_NAME", + humanReadableSettingName: "Error reporting", +}); diff --git a/lib/common/commands/autocompletion.ts b/lib/common/commands/autocompletion.ts index 4a916995d4..30d6ebed5f 100644 --- a/lib/common/commands/autocompletion.ts +++ b/lib/common/commands/autocompletion.ts @@ -2,7 +2,6 @@ import * as helpers from "../helpers"; import { IAutoCompletionService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -import { registerCommand } from "../services/command-definition-adapter"; export interface IAutoCompleteCommandServices { $autoCompletionService: IAutoCompletionService; @@ -101,8 +100,3 @@ export const autoCompleteStatusCommandDefinition = defineCommand({ } }, }); - -registerCommand(autoCompleteCommandDefinition); -registerCommand(disableAutoCompleteCommandDefinition); -registerCommand(enableAutoCompleteCommandDefinition); -registerCommand(autoCompleteStatusCommandDefinition); diff --git a/lib/common/commands/device/device-log-stream.ts b/lib/common/commands/device/device-log-stream.ts index 971d1d524a..8c0dcda6f6 100644 --- a/lib/common/commands/device/device-log-stream.ts +++ b/lib/common/commands/device/device-log-stream.ts @@ -7,7 +7,6 @@ import { stringOption, } from "../../define-command"; import { inject } from "../../di"; -import { registerCommand } from "../../services/command-definition-adapter"; const NOT_SPECIFIED_DEVICE_ERROR_MESSAGE = "More than one device found. Specify device explicitly."; @@ -75,5 +74,3 @@ export const openDeviceLogStreamCommandDefinition = defineCommand({ setup: setupOpenDeviceLogStreamCommand, run: runOpenDeviceLogStreamCommand, }); - -registerCommand(openDeviceLogStreamCommandDefinition); diff --git a/lib/common/commands/device/get-file.ts b/lib/common/commands/device/get-file.ts index 46827575a0..a005cd5361 100644 --- a/lib/common/commands/device/get-file.ts +++ b/lib/common/commands/device/get-file.ts @@ -7,7 +7,6 @@ import { stringOption, } from "../../define-command"; import { inject } from "../../di"; -import { registerCommand } from "../../services/command-definition-adapter"; const getFileCommandOptions = { device: stringOption(), @@ -78,5 +77,3 @@ export const getFileCommandDefinition = defineCommand({ setup: setupGetFileCommand, run: runGetFileCommand, }); - -registerCommand(getFileCommandDefinition); diff --git a/lib/common/commands/device/list-applications.ts b/lib/common/commands/device/list-applications.ts index fc11a67a7b..b53f6af372 100644 --- a/lib/common/commands/device/list-applications.ts +++ b/lib/common/commands/device/list-applications.ts @@ -8,7 +8,6 @@ import { stringOption, } from "../../define-command"; import { inject } from "../../di"; -import { registerCommand } from "../../services/command-definition-adapter"; const listApplicationsCommandOptions = { device: stringOption(), @@ -65,5 +64,3 @@ export const listApplicationsCommandDefinition = defineCommand({ setup: setupListApplicationsCommand, run: runListApplicationsCommand, }); - -registerCommand(listApplicationsCommandDefinition); diff --git a/lib/common/commands/device/list-devices.ts b/lib/common/commands/device/list-devices.ts index e3c322f3ad..3564288a9e 100644 --- a/lib/common/commands/device/list-devices.ts +++ b/lib/common/commands/device/list-devices.ts @@ -4,18 +4,12 @@ import { IErrors } from "../../declarations"; import { booleanOption, CommandContext, + CommandName, CommandOptionsSchema, defineCommand, } from "../../define-command"; -import { inject, InjectionToken } from "../../di"; +import { inject } from "../../di"; import { createTable, formatListOfNames } from "../../helpers"; -import { registerCommandDefinition } from "../../services/command-definition-adapter"; -import { injector } from "../../yok"; - -/** Which `$devicePlatformsConstants` entry this registration lists. */ -const LIST_DEVICES_PLATFORM = new InjectionToken<"iOS" | "Android">( - "listDevicesCommandPlatform", -); const listDevicesCommandOptions = { availableDevices: booleanOption(), @@ -181,42 +175,39 @@ export const listDevicesCommandDefinition = defineCommand({ }, }); -registerCommandDefinition(listDevicesCommandDefinition); - interface IListPlatformDevicesCommandServices extends IListDevicesCommandServices { platform: string; } -export const listPlatformDevicesCommandDefinition = defineCommand({ - name: ["device|android", "devices|android"], - description: "Lists the connected devices and emulators for one platform.", - options: listDevicesCommandOptions, - arguments: "none", - setup(): IListPlatformDevicesCommandServices { - const $devicePlatformsConstants = inject( - "devicePlatformsConstants", - ); - - return { - ...setupListDevicesCommand(), - platform: $devicePlatformsConstants[inject(LIST_DEVICES_PLATFORM)], - }; - }, - run(context, services): Promise { - return runListDevicesCommand(context, services, services.platform); - }, -}); +const defineListPlatformDevicesCommand = ( + name: TName, + listedPlatform: "iOS" | "Android", +) => + defineCommand({ + name, + description: "Lists the connected devices and emulators for one platform.", + options: listDevicesCommandOptions, + arguments: "none", + setup(): IListPlatformDevicesCommandServices { + const $devicePlatformsConstants = + inject("devicePlatformsConstants"); + + return { + ...setupListDevicesCommand(), + platform: $devicePlatformsConstants[listedPlatform], + }; + }, + run(context, services): Promise { + return runListDevicesCommand(context, services, services.platform); + }, + }); -const listDevicesPlatforms: [string[], "iOS" | "Android"][] = [ - [["device|android", "devices|android"], "Android"], - [["device|ios", "devices|ios"], "iOS"], -]; - -for (const [name, platform] of listDevicesPlatforms) { - registerCommandDefinition( - { ...listPlatformDevicesCommandDefinition, name }, - injector.createChild([ - { provide: LIST_DEVICES_PLATFORM, useValue: platform }, - ]), - ); -} +export const androidListDevicesCommand = defineListPlatformDevicesCommand( + ["device|android", "devices|android"], + "Android", +); + +export const iosListDevicesCommand = defineListPlatformDevicesCommand( + ["device|ios", "devices|ios"], + "iOS", +); diff --git a/lib/common/commands/device/list-files.ts b/lib/common/commands/device/list-files.ts index ac5c2b9d8f..29b02f3097 100644 --- a/lib/common/commands/device/list-files.ts +++ b/lib/common/commands/device/list-files.ts @@ -7,7 +7,6 @@ import { stringOption, } from "../../define-command"; import { inject } from "../../di"; -import { registerCommand } from "../../services/command-definition-adapter"; const listFilesCommandOptions = { device: stringOption(), @@ -74,5 +73,3 @@ export const listFilesCommandDefinition = defineCommand({ setup: setupListFilesCommand, run: runListFilesCommand, }); - -registerCommand(listFilesCommandDefinition); diff --git a/lib/common/commands/device/put-file.ts b/lib/common/commands/device/put-file.ts index 1023c0876d..b2ff383305 100644 --- a/lib/common/commands/device/put-file.ts +++ b/lib/common/commands/device/put-file.ts @@ -7,7 +7,6 @@ import { stringOption, } from "../../define-command"; import { inject } from "../../di"; -import { registerCommand } from "../../services/command-definition-adapter"; const putFileCommandOptions = { device: stringOption(), @@ -77,5 +76,3 @@ export const putFileCommandDefinition = defineCommand({ setup: setupPutFileCommand, run: runPutFileCommand, }); - -registerCommand(putFileCommandDefinition); diff --git a/lib/common/commands/device/run-application.ts b/lib/common/commands/device/run-application.ts index c4b9370959..147060988f 100644 --- a/lib/common/commands/device/run-application.ts +++ b/lib/common/commands/device/run-application.ts @@ -6,7 +6,6 @@ import { stringOption, } from "../../define-command"; import { inject } from "../../di"; -import { registerCommand } from "../../services/command-definition-adapter"; const runApplicationOnDeviceCommandOptions = { device: stringOption(), @@ -64,5 +63,3 @@ export const runApplicationOnDeviceCommandDefinition = defineCommand({ setup: setupRunApplicationOnDeviceCommand, run: runRunApplicationOnDeviceCommand, }); - -registerCommand(runApplicationOnDeviceCommandDefinition); diff --git a/lib/common/commands/device/stop-application.ts b/lib/common/commands/device/stop-application.ts index e3d8a8de30..7c9c7a0e48 100644 --- a/lib/common/commands/device/stop-application.ts +++ b/lib/common/commands/device/stop-application.ts @@ -5,7 +5,6 @@ import { stringOption, } from "../../define-command"; import { inject } from "../../di"; -import { registerCommand } from "../../services/command-definition-adapter"; const stopApplicationOnDeviceCommandOptions = { device: stringOption(), @@ -52,5 +51,3 @@ export const stopApplicationOnDeviceCommandDefinition = defineCommand({ setup: setupStopApplicationOnDeviceCommand, run: runStopApplicationOnDeviceCommand, }); - -registerCommand(stopApplicationOnDeviceCommandDefinition); diff --git a/lib/common/commands/device/uninstall-application.ts b/lib/common/commands/device/uninstall-application.ts index 92747dc5f0..b37cffaa2d 100644 --- a/lib/common/commands/device/uninstall-application.ts +++ b/lib/common/commands/device/uninstall-application.ts @@ -5,7 +5,6 @@ import { stringOption, } from "../../define-command"; import { inject } from "../../di"; -import { registerCommand } from "../../services/command-definition-adapter"; const uninstallApplicationCommandOptions = { device: stringOption(), @@ -47,5 +46,3 @@ export const uninstallApplicationCommandDefinition = defineCommand({ setup: setupUninstallApplicationCommand, run: runUninstallApplicationCommand, }); - -registerCommand(uninstallApplicationCommandDefinition); diff --git a/lib/common/commands/doctor.ts b/lib/common/commands/doctor.ts index 6ede1f9c5a..b780b0d9e5 100644 --- a/lib/common/commands/doctor.ts +++ b/lib/common/commands/doctor.ts @@ -1,57 +1,52 @@ import { IDoctorService, IProjectHelper } from "../declarations"; -import { defineCommand } from "../define-command"; -import { inject, InjectionToken } from "../di"; -import { registerCommand } from "../services/command-definition-adapter"; -import { getInjector } from "../yok"; +import { CommandName, defineCommand } from "../define-command"; +import { inject } from "../di"; import { PlatformTypes } from "../../constants"; -/** Which platform this registration checks; absent for the whole environment. */ -const DOCTOR_PLATFORM = new InjectionToken( - "doctorCommandPlatform", -); - export interface IDoctorCommandServices { platform: PlatformTypes; $doctorService: IDoctorService; $projectHelper: IProjectHelper; } -export function setupDoctorCommand(): IDoctorCommandServices { +export function setupDoctorCommand( + platform?: PlatformTypes, +): IDoctorCommandServices { return { - platform: inject(DOCTOR_PLATFORM, { optional: true }), + platform, $doctorService: inject("doctorService"), $projectHelper: inject("projectHelper"), }; } -export const doctorCommandDefinition = defineCommand({ - name: "doctor|*all", - description: - "Checks the local environment for configuration issues, and prints what it finds.", - arguments: "none", - setup: setupDoctorCommand, - run(context, services): Promise { - return services.$doctorService.printWarnings({ - trackResult: false, - projectDir: services.$projectHelper.projectDir, - forceCheck: true, - ...(services.platform ? { platform: services.platform } : {}), - }); - }, -}); +const defineDoctorCommand = ( + name: TName, + platform?: PlatformTypes, +) => + defineCommand({ + name, + description: + "Checks the local environment for configuration issues, and prints what it finds.", + arguments: "none", + setup: () => setupDoctorCommand(platform), + run(context, services): Promise { + return services.$doctorService.printWarnings({ + trackResult: false, + projectDir: services.$projectHelper.projectDir, + forceCheck: true, + ...(services.platform ? { platform: services.platform } : {}), + }); + }, + }); -const doctorPlatforms: [string, PlatformTypes][] = [ - ["doctor|ios", PlatformTypes.ios], - ["doctor|android", PlatformTypes.android], -]; +export const doctorCommandDefinition = defineDoctorCommand("doctor|*all"); -registerCommand(doctorCommandDefinition); +export const iosDoctorCommand = defineDoctorCommand( + "doctor|ios", + PlatformTypes.ios, +); -for (const [name, platform] of doctorPlatforms) { - registerCommand( - { ...doctorCommandDefinition, name }, - getInjector().createChild([ - { provide: DOCTOR_PLATFORM, useValue: platform }, - ]), - ); -} +export const androidDoctorCommand = defineDoctorCommand( + "doctor|android", + PlatformTypes.android, +); diff --git a/lib/common/commands/generate-messages.ts b/lib/common/commands/generate-messages.ts index 66860086a4..80cc52dfb5 100644 --- a/lib/common/commands/generate-messages.ts +++ b/lib/common/commands/generate-messages.ts @@ -6,7 +6,6 @@ import { defineCommand, } from "../define-command"; import { inject } from "../di"; -import { registerCommand } from "../services/command-definition-adapter"; const MESSAGES_DEFINITIONS_FILE_NAME = "messages.interface.d.ts"; const MESSAGES_IMPLEMENTATION_FILE_NAME = "messages.ts"; @@ -57,5 +56,3 @@ export const generateMessagesCommandDefinition = defineCommand({ services.$fs.writeFile(implementationFilePath, result.implementationFile); }, }); - -registerCommand(generateMessagesCommandDefinition); diff --git a/lib/common/commands/help.ts b/lib/common/commands/help.ts index 17f535a42d..eba2fad259 100644 --- a/lib/common/commands/help.ts +++ b/lib/common/commands/help.ts @@ -8,7 +8,6 @@ import { defineCommand, } from "../define-command"; import { inject } from "../di"; -import { registerCommand } from "../services/command-definition-adapter"; export const helpCommandOptions = { help: booleanOption(), @@ -68,5 +67,3 @@ export const helpCommandDefinition = defineCommand({ setup: setupHelpCommand, run: runHelpCommand, }); - -registerCommand(helpCommandDefinition); diff --git a/lib/common/commands/package-manager-get.ts b/lib/common/commands/package-manager-get.ts index 883c4aa1a1..f47eabaf0d 100644 --- a/lib/common/commands/package-manager-get.ts +++ b/lib/common/commands/package-manager-get.ts @@ -1,7 +1,6 @@ import { IUserSettingsService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -import { registerCommand } from "../services/command-definition-adapter"; export interface IPackageManagerGetCommandServices { $logger: ILogger; @@ -30,5 +29,3 @@ export const packageManagerGetCommandDefinition = defineCommand({ ); }, }); - -registerCommand(packageManagerGetCommandDefinition); diff --git a/lib/common/commands/package-manager-set.ts b/lib/common/commands/package-manager-set.ts index 0e3e113641..d192218634 100644 --- a/lib/common/commands/package-manager-set.ts +++ b/lib/common/commands/package-manager-set.ts @@ -2,7 +2,6 @@ import { PackageManagers } from "../../constants"; import { IErrors, IUserSettingsService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -import { registerCommand } from "../services/command-definition-adapter"; export interface IPackageManagerSetCommandServices { $userSettingsService: IUserSettingsService; @@ -50,5 +49,3 @@ export const packageManagerSetCommandDefinition = defineCommand({ ); }, }); - -registerCommand(packageManagerSetCommandDefinition); diff --git a/lib/common/commands/post-install.ts b/lib/common/commands/post-install.ts index 75e19de477..dfbdfe571d 100644 --- a/lib/common/commands/post-install.ts +++ b/lib/common/commands/post-install.ts @@ -1,7 +1,6 @@ import { IErrors } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -import { registerCommand } from "../services/command-definition-adapter"; export const postInstallCommandDefinition = defineCommand({ name: "dev-post-install", @@ -17,5 +16,3 @@ export const postInstallCommandDefinition = defineCommand({ ); }, }); - -registerCommand(postInstallCommandDefinition); diff --git a/lib/common/commands/preuninstall.ts b/lib/common/commands/preuninstall.ts index 4a61c9e4e3..0eaa95b4c2 100644 --- a/lib/common/commands/preuninstall.ts +++ b/lib/common/commands/preuninstall.ts @@ -12,7 +12,6 @@ import { } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -import { registerCommand } from "../services/command-definition-adapter"; import { IExtensibilityService } from "../definitions/extensibility"; // disabled for now (6/24/2020) @@ -90,5 +89,3 @@ export const preUninstallCommandDefinition = defineCommand({ await services.$analyticsService.finishTracking(); }, }); - -registerCommand(preUninstallCommandDefinition); diff --git a/lib/common/commands/proxy/proxy-clear.ts b/lib/common/commands/proxy/proxy-clear.ts index b3eec7343b..a48655f22f 100644 --- a/lib/common/commands/proxy/proxy-clear.ts +++ b/lib/common/commands/proxy/proxy-clear.ts @@ -1,5 +1,4 @@ import { defineCommand } from "../../define-command"; -import { registerCommand } from "../../services/command-definition-adapter"; import { injectProxyCommandServices, IProxyCommandServices, @@ -20,5 +19,3 @@ export const proxyClearCommandDefinition = defineCommand({ await tryTrackProxyCommandUsage(services, proxyClearCommandName); }, }); - -registerCommand(proxyClearCommandDefinition); diff --git a/lib/common/commands/proxy/proxy-get.ts b/lib/common/commands/proxy/proxy-get.ts index 2682b3e869..7325648ed2 100644 --- a/lib/common/commands/proxy/proxy-get.ts +++ b/lib/common/commands/proxy/proxy-get.ts @@ -1,5 +1,4 @@ import { defineCommand } from "../../define-command"; -import { registerCommand } from "../../services/command-definition-adapter"; import { injectProxyCommandServices, IProxyCommandServices, @@ -19,5 +18,3 @@ export const proxyGetCommandDefinition = defineCommand({ await tryTrackProxyCommandUsage(services, proxyGetCommandName); }, }); - -registerCommand(proxyGetCommandDefinition); diff --git a/lib/common/commands/proxy/proxy-set.ts b/lib/common/commands/proxy/proxy-set.ts index 3206b57607..fd99b24ba9 100644 --- a/lib/common/commands/proxy/proxy-set.ts +++ b/lib/common/commands/proxy/proxy-set.ts @@ -15,7 +15,6 @@ import { } from "../../define-command"; import { inject } from "../../di"; import { isInteractive } from "../../helpers"; -import { registerCommand } from "../../services/command-definition-adapter"; import { injectProxyCommandServices, IProxyCommandServices, @@ -210,5 +209,3 @@ export const proxySetCommandDefinition = defineCommand({ setup: setupProxySetCommand, run: runProxySetCommand, }); - -registerCommand(proxySetCommandDefinition); diff --git a/lib/common/contracts/command-registry.ts b/lib/common/contracts/command-registry.ts index d6484f4e7d..ed4140c81c 100644 --- a/lib/common/contracts/command-registry.ts +++ b/lib/common/contracts/command-registry.ts @@ -1,14 +1,27 @@ import { Contract } from "../di/contract"; +import { InjectionToken } from "../di/injection-token"; import type { ICommand } from "../definitions/commands"; +/** + * Who a command registered during the current injection context belongs to. + * An extension's module is loaded under a child injector providing it, so a + * command the module registers on its own is attributed to the extension + * without the registration site naming anyone. + */ +export const COMMAND_OWNER = new InjectionToken("commandOwner"); + export interface DeferredCommandOptions { /** * Names the registrant in conflict and failure reports. Re-registering the * same command under the same owner is a no-op rather than a conflict. */ owner: string; - /** Where the implementation comes from; named when loading it fails. */ - source: string; + /** + * Where the implementation comes from, named when loading it fails. Omitted + * when `load` is a closure over the path, which names itself in its own + * failure. + */ + source?: string; /** * Runs on first resolution of the command. It must leave a real resolver on * the command name — by exporting a definition the caller registers, or by @@ -33,9 +46,30 @@ export type DeferredCommandRejection = */ | { reason: "parent-is-command"; parent: string }; +/** + * The one rendering of a rejection: the registry reports structurally so that + * the wording lives here rather than in each consumer, and a name the CLI owns + * and a name an extension owns are refused in the same words. + */ +export function describeRejection(rejection: DeferredCommandRejection): string { + switch (rejection.reason) { + case "invalid-name": + return rejection.detail; + case "claimed": + return `it is already registered by ${rejection.owner}`; + case "built-in": + return "it is already provided by the CLI"; + case "subcommand-parent": + return "it is already in use as the parent of its subcommands"; + case "parent-is-command": + return `'${rejection.parent}' is already registered as a command of its own, so the subcommand could never be reached`; + } +} + /** * Outcome of a deferred registration. Callers branch on `rejection.reason` - * rather than on message text, so the wording of the report stays theirs. + * rather than on message text; describeRejection renders it when the report + * is for a human. */ export interface DeferredCommandResult { registered: boolean; diff --git a/lib/common/contracts/index.ts b/lib/common/contracts/index.ts index 29e06c2109..229a54a577 100644 --- a/lib/common/contracts/index.ts +++ b/lib/common/contracts/index.ts @@ -4,7 +4,11 @@ // default. Each token resolves to the facade itself until its subsystem is // physically extracted — at which point the provider is swapped and consumers // keep working unchanged. -export { CommandRegistry } from "./command-registry"; +export { + CommandRegistry, + COMMAND_OWNER, + describeRejection, +} from "./command-registry"; export type { DeferredCommandOptions, DeferredCommandRejection, diff --git a/lib/common/define-command.ts b/lib/common/define-command.ts index 4deb6d0f92..829e215223 100644 --- a/lib/common/define-command.ts +++ b/lib/common/define-command.ts @@ -128,7 +128,7 @@ export interface CommandDefinition< TSetup = void, > { /** `"widget|add"`; `|` separates hierarchy levels. Several names alias one command. */ - name: string | string[]; + name: CommandName; description?: string; options?: TSchema; /** @@ -169,7 +169,7 @@ export interface CommandDefinition< /** * What `defineCommand` returns: a definition carrying the marker in its type, - * so `registerCommandDefinition` can require a definition that went through + * so `registerCommand` can require a definition that went through * define-time validation rather than any object of the right shape. */ export type DefinedCommand< @@ -521,13 +521,43 @@ const validateDefinition = (definition: any): void => { } }; +/** + * A definition that carries the name it declares in its own type. `Omit` rather + * than an intersection: intersecting the declared name with the wider `name` of + * `CommandDefinition` widens it straight back to `string`. + */ +export type NamedCommand< + TSchema extends CommandOptionsSchema, + TResult, + TSetup, + TName extends CommandName, +> = Omit, "name"> & { + readonly name: TName; +}; + +/** What a definition's `name` may be: one name, or aliases for one command. */ +export type CommandName = string | readonly string[]; + +/** + * The names a definition declares, as literal types, so a registration site can + * be checked against them. + */ +export type CommandNamesOf = TDefinition extends { + name: infer TName; +} + ? TName extends readonly (infer TAlias)[] + ? TAlias + : TName + : never; + export function defineCommand< TSchema extends CommandOptionsSchema = {}, TResult = void, TSetup = void, + const TName extends CommandName = CommandName, >( - definition: CommandDefinition, -): DefinedCommand { + definition: CommandDefinition & { name: TName }, +): NamedCommand { validateDefinition(definition); const marked: any = { ...definition }; diff --git a/lib/common/deprecation.ts b/lib/common/deprecation.ts index 5913b18d97..7026f0fc30 100644 --- a/lib/common/deprecation.ts +++ b/lib/common/deprecation.ts @@ -84,7 +84,7 @@ function tryResolveGlobalLogger(): IDeprecationLogger | null { try { // Required at call time: yok imports this module, so a static import // would be a cycle. Every reporting site already runs with yok loaded. - const injector = require("./yok").getInjector(); + const injector = require("./yok").getRootInjector(); if (!injector) { return null; } diff --git a/lib/common/di/index.ts b/lib/common/di/index.ts index 8a3ca29b4f..3083956cf1 100644 --- a/lib/common/di/index.ts +++ b/lib/common/di/index.ts @@ -1,6 +1,6 @@ export { Injector } from "./injector"; export type { InjectOptions } from "./injector"; -export { inject, runInInjectionContext } from "./inject"; +export { inject, getCurrentInjector, runInInjectionContext } from "./inject"; export { forwardRef, resolveForwardRef } from "./forward-ref"; export { Contract, diff --git a/lib/common/di/inject.ts b/lib/common/di/inject.ts index c29be1bed2..12e94be9af 100644 --- a/lib/common/di/inject.ts +++ b/lib/common/di/inject.ts @@ -65,6 +65,15 @@ export function inject( return frame.injector.get(token, options); } +/** + * The injector serving the current injection context, or null outside one. + * Unlike inject(), it never throws, so a caller can fall back to a global. + */ +export function getCurrentInjector(): Injector | null { + const frame = currentFrame(); + return frame ? frame.injector : null; +} + export function runInInjectionContext(injector: Injector, fn: () => T): T { const g = globalThis; const previous = g[CONTEXT_SLOT]; diff --git a/lib/common/helpers.ts b/lib/common/helpers.ts index 878fcb9206..9ee0886f8f 100644 --- a/lib/common/helpers.ts +++ b/lib/common/helpers.ts @@ -593,7 +593,7 @@ export function hook(commandName: string) { // self.$hooksService / self.$injector, and a class migrated off // property injection has neither — only then may it be used. It is // required at call time because yok imports this module (cycle). - const injector = self.$injector || require("./yok").getInjector(); + const injector = self.$injector || require("./yok").getRootInjector(); if (!injector) { throw Error( "Type with hooks needs to have either $hooksService or $injector injected.", diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index bc09e25903..36d613f532 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -1,20 +1,28 @@ import { EOL } from "os"; import { OptionType } from "../enums"; -import { injector } from "../yok"; -import { runInInjectionContext } from "../di/inject"; +import { getRootInjector } from "../yok"; +import { getCurrentInjector, runInInjectionContext } from "../di/inject"; import { Injector } from "../di/injector"; import { IDictionary, IDashedOption, IErrors } from "../declarations"; import { ICommand } from "../definitions/commands"; -import { CommandRegistry } from "../contracts/command-registry"; +import { + COMMAND_OWNER, + CommandRegistry, + DeferredCommandResult, + describeRejection, +} from "../contracts/command-registry"; +import { Provider } from "../di/providers"; import { ArgumentSpec, CommandArgumentValues, CommandContext, CommandDefinition, + CommandNamesOf, CommandOptionSpec, CommandOptionType, CommandOptionsSchema, DefinedCommand, + defineCommand, isCommandDefinition, } from "../define-command"; @@ -175,7 +183,7 @@ export function createCommandFromDefinition< TSetup = any, >( definition: CommandDefinition, - targetInjector: Injector = injector, + targetInjector: Injector = (getRootInjector()), ): ICommand { const schema = definition.options || {}; const optionNames = Object.keys(schema); @@ -411,7 +419,7 @@ export function registerDefinitionAs< >( name: string, definition: DefinedCommand, - targetInjector: Injector = injector, + targetInjector: Injector = (getRootInjector()), ): void { // The registry facet rather than the injector itself, so a child injector // that provides its own CommandRegistry receives the registration. @@ -423,26 +431,164 @@ export function registerDefinitionAs< ); } -export function registerCommandDefinition< +/** Names the CLI's own registrations in conflict and failure reports. */ +const CLI_OWNER = "the NativeScript CLI"; + +const namesOf = (definition: DefinedCommand): string[] => + Array.isArray(definition.name) ? definition.name : [definition.name]; + +/** + * Where a registration lands: the injector serving the code that is running, + * so an extension module loaded under a scope of its own registers into that + * scope without naming it. Outside any context it is the CLI's own injector. + */ +const registrationTarget = (): Injector => + getCurrentInjector() || (getRootInjector()); + +/** + * Registers a command with the CLI. Takes either the result of defineCommand() + * or the definition itself, which it defines on the caller's behalf. + * + * Registration targets the injector of the current injection context, and + * `providers` scope the command to a child of it. To register against some + * other injector, run the call in its context: + * `runInInjectionContext(injector, () => registerCommand(definition))`. + * + * Every registration has an owner and claims its names, the way + * registerLazyCommand does: the owner is ambient in the context the caller + * runs under - an extension's, for a module loaded under its scope - and the + * CLI itself outside one. + */ +export function registerCommand< TSchema extends CommandOptionsSchema, TResult = any, TSetup = any, >( - definition: DefinedCommand, - targetInjector: Injector = injector, + definition: + | DefinedCommand + | CommandDefinition, + providers: Provider[] = [], +): DeferredCommandResult { + const defined = isCommandDefinition(definition) + ? definition + : defineCommand(>definition); + const target = registrationTarget(); + const scope = providers.length ? target.createChild(providers) : target; + const owner = target.get(COMMAND_OWNER, { optional: true }) || CLI_OWNER; + const registry = target.get(CommandRegistry); + + for (const name of namesOf(defined)) { + const result = registry.registerDeferredCommand(name, { + owner, + load: () => registerDefinitionAs(name, defined, scope), + }); + + if (!result.registered) { + return result; + } + } + + return { registered: true }; +} + +/** + * Reads as the `name` parameter's type when the type argument is left off, so + * the compiler names the fix in the error it reports on the command name. + */ +type MissingTypeArgument = + "Pass the definition type: registerLazyCommand(...)"; + +/** + * Registers a command name against a definition the loader produces on first + * use: the name routes — including through a synthesized parent — without the + * module being loaded, and `load` runs when that one command is resolved. It + * must stay synchronous, because CommandsService reads the resolved command's + * options before it validates the command line. + * + * The definition's type is a required type argument: `require()` is `any`, so + * nothing infers from `load`, and without it the name would be checked against + * nothing. + * + * registerLazyCommand( + * "run|ios", + * () => require("./commands/run").iosRunCommand, + * ); + * + * `providers` scope the command to a child injector, built when the command is + * constructed rather than when its name is claimed. + * + * Registration targets the injector of the current injection context, if there + * is one, and takes its owner from that injector's COMMAND_OWNER — which is + * how a command an extension's module registers while loading is attributed to + * the extension. Outside a context it is the CLI's own injector, and the CLI + * itself is the owner. To register against some other injector, run the call + * in its context with runInInjectionContext. + */ +/** + * Registers one of the CLI's own commands. A built-in that cannot claim its + * name is a bug in the bootstrap rather than a conflict to arbitrate, so this + * aborts startup instead of returning a result nobody would check. + */ +export function registerBuiltInCommand< + TDefinition extends DefinedCommand = never, +>( + name: [TDefinition] extends [never] + ? MissingTypeArgument + : CommandNamesOf & string, + load: () => NoInfer, + providers: Provider[] = [], ): void { - if (!isCommandDefinition(definition)) { + // The conditional name type cannot be narrowed while forwarding it. + const result = registerLazyCommand(name, load, providers); + + if (!result.registered) { throw new Error( - "registerCommandDefinition() takes the result of defineCommand(); " + - "the value passed carries no command-definition marker.", + `Unable to register command '${name}': ${describeRejection( + result.rejection, + )}.`, ); } +} - const names = Array.isArray(definition.name) - ? definition.name - : [definition.name]; +export function registerLazyCommand< + TDefinition extends DefinedCommand = never, +>( + name: [TDefinition] extends [never] + ? MissingTypeArgument + : CommandNamesOf & string, + load: () => NoInfer, + providers: Provider[] = [], +): DeferredCommandResult { + const commandName = (name); + const target = registrationTarget(); + const registry = target.get(CommandRegistry); + + return registry.registerDeferredCommand(commandName, { + owner: target.get(COMMAND_OWNER, { optional: true }) || CLI_OWNER, + load: () => { + const definition = load(); + + // The compile-time check above is only as good as the type argument the + // call site passes, so the same mismatch is caught here as well. + if (!isCommandDefinition(definition)) { + throw new Error( + "the loader did not return a defineCommand() definition", + ); + } - for (const name of names) { - registerDefinitionAs(name, definition, targetInjector); - } + const declared = namesOf(definition); + if (declared.indexOf(commandName) === -1) { + throw new Error( + "the definition it loaded declares itself as " + + declared.map((entry) => `'${entry}'`).join(", "), + ); + } + + registerDefinitionAs( + commandName, + definition, + providers.length ? target.createChild(providers) : target, + ); + }, + }); } diff --git a/lib/common/test/unit-tests/preuninstall.ts b/lib/common/test/unit-tests/preuninstall.ts index 1dc569db3c..5d58599370 100644 --- a/lib/common/test/unit-tests/preuninstall.ts +++ b/lib/common/test/unit-tests/preuninstall.ts @@ -9,6 +9,7 @@ import { IEventActionData } from "../../definitions/google-analytics"; import { IFileSystem, IAnalyticsService } from "../../declarations"; import { ICommand } from "../../definitions/commands"; import { IExtensibilityService } from "../../definitions/extensibility"; +import { runInInjectionContext } from "../../di"; const helpers = require("../../helpers"); describe("preuninstall", () => { @@ -43,7 +44,9 @@ describe("preuninstall", () => { finishTracking: async (): Promise => undefined, }); - registerCommand(preUninstallCommandDefinition, testInjector); + runInInjectionContext(testInjector, () => + registerCommand(preUninstallCommandDefinition), + ); return testInjector; }; diff --git a/lib/common/yok.ts b/lib/common/yok.ts index 13642ad417..5f252d3efc 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -210,16 +210,18 @@ export class Yok extends Injector implements IInjector { options.load(); } catch (err) { throw new Error( - `Unable to load command '${name}' of ${options.owner} from ` + - `${options.source}: ${err.message}`, + `Unable to load command '${name}' of ${options.owner}` + + `${options.source ? ` from ${options.source}` : ""}: ` + + `${err.message}`, ); } if (!this.hasResolver(commandRecordName)) { throw new Error( `Command '${name}' of ${options.owner} was not registered when ` + - `${options.source} loaded. The module must export a ` + - `defineCommand() definition or register the command itself.`, + `${options.source || "its module"} loaded. The module must ` + + `export a defineCommand() definition or register the command ` + + `itself.`, ); } }, @@ -501,6 +503,12 @@ export class Yok extends Injector implements IInjector { commandName = defaultCommand ? this.getHierarchicalCommandName(name, defaultCommand) : "help"; + + if (commandName === "help") { + // Without this the help command opens a browser, so a + // mistyped subcommand would launch one. + this.resolve("options").help = true; + } // If we'll execute the default command, but it's full name had been written by the user // for example "ns run ios", we have to remove the "ios" option from the arguments that we'll pass to the command. if ( @@ -773,8 +781,8 @@ export class Yok extends Injector implements IInjector { // The global is the published legacy surface. It is an accessor pair so a // direct `global.$injector = x` assignment — allowed for third parties — -// stays synchronized with the module binding that getInjector() and internal -// code read; a plain data property would silently fork the two. +// stays synchronized with the module binding that getRootInjector() and +// internal code read; a plain data property would silently fork the two. injector = (global).$injector || new Yok(); Object.defineProperty(global, "$injector", { get: () => injector, @@ -785,13 +793,15 @@ Object.defineProperty(global, "$injector", { }); /** - * Accessor for the process-wide facade, for code that cannot receive the - * injector through DI or a static import (import cycles, decorator bodies). - * Prefer inject(Injector) in an injection context; prefer a constructor - * dependency in services. Never read global.$injector directly — the global - * exists only as the published legacy surface for extensions and hooks. + * Accessor for the process-wide facade — the root of every injector in the + * process, as opposed to getCurrentInjector(), which serves whichever one the + * caller is running under. For code that cannot receive the injector through + * DI or a static import (import cycles, decorator bodies). Prefer + * inject(Injector) in an injection context; prefer a constructor dependency in + * services. Never read global.$injector directly — the global exists only as + * the published legacy surface for extensions and hooks. */ -export function getInjector(): IInjector { +export function getRootInjector(): IInjector { return injector; } diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index fe02c8d3ff..2648028399 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -21,9 +21,12 @@ import { import { injector } from "../common/yok"; import { IInjector } from "../common/definitions/yok"; import { CommandsDelimiters } from "../common/constants"; -import { inject } from "../common/di/inject"; -import { CommandRegistry } from "../common/contracts"; -import type { DeferredCommandRejection } from "../common/contracts"; +import { inject, runInInjectionContext } from "../common/di/inject"; +import { + COMMAND_OWNER, + CommandRegistry, + describeRejection, +} from "../common/contracts"; import { DefinedCommand, isCommandDefinition } from "../common/define-command"; import { registerDefinitionAs } from "../common/services/command-definition-adapter"; @@ -60,21 +63,6 @@ function getEntryModulePath(value: any): string { const isDefaultCommandName = (name: string): boolean => name.indexOf(CommandsDelimiters.DefaultHierarchicalCommand) !== -1; -function describeRejection(rejection: DeferredCommandRejection): string { - switch (rejection.reason) { - case "invalid-name": - return rejection.detail; - case "claimed": - return `it is already registered by extension ${rejection.owner}`; - case "built-in": - return "it is already provided by the CLI"; - case "subcommand-parent": - return "it is already in use as the parent of its subcommands"; - case "parent-is-command": - return `'${rejection.parent}' is already registered as a command of its own, so the subcommand could never be reached`; - } -} - /** * Reads the names of the commands an extension contributes out of either shape * of `nativescript.commands` - the legacy array of names, or the map of name to @@ -249,7 +237,9 @@ export class ExtensibilityService implements IExtensibilityService { detail: extensionName, logger: this.$logger, }); - this.$requireService.require(pathToExtension); + this.loadInExtensionScope(extensionName, () => + this.$requireService.require(pathToExtension), + ); } return this.getInstalledExtensionData(extensionName); @@ -378,6 +368,21 @@ export class ExtensibilityService implements IExtensibilityService { return isCommandsMap(commands) ? commands : null; } + /** + * Runs an extension's module load under an injector of the extension's own, + * so a command the module registers while loading is attributed — and + * scoped — to the extension instead of to the CLI. Module loading is + * synchronous, so the context covers the whole of the module's body. + */ + private loadInExtensionScope(extensionName: string, load: () => T): T { + return runInInjectionContext( + this.$injector.createChild([ + { provide: COMMAND_OWNER, useValue: extensionName }, + ]), + load, + ); + } + /** * Registers each declared command as a deferred load of its own module, so * nothing from the extension is loaded until one of its commands is executed. @@ -441,7 +446,9 @@ export class ExtensibilityService implements IExtensibilityService { commandName: string, absoluteModulePath: string, ): void { - const exported = require(absoluteModulePath); + const exported = this.loadInExtensionScope(extensionName, () => + require(absoluteModulePath), + ); const candidate = (exported && exported.default) ?? exported; if (!isCommandDefinition(candidate)) { diff --git a/test/command-registration.ts b/test/command-registration.ts index 6b497cc7f1..362305c737 100644 --- a/test/command-registration.ts +++ b/test/command-registration.ts @@ -1,5 +1,6 @@ import { assert } from "chai"; import { Yok } from "../lib/common/yok"; +import { LoggerStub } from "./stubs"; const noopCommandFactory = () => ({ execute: async (): Promise => undefined, @@ -50,6 +51,23 @@ describe("yok: command registration", () => { ]); }); + it("keeps a registered command when a subcommand would shadow it", () => { + injector.register("logger", LoggerStub); + injector.registerCommand("dev", noopCommandFactory); + + injector.registerCommand("dev|test", noopCommandFactory); + + const parent = injector.resolveCommand("dev"); + assert.isUndefined((parent).isHierarchicalCommand); + assert.isFunction(injector.resolveCommand("dev|test").execute); + + const logger: LoggerStub = injector.resolve("logger"); + assert.match( + logger.warnOutput, + /'dev' is already registered as a command of its own.*'dev\|test' cannot be reached/, + ); + }); + it("does not duplicate a subcommand already recorded by requireCommand", () => { injector.requireCommand("dev|test", "some-file"); injector.registerCommand("dev|test", noopCommandFactory); diff --git a/test/commands/post-install.ts b/test/commands/post-install.ts index f7dd021f95..d39cb11327 100644 --- a/test/commands/post-install.ts +++ b/test/commands/post-install.ts @@ -5,6 +5,7 @@ import { registerCommand } from "../../lib/common/services/command-definition-ad import { SettingsService } from "../../lib/common/test/unit-tests/stubs"; import { IInjector } from "../../lib/common/definitions/yok"; import { IHelpService, IAnalyticsService } from "../../lib/common/declarations"; +import { runInInjectionContext } from "../../lib/common/di"; const createTestInjector = (): IInjector => { const testInjector = new Yok(); @@ -45,7 +46,9 @@ const createTestInjector = (): IInjector => { testInjector.register("settingsService", SettingsService); - registerCommand(postInstallCliCommandDefinition, testInjector); + runInInjectionContext(testInjector, () => + registerCommand(postInstallCliCommandDefinition), + ); testInjector.register("hostInfo", {}); diff --git a/test/compat/injector-facade-surface.ts b/test/compat/injector-facade-surface.ts index 96f4e3c522..446ebd677b 100644 --- a/test/compat/injector-facade-surface.ts +++ b/test/compat/injector-facade-surface.ts @@ -1,5 +1,5 @@ import { assert } from "chai"; -import { Yok, getInjector } from "../../lib/common/yok"; +import { Yok, getRootInjector } from "../../lib/common/yok"; import { Injector, inject, runInInjectionContext } from "../../lib/common/di"; import { CommandRegistry, @@ -62,17 +62,17 @@ describe("injector facade surface", () => { assert.strictEqual(sub.resolve("injector"), sub); }); - it("keeps getInjector() synchronized with a direct global.$injector assignment", () => { - const previous = getInjector(); + it("keeps getRootInjector() synchronized with a direct global.$injector assignment", () => { + const previous = getRootInjector(); const fresh = new Yok(); (global).$injector = fresh; try { - assert.strictEqual(getInjector(), fresh); + assert.strictEqual(getRootInjector(), fresh); } finally { (global).$injector = previous; } - assert.strictEqual(getInjector(), previous); + assert.strictEqual(getRootInjector(), previous); }); it("assigns the process-wide global.$injector", () => { diff --git a/test/compat/legacy-hooks.ts b/test/compat/legacy-hooks.ts index ed8c1d9f66..fddca64d80 100644 --- a/test/compat/legacy-hooks.ts +++ b/test/compat/legacy-hooks.ts @@ -2,7 +2,7 @@ import { assert } from "chai"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { Yok, getInjector, setGlobalInjector } from "../../lib/common/yok"; +import { Yok, getRootInjector, setGlobalInjector } from "../../lib/common/yok"; import { HooksService } from "../../lib/common/services/hooks-service"; import { hook } from "../../lib/common/helpers"; import { IInjector } from "../../lib/common/definitions/yok"; @@ -301,7 +301,7 @@ describe("legacy hook contract", () => { } } - const previousInjector = getInjector(); + const previousInjector = getRootInjector(); setGlobalInjector(testInjector); try { const result = await new Subject().doWork(); @@ -328,7 +328,7 @@ describe("legacy hook contract", () => { } } - const previousInjector = getInjector(); + const previousInjector = getRootInjector(); setGlobalInjector({ resolve: () => { throw new Error("the process-wide injector must be the last resort"); diff --git a/test/define-command.ts b/test/define-command.ts index e6cce3b542..b71c51fea1 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -1,10 +1,18 @@ import { assert } from "chai"; import { spawnSync } from "child_process"; import * as path from "path"; -import { Yok } from "../lib/common/yok"; +import { getRootInjector, Yok } from "../lib/common/yok"; import { IInjector } from "../lib/common/definitions/yok"; -import { inject, InjectionToken } from "../lib/common/di"; -import { CommandRegistry } from "../lib/common/contracts/command-registry"; +import { + inject, + InjectionToken, + runInInjectionContext, +} from "../lib/common/di"; +import { + COMMAND_OWNER, + CommandRegistry, + DeferredCommandResult, +} from "../lib/common/contracts/command-registry"; import { CommandsService } from "../lib/common/services/commands-service"; import { Options } from "../lib/options"; import { Errors } from "../lib/common/errors"; @@ -19,7 +27,8 @@ import { } from "../lib/common/define-command"; import { createCommandFromDefinition, - registerCommandDefinition, + registerCommand, + registerLazyCommand, } from "../lib/common/services/command-definition-adapter"; const createTestInjector = (options: any = {}): IInjector => { @@ -247,7 +256,7 @@ describe("defineCommand", () => { }); const testInjector = createTestInjector(); - registerCommandDefinition(definition, testInjector); + runInInjectionContext(testInjector, () => registerCommand(definition)); const command = testInjector.resolveCommand("dctestwidget|add"); assert.isFunction(command.execute); @@ -264,9 +273,10 @@ describe("defineCommand", () => { it("caches one command instance per registered name", () => { const testInjector = createTestInjector(); - registerCommandDefinition( - defineCommand({ name: "dctestflat", run: (): void => undefined }), - testInjector, + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ name: "dctestflat", run: (): void => undefined }), + ), ); assert.strictEqual( @@ -277,26 +287,39 @@ describe("defineCommand", () => { it("registers every alias of a multi-name definition", () => { const testInjector = createTestInjector(); - registerCommandDefinition( - defineCommand({ - name: ["dctestalias", "dctestalias2"], - run: (): void => undefined, - }), - testInjector, + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: ["dctestalias", "dctestalias2"], + run: (): void => undefined, + }), + ), ); assert.isFunction(testInjector.resolveCommand("dctestalias").execute); assert.isFunction(testInjector.resolveCommand("dctestalias2").execute); }); - it("refuses a value that did not come from defineCommand", () => { + it("defines a bare definition on the caller's behalf", () => { + const testInjector = createTestInjector(); + + runInInjectionContext(testInjector, () => + registerCommand({ name: "dctestraw", run: (): void => undefined }), + ); + + assert.isFunction(testInjector.resolveCommand("dctestraw").execute); + }); + + it("still validates a bare definition at registration", () => { assert.throws( () => - registerCommandDefinition( - { name: "dctestraw", run: (): void => undefined }, - createTestInjector(), + runInInjectionContext(createTestInjector(), () => + registerCommand({ + name: "dctestrawbad", + run: "not a function", + }), ), - /carries no command-definition marker/, + /run/, ); }); @@ -306,42 +329,91 @@ describe("defineCommand", () => { testInjector.register({ provide: CommandRegistry, useValue: { - registerCommand: (name: string) => registered.push(name), + registerDeferredCommand: (name: string): DeferredCommandResult => { + registered.push(name); + return { registered: true }; + }, }, }); - registerCommandDefinition( - defineCommand({ - name: ["dctestfacet", "dctestfacet2"], - run: (): void => undefined, - }), - testInjector, + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: ["dctestfacet", "dctestfacet2"], + run: (): void => undefined, + }), + ), ); assert.deepEqual(registered, ["dctestfacet", "dctestfacet2"]); assert.isNull(testInjector.resolveCommand("dctestfacet")); }); - it("keeps a registered command when a subcommand would shadow it", () => { + it("refuses a subcommand that would shadow a registered command", () => { const testInjector = createTestInjector(); - registerCommandDefinition( - defineCommand({ name: "dctestowned", run: (): void => undefined }), - testInjector, + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ name: "dctestowned", run: (): void => undefined }), + ), ); - registerCommandDefinition( - defineCommand({ name: "dctestowned|sub", run: (): void => undefined }), - testInjector, + const result = runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: "dctestowned|sub", + run: (): void => undefined, + }), + ), ); + assert.deepStrictEqual(result, { + registered: false, + rejection: { reason: "parent-is-command", parent: "dctestowned" }, + }); + const owner = testInjector.resolveCommand("dctestowned"); assert.isUndefined(owner.isHierarchicalCommand); - assert.isFunction(testInjector.resolveCommand("dctestowned|sub").execute); + assert.isNull(testInjector.resolveCommand("dctestowned|sub")); const logger: LoggerStub = testInjector.resolve("logger"); - assert.match( - logger.warnOutput, - /'dctestowned' is already registered as a command of its own.*'dctestowned\|sub' cannot be reached/, + assert.isEmpty(logger.warnOutput); + }); + + it("registers against the injection context and takes its owner", async () => { + const testInjector = createTestInjector(); + const scope = testInjector.createChild([ + { provide: COMMAND_OWNER, useValue: "dctest-ambient-extension" }, + ]); + let seenInjector: any; + const definition = defineCommand({ + name: "dctestambient", + run: (ctx): void => { + seenInjector = ctx.injector; + }, + }); + + assert.deepStrictEqual( + runInInjectionContext(scope, () => registerCommand(definition)), + { registered: true }, + ); + + await testInjector.resolveCommand("dctestambient").execute([]); + assert.strictEqual(seenInjector, scope); + + assert.deepStrictEqual( + runInInjectionContext(testInjector, () => + registerLazyCommand( + "dctestambient", + () => definition, + ), + ), + { + registered: false, + rejection: { + reason: "claimed", + owner: "dctest-ambient-extension", + }, + }, ); }); }); @@ -982,16 +1054,17 @@ describe("defineCommand", () => { const testInjector = createCommandsServiceInjector({ verbose: true }); let ran: any; - registerCommandDefinition( - defineCommand({ - name: "dctest-e2e", - options: { verbose: booleanOption({ default: false }) }, - arguments: "any", - run: (context) => { - ran = context; - }, - }), - testInjector, + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: "dctest-e2e", + options: { verbose: booleanOption({ default: false }) }, + arguments: "any", + run: (context) => { + ran = context; + }, + }), + ), ); const commandsService: ICommandsService = @@ -1009,14 +1082,15 @@ describe("defineCommand", () => { const testInjector = createCommandsServiceInjector(); let ran = false; - registerCommandDefinition( - defineCommand({ - name: "dctest-e2e-none", - run: () => { - ran = true; - }, - }), - testInjector, + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: "dctest-e2e-none", + run: () => { + ran = true; + }, + }), + ), ); const commandsService: ICommandsService = @@ -1032,15 +1106,16 @@ describe("defineCommand", () => { const testInjector = createCommandsServiceInjector(); let ran = false; - registerCommandDefinition( - defineCommand({ - name: "dctest-e2e-refine", - canExecute: () => true, - run: () => { - ran = true; - }, - }), - testInjector, + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: "dctest-e2e-refine", + canExecute: () => true, + run: () => { + ran = true; + }, + }), + ), ); const commandsService: ICommandsService = @@ -1056,15 +1131,16 @@ describe("defineCommand", () => { const testInjector = createCommandsServiceInjector(); let ran: any; - registerCommandDefinition( - defineCommand({ - name: "dctest-widget|add", - arguments: "any", - run: (context) => { - ran = context; - }, - }), - testInjector, + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: "dctest-widget|add", + arguments: "any", + run: (context) => { + ran = context; + }, + }), + ), ); const commandsService: ICommandsService = @@ -1081,15 +1157,16 @@ describe("defineCommand", () => { const testInjector = createCommandsServiceInjector(); const runs: string[][] = []; - registerCommandDefinition( - defineCommand({ - name: "dctest-gadget|*all", - arguments: "any", - run: (context) => { - runs.push(context.args); - }, - }), - testInjector, + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: "dctest-gadget|*all", + arguments: "any", + run: (context) => { + runs.push(context.args); + }, + }), + ), ); const commandsService: ICommandsService = @@ -1710,16 +1787,17 @@ describe("defineCommand", () => { testInjector.register("commandsService", CommandsService); let ran = false; - registerCommandDefinition( - defineCommand({ - name: "dctest-unknown-e2e", - allowUnknownOptions: true, - arguments: "any", - run: () => { - ran = true; - }, - }), - testInjector, + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: "dctest-unknown-e2e", + allowUnknownOptions: true, + arguments: "any", + run: () => { + ran = true; + }, + }), + ), ); const commandsService: ICommandsService = @@ -1733,6 +1811,215 @@ describe("defineCommand", () => { }); }); + describe("lazy registration", () => { + it("claims the name and routes without loading the definition", () => { + const testInjector = createTestInjector(); + let loads = 0; + const definition = defineCommand({ + name: "dctestlazy|sub", + run: (): void => undefined, + }); + + runInInjectionContext(testInjector, () => + registerLazyCommand("dctestlazy|sub", () => { + loads++; + return definition; + }), + ); + + assert.strictEqual(loads, 0); + assert.deepStrictEqual( + testInjector.getChildrenCommandsNames("dctestlazy"), + ["sub"], + ); + assert.deepStrictEqual( + testInjector.buildHierarchicalCommand("dctestlazy", ["sub", "extra"]), + { commandName: "dctestlazy|sub", remainingArguments: ["extra"] }, + ); + assert.strictEqual(loads, 0); + + assert.isFunction(testInjector.resolveCommand("dctestlazy|sub").execute); + assert.strictEqual(loads, 1); + }); + + it("rejects a definition that declares another name", () => { + const testInjector = createTestInjector(); + const definition = defineCommand({ + name: "dctestlazyother", + run: (): void => undefined, + }); + + runInInjectionContext(testInjector, () => + registerLazyCommand("dctestlazymismatch", () => definition), + ); + + assert.throws( + () => testInjector.resolveCommand("dctestlazymismatch"), + /declares itself as 'dctestlazyother'/, + ); + }); + + it("rejects a loader that does not return a definition", () => { + const testInjector = createTestInjector(); + + runInInjectionContext(testInjector, () => + registerLazyCommand( + "dctestlazyraw", + () => { name: "dctestlazyraw", run: (): void => undefined }, + ), + ); + + assert.throws( + () => testInjector.resolveCommand("dctestlazyraw"), + /defineCommand\(\) definition/, + ); + }); + + it("scopes the command to a child injector built on first resolution", async () => { + const testInjector = createTestInjector(); + const GREETING = new InjectionToken("dctestLazyGreeting"); + let seen: string; + const definition = defineCommand({ + name: "dctestlazyscoped", + setup: () => inject(GREETING), + run: (context, greeting: string): void => { + seen = greeting; + }, + }); + + let children = 0; + const createChild = (testInjector).createChild.bind(testInjector); + (testInjector).createChild = (providers: any) => { + children++; + return createChild(providers); + }; + + runInInjectionContext(testInjector, () => + registerLazyCommand( + "dctestlazyscoped", + () => definition, + [{ provide: GREETING, useValue: "hello" }], + ), + ); + + assert.strictEqual(children, 0); + + const command = testInjector.resolveCommand("dctestlazyscoped"); + assert.strictEqual(children, 1); + + await command.execute([]); + assert.strictEqual(seen, "hello"); + // The provider lives in the command's own scope, not the injector the + // registration was made against. + assert.isNotOk((testInjector).get(GREETING, { optional: true })); + }); + + it("reports a name the CLI already provides", () => { + const testInjector = createTestInjector(); + testInjector.registerCommand("dctestlazytaken", () => ({ + allowedParameters: [], + execute: async (): Promise => undefined, + })); + + const result = runInInjectionContext(testInjector, () => + registerLazyCommand("dctestlazytaken", () => null), + ); + + assert.deepStrictEqual(result, { + registered: false, + rejection: { reason: "built-in" }, + }); + }); + + it("registers against the injection context and takes its owner", async () => { + const testInjector = createTestInjector(); + const scope = testInjector.createChild([ + { provide: COMMAND_OWNER, useValue: "dctest-extension" }, + ]); + let seenInjector: any; + const definition = defineCommand({ + name: "dctestlazyambient", + run: (ctx): void => { + seenInjector = ctx.injector; + }, + }); + + const result = runInInjectionContext(scope, () => + registerLazyCommand( + "dctestlazyambient", + () => definition, + ), + ); + assert.deepStrictEqual(result, { registered: true }); + + await testInjector.resolveCommand("dctestlazyambient").execute([]); + assert.strictEqual(seenInjector, scope); + + assert.deepStrictEqual( + runInInjectionContext(testInjector, () => + registerLazyCommand( + "dctestlazyambient", + () => definition, + ), + ), + { + registered: false, + rejection: { reason: "claimed", owner: "dctest-extension" }, + }, + ); + }); + + it("belongs to the CLI outside an injection context", () => { + const testInjector = createTestInjector(); + const definition = defineCommand({ + name: "dctestlazyunowned", + run: (): void => undefined, + }); + + const register = () => + runInInjectionContext(testInjector, () => + registerLazyCommand( + "dctestlazyunowned", + () => definition, + ), + ); + + assert.deepStrictEqual(register(), { registered: true }); + // Same owner: re-registering the CLI's own name is a no-op, not a + // conflict. + assert.deepStrictEqual(register(), { registered: true }); + + const rootDefinition = defineCommand({ + name: "dctestlazyroot|sub", + run: (): void => undefined, + }); + registerLazyCommand( + "dctestlazyroot|sub", + () => rootDefinition, + ); + assert.deepStrictEqual( + getRootInjector().getChildrenCommandsNames("dctestlazyroot"), + ["sub"], + ); + + const scope = testInjector.createChild([ + { provide: COMMAND_OWNER, useValue: "dctest-extension" }, + ]); + assert.deepStrictEqual( + runInInjectionContext(scope, () => + registerLazyCommand( + "dctestlazyunowned", + () => definition, + ), + ), + { + registered: false, + rejection: { reason: "claimed", owner: "the NativeScript CLI" }, + }, + ); + }); + }); + describe("ctx.injector", () => { it("is the injector the command was registered against", async () => { const testInjector = createTestInjector(); @@ -1801,9 +2088,10 @@ describe("defineCommand", () => { }); for (const platform of ["android", "ios"]) { - registerCommandDefinition( - { ...definition, name: `dctest-run|${platform}` }, - testInjector.createChild([{ provide: PLATFORM, useValue: platform }]), + runInInjectionContext(testInjector, () => + registerCommand({ ...definition, name: `dctest-run|${platform}` }, [ + { provide: PLATFORM, useValue: platform }, + ]), ); } diff --git a/test/deprecation.ts b/test/deprecation.ts index 8de2df0592..dcf87be573 100644 --- a/test/deprecation.ts +++ b/test/deprecation.ts @@ -1,5 +1,5 @@ import { assert } from "chai"; -import { Yok, getInjector, setGlobalInjector } from "../lib/common/yok"; +import { Yok, getRootInjector, setGlobalInjector } from "../lib/common/yok"; import { reportDeprecation, clearReportedDeprecations, @@ -60,7 +60,7 @@ describe("deprecation tracer", () => { }); it("falls back to the process-wide injector's logger when none is passed", () => { - const previousInjector = getInjector(); + const previousInjector = getRootInjector(); const freshInjector = new Yok(); const freshLogger = new LoggerStub(); freshInjector.register("logger", freshLogger); @@ -75,7 +75,7 @@ describe("deprecation tracer", () => { }); it("drops the report silently when no logger is resolvable", () => { - const previousInjector = getInjector(); + const previousInjector = getRootInjector(); setGlobalInjector(new Yok()); try { @@ -86,7 +86,7 @@ describe("deprecation tracer", () => { }); it("still delivers a report that was previously dropped for lack of a logger", () => { - const previousInjector = getInjector(); + const previousInjector = getRootInjector(); setGlobalInjector(new Yok()); try { reportDeprecation({ api: "test.redeliver" }); diff --git a/test/extension-manifests.ts b/test/extension-manifests.ts index de902a81e4..9a213310d9 100644 --- a/test/extension-manifests.ts +++ b/test/extension-manifests.ts @@ -3,7 +3,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { ExtensibilityService } from "../lib/services/extensibility-service"; -import { Yok, getInjector, setGlobalInjector } from "../lib/common/yok"; +import { Yok, getRootInjector, setGlobalInjector } from "../lib/common/yok"; import { LoggerStub } from "./stubs"; import { clearReportedDeprecations } from "../lib/common/deprecation"; import { CommandsDelimiters } from "../lib/common/constants"; @@ -13,6 +13,8 @@ import { IExtensionData, } from "../lib/common/definitions/extensibility"; import { IStringDictionary } from "../lib/common/declarations"; +import { runInInjectionContext } from "../lib/common/di"; +import { registerLazyCommand } from "../lib/common/services/command-definition-adapter"; // Every assertion about registered commands goes through the per-test // injector: the service takes $injector as a constructor dependency. The @@ -39,7 +41,7 @@ describe("extension manifests", () => { beforeEach(() => { profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-ext-manifest-")); testInjector = getTestInjector(); - previousProcessInjector = getInjector(); + previousProcessInjector = getRootInjector(); setGlobalInjector(testInjector); requiredPaths = []; capture = (global).__nsmCapture = { @@ -138,6 +140,30 @@ describe("extension manifests", () => { global.__nsmCapture.loadedModules.push(${JSON.stringify(marker)}); global.$injector.registerCommand(${JSON.stringify(commandName)}, TestCommand);`; + /** + * A legacy-shape main module that registers a definition of its own, the + * way an extension written against the CLI's helper does - through the + * running CLI's copy of it, resolved by path because the fixture is written + * outside the repo. + */ + const selfRegisteringModule = (commandName: string, marker: string): string => + `const { registerCommand } = require(${JSON.stringify( + require.resolve("../lib/common/services/command-definition-adapter"), + )}); + const { COMMAND_OWNER } = require(${JSON.stringify( + require.resolve("../lib/common/contracts/command-registry"), + )}); + registerCommand({ + name: ${JSON.stringify(commandName)}, + arguments: "any", + run: (ctx) => { + global.__nsmCapture.executed.push({ + marker: ${JSON.stringify(marker)}, + owner: ctx.injector.get(COMMAND_OWNER, { optional: true }), + }); + }, + });`; + const getTestInjector = (): IInjector => { const testInjector = new Yok(); testInjector.register("fs", { @@ -347,6 +373,33 @@ describe("extension manifests", () => { assert.include(getLogger(testInjector).traceOutput, DEPRECATION_API); assert.isUndefined(extensionData.commands); }); + + it("attributes a definition the main module registers to the extension", async () => { + const extensionName = "nsm-self-register-ext"; + writeExtension( + extensionName, + { commands: ["nsmselfreg"] }, + { "main.js": selfRegisteringModule("nsmselfreg", "self-run") }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + await testInjector.resolveCommand("nsmselfreg").execute([]); + assert.deepStrictEqual(capture.executed, [ + { marker: "self-run", owner: extensionName }, + ]); + + assert.deepStrictEqual( + runInInjectionContext(testInjector, () => + registerLazyCommand("nsmselfreg", () => null), + ), + { + registered: false, + rejection: { reason: "claimed", owner: extensionName }, + }, + ); + }); }); describe("getInstalledExtensionsData", () => { diff --git a/test/platform-commands.ts b/test/platform-commands.ts index 6dcc2739a1..dff99febfb 100644 --- a/test/platform-commands.ts +++ b/test/platform-commands.ts @@ -35,6 +35,7 @@ import { IPlatformCommandHelper } from "../lib/declarations"; import { IErrors, IFailOptions, IFileSystem } from "../lib/common/declarations"; import * as _ from "lodash"; import { IInjector } from "../lib/common/definitions/yok"; +import { runInInjectionContext } from "../lib/common/di"; let isCommandExecuted = true; @@ -155,10 +156,18 @@ function createTestInjector() { testInjector.register("prompter", {}); testInjector.register("sysInfo", {}); testInjector.register("commands-service", CommandsServiceLib.CommandsService); - registerCommand(addPlatformCommandDefinition, testInjector); - registerCommand(removePlatformCommandDefinition, testInjector); - registerCommand(updatePlatformCommandDefinition, testInjector); - registerCommand(platformCleanCommandDefinition, testInjector); + runInInjectionContext(testInjector, () => + registerCommand(addPlatformCommandDefinition), + ); + runInInjectionContext(testInjector, () => + registerCommand(removePlatformCommandDefinition), + ); + runInInjectionContext(testInjector, () => + registerCommand(updatePlatformCommandDefinition), + ); + runInInjectionContext(testInjector, () => + registerCommand(platformCleanCommandDefinition), + ); testInjector.register("resources", {}); testInjector.register("commandsService", { tryExecuteCommand: () => { diff --git a/test/plugin-create.ts b/test/plugin-create.ts index 399a8927a3..6c533a0ea8 100644 --- a/test/plugin-create.ts +++ b/test/plugin-create.ts @@ -20,6 +20,7 @@ import * as util from "util"; import { IOptions } from "../lib/declarations"; import { IInjector } from "../lib/common/definitions/yok"; import { IDictionary } from "../lib/common/declarations"; +import { runInInjectionContext } from "../lib/common/di"; interface IPacoteOutput { packageName: string; @@ -72,7 +73,9 @@ function createTestInjector() { }, }); - registerCommand(createPluginCommandDefinition, testInjector); + runInInjectionContext(testInjector, () => + registerCommand(createPluginCommandDefinition), + ); return testInjector; } diff --git a/test/plugins-service.ts b/test/plugins-service.ts index 5fe354efea..edf62e97b0 100644 --- a/test/plugins-service.ts +++ b/test/plugins-service.ts @@ -52,6 +52,7 @@ import { // import { ProjectConfigService } from "../lib/services/project-config-service"; import { FileSystem } from "../lib/common/file-system"; import { ProjectHelper } from "../lib/common/project-helper"; +import { runInInjectionContext } from "../lib/common/di"; // import { basename } from 'path'; let isErrorThrown = false; @@ -327,7 +328,9 @@ describe("Plugins service", () => { const commands = ["add", "install"]; beforeEach(() => { testInjector = createTestInjector(); - registerCommand(addPluginCommandDefinition, testInjector); + runInInjectionContext(testInjector, () => + registerCommand(addPluginCommandDefinition), + ); }); _.each(commands, (command) => { diff --git a/test/project-commands.ts b/test/project-commands.ts index 6fd748279e..567d1294dc 100644 --- a/test/project-commands.ts +++ b/test/project-commands.ts @@ -16,6 +16,7 @@ import { IOptions } from "../lib/declarations"; import { IInjector } from "../lib/common/definitions/yok"; import { ICommand } from "../lib/common/definitions/commands"; import { IDictionary } from "../lib/common/declarations"; +import { runInInjectionContext } from "../lib/common/di"; let selectedTemplateName: string; let isProjectCreated: boolean; @@ -169,7 +170,9 @@ function createTestInjector() { ng: false, template: undefined, }); - registerCommand(createProjectCommandDefinition, testInjector); + runInInjectionContext(testInjector, () => + registerCommand(createProjectCommandDefinition), + ); testInjector.register("stringParameter", StringCommandParameter); testInjector.register("prompter", PrompterStub); diff --git a/test/tns-appstore-upload.ts b/test/tns-appstore-upload.ts index 72e1ba0d6d..e6ca259ec3 100644 --- a/test/tns-appstore-upload.ts +++ b/test/tns-appstore-upload.ts @@ -15,6 +15,7 @@ import { IOSBuildData } from "../lib/data/build-data"; import { IITMSData } from "../lib/declarations"; import { IInjector } from "../lib/common/definitions/yok"; import { ICommand } from "../lib/common/definitions/commands"; +import { runInInjectionContext } from "../lib/common/di"; class AppStore { static itunesconnect = { @@ -102,9 +103,8 @@ class AppStore { } } - registerCommand( - { ...publishIOSCommandDefinition, name: "appstore" }, - (this.injector), + runInInjectionContext((this.injector), () => + registerCommand({ ...publishIOSCommandDefinition, name: "appstore" }), ); this.injector.register("projectDataService", ProjectDataServiceStub); diff --git a/test/type-fixtures/define-command-types.ts b/test/type-fixtures/define-command-types.ts index 68fc931643..d9c6145a30 100644 --- a/test/type-fixtures/define-command-types.ts +++ b/test/type-fixtures/define-command-types.ts @@ -14,6 +14,7 @@ import { stringOption, } from "../../lib/common/define-command"; import type { CommandArgumentValues } from "../../lib/common/define-command"; +import { registerLazyCommand } from "../../lib/common/services/command-definition-adapter"; import type { Injector } from "../../lib/common/di/injector"; type IsExact = @@ -186,3 +187,60 @@ defineCommand({ allowUnknownOptions: "yes", run: () => undefined, }); + +// A lazy registration is only checked when the call site names the type of the +// definition it loads: `require()` is `any`, so nothing infers from the loader. +declare const require: (id: string) => any; + +const lazyPlatform = defineCommand({ + name: "typefixture|lazy-ios", + run: () => undefined, +}); + +const lazyAliases = defineCommand({ + name: ["typefixture|lazy-vision", "typefixture|lazy-visionos"], + run: () => undefined, +}); + +registerLazyCommand( + "typefixture|lazy-ios", + () => require("./commands/lazy").lazyPlatform, +); + +registerLazyCommand( + // @ts-expect-error - the definition loaded declares 'typefixture|lazy-ios' + "typefixture|lazy-iosss", + () => require("./commands/lazy").lazyPlatform, +); + +registerLazyCommand( + "typefixture|lazy-visionos", + () => require("./commands/lazy").lazyAliases, +); + +registerLazyCommand( + // @ts-expect-error - not one of the names the definition declares + "typefixture|lazy-vision2", + () => require("./commands/lazy").lazyAliases, +); + +registerLazyCommand< + // @ts-expect-error - the loader must point at a defineCommand() definition + typeof setupLazyCommand +>("typefixture|lazy-ios", () => require("./commands/lazy").setupLazyCommand); + +// Omitting the type argument checks nothing, so the name parameter turns into +// the instruction to pass one. +registerLazyCommand( + // @ts-expect-error - the definition's type must be passed explicitly + "typefixture|lazy-ios", + () => require("./commands/lazy").lazyPlatform, +); + +registerLazyCommand( + // @ts-expect-error - a name no definition backs is still not enough + "typefixture|lazy-anything", + () => require("./commands/lazy").lazyPlatform, +); + +declare function setupLazyCommand(): { projectDir: string }; diff --git a/test/update.ts b/test/update.ts index c20cf94922..1cd0eda4fc 100644 --- a/test/update.ts +++ b/test/update.ts @@ -9,6 +9,7 @@ import { StaticConfig } from "../lib/config"; import { SettingsService } from "../lib/common/test/unit-tests/stubs"; import { DevicePlatformsConstants } from "../lib/common/mobile/device-platforms-constants"; import { IInjector } from "../lib/common/definitions/yok"; +import { runInInjectionContext } from "../lib/common/di"; const projectFolder = "test"; function createTestInjector(projectDir: string = projectFolder): IInjector { @@ -44,7 +45,9 @@ function createTestInjector(projectDir: string = projectFolder): IInjector { }, }); - registerCommand(updateCommandDefinition, testInjector); + runInInjectionContext(testInjector, () => + registerCommand(updateCommandDefinition), + ); return testInjector; } From 70ff0200d65102b01a440c008f96153fb0ab0565 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 16:44:51 -0300 Subject: [PATCH 04/19] feat(commands)!: dispatch in process, rename ctx.arguments to ctx.params A command can run another in process without exiting on failure; a definition's setup state is scoped to one invocation; DeferredCommandResult is a discriminated union; the command-name types an extension needs are exported from the contracts. BREAKING CHANGE: ctx.arguments is now ctx.params on the command context. --- defining-commands.md | 85 +++++- lib/common/contracts/command-registry.ts | 17 +- lib/common/define-command.ts | 6 +- lib/common/definitions/commands-service.d.ts | 12 +- lib/common/errors.ts | 51 ++-- .../services/command-definition-adapter.ts | 106 +++++-- lib/common/services/commands-service.ts | 205 +++++++++---- lib/common/test/unit-tests/stubs.ts | 5 + lib/contracts/errors.ts | 9 + lib/contracts/index.ts | 3 + lib/services/extensibility-service.ts | 2 +- test/commands-service.ts | 275 +++++++++++++++++- test/define-command.ts | 56 +++- test/platform-commands.ts | 5 + test/stubs.ts | 12 + test/type-fixtures/define-command-types.ts | 6 +- 16 files changed, 712 insertions(+), 143 deletions(-) diff --git a/defining-commands.md b/defining-commands.md index 0d510e1c00..49ca625488 100644 --- a/defining-commands.md +++ b/defining-commands.md @@ -253,16 +253,16 @@ defineCommand({ { name: "files", variadic: true }, ], async run(ctx) { - ctx.arguments.platform; // "android" - ctx.arguments.template; // "blank", or absent - ctx.arguments.files; // string[], possibly empty + ctx.params.platform; // "android" + ctx.params.template; // "blank", or absent + ctx.params.files; // string[], possibly empty }, }); ``` A spec accepts: -- `name` — the key the value appears under on `ctx.arguments`, and the name +- `name` — the key the value appears under on `ctx.params`, and the name messages use. - `required` — defaults to false. A required argument may not follow an optional one; positional matching would never be able to satisfy it. @@ -288,11 +288,11 @@ and lets a mandatory parameter claim whichever argument happens to satisfy it so `ns command b a` could satisfy `[a, b]`. Nothing in the CLI depends on that behaviour, and positional is what the declaration reads like. -The practical consequence: `ctx.arguments.template` is `args[1]` whether or not +The practical consequence: `ctx.params.template` is `args[1]` whether or not `args[1]` looks like a template. An argument that could be several things is a job for `validate` or for `canExecute`, not for the matcher. -`ctx.arguments` is always present, even with `arguments: "none"` or `"any"` — +`ctx.params` is always present, even with `arguments: "none"` or `"any"` — it is simply `{}` when no specs are declared. An optional non-variadic argument the command line did not reach is absent from it; a variadic one is always there, as an array. @@ -334,8 +334,10 @@ The run context - `ctx.args` — `string[]`, the positional arguments left after the command name (including any subcommand segments) has been consumed. -- `ctx.arguments` — the same arguments keyed by the names the `arguments` specs - declare, `{}` when there are none. +- `ctx.params` — the same arguments keyed by the names the `arguments` specs + declare, `{}` when there are none. It is spelled `params` because + `arguments` 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.injector` — the injector this command was registered against; see @@ -458,7 +460,7 @@ export default defineCommand({ name: "create", arguments: [{ name: "appName", required: true }], async run(ctx) { - const projectDir = await createProject(ctx.arguments.appName as string); + const projectDir = await createProject(ctx.params.appName as string); return { projectDir }; }, postRun(ctx, { projectDir }) { @@ -648,6 +650,71 @@ after the first `await` — and needs to know nothing else. The spread keeps the This replaces the class-inheritance pattern the legacy commands use, where a per-platform command subclasses a shared base to override one field. +Running a command in process +---------------------------- + +`runCommand` dispatches a registered command from inside the process that is +already running: + +```ts +import { runCommand } from "../common/services/command-definition-adapter"; + +await runCommand("open|ios"); +await runCommand("install", ["lodash"]); +``` + +The command gets what a typed command line gives it, in the same order: its +declared options are primed into the parser — so `ctx.options` holds this +command's values and its declared defaults rather than the outer command +line's — then the `arguments` policy, then `canExecute`, then `run`, +`postRun`, and the command's hooks. + +Two things differ, both because the caller is a process that has to keep +running afterwards: + +- **A failure throws instead of exiting.** A failed command line ends in + `process.exit`. `runCommand` reports the failure the same way — the same + message formatting, the same `ns … --help` suggestion — and then throws, so + the caller decides what happens next. +- **Analytics do not fire.** An in-process dispatch is not a new invocation of + the CLI, and the consent check can prompt on a terminal the caller has put + into raw mode. Hooks do fire: a project's `before-open-ios` hook is part of + what `open|ios` means, however the command was reached. + +The options service is put back the way it was found. Merging a command's +declarations into it rewrites the values the host process is still running on +— `open|ios` declares `watch: false`, which would otherwise leave an `ns start` +out of watch mode for the rest of its life. + +Which injector it dispatches through follows the rule `registerCommand` does: +the injector of the current injection context, and the CLI's own outside one. +`runCommand` is a thin call onto `CommandsService.executeCommandInProcess`, +where the pipeline itself lives. + +### Key shortcuts + +The interactive keys `ns start` and `ns run` offer are the CLI's own caller. A +shortcut is a table entry with a `when` deciding whether the key is live, and +an `action` that runs it: + +```ts +{ + key: "I", + description: "Open project in Xcode", + when: onPlatform("iOS"), + action: () => runCommand("open|ios"), +} +``` + +The context an action receives carries state and nothing else — the platform +being watched, whether this is `ns start` or an `ns run` child it spawned, and +the injector. Capabilities are resolved from that injector rather than handed +over as context methods: + +```ts +action: (ctx) => ctx.injector.get("startService").runIOS(), +``` + Relationship to `ICommand` -------------------------- diff --git a/lib/common/contracts/command-registry.ts b/lib/common/contracts/command-registry.ts index ed4140c81c..205d1248d0 100644 --- a/lib/common/contracts/command-registry.ts +++ b/lib/common/contracts/command-registry.ts @@ -67,15 +67,16 @@ export function describeRejection(rejection: DeferredCommandRejection): string { } /** - * Outcome of a deferred registration. Callers branch on `rejection.reason` - * rather than on message text; describeRejection renders it when the report - * is for a human. + * Outcome of a deferred registration. Checking `registered` narrows the result, + * so a rejected one carries its rejection without an assertion — inside the CLI + * that check has to read `registered === false`, because the build leaves + * strictNullChecks off and truthiness alone does not narrow a literal + * discriminant there. Callers branch on `rejection.reason` rather than on + * message text; describeRejection renders it when the report is for a human. */ -export interface DeferredCommandResult { - registered: boolean; - /** Set exactly when `registered` is false. */ - rejection?: DeferredCommandRejection; -} +export type DeferredCommandResult = + | { registered: true } + | { registered: false; rejection: DeferredCommandRejection }; /** * The command-registry face of the injector facade. Transitional contract: it diff --git a/lib/common/define-command.ts b/lib/common/define-command.ts index 829e215223..9c4e3aca83 100644 --- a/lib/common/define-command.ts +++ b/lib/common/define-command.ts @@ -82,7 +82,7 @@ export interface CommandArgumentValues { * spec takes the first argument, and so on. */ export interface ArgumentSpec { - /** Key under which the value appears on `ctx.arguments`. */ + /** Key under which the value appears on `ctx.params`. */ name: string; /** Defaults to false. A required spec may not follow an optional one. */ required?: boolean; @@ -110,7 +110,7 @@ export interface CommandContext { /** Positional arguments, after the command name has been consumed. */ args: string[]; /** The same arguments keyed by the names the `arguments` specs declare. */ - arguments: CommandArgumentValues; + params: CommandArgumentValues; /** Current value of every option declared in the schema, and nothing else. */ options: CommandOptionValues; /** @@ -386,7 +386,7 @@ const validateArgumentSpecs = (definition: any, specs: any[]): void => { if (seen.indexOf(spec.name) !== -1) { invalid( definition, - `'arguments' declares '${spec.name}' twice; argument names key ctx.arguments and must be unique`, + `'arguments' declares '${spec.name}' twice; argument names key ctx.params and must be unique`, ); } seen.push(spec.name); diff --git a/lib/common/definitions/commands-service.d.ts b/lib/common/definitions/commands-service.d.ts index 8aafba931d..26403579b2 100644 --- a/lib/common/definitions/commands-service.d.ts +++ b/lib/common/definitions/commands-service.d.ts @@ -3,12 +3,20 @@ interface ICommandsService { allCommands(opts: { includeDevCommands: boolean }): string[]; tryExecuteCommand( commandName: string, - commandArguments: string[] + commandArguments: string[], ): Promise; executeCommandUnchecked( commandName: string, - commandArguments: string[] + commandArguments: string[], ): Promise; + /** + * Runs a command inside the running process, throwing on failure rather + * than exiting, so a long-lived host survives it. + */ + executeCommandInProcess( + commandName: string, + commandArguments?: string[], + ): Promise; } /** diff --git a/lib/common/errors.ts b/lib/common/errors.ts index f0b9f489d8..331185ee86 100644 --- a/lib/common/errors.ts +++ b/lib/common/errors.ts @@ -213,6 +213,33 @@ export class Errors implements IErrors { throw exception; } + public async reportCommandError( + error: any, + printCommandHelpSuggestion: () => Promise, + ): Promise { + const logger = this.$injector.resolve("logger"); + const loggerLevel: string = logger.getLevel().toUpperCase(); + const printCallStack = + this.printCallStack || loggerLevel === "TRACE" || loggerLevel === "DEBUG"; + const message = printCallStack + ? await resolveCallStack(error) + : isInteractive() + ? `\x1B[31;1m${error.message}\x1B[0m` + : error.message; + + if (error.printOnStdout) { + logger.info(message); + } else { + logger.error(message); + } + + if (error.suggestCommandHelp) { + await printCommandHelpSuggestion(); + } + + await tryTrackException(error, this.$injector); + } + public async beginCommand( action: () => Promise, printCommandHelpSuggestion: () => Promise, @@ -220,29 +247,7 @@ export class Errors implements IErrors { try { return await action(); } catch (ex) { - const logger = this.$injector.resolve("logger"); - const loggerLevel: string = logger.getLevel().toUpperCase(); - const printCallStack = - this.printCallStack || - loggerLevel === "TRACE" || - loggerLevel === "DEBUG"; - const message = printCallStack - ? await resolveCallStack(ex) - : isInteractive() - ? `\x1B[31;1m${ex.message}\x1B[0m` - : ex.message; - - if (ex.printOnStdout) { - logger.info(message); - } else { - logger.error(message); - } - - if (ex.suggestCommandHelp) { - await printCommandHelpSuggestion(); - } - - await tryTrackException(ex, this.$injector); + await this.reportCommandError(ex, printCommandHelpSuggestion); process.exit( _.isNumber(ex.errorCode) ? ex.errorCode : ErrorCodes.UNKNOWN, ); diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index 36d613f532..2741193c4d 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -175,7 +175,9 @@ const warnOnCliOptionCollisions = ( * * CommandsService calls canExecute, execute and postCommandAction as three * separate entry points into one invocation, which is why the setup result and - * the run result are held here rather than passed between them. + * the run result are held on an invocation record here rather than passed + * between them. The command object is resolved once and cached for the process + * lifetime, so that record is replaced per invocation. */ export function createCommandFromDefinition< TSchema extends CommandOptionsSchema, @@ -253,7 +255,7 @@ export function createCommandFromDefinition< return { args, - arguments: mapArguments(args), + params: mapArguments(args), options, injector: targetInjector, fail, @@ -325,27 +327,43 @@ export function createCommandFromDefinition< } }; - // One invocation spans canExecute, execute and postCommandAction, which the - // CommandsService calls separately; setup must run for the first of them - // that happens and be reused by the rest. - let setupPromise: Promise> = null; - const ensureSetup = ( + // The state of one invocation. The command object itself is cached for the + // process, so nothing invocation-scoped may live outside one of these. + interface Invocation { + setup: Promise>; + hasRun: boolean; + runResult?: Awaited; + } + + const startSetup = ( context: CommandContext, - ): Promise> => { - if (!setupPromise) { - setupPromise = definition.setup - ? Promise.resolve( - runInInjectionContext(targetInjector, () => - definition.setup.call(definition, context), - ), - ) - : Promise.resolve(>undefined); - } + ): Promise> => + // The executor runs synchronously, so setup keeps its injection context + // up to its first await, while a synchronous failure - ctx.fail() is one - + // rejects the promise instead of escaping into the caller. + new Promise>((resolve) => + resolve( + definition.setup + ? ( + runInInjectionContext(targetInjector, () => + definition.setup.call(definition, context), + ) + ) + : undefined, + ), + ); - return setupPromise; - }; + // CommandsService calls canExecute, execute and postCommandAction as three + // separate entry points with nothing tying them together, so the boundary + // between invocations is inferred: canExecute always opens one, and execute + // opens one only when the current invocation has already run. + let currentInvocation: Invocation = null; + + const beginInvocation = (context: CommandContext): Invocation => { + currentInvocation = { setup: startSetup(context), hasRun: false }; - let runResult: Awaited; + return currentInvocation; + }; return { allowedParameters: [], @@ -364,12 +382,13 @@ export function createCommandFromDefinition< : { postCommandAction: async (args: string[]): Promise => { const context = buildContext(args); - const setupResult = await ensureSetup(context); + const invocation = currentInvocation || beginInvocation(context); + const setupResult = await invocation.setup; await runInInjectionContext(targetInjector, () => definition.postRun.call( definition, context, - runResult, + invocation.runResult, setupResult, ), ); @@ -382,7 +401,7 @@ export function createCommandFromDefinition< // arguments - so an argument validator can rely on it, and a command // run in the wrong place still reports that before complaining about // arity. - const setupResult = await ensureSetup(context); + const setupResult = await beginInvocation(context).setup; await enforceArguments(context); @@ -399,8 +418,14 @@ export function createCommandFromDefinition< }, execute: async (args: string[]): Promise => { const context = buildContext(args); - const setupResult = await ensureSetup(context); - runResult = await runInInjectionContext(targetInjector, () => + const invocation = + currentInvocation && !currentInvocation.hasRun + ? currentInvocation + : beginInvocation(context); + invocation.hasRun = true; + + const setupResult = await invocation.setup; + invocation.runResult = await runInInjectionContext(targetInjector, () => definition.run.call(definition, context, setupResult), ); }, @@ -438,13 +463,30 @@ const namesOf = (definition: DefinedCommand): string[] => Array.isArray(definition.name) ? definition.name : [definition.name]; /** - * Where a registration lands: the injector serving the code that is running, - * so an extension module loaded under a scope of its own registers into that - * scope without naming it. Outside any context it is the CLI's own injector. + * The injector serving the code that is running, so an extension module loaded + * under a scope of its own registers into — and dispatches through — that scope + * without naming it. Outside any context it is the CLI's own injector. */ -const registrationTarget = (): Injector => +const contextInjector = (): Injector => getCurrentInjector() || (getRootInjector()); +/** + * Runs a registered command in the current process. The command gets what a + * typed command line gives it — its declared options primed with their + * defaults, the arguments policy, `canExecute`, hooks and `postRun` — and a + * failure throws instead of exiting, so a process that has to keep running + * (`ns start`, dispatching a key shortcut) can catch it. + */ +export async function runCommand( + name: string, + args: string[] = [], +): Promise { + const commandsService = + contextInjector().get("commandsService"); + + await commandsService.executeCommandInProcess(name, args); +} + /** * Registers a command with the CLI. Takes either the result of defineCommand() * or the definition itself, which it defines on the caller's behalf. @@ -472,7 +514,7 @@ export function registerCommand< const defined = isCommandDefinition(definition) ? definition : defineCommand(>definition); - const target = registrationTarget(); + const target = contextInjector(); const scope = providers.length ? target.createChild(providers) : target; const owner = target.get(COMMAND_OWNER, { optional: true }) || CLI_OWNER; const registry = target.get(CommandRegistry); @@ -541,7 +583,7 @@ export function registerBuiltInCommand< // The conditional name type cannot be narrowed while forwarding it. const result = registerLazyCommand(name, load, providers); - if (!result.registered) { + if (result.registered === false) { throw new Error( `Unable to register command '${name}': ${describeRejection( result.rejection, @@ -560,7 +602,7 @@ export function registerLazyCommand< providers: Provider[] = [], ): DeferredCommandResult { const commandName = (name); - const target = registrationTarget(); + const target = contextInjector(); const registry = target.get(CommandRegistry); return registry.registerDeferredCommand(commandName, { diff --git a/lib/common/services/commands-service.ts b/lib/common/services/commands-service.ts index 54247722ba..c232cc377d 100644 --- a/lib/common/services/commands-service.ts +++ b/lib/common/services/commands-service.ts @@ -57,71 +57,91 @@ export class CommandsService implements ICommandsService { commandArguments: string[], ): Promise { this.commands.push({ commandName, commandArguments }); - const command = this.$injector.resolveCommand(commandName); - if (command) { - if ( - !this.$staticConfig.disableAnalytics && - !command.disableAnalytics && - !this.$options.disableAnalytics - ) { - const analyticsService = - this.$injector.resolve("analyticsService"); // This should be resolved here due to cyclic dependency - await analyticsService.checkConsent(); + try { + const command = this.$injector.resolveCommand(commandName); + if (!command) { + return false; + } - const beautifiedCommandName = this.beautifyCommandName( - commandName, - ).replace(/\|/g, " "); + await this.runResolvedCommand(command, commandName, commandArguments, { + trackAnalytics: true, + }); - const googleAnalyticsPageData: IGoogleAnalyticsPageviewData = { - googleAnalyticsDataType: GoogleAnalyticsDataType.Page, - path: beautifiedCommandName, - title: beautifiedCommandName, - }; + return true; + } finally { + this.commands.pop(); + } + } - await analyticsService.trackInGoogleAnalytics(googleAnalyticsPageData); - await this.$optionsTracker.trackOptions(this.$options); - } + /** + * Runs a command the caller has already resolved and cleared to run. The + * caller owns the entry on `this.commands`, because it also owns whatever + * ran before this — option priming, the arguments policy — under that name. + */ + private async runResolvedCommand( + command: ICommand, + commandName: string, + commandArguments: string[], + opts: { trackAnalytics: boolean }, + ): Promise { + if ( + opts.trackAnalytics && + !this.$staticConfig.disableAnalytics && + !command.disableAnalytics && + !this.$options.disableAnalytics + ) { + const analyticsService = + this.$injector.resolve("analyticsService"); // This should be resolved here due to cyclic dependency + await analyticsService.checkConsent(); + + const beautifiedCommandName = this.beautifyCommandName( + commandName, + ).replace(/\|/g, " "); - const shouldExecuteHooks = - !this.$staticConfig.disableCommandHooks && - (command.enableHooks === undefined || command.enableHooks === true); - if (shouldExecuteHooks) { - // Handle correctly hierarchical commands - const hierarchicalCommandName = this.$injector.buildHierarchicalCommand( - commandName, - commandArguments, - ); - if (hierarchicalCommandName) { - commandName = helpers.stringReplaceAll( - hierarchicalCommandName.commandName, - CommandsDelimiters.DefaultHierarchicalCommand, - CommandsDelimiters.HooksCommand, - ); - commandName = helpers.stringReplaceAll( - commandName, - CommandsDelimiters.HierarchicalCommand, - CommandsDelimiters.HooksCommand, - ); - } + const googleAnalyticsPageData: IGoogleAnalyticsPageviewData = { + googleAnalyticsDataType: GoogleAnalyticsDataType.Page, + path: beautifiedCommandName, + title: beautifiedCommandName, + }; - await this.$hooksService.executeBeforeHooks(commandName); - } + await analyticsService.trackInGoogleAnalytics(googleAnalyticsPageData); + await this.$optionsTracker.trackOptions(this.$options); + } - await command.execute(commandArguments); - if (command.postCommandAction) { - await command.postCommandAction(commandArguments); + const shouldExecuteHooks = + !this.$staticConfig.disableCommandHooks && + (command.enableHooks === undefined || command.enableHooks === true); + let hookCommandName = commandName; + if (shouldExecuteHooks) { + // Handle correctly hierarchical commands + const hierarchicalCommandName = this.$injector.buildHierarchicalCommand( + commandName, + commandArguments, + ); + if (hierarchicalCommandName) { + hookCommandName = helpers.stringReplaceAll( + hierarchicalCommandName.commandName, + CommandsDelimiters.DefaultHierarchicalCommand, + CommandsDelimiters.HooksCommand, + ); + hookCommandName = helpers.stringReplaceAll( + hookCommandName, + CommandsDelimiters.HierarchicalCommand, + CommandsDelimiters.HooksCommand, + ); } - if (shouldExecuteHooks) { - await this.$hooksService.executeAfterHooks(commandName); - } + await this.$hooksService.executeBeforeHooks(hookCommandName); + } - this.commands.pop(); - return true; + await command.execute(commandArguments); + if (command.postCommandAction) { + await command.postCommandAction(commandArguments); } - this.commands.pop(); - return false; + if (shouldExecuteHooks) { + await this.$hooksService.executeAfterHooks(hookCommandName); + } } private printHelpSuggestion(commandName?: string): Promise { @@ -203,6 +223,83 @@ export class CommandsService implements ICommandsService { } } + /** + * Runs a command inside a process that has to outlive its failure — a key + * shortcut pressed while `ns start` holds the terminal, where the exit + * `tryExecuteCommand` ends in would take the session with it. The command + * gets the same option priming, arguments policy, `canExecute` and hooks a + * typed command line gives it, and a failure is reported the same way and + * then thrown. + * + * Analytics stay out of it: this is not a new CLI invocation, and + * `checkConsent` may prompt on a terminal the caller has put in raw mode. + */ + public async executeCommandInProcess( + commandName: string, + commandArguments: string[] = [], + ): Promise { + try { + const command = this.$injector.resolveCommand(commandName); + if (!command) { + this.$errors.failWithHelp( + `Unknown command '${helpers.stringReplaceAll(commandName, "|", " ")}'.`, + ); + } + + this.commands.push({ commandName, commandArguments }); + const restoreOptions = this.primeOptions(command); + try { + if (!(await this.canExecuteCommand(commandName, commandArguments))) { + let commandWithArgs = commandName; + if (commandArguments && commandArguments.length) { + commandWithArgs += ` ${commandArguments.join(" ")}`; + } + this.$errors.failWithHelp( + `Command '${commandWithArgs}' cannot be executed.`, + ); + } + + await this.runResolvedCommand(command, commandName, commandArguments, { + trackAnalytics: false, + }); + } finally { + restoreOptions(); + this.commands.pop(); + } + } catch (ex) { + await this.$errors.reportCommandError(ex, () => + this.printHelpSuggestion(commandName), + ); + + throw ex; + } + } + + /** + * Merging a command's options into the parser rewrites the values the host + * process is still running on: a declared default replaces the CLI-wide one + * and the host keeps reading the replacement long after the command is + * done. An in-process dispatch has to put the parser back where it found it. + */ + private primeOptions(command: ICommand): () => void { + if (command.isHierarchicalCommand) { + return () => undefined; + } + + const declaredOptions = { ...this.$options.options }; + const parsedArgv = this.$options.argv; + + this.$options.validateOptions( + command.dashedOptions, + command.allowUnknownOptions, + ); + + return () => { + this.$options.options = declaredOptions; + this.$options.argv = parsedArgv; + }; + } + private async canExecuteCommand( commandName: string, commandArguments: string[], diff --git a/lib/common/test/unit-tests/stubs.ts b/lib/common/test/unit-tests/stubs.ts index 8bccdc038b..8bcb472531 100644 --- a/lib/common/test/unit-tests/stubs.ts +++ b/lib/common/test/unit-tests/stubs.ts @@ -112,6 +112,11 @@ export class ErrorsStub implements IErrors { return action(); } + async reportCommandError( + error: any, + printHelpCommand: () => Promise, + ): Promise {} + executeAction(action: Function): any { return action(); } diff --git a/lib/contracts/errors.ts b/lib/contracts/errors.ts index 215b750d5b..f2f4253bd1 100644 --- a/lib/contracts/errors.ts +++ b/lib/contracts/errors.ts @@ -27,6 +27,15 @@ export abstract class Errors { printCommandHelp: () => Promise, ): Promise; + /** + * Renders a command failure the way `beginCommand` does, and stops there: + * what happens to the process afterwards is the caller's to decide. + */ + abstract reportCommandError( + error: any, + printCommandHelp: () => Promise, + ): Promise; + abstract verifyHeap(message: string): void; abstract printCallStack: boolean; diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts index 6c843708c5..925ce102c7 100644 --- a/lib/contracts/index.ts +++ b/lib/contracts/index.ts @@ -61,7 +61,10 @@ export type { ArgumentsPolicy, CommandArgumentValues, CommandDefinition, + CommandName, + CommandNamesOf, DefinedCommand, + NamedCommand, CommandContext, CommandOptionSpec, DefaultedCommandOptionSpec, diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index 2648028399..48fc3584f6 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -426,7 +426,7 @@ export class ExtensibilityService implements IExtensibilityService { ), }); - if (!result.registered) { + if (result.registered === false) { this.$logger.warn( `Extension ${extensionName} is unable to register command '${commandName}': ${describeRejection( result.rejection, diff --git a/test/commands-service.ts b/test/commands-service.ts index 5be486216a..48bec5ab0d 100644 --- a/test/commands-service.ts +++ b/test/commands-service.ts @@ -2,11 +2,14 @@ import { assert } from "chai"; import { Yok } from "../lib/common/yok"; import { CommandsService } from "../lib/common/services/commands-service"; import { ICommand } from "../lib/common/definitions/commands"; +import { OptionType } from "../lib/common/enums"; -function createTestInjector(command: ICommand): { +interface ITestSetup { injector: Yok; validatedWith: { called: boolean; allowUnknown?: boolean }; -} { +} + +function createTestInjector(command: ICommand): ITestSetup { const injector = new Yok(); const validatedWith: { called: boolean; allowUnknown?: boolean } = { called: false, @@ -37,6 +40,124 @@ function createTestInjector(command: ICommand): { return { injector, validatedWith }; } +/** What an in-process dispatch touched, recorded off the collaborators. */ +interface IDispatchRecord { + primedWith: { dashedOptions: any; allowUnknown?: boolean }[]; + hooks: string[]; + analytics: string[]; + executed: string[][]; + postCommandActions: string[][]; + reported: any[]; + helpSuggestions: number; +} + +const cliOption = { type: OptionType.Boolean, hasSensitiveValue: false }; + +function createDispatchInjector(command: ICommand): { + injector: Yok; + record: IDispatchRecord; + options: any; + initialArgv: any; +} { + const injector = new Yok(); + const record: IDispatchRecord = { + primedWith: [], + hooks: [], + analytics: [], + executed: [], + postCommandActions: [], + reported: [], + helpSuggestions: 0, + }; + + const initialArgv: any = { watch: true }; + const options: any = { + options: { watch: cliOption }, + argv: initialArgv, + validateOptions(dashedOptions: any, allowUnknown?: boolean): void { + record.primedWith.push({ dashedOptions, allowUnknown }); + // The real parser merges the command's declarations into the shared + // table and re-parses, replacing both. + this.options = { ...this.options, ...dashedOptions }; + this.argv = { ...this.argv, watch: false }; + }, + }; + + injector.register("errors", { + fail: (message: string): never => { + throw new Error(message); + }, + failWithHelp: (message: string): never => { + throw new Error(message); + }, + beginCommand: (): Promise => { + throw new Error( + "beginCommand exits the process; an in-process dispatch must not use it.", + ); + }, + reportCommandError: async ( + error: any, + printCommandHelp: () => Promise, + ): Promise => { + record.reported.push(error); + await printCommandHelp(); + }, + }); + injector.register("hooksService", { + executeBeforeHooks: async (name: string): Promise => { + record.hooks.push(`before:${name}`); + }, + executeAfterHooks: async (name: string): Promise => { + record.hooks.push(`after:${name}`); + }, + }); + injector.register("logger", { + warn: (): void => undefined, + error: (): void => undefined, + printMarkdown: (): void => { + record.helpSuggestions++; + }, + }); + injector.register("options", options); + injector.register("staticConfig", {}); + injector.register("extensibilityService", {}); + injector.register("optionsTracker", { + trackOptions: async (): Promise => { + record.analytics.push("options"); + }, + }); + injector.register("analyticsService", { + checkConsent: async (): Promise => { + record.analytics.push("consent"); + }, + trackInGoogleAnalytics: async (): Promise => { + record.analytics.push("pageview"); + }, + }); + + injector.resolveCommand = () => command; + injector.buildHierarchicalCommand = (): any => null; + injector.isValidHierarchicalCommand = async (): Promise => false; + + return { injector, record, options, initialArgv }; +} + +/** A command shaped the way the definition adapter compiles one. */ +function definedCommand( + record: IDispatchRecord, + overrides: Partial = {}, +): ICommand { + return { + allowedParameters: [], + dashedOptions: { watch: { ...cliOption, default: false } }, + canExecute: async (): Promise => true, + execute: async (args: string[]): Promise => { + record.executed.push(args); + }, + ...overrides, + }; +} + describe("commands-service", () => { describe("option validation", () => { const baseCommand: ICommand = { @@ -69,4 +190,154 @@ describe("commands-service", () => { assert.isTrue(validatedWith.allowUnknown); }); }); + + describe("executeCommandInProcess", () => { + it("primes the command's declared options before it runs", async () => { + const { injector, record } = createDispatchInjector(null); + const command = definedCommand(record, { allowUnknownOptions: true }); + injector.resolveCommand = () => command; + const service = injector.resolve(CommandsService); + + await service.executeCommandInProcess("open|ios"); + + assert.deepEqual(record.primedWith, [ + { dashedOptions: command.dashedOptions, allowUnknown: true }, + ]); + assert.deepEqual(record.executed, [[]]); + }); + + it("hands the parser back the state the host process was running on", async () => { + const { injector, record, options, initialArgv } = + createDispatchInjector(null); + injector.resolveCommand = () => definedCommand(record); + const service = injector.resolve(CommandsService); + + await service.executeCommandInProcess("open|ios"); + + assert.deepEqual(options.options, { watch: cliOption }); + assert.strictEqual(options.argv, initialArgv); + }); + + it("restores the parser even when the command fails", async () => { + const { injector, record, options, initialArgv } = + createDispatchInjector(null); + injector.resolveCommand = () => + definedCommand(record, { + execute: async (): Promise => { + throw new Error("boom"); + }, + }); + const service = injector.resolve(CommandsService); + + await assert.isRejected(service.executeCommandInProcess("open|ios")); + + assert.deepEqual(options.options, { watch: cliOption }); + assert.strictEqual(options.argv, initialArgv); + }); + + it("refuses a command whose canExecute says no", async () => { + const { injector, record } = createDispatchInjector(null); + injector.resolveCommand = () => + definedCommand(record, { + canExecute: async (): Promise => false, + }); + const service = injector.resolve(CommandsService); + + await assert.isRejected( + service.executeCommandInProcess("open|ios"), + "Command 'open|ios' cannot be executed.", + ); + + assert.deepEqual(record.executed, []); + }); + + it("enforces the arguments policy of a command without canExecute", async () => { + const { injector, record } = createDispatchInjector(null); + injector.resolveCommand = () => + definedCommand(record, { canExecute: undefined }); + const service = injector.resolve(CommandsService); + + await assert.isRejected( + service.executeCommandInProcess("open|ios", ["extra"]), + "This command doesn't accept parameters.", + ); + + assert.deepEqual(record.executed, []); + }); + + it("runs postCommandAction after the command", async () => { + const { injector, record } = createDispatchInjector(null); + injector.resolveCommand = () => + definedCommand(record, { + postCommandAction: async (args: string[]): Promise => { + record.postCommandActions.push(args); + }, + }); + const service = injector.resolve(CommandsService); + + await service.executeCommandInProcess("install", ["lodash"]); + + assert.deepEqual(record.executed, [["lodash"]]); + assert.deepEqual(record.postCommandActions, [["lodash"]]); + }); + + it("throws the failure at the caller instead of exiting", async () => { + const { injector, record } = createDispatchInjector(null); + const failure = new Error("Unable to open the project."); + injector.resolveCommand = () => + definedCommand(record, { + execute: async (): Promise => { + throw failure; + }, + }); + const service = injector.resolve(CommandsService); + + let raised: Error = null; + try { + await service.executeCommandInProcess("open|ios"); + } catch (err) { + raised = err; + } + + assert.strictEqual(raised, failure); + assert.deepEqual(record.reported, [failure]); + }); + + it("reports an unknown command the way a typed one is", async () => { + const { injector, record } = createDispatchInjector(null); + injector.resolveCommand = (): ICommand => null; + const service = injector.resolve(CommandsService); + + await assert.isRejected( + service.executeCommandInProcess("nope"), + "Unknown command 'nope'.", + ); + + assert.equal(record.reported.length, 1); + assert.equal(record.helpSuggestions, 1); + }); + + it("runs the command once per dispatch", async () => { + const { injector, record } = createDispatchInjector(null); + injector.resolveCommand = () => definedCommand(record); + const service = injector.resolve(CommandsService); + + await service.executeCommandInProcess("open|ios"); + await service.executeCommandInProcess("open|ios"); + + assert.deepEqual(record.executed, [[], []]); + assert.equal(record.primedWith.length, 2); + }); + + it("runs hooks, and leaves analytics to the command line", async () => { + const { injector, record } = createDispatchInjector(null); + injector.resolveCommand = () => definedCommand(record); + const service = injector.resolve(CommandsService); + + await service.executeCommandInProcess("open|ios"); + + assert.deepEqual(record.hooks, ["before:open|ios", "after:open|ios"]); + assert.deepEqual(record.analytics, []); + }); + }); }); diff --git a/test/define-command.ts b/test/define-command.ts index b71c51fea1..a6cebf9a8b 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -1189,13 +1189,13 @@ describe("defineCommand", () => { ...(extra.variadic ? [{ name: "rest", variadic: true }] : []), ], run: (ctx) => { - extra.seen = ctx.arguments; + extra.seen = ctx.params; }, }), createTestInjector(), ); - it("maps arguments onto ctx.arguments strictly by position", async () => { + it("maps arguments onto ctx.params strictly by position", async () => { const extra: any = {}; const command = platformCommand(extra); @@ -1205,7 +1205,7 @@ describe("defineCommand", () => { assert.deepEqual(extra.seen, { platform: "android", target: "device" }); }); - it("leaves an unfilled optional argument off ctx.arguments", async () => { + it("leaves an unfilled optional argument off ctx.params", async () => { const extra: any = {}; const command = platformCommand(extra); @@ -1233,14 +1233,14 @@ describe("defineCommand", () => { }); }); - it("exposes an empty ctx.arguments when no specs are declared", async () => { + it("exposes an empty ctx.params when no specs are declared", async () => { let seen: any; const command = createCommandFromDefinition( defineCommand({ name: "dctest-noargspecs", arguments: "any", run: (ctx) => { - seen = ctx.arguments; + seen = ctx.params; }, }), createTestInjector(), @@ -1404,7 +1404,7 @@ describe("defineCommand", () => { await command.canExecute(["android"]); assert.deepEqual(capturedContext.options, { force: true }); - assert.deepEqual(capturedContext.arguments, { platform: "android" }); + assert.deepEqual(capturedContext.params, { platform: "android" }); }); it("enforces the specs before consulting the definition canExecute", async () => { @@ -1588,6 +1588,50 @@ describe("defineCommand", () => { assert.strictEqual(runs, 1); }); + it("runs again for the next invocation of the same command object", async () => { + let runs = 0; + const seen: number[] = []; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-setup-per-invocation", + setup: async () => ++runs, + run: (ctx, attempt: number) => { + seen.push(attempt); + }, + }), + createTestInjector(), + ); + + await command.canExecute([]); + await command.execute([]); + await command.canExecute([]); + await command.execute([]); + + assert.strictEqual(runs, 2); + assert.deepEqual(seen, [1, 2]); + }); + + it("starts a new invocation for an execute with no canExecute of its own", async () => { + let runs = 0; + const seen: number[] = []; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-setup-repeat-execute", + setup: async () => ++runs, + run: (ctx, attempt: number) => { + seen.push(attempt); + }, + }), + createTestInjector(), + ); + + await command.execute([]); + await command.execute([]); + + assert.strictEqual(runs, 2); + assert.deepEqual(seen, [1, 2]); + }); + it("hands undefined through when no setup is declared", async () => { let seen: any = "untouched"; const command = createCommandFromDefinition( diff --git a/test/platform-commands.ts b/test/platform-commands.ts index dff99febfb..35dd3d0836 100644 --- a/test/platform-commands.ts +++ b/test/platform-commands.ts @@ -98,6 +98,11 @@ class ErrorsNoFailStub implements IErrors { return result; } + async reportCommandError( + error: any, + printHelpCommand: () => Promise, + ): Promise {} + executeAction(action: Function): any { return action(); } diff --git a/test/stubs.ts b/test/stubs.ts index 7be77bc26a..857336ba85 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -366,6 +366,11 @@ export class ErrorsStub implements IErrors { throw new Error("not supported"); } + async reportCommandError( + error: any, + printHelpCommand: () => Promise, + ): Promise {} + executeAction(action: Function): any { return action(); } @@ -1322,6 +1327,13 @@ export class CommandsService implements ICommandsService { return Promise.resolve(true); } + public executeCommandInProcess( + commandName: string, + commandArguments?: string[], + ): Promise { + return Promise.resolve(); + } + public completeCommand(): Promise { return Promise.resolve(true); } diff --git a/test/type-fixtures/define-command-types.ts b/test/type-fixtures/define-command-types.ts index d9c6145a30..85fd7107d2 100644 --- a/test/type-fixtures/define-command-types.ts +++ b/test/type-fixtures/define-command-types.ts @@ -86,7 +86,7 @@ defineCommand({ run: () => undefined, }); -// `arguments` accepts positional specs, and `ctx.arguments` keys the values by +// `arguments` accepts positional specs, and `ctx.params` keys the values by // the declared names. The keys are not inferred from the spec array — the // value type is what the declaration pins. defineCommand({ @@ -96,9 +96,9 @@ defineCommand({ { name: "extra", variadic: true }, ], run(ctx) { - expectExactType>(); + expectExactType>(); expectExactType< - IsExact<(typeof ctx.arguments)["platform"], string | string[]> + IsExact<(typeof ctx.params)["platform"], string | string[]> >(); }, }); From 83381c5e1da63bc0671303fd846edc74e569e620 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 16:44:52 -0300 Subject: [PATCH 05/19] feat(key-shortcuts): declarative table with a generic engine ns start's key handling becomes a declarative table over a caller-supplied context, with state on the context and capabilities on the injector; the key-command surface leaves the injector facade. Failures from the spawned run children surface in the parent, and NS_NO_OPEN keeps the CLI from launching a browser where nobody is watching. --- lib/bootstrap.ts | 19 +- lib/commands/open.ts | 28 +- lib/commands/run.ts | 24 +- lib/common/contracts/index.ts | 1 - lib/common/contracts/key-command-registry.ts | 15 - lib/common/definitions/key-commands.ts | 62 -- lib/common/definitions/yok.d.ts | 8 +- lib/common/opener.ts | 18 + lib/common/services/help-service.ts | 7 + lib/common/yok.ts | 65 +- lib/helpers/key-command-helper.ts | 154 ---- lib/key-commands/bootstrap.ts | 37 - lib/key-commands/index.ts | 340 --------- lib/services/key-shortcuts.ts | 451 ++++++++++++ lib/services/start-service.ts | 138 ++-- test/compat/injector-facade-surface.ts | 12 +- test/opener.ts | 55 ++ test/services/key-shortcuts.ts | 711 +++++++++++++++++++ test/test-bootstrap.ts | 3 + 19 files changed, 1395 insertions(+), 753 deletions(-) delete mode 100644 lib/common/contracts/key-command-registry.ts delete mode 100644 lib/common/definitions/key-commands.ts delete mode 100644 lib/helpers/key-command-helper.ts delete mode 100644 lib/key-commands/bootstrap.ts delete mode 100644 lib/key-commands/index.ts create mode 100644 lib/services/key-shortcuts.ts create mode 100644 test/opener.ts create mode 100644 test/services/key-shortcuts.ts diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index d552ebf26d..69c5920574 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -243,6 +243,22 @@ registerBuiltInCommand( "run|visionos", () => require("./commands/run").visionRunCommand, ); +registerBuiltInCommand( + "open|ios", + () => require("./commands/open").iosOpenCommand, +); +registerBuiltInCommand( + "open|android", + () => require("./commands/open").androidOpenCommand, +); +registerBuiltInCommand( + "open|visionos", + () => require("./commands/open").visionOpenCommand, +); +registerBuiltInCommand( + "open|vision", + () => require("./commands/open").visionOpenCommand, +); registerBuiltInCommand< typeof import("./commands/typings").typingsCommandDefinition >("typings", () => require("./commands/typings").typingsCommandDefinition); @@ -705,7 +721,7 @@ injector.require("tempService", "./services/temp-service"); injector.require("sharedEventBus", "./shared-event-bus"); -injector.require("keyCommandHelper", "./helpers/key-command-helper"); +injector.require("keyShortcutService", "./services/key-shortcuts"); registerBuiltInCommand< typeof import("./commands/start").startCommandDefinition @@ -744,4 +760,3 @@ registerBuiltInCommand< registerBuiltInCommand< typeof import("./commands/widget").widgetIOSCommandDefinition >("widget|ios", () => require("./commands/widget").widgetIOSCommandDefinition); -require("./key-commands/bootstrap"); diff --git a/lib/commands/open.ts b/lib/commands/open.ts index 683dce71f0..fe57eabf14 100644 --- a/lib/commands/open.ts +++ b/lib/commands/open.ts @@ -195,9 +195,20 @@ const openCommandOptions = { /** * `prepare` reads the options service rather than this command's context, so * the CLI-wide `--watch` has to be pinned there and not just defaulted here. + * It is restored afterwards because a key shortcut runs this inside a process + * whose own live sync is still watching. */ -const disableWatch = ($options: IOptions): void => { +const withoutWatch = async ( + $options: IOptions, + work: () => Promise, +): Promise => { + const previous = $options.watch; $options.watch = false; + try { + return await work(); + } finally { + $options.watch = previous; + } }; export const iosOpenCommand = defineCommand({ @@ -212,8 +223,9 @@ export const iosOpenCommand = defineCommand({ }; }, async run(context, services): Promise { - disableWatch(services.$options); - await openXcodeProject(services, "ios", false); + await withoutWatch(services.$options, () => + openXcodeProject(services, "ios", false), + ); }, }); @@ -229,8 +241,9 @@ export const visionOpenCommand = defineCommand({ }; }, async run(context, services): Promise { - disableWatch(services.$options); - await openVisionOSProject(services, services.$options, false); + await withoutWatch(services.$options, () => + openVisionOSProject(services, services.$options, false), + ); }, }); @@ -246,7 +259,8 @@ export const androidOpenCommand = defineCommand({ }; }, async run(context, services): Promise { - disableWatch(services.$options); - await openAndroidStudioProject(services, "Android", false); + await withoutWatch(services.$options, () => + openAndroidStudioProject(services, "Android", false), + ); }, }); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index 90c9961c71..28e36a55fe 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -1,9 +1,5 @@ import { ERROR_NO_VALID_SUBCOMMAND_FORMAT } from "../common/constants"; import { IErrors, IHostInfo } from "../common/declarations"; -import { - IKeyCommandHelper, - IKeyCommandPlatform, -} from "../common/definitions/key-commands"; import { booleanOption, CommandContext, @@ -21,6 +17,11 @@ import { import { IOptions, IPlatformValidationService } from "../declarations"; import { IMigrateController } from "../definitions/migrate"; import { IProjectData, IProjectDataService } from "../definitions/project"; +import { + DevicePlatformName, + IKeyShortcutService, + keyShortcuts, +} from "../services/key-shortcuts"; const runCommandOptions = { force: booleanOption(), @@ -43,7 +44,7 @@ export interface IRunCommandServices { $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; $errors: IErrors; $hostInfo: IHostInfo; - $keyCommandHelper: IKeyCommandHelper; + $keyShortcutService: IKeyShortcutService; $liveSyncCommandHelper: ILiveSyncCommandHelper; $migrateController: IMigrateController; $options: IOptions; @@ -60,7 +61,7 @@ export function setupRunCommand(): IRunCommandServices { ), $errors: inject("errors"), $hostInfo: inject("hostInfo"), - $keyCommandHelper: inject("keyCommandHelper"), + $keyShortcutService: inject("keyShortcutService"), $liveSyncCommandHelper: inject( "liveSyncCommandHelper", ), @@ -126,10 +127,13 @@ export async function runRunCommand( ); if (process.env.NS_IS_INTERACTIVE) { - services.$keyCommandHelper.attachKeyCommands( - services.platform, - "run", - ); + services.$keyShortcutService.attach({ + context: { + platform: services.platform, + processType: "run", + }, + shortcuts: keyShortcuts(), + }); } } diff --git a/lib/common/contracts/index.ts b/lib/common/contracts/index.ts index 229a54a577..94e37d0104 100644 --- a/lib/common/contracts/index.ts +++ b/lib/common/contracts/index.ts @@ -14,6 +14,5 @@ export type { DeferredCommandRejection, DeferredCommandResult, } from "./command-registry"; -export { KeyCommandRegistry } from "./key-command-registry"; export { ModuleRegistry } from "./module-registry"; export { PublicApiBuilder } from "./public-api-builder"; diff --git a/lib/common/contracts/key-command-registry.ts b/lib/common/contracts/key-command-registry.ts deleted file mode 100644 index f16e1ee97d..0000000000 --- a/lib/common/contracts/key-command-registry.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Contract } from "../di/contract"; -import type { IKeyCommand, IValidKeyName } from "../definitions/key-commands"; - -/** - * The key-command face of the injector facade (the `keyCommands.` namespace). - * Kept separate from CommandRegistry because the two registries are redesigned - * on different tracks. - */ -@Contract({ name: "keyCommandRegistry" }) -export abstract class KeyCommandRegistry { - abstract requireKeyCommand(name: IValidKeyName, file: string): void; - abstract registerKeyCommand(name: IValidKeyName, resolver: any): void; - abstract resolveKeyCommand(name: string): IKeyCommand; - abstract getRegisteredKeyCommandsNames(): string[]; -} diff --git a/lib/common/definitions/key-commands.ts b/lib/common/definitions/key-commands.ts deleted file mode 100644 index ff08673439..0000000000 --- a/lib/common/definitions/key-commands.ts +++ /dev/null @@ -1,62 +0,0 @@ -export type IKeyCommandPlatform = "Android" | "iOS" | "visionOS" | "all"; -export type IKeysLowerCase = - | "a" - | "b" - | "c" - | "d" - | "e" - | "f" - | "g" - | "h" - | "i" - | "j" - | "k" - | "l" - | "m" - | "n" - | "o" - | "p" - | "q" - | "r" - | "s" - | "t" - | "u" - | "v" - | "w" - | "x" - | "y" - | "z"; - -export type IKeysUpperCase = Uppercase; - -export enum SpecialKeys { - CtrlC = "\u0003", - QuestionMark = "?", -} - -export type IKeysSpecial = `${SpecialKeys}`; - -export type IValidKeyName = IKeysLowerCase | IKeysUpperCase | IKeysSpecial; - -export interface IKeyCommandHelper { - attachKeyCommands: ( - platform: IKeyCommandPlatform, - processType: SupportedProcessType, - ) => void; - - addOverride(key: IValidKeyName, execute: () => Promise): void; - removeOverride(key: IValidKeyName): void; - printCommands(platform: IKeyCommandPlatform): void; -} - -export type SupportedProcessType = "start" | "run"; - -export interface IKeyCommand { - key: IValidKeyName; - platform: IKeyCommandPlatform; - description: string; - group: string; - willBlockKeyCommandExecution?: boolean; - execute(platform: string): Promise; - canExecute?: (processType: SupportedProcessType) => boolean; -} diff --git a/lib/common/definitions/yok.d.ts b/lib/common/definitions/yok.d.ts index 89c544444b..0b28d8f78b 100644 --- a/lib/common/definitions/yok.d.ts +++ b/lib/common/definitions/yok.d.ts @@ -2,7 +2,6 @@ import { IDictionary } from "../declarations"; import { Injector } from "../di/injector"; import { Provider } from "../di/providers"; import { CommandRegistry } from "../contracts/command-registry"; -import { KeyCommandRegistry } from "../contracts/key-command-registry"; import { ModuleRegistry } from "../contracts/module-registry"; import { PublicApiBuilder } from "../contracts/public-api-builder"; @@ -13,12 +12,7 @@ import { PublicApiBuilder } from "../contracts/public-api-builder"; * this; the interface survives until the hook/extension deprecation completes. */ interface IInjector - extends - Injector, - CommandRegistry, - KeyCommandRegistry, - ModuleRegistry, - PublicApiBuilder { + extends Injector, CommandRegistry, ModuleRegistry, PublicApiBuilder { /** * Resolves an implementation by constructor function. * The injector will create new instances for every call. diff --git a/lib/common/opener.ts b/lib/common/opener.ts index 74accbc689..5f8ed3a453 100644 --- a/lib/common/opener.ts +++ b/lib/common/opener.ts @@ -2,8 +2,26 @@ import * as xopen from "open"; import { IOpener } from "../declarations"; import { injector } from "./yok"; +/** + * Launching a browser or an external app is unwanted wherever nobody is + * watching a desktop: CI, test runs, and agents driving the CLI. Opting out + * has to live here because this is the only place the CLI opens anything. + */ +export function isOpeningExternallyDisabled(): boolean { + const flag = (process.env.NS_NO_OPEN || "").toLowerCase(); + if (flag) { + return !["0", "false", "off", "no"].includes(flag); + } + + return !!(process.env.CI || process.env.JENKINS_HOME); +} + export class Opener implements IOpener { public open(target: string, appname?: string): any { + if (isOpeningExternallyDisabled()) { + return undefined; + } + return xopen(target, { app: { name: appname, diff --git a/lib/common/services/help-service.ts b/lib/common/services/help-service.ts index 28b1c93164..3afc3ba9c5 100644 --- a/lib/common/services/help-service.ts +++ b/lib/common/services/help-service.ts @@ -10,6 +10,7 @@ import { } from "../declarations"; import { IInjector } from "../definitions/yok"; import { injector } from "../yok"; +import { isOpeningExternallyDisabled } from "../opener"; import { IExtensibilityService } from "../definitions/extensibility"; import { IOpener } from "../../declarations"; import * as _ from "lodash"; @@ -83,6 +84,12 @@ export class HelpService implements IHelpService { public async openHelpForCommandInBrowser( commandData: ICommandData, ): Promise { + if (isOpeningExternallyDisabled()) { + // Nothing is watching a desktop, so the terminal is the only place + // this help can land. + return this.showCommandLineHelp(commandData); + } + const { commandName } = commandData; const htmlPage = (await this.convertCommandNameToFileName(commandData)) + diff --git a/lib/common/yok.ts b/lib/common/yok.ts index 5f252d3efc..da0e737aa5 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -7,15 +7,9 @@ import { CommandsDelimiters } from "./constants"; import { IDictionary } from "./declarations"; import { IInjector } from "./definitions/yok"; import { ICommandArgument, ICommand } from "./definitions/commands"; -import { IKeyCommand, IValidKeyName } from "./definitions/key-commands"; import { Injector } from "./di/injector"; import type { Provider } from "./di/providers"; -import { - CommandRegistry, - KeyCommandRegistry, - ModuleRegistry, - PublicApiBuilder, -} from "./contracts"; +import { CommandRegistry, ModuleRegistry, PublicApiBuilder } from "./contracts"; import type { DeferredCommandOptions, DeferredCommandRejection, @@ -64,10 +58,10 @@ export interface IDependency { /** * The Yok facade IS the token-based `Injector` — it extends it — plus the - * legacy surface: command routing, the key-command namespace, the module - * loader, and the public-API builder. Those subsystems historically shared - * the container object and migrate out separately; until then they live here, - * individually marked @deprecated. + * legacy surface: command routing, the module loader, and the public-API + * builder. Those subsystems historically shared the container object and + * migrate out separately; until then they live here, individually marked + * @deprecated. */ export class Yok extends Injector implements IInjector { /** @@ -83,7 +77,6 @@ export class Yok extends Injector implements IInjector { // consumers of the token. this.register([ { provide: CommandRegistry, useValue: this }, - { provide: KeyCommandRegistry, useValue: this }, { provide: ModuleRegistry, useValue: this }, { provide: PublicApiBuilder, useValue: this }, ]); @@ -102,7 +95,6 @@ export class Yok extends Injector implements IInjector { * meant to replace it once that module registers itself. */ private placeholderParents = new Set(); - private KEY_COMMANDS_NAMESPACE: string = "keyCommands"; // Keyed by command names, which extensions choose freely: a null prototype // keeps a name like 'constructor' from reading back as an inherited member. private hierarchicalCommands: IDictionary = Object.create(null); @@ -262,14 +254,6 @@ export class Yok extends Injector implements IInjector { forEachName(names, (name) => this.requireOne(name, file)); } - /** - * @deprecated Key-command counterpart of requireCommand; replaced together - * with the command registry. - */ - public requireKeyCommand(name: any, file: string): void { - this.requireOne(this.createKeyCommandName(name), file); - } - /** * @deprecated Backing store of the require('nativescript') surface. * Do not add new entries through it. @@ -381,13 +365,6 @@ export class Yok extends Injector implements IInjector { }); } - /** - * @deprecated Replaced together with the command registry. - */ - public registerKeyCommand(name: IValidKeyName, resolver: IKeyCommand): void { - this.register(this.createKeyCommandName(name), resolver); - } - private getDefaultCommand(name: string, commandArguments: string[]) { const subCommands = this.hierarchicalCommands[name]; const defaultCommand = _.find(subCommands, (command) => @@ -655,21 +632,6 @@ export class Yok extends Injector implements IInjector { return command; } - /** - * @deprecated Legacy command-registry lookup. - */ - public resolveKeyCommand(name: string): IKeyCommand { - let command: IKeyCommand; - const commandModuleName = this.createKeyCommandName(name); - if (!this.has(commandModuleName)) { - return null; - } - - command = this.resolve(commandModuleName); - - return command; - } - /** * @deprecated Use inject(Token) in an injection context, or Injector.get / * createInstance from lib/common/di (via `Yok.di`). @@ -742,19 +704,6 @@ export class Yok extends Injector implements IInjector { return commands; } - /** - * @deprecated Legacy command-registry enumeration. - */ - public getRegisteredKeyCommandsNames(): string[] { - const commandsNames = this.getRegisteredNames( - `${this.KEY_COMMANDS_NAMESPACE}.`, - ); - const commands = _.map(commandsNames, (commandName: string) => - commandName.slice(this.KEY_COMMANDS_NAMESPACE.length + 1), - ); - return commands; - } - /** * @deprecated Legacy command-registry routing. */ @@ -766,10 +715,6 @@ export class Yok extends Injector implements IInjector { return `${this.COMMANDS_NAMESPACE}.${name}`; } - private createKeyCommandName(name: string) { - return `${this.KEY_COMMANDS_NAMESPACE}.${name}`; - } - /** * @deprecated Delegates to Injector.dispose (reverse instantiation order); * new code disposes the di container directly. diff --git a/lib/helpers/key-command-helper.ts b/lib/helpers/key-command-helper.ts deleted file mode 100644 index 59d9804e8c..0000000000 --- a/lib/helpers/key-command-helper.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { color } from "../color"; -import { stripVTControlCharacters } from "node:util"; -import { - IKeyCommandHelper, - IKeyCommandPlatform, - IValidKeyName, - SpecialKeys, - SupportedProcessType, -} from "../common/definitions/key-commands"; -import { injector } from "../common/yok"; - -export default class KeyCommandHelper implements IKeyCommandHelper { - public keyCommandExecutionBlocked: boolean; - private platform: string = "all"; - private processType: SupportedProcessType; - private overrides: { [key: string]: () => Promise } = {}; - - public addOverride(key: IValidKeyName, execute: () => Promise) { - this.overrides[key] = execute; - } - - public removeOverride(key: IValidKeyName) { - this.overrides[key] = undefined; - } - - private onKeyPressed = async (data: Buffer) => { - const key = data.toString(); - - // Allow Ctrl + C always. - if (this.keyCommandExecutionBlocked && key !== SpecialKeys.CtrlC) return; - - try { - const exists = injector.getRegisteredKeyCommandsNames().includes(key); - - if (exists) { - const keyCommand = injector.resolveKeyCommand(key as IValidKeyName); - - if ( - keyCommand.platform === "all" || - keyCommand.platform === this.platform || - this.platform === "all" - ) { - if ( - keyCommand.canExecute && - !keyCommand.canExecute(this.processType) - ) { - console.log("blocked execution"); - return; - } - - if (keyCommand.willBlockKeyCommandExecution) - this.keyCommandExecutionBlocked = true; - - if (this.overrides[key]) { - if (!(await this.overrides[key]())) { - this.keyCommandExecutionBlocked = false; - process.stdin.resume(); - return; - } - } - - if (keyCommand.key !== SpecialKeys.CtrlC) { - const line = ` ${color.dim("→")} ${color.bold(keyCommand.key)} — ${ - keyCommand.description - }`; - const lineLength = stripVTControlCharacters(line).length - 1; - console.log(color.dim(` ┌${"─".repeat(lineLength)}┐`)); - console.log(line + color.dim(" │")); - console.log(color.dim(` └${"─".repeat(lineLength)}┘`)); - console.log(""); - } - const result = await keyCommand.execute(this.platform); - this.keyCommandExecutionBlocked = false; - - if (process.stdin.setRawMode) { - process.stdin.resume(); - } - - return result; - } - } - - process.stdout.write(key); - } catch (e) { - const $logger = injector.resolve("logger") as ILogger; - $logger.error(e.message); - } - }; - - public printCommands(platform: IKeyCommandPlatform) { - const commands = injector.getRegisteredKeyCommandsNames(); - const groupings: { [key: string]: boolean } = {}; - const commandHelp = commands.reduce((arr, key) => { - const command = injector.resolveKeyCommand(key as IValidKeyName); - - if ( - !command.description || - (command.platform !== "all" && - command.platform !== platform && - platform !== "all") || - (command.canExecute && !command.canExecute(this.processType)) - ) { - return arr; - } else { - if (!groupings[command.group]) { - groupings[command.group] = true; - arr.push(` \n${color.underline(color.bold(command.group))}\n`); - } - arr.push(` ${color.bold(command.key)} — ${command.description}`); - return arr; - } - }, []); - - console.info( - [ - "", - ` The CLI is ${color.underline( - `interactive`, - )}, you can press the following keys any time (make sure the terminal has focus).`, - "", - ...commandHelp, - "", - ].join("\n"), - ); - } - - public attachKeyCommands( - platform: IKeyCommandPlatform, - processType: SupportedProcessType, - ) { - this.processType = processType; - this.platform = platform; - - const stdin = process.stdin; - if (!stdin.setRawMode) { - process.on("message", (key: string) => { - this.onKeyPressed(Buffer.from(key)); - }); - } else { - stdin.setRawMode(false); - stdin.setRawMode(true); - stdin.resume(); - - stdin.on("data", this.onKeyPressed); - } - } - - public detachKeyCommands() { - process.stdin.off("data", this.onKeyPressed); - process.stdin.setRawMode(false); - } -} - -injector.register("keyCommandHelper", KeyCommandHelper); diff --git a/lib/key-commands/bootstrap.ts b/lib/key-commands/bootstrap.ts deleted file mode 100644 index bf3761c463..0000000000 --- a/lib/key-commands/bootstrap.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { SpecialKeys } from "../common/definitions/key-commands"; -import { registerBuiltInCommand } from "../common/services/command-definition-adapter"; -import { injector } from "../common/yok"; - -const path = "./key-commands/index"; - -injector.requireKeyCommand("a", path); -injector.requireKeyCommand("A", path); -injector.requireKeyCommand("i", path); -injector.requireKeyCommand("I", path); -injector.requireKeyCommand("v", path); -injector.requireKeyCommand("V", path); -injector.requireKeyCommand("r", path); -injector.requireKeyCommand("R", path); -injector.requireKeyCommand("w", path); -injector.requireKeyCommand("c", path); -injector.requireKeyCommand("n", path); - -injector.requireKeyCommand(SpecialKeys.QuestionMark, path); -injector.requireKeyCommand(SpecialKeys.CtrlC, path); - -registerBuiltInCommand( - "open|ios", - () => require("../commands/open").iosOpenCommand, -); -registerBuiltInCommand( - "open|android", - () => require("../commands/open").androidOpenCommand, -); -registerBuiltInCommand( - "open|visionos", - () => require("../commands/open").visionOpenCommand, -); -registerBuiltInCommand( - "open|vision", - () => require("../commands/open").visionOpenCommand, -); diff --git a/lib/key-commands/index.ts b/lib/key-commands/index.ts deleted file mode 100644 index e09ffc84cd..0000000000 --- a/lib/key-commands/index.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { color } from "../color"; -import { - getAndroidStudioPath, - openAndroidStudioProject, - openVisionOSProject, - openXcodeProject, -} from "../commands/open"; -import { IChildProcess, IXcodeSelectService } from "../common/declarations"; -import { ICommand } from "../common/definitions/commands"; -import { - IKeyCommand, - IKeyCommandHelper, - IKeyCommandPlatform, - IValidKeyName, - SpecialKeys, - SupportedProcessType, -} from "../common/definitions/key-commands"; -import { injector } from "../common/yok"; -import { IProjectData } from "../definitions/project"; -import { IStartService } from "../definitions/start-service"; -import { IOSProjectService } from "../services/ios-project-service"; -import { IOptions } from "../declarations"; - -export class A implements IKeyCommand { - key: IValidKeyName = "a"; - platform: IKeyCommandPlatform = "Android"; - description: string = "Run Android app"; - group = "Android"; - - constructor(private $startService: IStartService) {} - - async execute(): Promise { - this.$startService.runAndroid(); - } - - canExecute(processType: SupportedProcessType) { - return processType === "start"; - } -} - -export class ShiftA implements IKeyCommand { - key: IValidKeyName = "A"; - platform: IKeyCommandPlatform = "Android"; - description: string = "Open project in Android Studio"; - group = "Android"; - willBlockKeyCommandExecution: boolean = true; - protected isInteractive: boolean = true; - constructor( - private $logger: ILogger, - private $liveSyncCommandHelper: ILiveSyncCommandHelper, - private $childProcess: IChildProcess, - private $projectData: IProjectData, - ) {} - - getAndroidStudioPath(): string | null { - return getAndroidStudioPath(); - } - - async execute(): Promise { - await openAndroidStudioProject( - { - $logger: this.$logger, - $liveSyncCommandHelper: this.$liveSyncCommandHelper, - $childProcess: this.$childProcess, - $projectData: this.$projectData, - }, - this.platform, - this.isInteractive, - ); - } -} - -export class I implements IKeyCommand { - key: IValidKeyName = "i"; - platform: IKeyCommandPlatform = "iOS"; - description: string = "Run iOS app"; - group = "iOS"; - - constructor(private $startService: IStartService) {} - - async execute(): Promise { - this.$startService.runIOS(); - } - - canExecute(processType: SupportedProcessType) { - return processType === "start"; - } -} - -export class ShiftI implements IKeyCommand { - key: IValidKeyName = "I"; - platform: IKeyCommandPlatform = "iOS"; - description: string = "Open project in Xcode"; - group = "iOS"; - willBlockKeyCommandExecution: boolean = true; - protected isInteractive: boolean = true; - - constructor( - private $iOSProjectService: IOSProjectService, - private $logger: ILogger, - private $childProcess: IChildProcess, - private $projectData: IProjectData, - private $xcodeSelectService: IXcodeSelectService, - private $xcodebuildArgsService: IXcodebuildArgsService, - ) {} - - async execute(): Promise { - await openXcodeProject( - { - $iOSProjectService: this.$iOSProjectService, - $logger: this.$logger, - $childProcess: this.$childProcess, - $projectData: this.$projectData, - $xcodeSelectService: this.$xcodeSelectService, - $xcodebuildArgsService: this.$xcodebuildArgsService, - }, - "ios", - this.isInteractive, - ); - } -} - -export class V implements IKeyCommand { - key: IValidKeyName = "v"; - platform: IKeyCommandPlatform = "visionOS"; - description: string = "Run visionOS app"; - group = "visionOS"; - - constructor(private $startService: IStartService) {} - - async execute(): Promise { - this.$startService.runVisionOS(); - } - - canExecute(processType: SupportedProcessType) { - return processType === "start"; - } -} - -export class ShiftV implements IKeyCommand { - key: IValidKeyName = "V"; - platform: IKeyCommandPlatform = "visionOS"; - description: string = "Open project in Xcode"; - group = "visionOS"; - willBlockKeyCommandExecution: boolean = true; - protected isInteractive: boolean = true; - - constructor( - private $iOSProjectService: IOSProjectService, - private $logger: ILogger, - private $childProcess: IChildProcess, - private $projectData: IProjectData, - private $xcodeSelectService: IXcodeSelectService, - private $xcodebuildArgsService: IXcodebuildArgsService, - protected $options: IOptions, - ) {} - - async execute(): Promise { - await openVisionOSProject( - { - $iOSProjectService: this.$iOSProjectService, - $logger: this.$logger, - $childProcess: this.$childProcess, - $projectData: this.$projectData, - $xcodeSelectService: this.$xcodeSelectService, - $xcodebuildArgsService: this.$xcodebuildArgsService, - }, - this.$options, - this.isInteractive, - ); - } -} - -export class R implements IKeyCommand { - key: IValidKeyName = "r"; - platform: IKeyCommandPlatform = "all"; - description: string = "Rebuild native app if needed and restart"; - group = "Development Workflow"; - willBlockKeyCommandExecution: boolean = true; - - constructor(private $liveSyncCommandHelper: ILiveSyncCommandHelper) {} - - async execute(platform: string): Promise { - const devices = - await this.$liveSyncCommandHelper.getDeviceInstances(platform); - - await this.$liveSyncCommandHelper.executeLiveSyncOperation( - devices, - platform, - { - restartLiveSync: true, - } as ILiveSyncCommandHelperAdditionalOptions, - ); - } -} - -export class ShiftR implements IKeyCommand { - key: IValidKeyName = "R"; - platform: IKeyCommandPlatform = "all"; - description: string = "Force rebuild native app and restart"; - group = "Development Workflow"; - willBlockKeyCommandExecution: boolean = true; - - constructor(private $liveSyncCommandHelper: ILiveSyncCommandHelper) {} - - async execute(platform: string): Promise { - const devices = - await this.$liveSyncCommandHelper.getDeviceInstances(platform); - await this.$liveSyncCommandHelper.executeLiveSyncOperation( - devices, - platform, - { - skipNativePrepare: false, - forceRebuildNativeApp: true, - restartLiveSync: true, - } as ILiveSyncCommandHelperAdditionalOptions, - ); - } -} - -export class CtrlC implements IKeyCommand { - key: IValidKeyName = SpecialKeys.CtrlC; - platform: IKeyCommandPlatform = "all"; - description: string; - group = "Development Workflow"; - willBlockKeyCommandExecution: boolean = false; - - async execute(): Promise { - process.exit(); - } -} - -export class W implements IKeyCommand { - key: IValidKeyName = "w"; - platform: IKeyCommandPlatform = "all"; - description: string = "Toggle file watcher"; - group = "Development Workflow"; - willBlockKeyCommandExecution: boolean = true; - - constructor(private $prepareController: IPrepareController) {} - - async execute(): Promise { - try { - const paused = await this.$prepareController.toggleFileWatcher(); - process.stdout.write( - paused - ? color.gray("Paused watching file changes... Press 'w' to resume.") - : color.bgGreen("Resumed watching file changes"), - ); - } catch (e) {} - } -} - -export class C implements IKeyCommand { - key: IValidKeyName = "c"; - platform: IKeyCommandPlatform = "all"; - description: string = "Clean project"; - group = "Development Workflow"; - willBlockKeyCommandExecution: boolean = true; - - constructor( - private $childProcess: IChildProcess, - private $liveSyncCommandHelper: ILiveSyncCommandHelper, - ) {} - - async execute(): Promise { - await this.$liveSyncCommandHelper.stop(); - - const clean = this.$childProcess.spawn("ns", ["clean"]); - clean.stdout.on("data", (data) => { - process.stdout.write(data); - if ( - data.toString().includes("Project successfully cleaned.") || - data.toString().includes("Project unsuccessfully cleaned.") - ) { - clean.kill("SIGINT"); - } - }); - } -} - -export class N implements IKeyCommand { - key: IValidKeyName = "n"; - platform: IKeyCommandPlatform = "all"; - description: string = "Install dependencies"; - group = "Development Workflow"; - willBlockKeyCommandExecution: boolean = true; - - async execute(platform: string): Promise { - const install = injector.resolveCommand("install") as ICommand; - await install.execute([]); - process.stdin.resume(); - } -} - -export class QuestionMark implements IKeyCommand { - key: IValidKeyName = SpecialKeys.QuestionMark; - platform: IKeyCommandPlatform = "all"; - description: string = "Show this help"; - group = "Development Workflow"; - willBlockKeyCommandExecution: boolean = true; - - constructor(private $keyCommandHelper: IKeyCommandHelper) {} - - async execute(platform_: string): Promise { - let platform: IKeyCommandPlatform; - switch (platform_.toLowerCase()) { - case "android": - platform = "Android"; - break; - case "ios": - platform = "iOS"; - break; - case "visionOS": - case "vision": - platform = "visionOS"; - break; - default: - platform = "all"; - break; - } - this.$keyCommandHelper.printCommands(platform); - process.stdin.resume(); - } -} - -injector.registerKeyCommand("a", A); -injector.registerKeyCommand("A", ShiftA); -injector.registerKeyCommand("i", I); -injector.registerKeyCommand("I", ShiftI); -injector.registerKeyCommand("v", V); -injector.registerKeyCommand("V", ShiftV); -injector.registerKeyCommand("r", R); -injector.registerKeyCommand("R", ShiftR); -injector.registerKeyCommand("w", W); -injector.registerKeyCommand("c", C); -injector.registerKeyCommand("A", ShiftA); -injector.registerKeyCommand("n", N); -injector.registerKeyCommand(SpecialKeys.QuestionMark, QuestionMark); -injector.registerKeyCommand(SpecialKeys.CtrlC, CtrlC); diff --git a/lib/services/key-shortcuts.ts b/lib/services/key-shortcuts.ts new file mode 100644 index 0000000000..c1f9bf9d5d --- /dev/null +++ b/lib/services/key-shortcuts.ts @@ -0,0 +1,451 @@ +import { stripVTControlCharacters } from "node:util"; +import { color } from "../color"; +import { IChildProcess } from "../common/declarations"; +import { Injector } from "../common/di/injector"; +import { runCommand } from "../common/services/command-definition-adapter"; +import { injector } from "../common/yok"; +import { IStartService } from "../definitions/start-service"; + +/** A terminal in raw mode delivers this byte instead of raising SIGINT. */ +const CTRL_C = "\u0003"; +const HELP_KEY = "?"; +const WORKFLOW_GROUP = "Development Workflow"; + +/** + * What every shortcut can count on. The context carries state; capabilities + * come from the injector. Callers extend it with the dimensions their own + * tables ask about — nothing here inspects the context beyond handing it to + * `when` and `action`. + */ +export interface KeyContextBase { + injector: Injector; +} + +/** The half of a context its caller owns; the service provides the rest. */ +export type KeyContextExtras = Omit< + TContext, + keyof KeyContextBase +>; + +export interface KeyShortcut { + key: string; + description: string; + group?: string; + /** Availability AND help visibility — one verdict feeds both. */ + when?(ctx: TContext): boolean; + action?(ctx: TContext): void | Promise; + /** + * Suppresses the keypress banner. Set by shortcuts that hand the key to a + * child process, which announces and runs it itself. + */ + quiet?: boolean; +} + +export interface IKeyShortcutService { + /** Returns false when the terminal cannot take raw mode. */ + attach(options: { + context?: KeyContextExtras; + shortcuts: KeyShortcut[]; + }): boolean; + detach(): void; + printHelp(): void; +} + +const helpShortcut: KeyShortcut = { + key: HELP_KEY, + description: "Show this help", + group: WORKFLOW_GROUP, + action: (ctx) => + ctx.injector.get("keyShortcutService").printHelp(), +}; + +/** + * Later entries replace earlier ones by key, keeping the position the key was + * first declared at so help stays ordered; `?` is reserved and cannot be + * replaced. Dropping `action` removes a shortcut outright — it disappears from + * help and the key goes inert. + */ +export function resolveShortcuts( + shortcuts: KeyShortcut[], + ctx: TContext, +): KeyShortcut[] { + const byKey = new Map>(); + + for (const shortcut of shortcuts) { + if (shortcut.key === HELP_KEY) { + continue; + } + byKey.set(shortcut.key, shortcut); + } + + byKey.set(HELP_KEY, helpShortcut); + + return Array.from(byKey.values()).filter( + (shortcut) => + shortcut.action !== undefined && (!shortcut.when || shortcut.when(ctx)), + ); +} + +/** + * Reading the entry back rather than restating it is what keeps a redefinition + * from drifting away from the help text it replaces. + */ +export function findShortcut( + shortcuts: KeyShortcut[], + key: string, +): KeyShortcut { + const shortcut = shortcuts.find((candidate) => candidate.key === key); + if (!shortcut) { + throw new Error(`No key shortcut is defined for '${key}'.`); + } + + return shortcut; +} + +const OFF_VALUES = ["0", "false", "off", "no"]; + +/** + * Raw mode outlives the process that set it, so a CI runner or a redirected + * stdin must never get it; `NS_KEY_SHORTCUTS=false` is the manual opt-out. + */ +export function keyShortcutsEnabled(): boolean { + const setting = process.env.NS_KEY_SHORTCUTS; + if (setting !== undefined) { + return !OFF_VALUES.includes(setting.toLowerCase()); + } + + if (process.env.CI || process.env.JENKINS_HOME) { + return false; + } + + return !!process.stdin.isTTY; +} + +/** + * The names `devicePlatformsConstants` hands out, read off the constants + * rather than spelled again here. + */ +export type DevicePlatformName = { + [ + K in keyof Mobile.IDevicePlatformsConstants + ]: Mobile.IDevicePlatformsConstants[K] extends string ? K : never; +}[keyof Mobile.IDevicePlatformsConstants]; + +export type KeyProcessType = "start" | "run"; + +/** What the NativeScript shortcuts below ask about, beyond the base context. */ +export interface NsKeyContext extends KeyContextBase { + /** The platform being watched; unset while `ns start` owns the terminal. */ + platform?: DevicePlatformName; + processType: KeyProcessType; +} + +const onPlatform = + (platform: DevicePlatformName) => + (ctx: NsKeyContext): boolean => + !ctx.platform || ctx.platform === platform; + +const duringStart = + (platform: DevicePlatformName) => + (ctx: NsKeyContext): boolean => + ctx.processType === "start" && onPlatform(platform)(ctx); + +const launch = + (run: (startService: IStartService) => Promise) => + (ctx: NsKeyContext): Promise => + run(ctx.injector.get("startService")); + +const restart = async ( + ctx: NsKeyContext, + forceRebuildNativeApp: boolean, +): Promise => { + const $liveSyncCommandHelper = ctx.injector.get( + "liveSyncCommandHelper", + ); + const devices = await $liveSyncCommandHelper.getDeviceInstances(ctx.platform); + + await $liveSyncCommandHelper.executeLiveSyncOperation(devices, ctx.platform, < + ILiveSyncCommandHelperAdditionalOptions + >{ + restartLiveSync: true, + ...(forceRebuildNativeApp + ? { skipNativePrepare: false, forceRebuildNativeApp: true } + : {}), + }); +}; + +const toggleFileWatcher = async (ctx: NsKeyContext): Promise => { + const $prepareController = + ctx.injector.get("prepareController"); + + try { + const paused = await $prepareController.toggleFileWatcher(); + process.stdout.write( + paused + ? color.gray("Paused watching file changes... Press 'w' to resume.") + : color.bgGreen("Resumed watching file changes"), + ); + } catch (e) {} +}; + +const cleanProject = async (ctx: NsKeyContext): Promise => { + const $childProcess = ctx.injector.get("childProcess"); + const $liveSyncCommandHelper = ctx.injector.get( + "liveSyncCommandHelper", + ); + + await $liveSyncCommandHelper.stop(); + + const clean = $childProcess.spawn("ns", ["clean"]); + clean.stdout.on("data", (data: Buffer) => { + process.stdout.write(data); + if ( + data.toString().includes("Project successfully cleaned.") || + data.toString().includes("Project unsuccessfully cleaned.") + ) { + clean.kill("SIGINT"); + } + }); +}; + +/** + * The shortcuts every interactive process shares. `ns start` appends its own + * entries on top of these; the `ns run` children it spawns use them as they + * are, driven over IPC. + */ +export function keyShortcuts(): KeyShortcut[] { + return [ + { + key: "a", + description: "Run Android app", + group: "Android", + when: duringStart("Android"), + action: launch((startService) => startService.runAndroid()), + }, + { + key: "A", + description: "Open project in Android Studio", + group: "Android", + when: onPlatform("Android"), + action: () => runCommand("open|android"), + }, + { + key: "i", + description: "Run iOS app", + group: "iOS", + when: duringStart("iOS"), + action: launch((startService) => startService.runIOS()), + }, + { + key: "I", + description: "Open project in Xcode", + group: "iOS", + when: onPlatform("iOS"), + action: () => runCommand("open|ios"), + }, + { + key: "v", + description: "Run visionOS app", + group: "visionOS", + when: duringStart("visionOS"), + action: launch((startService) => startService.runVisionOS()), + }, + { + key: "V", + description: "Open project in Xcode", + group: "visionOS", + when: onPlatform("visionOS"), + action: () => runCommand("open|visionos"), + }, + { + key: "r", + description: "Rebuild native app if needed and restart", + group: WORKFLOW_GROUP, + action: (ctx) => restart(ctx, false), + }, + { + key: "R", + description: "Force rebuild native app and restart", + group: WORKFLOW_GROUP, + action: (ctx) => restart(ctx, true), + }, + { + key: "w", + description: "Toggle file watcher", + group: WORKFLOW_GROUP, + action: toggleFileWatcher, + }, + { + key: "c", + description: "Clean project", + group: WORKFLOW_GROUP, + action: cleanProject, + }, + { + key: "n", + description: "Install dependencies", + group: WORKFLOW_GROUP, + action: () => runCommand("install"), + }, + ]; +} + +export class KeyShortcutService implements IKeyShortcutService { + /** The table as the caller declared it; `when` is applied per read. */ + private shortcuts: KeyShortcut[] = []; + private context: KeyContextBase; + private running: boolean = false; + private attached: boolean = false; + + constructor( + private $injector: Injector, + private $logger: ILogger, + ) {} + + public attach(options: { + context?: KeyContextExtras; + shortcuts: KeyShortcut[]; + }): boolean { + this.detach(); + + this.context = { ...options.context, injector: this.$injector }; + this.shortcuts = options.shortcuts; + + const stdin = process.stdin; + if (!stdin.isTTY || typeof stdin.setRawMode !== "function") { + // Keys reach a spawned `ns run` over IPC; its stdin is not a terminal. + process.on("message", this.onMessage); + this.attached = true; + + return true; + } + + if (!keyShortcutsEnabled()) { + return false; + } + + stdin.setRawMode(false); + stdin.setRawMode(true); + stdin.resume(); + stdin.on("data", this.onData); + process.once("exit", this.onExit); + this.attached = true; + + return true; + } + + public detach(): void { + if (!this.attached) { + return; + } + this.attached = false; + + process.off("message", this.onMessage); + process.off("exit", this.onExit); + + const stdin = process.stdin; + stdin.off("data", this.onData); + if (stdin.isTTY && typeof stdin.setRawMode === "function") { + stdin.setRawMode(false); + stdin.pause(); + } + } + + public printHelp(): void { + const printedGroups: { [group: string]: boolean } = {}; + const lines: string[] = []; + + for (const shortcut of this.resolve()) { + if (shortcut.group && !printedGroups[shortcut.group]) { + printedGroups[shortcut.group] = true; + lines.push(` \n${color.underline(color.bold(shortcut.group))}\n`); + } + lines.push(` ${color.bold(shortcut.key)} — ${shortcut.description}`); + } + + console.info( + [ + "", + ` The CLI is ${color.underline( + `interactive`, + )}, you can press the following keys any time (make sure the terminal has focus).`, + "", + ...lines, + "", + ].join("\n"), + ); + } + + /** + * Help and dispatch each read the table through this one function, so a + * `when` that changes while the process runs moves both together. + */ + private resolve(): KeyShortcut[] { + return resolveShortcuts(this.shortcuts, this.context); + } + + private onData = (data: Buffer): void => { + void this.dispatch(data.toString()); + }; + + private onMessage = (key: string): void => { + void this.dispatch(key); + }; + + private onExit = (): void => { + this.detach(); + }; + + private async dispatch(key: string): Promise { + if (key === CTRL_C) { + this.interrupt(); + return; + } + + if (this.running) { + return; + } + + const shortcut = this.resolve().find((candidate) => candidate.key === key); + if (!shortcut) { + process.stdout.write(key); + return; + } + + this.running = true; + try { + if (!shortcut.quiet) { + this.announce(shortcut); + } + + await shortcut.action(this.context); + } catch (e) { + this.$logger.error(e.message); + } finally { + this.running = false; + if (process.stdin.setRawMode) { + process.stdin.resume(); + } + } + } + + private interrupt(): void { + this.detach(); + // Raw mode turned the interrupt into a byte; re-raise it so the default + // disposition, rather than this process, decides what happens. + process.kill(process.pid, "SIGINT"); + } + + private announce(shortcut: KeyShortcut): void { + const line = ` ${color.dim("→")} ${color.bold(shortcut.key)} — ${ + shortcut.description + }`; + const lineLength = stripVTControlCharacters(line).length - 1; + console.log(color.dim(` ┌${"─".repeat(lineLength)}┐`)); + console.log(line + color.dim(" │")); + console.log(color.dim(` └${"─".repeat(lineLength)}┘`)); + console.log(""); + } +} + +injector.register("keyShortcutService", KeyShortcutService); diff --git a/lib/services/start-service.ts b/lib/services/start-service.ts index 7ed068916d..0f6c4798c8 100644 --- a/lib/services/start-service.ts +++ b/lib/services/start-service.ts @@ -1,13 +1,16 @@ import { ChildProcess } from "child_process"; import { IChildProcess } from "../common/declarations"; -import { - IKeyCommandHelper, - IValidKeyName, -} from "../common/definitions/key-commands"; import { injector } from "../common/yok"; import { IProjectData } from "../definitions/project"; import { IStartService } from "./../definitions/start-service.d"; import { IStaticConfig } from "../declarations"; +import { + findShortcut, + IKeyShortcutService, + KeyShortcut, + keyShortcuts, + NsKeyContext, +} from "./key-shortcuts"; export default class StartService implements IStartService { ios: ChildProcess; @@ -16,18 +19,18 @@ export default class StartService implements IStartService { verbose: boolean = false; constructor( - private $keyCommandHelper: IKeyCommandHelper, + private $keyShortcutService: IKeyShortcutService, private $childProcess: IChildProcess, private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, private $projectData: IProjectData, private $logger: ILogger, - private $staticConfig: IStaticConfig + private $staticConfig: IStaticConfig, ) {} toggleVerbose(): void { this.verbose = true; this.$logger.info( - this.verbose ? `Verbose logging enabled` : `Verbose logging disabled` + this.verbose ? `Verbose logging enabled` : `Verbose logging disabled`, ); } @@ -37,9 +40,9 @@ export default class StartService implements IStartService { async runForPlatform(platform: string) { const platformLowerCase = platform.toLowerCase(); - (this as any)[platformLowerCase] = this.$childProcess.spawn( - "node", - [this.$staticConfig.cliBinPath, "run", platform.toLowerCase()], + const child = this.$childProcess.spawn( + process.execPath, + [this.$staticConfig.cliBinPath, "run", platformLowerCase], { cwd: this.$projectData.projectDir, stdio: ["ipc"], @@ -49,28 +52,45 @@ export default class StartService implements IStartService { NS_IS_INTERACTIVE: true, ...process.env, }, - } + }, ); + (this as any)[platformLowerCase] = child; - (this as any)[platformLowerCase].stdout.on("data", (data: Buffer) => { + child.stdout.on("data", (data: Buffer) => { process.stdout.write(this.format(data, platform)); }); - (this as any)[platformLowerCase].stderr.on("data", (data: Buffer) => { + child.stderr.on("data", (data: Buffer) => { process.stderr.write(this.format(data, platform)); }); + + child.on("exit", (code: number) => { + if (code) { + this.$logger.error( + `Running the ${platform} app exited with code ${code}.`, + ); + } + }); + + await new Promise((resolve, reject) => { + child.once("spawn", () => { + child.on("error", (error: Error) => this.$logger.error(error.message)); + resolve(); + }); + child.once("error", reject); + }); } async runIOS(): Promise { - this.runForPlatform(this.$devicePlatformsConstants.iOS); + await this.runForPlatform(this.$devicePlatformsConstants.iOS); } async runVisionOS(): Promise { - this.runForPlatform(this.$devicePlatformsConstants.visionOS); + await this.runForPlatform(this.$devicePlatformsConstants.visionOS); } async runAndroid(): Promise { - this.runForPlatform(this.$devicePlatformsConstants.Android); + await this.runForPlatform(this.$devicePlatformsConstants.Android); } async stopIOS(): Promise { if (this.ios) { @@ -89,42 +109,66 @@ export default class StartService implements IStartService { } start() { - this.addKeyCommandOverrides(); - this.$keyCommandHelper.attachKeyCommands("all", "start"); - this.$keyCommandHelper.printCommands("all"); - } + const shortcuts = keyShortcuts(); + const attached = this.$keyShortcutService.attach({ + context: { processType: "start" }, + shortcuts: [...shortcuts, ...this.delegatedShortcuts(shortcuts)], + }); - addKeyCommandOverrides() { - const keys: IValidKeyName[] = ["w", "r", "R"]; + if (!attached) { + this.$logger.info( + "Key shortcuts need an interactive terminal. Set NS_KEY_SHORTCUTS=true to override, or run the platform commands directly.", + ); + return; + } + + this.$keyShortcutService.printHelp(); + } - for (let key of keys) { - this.$keyCommandHelper.addOverride(key, async () => { + /** + * The live sync runs in the spawned `ns run` children, so these keys are + * handed to them rather than acted on here; `c` has to stop them first. + * Each keeps the help text of the entry it replaces. + */ + private delegatedShortcuts( + shortcuts: KeyShortcut[], + ): KeyShortcut[] { + const forward = (key: string): KeyShortcut => ({ + ...findShortcut(shortcuts, key), + quiet: true, + action: () => { this.ios?.send(key); this.android?.send(key); - - return false; - }); - } - - this.$keyCommandHelper.addOverride("c", async () => { - await this.stopIOS(); - await this.stopAndroid(); - - const clean = this.$childProcess.spawn("node", [ - this.$staticConfig.cliBinPath, - "clean", - ]); - clean.stdout.on("data", (data) => { - process.stdout.write(data); - if ( - data.toString().includes("Project successfully cleaned.") || - data.toString().includes("Project unsuccessfully cleaned.") - ) { - clean.kill("SIGINT"); - } - }); - return false; + }, }); + + return [ + forward("w"), + forward("r"), + forward("R"), + { + ...findShortcut(shortcuts, "c"), + quiet: true, + action: async () => { + await this.stopIOS(); + await this.stopAndroid(); + + const clean = this.$childProcess.spawn("node", [ + this.$staticConfig.cliBinPath, + "clean", + ]); + clean.stdout.on("data", (data: Buffer) => { + process.stdout.write(data); + if ( + data.toString().includes("Project successfully cleaned.") || + data.toString().includes("Project unsuccessfully cleaned.") + ) { + clean.kill("SIGINT"); + } + }); + }, + }, + ]; } } diff --git a/test/compat/injector-facade-surface.ts b/test/compat/injector-facade-surface.ts index 446ebd677b..130ea2150e 100644 --- a/test/compat/injector-facade-surface.ts +++ b/test/compat/injector-facade-surface.ts @@ -3,7 +3,6 @@ import { Yok, getRootInjector } from "../../lib/common/yok"; import { Injector, inject, runInInjectionContext } from "../../lib/common/di"; import { CommandRegistry, - KeyCommandRegistry, ModuleRegistry, PublicApiBuilder, } from "../../lib/common/contracts"; @@ -18,15 +17,11 @@ const FACADE_METHODS = [ "requirePublic", "requirePublicClass", "requireCommand", - "requireKeyCommand", "resolve", "resolveCommand", - "resolveKeyCommand", "register", "registerCommand", - "registerKeyCommand", "getRegisteredCommandsNames", - "getRegisteredKeyCommandsNames", "dynamicCall", "getDynamicCallData", "isDefaultCommand", @@ -106,12 +101,7 @@ describe("injector facade surface", () => { it("registers its subsystem faces as tokens that resolve to the facade", () => { const inj = new Yok(); - for (const token of [ - CommandRegistry, - KeyCommandRegistry, - ModuleRegistry, - PublicApiBuilder, - ]) { + for (const token of [CommandRegistry, ModuleRegistry, PublicApiBuilder]) { assert.strictEqual(inj.get(token), inj); } assert.strictEqual(inj.resolve("commandRegistry"), inj); diff --git a/test/opener.ts b/test/opener.ts new file mode 100644 index 0000000000..1e0b5f0f0e --- /dev/null +++ b/test/opener.ts @@ -0,0 +1,55 @@ +import { assert } from "chai"; +import { isOpeningExternallyDisabled } from "../lib/common/opener"; + +describe("opener", () => { + const saved = { + NS_NO_OPEN: process.env.NS_NO_OPEN, + CI: process.env.CI, + JENKINS_HOME: process.env.JENKINS_HOME, + }; + + const setEnv = (values: { [key: string]: string }) => { + for (const key of Object.keys(saved)) { + delete process.env[key]; + } + + for (const key of Object.keys(values)) { + process.env[key] = values[key]; + } + }; + + afterEach(() => { + for (const key of Object.keys(saved)) { + const value = (saved)[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("is disabled while the test suite runs", () => { + assert.isTrue(isOpeningExternallyDisabled()); + }); + + it("is disabled on CI without any flag", () => { + setEnv({ CI: "true" }); + assert.isTrue(isOpeningExternallyDisabled()); + }); + + it("is enabled on a developer machine", () => { + setEnv({}); + assert.isFalse(isOpeningExternallyDisabled()); + }); + + it("lets the flag turn opening back on, even on CI", () => { + setEnv({ CI: "true", NS_NO_OPEN: "0" }); + assert.isFalse(isOpeningExternallyDisabled()); + }); + + it("treats any other flag value as disabling", () => { + setEnv({ NS_NO_OPEN: "1" }); + assert.isTrue(isOpeningExternallyDisabled()); + }); +}); diff --git a/test/services/key-shortcuts.ts b/test/services/key-shortcuts.ts new file mode 100644 index 0000000000..89c1c3fbcb --- /dev/null +++ b/test/services/key-shortcuts.ts @@ -0,0 +1,711 @@ +import { assert } from "chai"; +import { EventEmitter } from "events"; +import { runInInjectionContext } from "../../lib/common/di/inject"; +import { Injector } from "../../lib/common/di/injector"; +import { runCommand } from "../../lib/common/services/command-definition-adapter"; +import { + findShortcut, + KeyContextBase, + KeyShortcut, + KeyShortcutService, + keyShortcuts, + keyShortcutsEnabled, + NsKeyContext, + resolveShortcuts, +} from "../../lib/services/key-shortcuts"; + +class FakeStdin extends EventEmitter { + public isTTY: boolean = true; + public rawMode: boolean = null; + public resumeCount: number = 0; + public pauseCount: number = 0; + + public setRawMode(value: boolean): any { + this.rawMode = value; + return this; + } + + public resume(): any { + this.resumeCount++; + return this; + } + + public pause(): any { + this.pauseCount++; + return this; + } +} + +const fakeInjector = ( + registrations: Map = new Map(), +): Injector => + ({ get: (token: any) => registrations.get(token) }); + +const baseContext = (): KeyContextBase => ({ injector: fakeInjector() }); + +const context = (overrides: Partial = {}): NsKeyContext => ({ + ...baseContext(), + processType: "start", + ...overrides, +}); + +const keysOf = (shortcuts: KeyShortcut[]): string[] => + shortcuts.map((shortcut) => shortcut.key); + +const flush = async (): Promise => { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } +}; + +describe("key shortcuts", () => { + describe("resolveShortcuts", () => { + it("drops shortcuts whose `when` says no, and keeps the rest", () => { + const resolved = resolveShortcuts( + [ + { + key: "x", + description: "Excluded", + when: () => false, + action: noop, + }, + { key: "y", description: "Included", when: () => true, action: noop }, + { key: "z", description: "Unconditional", action: noop }, + ], + context(), + ); + + assert.deepEqual(keysOf(resolved), ["y", "z", "?"]); + }); + + it("asks `when` about a field the caller put on the context", () => { + interface DeviceContext extends KeyContextBase { + deviceConnected: boolean; + } + + const resolved = resolveShortcuts( + [ + { + key: "d", + description: "Needs a device", + when: (ctx) => ctx.deviceConnected, + action: noop, + }, + { key: "e", description: "Always", action: noop }, + ], + { ...baseContext(), deviceConnected: false }, + ); + + assert.deepEqual(keysOf(resolved), ["e", "?"]); + }); + + it("evaluates `when` exactly once per shortcut", () => { + let calls = 0; + resolveShortcuts( + [ + { + key: "x", + description: "Counted", + when: () => { + calls++; + return true; + }, + action: noop, + }, + ], + context(), + ); + + assert.equal(calls, 1); + }); + + it("lets a later entry win by key, at the position the key first took", () => { + const resolved = resolveShortcuts( + [ + { key: "r", description: "First", action: noop }, + { key: "w", description: "Watcher", action: noop }, + { key: "r", description: "Second", action: noop }, + ], + context(), + ); + + assert.deepEqual(keysOf(resolved), ["r", "w", "?"]); + assert.equal(resolved[0].description, "Second"); + }); + + it("removes a shortcut when a later entry has no action", () => { + const resolved = resolveShortcuts( + [ + { key: "w", description: "Watcher", action: noop }, + { key: "w", description: "Watcher", action: undefined }, + ], + context(), + ); + + assert.deepEqual(keysOf(resolved), ["?"]); + }); + + it("refuses to let anything shadow the help key", () => { + const resolved = resolveShortcuts( + [{ key: "?", description: "Hijacked", action: noop }], + context(), + ); + + assert.deepEqual(keysOf(resolved), ["?"]); + assert.equal(resolved[0].description, "Show this help"); + }); + + it("still offers help when a table tries to remove it", () => { + const resolved = resolveShortcuts( + [{ key: "?", description: "Gone", action: undefined }], + context(), + ); + + assert.deepEqual(keysOf(resolved), ["?"]); + }); + }); + + describe("the built-in table", () => { + it("offers every key while `ns start` owns the terminal", () => { + const resolved = resolveShortcuts( + keyShortcuts(), + context({ platform: undefined, processType: "start" }), + ); + + assert.deepEqual(keysOf(resolved), [ + "a", + "A", + "i", + "I", + "v", + "V", + "r", + "R", + "w", + "c", + "n", + "?", + ]); + }); + + it("narrows to the watched platform inside an `ns run` child", () => { + const resolved = resolveShortcuts( + keyShortcuts(), + context({ platform: "Android", processType: "run" }), + ); + + assert.deepEqual(keysOf(resolved), ["A", "r", "R", "w", "c", "n", "?"]); + }); + + it("routes the IDE shortcuts through the open commands", async () => { + const invoked: string[] = []; + const dispatcher = fakeInjector( + new Map([ + [ + "commandsService", + { + executeCommandInProcess: async (name: string): Promise => + void invoked.push(name), + }, + ], + ]), + ); + const ctx = context(); + const resolved = resolveShortcuts(keyShortcuts(), ctx); + + for (const key of ["A", "I", "V", "n"]) { + await runInInjectionContext(dispatcher, () => + findShortcut(resolved, key).action(ctx), + ); + } + + assert.deepEqual(invoked, [ + "open|android", + "open|ios", + "open|visionos", + "install", + ]); + }); + }); + + describe("findShortcut", () => { + it("fails loudly rather than returning a description-less entry", () => { + assert.throws( + () => findShortcut(keyShortcuts(), "q"), + "No key shortcut is defined for 'q'.", + ); + }); + }); + + describe("keyShortcutsEnabled", () => { + const env = ["NS_KEY_SHORTCUTS", "CI", "JENKINS_HOME"]; + let saved: { [key: string]: string }; + let stdin: FakeStdin; + let restoreStdin: () => void; + + beforeEach(() => { + saved = {}; + for (const name of env) { + saved[name] = process.env[name]; + delete process.env[name]; + } + stdin = new FakeStdin(); + restoreStdin = swapStdin(stdin); + }); + + afterEach(() => { + restoreStdin(); + for (const name of env) { + if (saved[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = saved[name]; + } + } + }); + + it("requires a terminal", () => { + assert.isTrue(keyShortcutsEnabled()); + stdin.isTTY = false; + assert.isFalse(keyShortcutsEnabled()); + }); + + it("stays out of CI", () => { + process.env.CI = "true"; + assert.isFalse(keyShortcutsEnabled()); + delete process.env.CI; + + process.env.JENKINS_HOME = "/var/jenkins"; + assert.isFalse(keyShortcutsEnabled()); + }); + + it("obeys NS_KEY_SHORTCUTS in both directions", () => { + process.env.NS_KEY_SHORTCUTS = "false"; + assert.isFalse(keyShortcutsEnabled()); + + process.env.CI = "true"; + process.env.NS_KEY_SHORTCUTS = "true"; + assert.isTrue(keyShortcutsEnabled()); + }); + }); + + describe("KeyShortcutService", () => { + let stdin: FakeStdin; + let restoreStdin: () => void; + let service: KeyShortcutService; + let errors: string[]; + let info: string[]; + let echoed: string[]; + let restoreConsole: () => void; + let savedSetting: string; + let registrations: Map; + + beforeEach(() => { + savedSetting = process.env.NS_KEY_SHORTCUTS; + process.env.NS_KEY_SHORTCUTS = "true"; + + stdin = new FakeStdin(); + restoreStdin = swapStdin(stdin); + + errors = []; + info = []; + echoed = []; + registrations = new Map(); + service = new KeyShortcutService(fakeInjector(registrations), (< + any + >{ + error: (message: string) => errors.push(message), + })); + registrations.set("keyShortcutService", service); + + const originalInfo = console.info; + const originalLog = console.log; + const originalWrite = process.stdout.write; + console.info = (message?: any) => void info.push(String(message)); + console.log = () => undefined; + (process.stdout).write = (chunk: any) => { + echoed.push(String(chunk)); + return true; + }; + restoreConsole = () => { + console.info = originalInfo; + console.log = originalLog; + (process.stdout).write = originalWrite; + }; + }); + + afterEach(() => { + service.detach(); + restoreConsole(); + restoreStdin(); + if (savedSetting === undefined) { + delete process.env.NS_KEY_SHORTCUTS; + } else { + process.env.NS_KEY_SHORTCUTS = savedSetting; + } + }); + + const press = async (key: string): Promise => { + stdin.emit("data", Buffer.from(key)); + await flush(); + }; + + it("gates dispatch and help on the same `when` verdict", async () => { + const ran: string[] = []; + // Answers differently per position rather than per call, so the two + // readers agree only by going through the same resolution. + let asked = 0; + const alternating = () => ++asked % 2 === 1; + + service.attach({ + shortcuts: [ + { + key: "x", + description: "AskedFirst", + when: alternating, + action: () => void ran.push("x"), + }, + { + key: "y", + description: "AskedSecond", + when: alternating, + action: () => void ran.push("y"), + }, + ], + }); + + service.printHelp(); + const help = info.join("\n"); + + await press("x"); + await press("y"); + + assert.include(help, "AskedFirst"); + assert.notInclude(help, "AskedSecond"); + assert.deepEqual(ran, ["x"]); + assert.deepEqual(echoed, ["y"]); + }); + + it("dispatches the later definition of a key", async () => { + const ran: string[] = []; + + service.attach({ + shortcuts: [ + { + key: "r", + description: "First", + action: () => void ran.push("first"), + }, + { + key: "r", + description: "Second", + action: () => void ran.push("second"), + }, + ], + }); + + await press("r"); + + assert.deepEqual(ran, ["second"]); + }); + + it("echoes a key whose shortcut was removed", async () => { + const ran: string[] = []; + + service.attach({ + shortcuts: [ + { + key: "w", + description: "Watcher", + action: () => void ran.push("w"), + }, + { key: "w", description: "Watcher", action: undefined }, + ], + }); + + service.printHelp(); + + await press("w"); + + assert.deepEqual(ran, []); + assert.deepEqual(echoed, ["w"]); + assert.notInclude(info.join("\n"), "Watcher"); + }); + + it("runs the built-in help even when a table claims '?'", async () => { + const ran: string[] = []; + + service.attach({ + shortcuts: [ + { + key: "?", + description: "Hijacked", + action: () => void ran.push("?"), + }, + ], + }); + + await press("?"); + + assert.deepEqual(ran, []); + assert.include(info.join("\n"), "Show this help"); + }); + + it("reports an action that throws instead of dying", async () => { + service.attach({ + shortcuts: [ + { + key: "x", + description: "Broken", + action: () => { + throw new Error("boom"); + }, + }, + ], + }); + + await press("x"); + + assert.deepEqual(errors, ["boom"]); + }); + + it("ignores keys while an action is still running", async () => { + const ran: string[] = []; + let release: () => void; + const blocked = new Promise((resolve) => (release = resolve)); + + service.attach({ + shortcuts: [ + { key: "x", description: "Slow", action: () => blocked }, + { key: "y", description: "Fast", action: () => void ran.push("y") }, + ], + }); + + stdin.emit("data", Buffer.from("x")); + await press("y"); + assert.deepEqual(ran, []); + + release(); + await flush(); + + await press("y"); + assert.deepEqual(ran, ["y"]); + }); + + it("puts the terminal in raw mode and takes it back out on teardown", () => { + assert.isTrue(service.attach({ shortcuts: [] })); + assert.isTrue(stdin.rawMode); + assert.equal(stdin.listenerCount("data"), 1); + + service.detach(); + + assert.isFalse(stdin.rawMode); + assert.equal(stdin.listenerCount("data"), 0); + assert.equal(stdin.pauseCount, 1); + }); + + it("tears down when the process exits", () => { + const before = new Set(process.listeners("exit")); + service.attach({ shortcuts: [] }); + assert.isTrue(stdin.rawMode); + + // Invoked directly: emitting "exit" would reach the test runner too. + const registered = process + .listeners("exit") + .filter((listener) => !before.has(listener)); + assert.equal(registered.length, 1); + (registered[0])(); + + assert.isFalse(stdin.rawMode); + assert.equal(stdin.listenerCount("data"), 0); + assert.equal( + process.listeners("exit").filter((l) => !before.has(l)).length, + 0, + ); + }); + + it("restores the terminal and re-raises the interrupt on Ctrl+C", async () => { + const signals: string[] = []; + const originalKill = process.kill; + (process).kill = (pid: number, signal: string): void => { + signals.push(signal); + }; + + try { + service.attach({ shortcuts: [] }); + + await press("\u0003"); + + assert.isFalse(stdin.rawMode); + assert.deepEqual(signals, ["SIGINT"]); + } finally { + process.kill = originalKill; + } + }); + + it("declines to attach when shortcuts are switched off", () => { + process.env.NS_KEY_SHORTCUTS = "false"; + + assert.isFalse(service.attach({ shortcuts: [] })); + assert.isNull(stdin.rawMode); + assert.equal(stdin.listenerCount("data"), 0); + }); + + it("listens over IPC when stdin is not a terminal", async () => { + stdin.isTTY = false; + const ran: string[] = []; + const before = new Set(process.listeners("message")); + + assert.isTrue( + service.attach({ + shortcuts: [ + { + key: "r", + description: "Restart", + action: () => void ran.push("r"), + }, + ], + }), + ); + assert.isNull(stdin.rawMode); + + // Invoked directly: the test runner talks to its workers over the same + // channel, so a synthetic "message" event must not reach it. + const registered = process + .listeners("message") + .filter((listener) => !before.has(listener)); + assert.equal(registered.length, 1); + (registered[0])("r"); + await flush(); + + assert.deepEqual(ran, ["r"]); + }); + + it("gives an action the injector and the caller's own context", async () => { + interface WatchContext extends KeyContextBase { + watching: boolean; + } + const toggled: string[] = []; + registrations.set("prepareController", { + toggleFileWatcher: () => toggled.push("toggled"), + }); + + service.attach({ + context: { watching: true }, + shortcuts: [ + { + key: "w", + description: "Toggle the watcher", + when: (ctx) => ctx.watching, + action: (ctx) => + void ctx.injector + .get("prepareController") + .toggleFileWatcher(), + }, + { + key: "s", + description: "Stop watching", + when: (ctx) => !ctx.watching, + action: () => void toggled.push("stopped"), + }, + ], + }); + + await press("w"); + await press("s"); + + assert.deepEqual(toggled, ["toggled"]); + assert.deepEqual(echoed, ["s"]); + }); + + it("re-reads the table on every keypress", async () => { + const ran: string[] = []; + let available = false; + + service.attach({ + shortcuts: [ + { + key: "x", + description: "Late arrival", + when: () => available, + action: () => void ran.push("x"), + }, + ], + }); + + await press("x"); + available = true; + await press("x"); + + assert.deepEqual(ran, ["x"]); + assert.deepEqual(echoed, ["x"]); + }); + }); + + describe("runCommand", () => { + it("dispatches through the commands service of the current context", async () => { + const dispatched: { name: string; args: string[] }[] = []; + const dispatcher = fakeInjector( + new Map([ + [ + "commandsService", + { + executeCommandInProcess: async ( + name: string, + args: string[], + ): Promise => void dispatched.push({ name, args }), + }, + ], + ]), + ); + + await runInInjectionContext(dispatcher, () => + runCommand("open|ios", ["--verbose"]), + ); + await runInInjectionContext(dispatcher, () => runCommand("install")); + + assert.deepEqual(dispatched, [ + { name: "open|ios", args: ["--verbose"] }, + { name: "install", args: [] }, + ]); + }); + + it("lets a failure reach the caller", async () => { + const dispatcher = fakeInjector( + new Map([ + [ + "commandsService", + { + executeCommandInProcess: async (): Promise => { + throw new Error("Unable to execute command 'open ios'."); + }, + }, + ], + ]), + ); + + let raised: Error = null; + try { + await runInInjectionContext(dispatcher, () => runCommand("open|ios")); + } catch (err) { + raised = err; + } + + assert.equal(raised.message, "Unable to execute command 'open ios'."); + }); + }); +}); + +function noop(): void { + // Only the presence of an action matters to these assertions. +} + +function swapStdin(stdin: FakeStdin): () => void { + const original = Object.getOwnPropertyDescriptor(process, "stdin"); + Object.defineProperty(process, "stdin", { + value: stdin, + configurable: true, + }); + + return () => Object.defineProperty(process, "stdin", original); +} diff --git a/test/test-bootstrap.ts b/test/test-bootstrap.ts index 9c375a58f7..3093d6064d 100644 --- a/test/test-bootstrap.ts +++ b/test/test-bootstrap.ts @@ -4,6 +4,9 @@ import "chai-as-promised"; import chaiAsPromised from "chai-as-promised"; import { ICliGlobal } from "../lib/common/definitions/cli-global"; +// No test may launch a browser or an external application. +process.env.NS_NO_OPEN = "1"; + shelljs.config.silent = true; shelljs.config.fatal = true; From 6d8cc6ea70284d96ff3fd26a263eeb5628edf20b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 16:44:52 -0300 Subject: [PATCH 06/19] feat(key-shortcuts): registry contract and command-declared shortcuts KeyShortcutRegistry is a contract with disposable registrations; the engine resolves help and dispatch through it, so an entry registered at runtime takes effect immediately. The shared entries become builders, a defineCommand may declare the keys it answers to, and ns run and ns debug get their own tables behind NS_COMMAND_SHORTCUTS (default off). --- lib/bootstrap.ts | 1 + lib/commands/debug.ts | 57 ++++- lib/commands/run.ts | 30 +++ lib/common/contracts/key-shortcuts.ts | 82 +++++++ lib/common/define-command.ts | 17 +- lib/common/definitions/commands-service.d.ts | 6 + .../services/command-definition-adapter.ts | 49 ++++ lib/common/services/commands-service.ts | 8 + lib/contracts/index.ts | 7 + lib/services/key-shortcut-registry.ts | 42 ++++ lib/services/key-shortcuts.ts | 215 ++++++++++-------- test/define-command.ts | 162 +++++++++++++ test/services/key-shortcut-registry.ts | 87 +++++++ test/services/key-shortcuts.ts | 93 +++++++- test/stubs.ts | 1 + 15 files changed, 754 insertions(+), 103 deletions(-) create mode 100644 lib/common/contracts/key-shortcuts.ts create mode 100644 lib/services/key-shortcut-registry.ts create mode 100644 test/services/key-shortcut-registry.ts diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 69c5920574..860a46f609 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -721,6 +721,7 @@ injector.require("tempService", "./services/temp-service"); injector.require("sharedEventBus", "./shared-event-bus"); +injector.require("keyShortcutRegistry", "./services/key-shortcut-registry"); injector.require("keyShortcutService", "./services/key-shortcuts"); registerBuiltInCommand< diff --git a/lib/commands/debug.ts b/lib/commands/debug.ts index c0d13631bf..a0cc1a5157 100644 --- a/lib/commands/debug.ts +++ b/lib/commands/debug.ts @@ -1,4 +1,5 @@ import { IErrors, ISysInfo } from "../common/declarations"; +import { commandShortcutsEnabled } from "../common/contracts/key-shortcuts"; import { booleanOption, CommandContext, @@ -18,6 +19,12 @@ import { } from "../definitions/debug"; import { IMigrateController } from "../definitions/migrate"; import { SystemWarningsSeverity } from "../definitions/system-warnings"; +import { + IKeyShortcutService, + KeyShortcutRegistry, + restartShortcut, + watcherShortcut, +} from "../services/key-shortcuts"; import { canExecuteCommandBase, injectPlatformCommandServices, @@ -152,17 +159,53 @@ export async function runDebugCommand( return; } + const liveSyncOptions = ( + additional: Partial, + ): ILiveSyncCommandHelperAdditionalOptions => ({ + deviceDebugMap: { + [selectedDeviceForDebug.deviceInfo.identifier]: true, + }, + buildPlatform: undefined, + skipNativePrepare: false, + ...additional, + }); + await services.$liveSyncCommandHelper.executeLiveSyncOperation( [selectedDeviceForDebug], services.platform, - { - deviceDebugMap: { - [selectedDeviceForDebug.deviceInfo.identifier]: true, - }, - buildPlatform: undefined, - skipNativePrepare: false, - }, + liveSyncOptions({}), ); + + if (!commandShortcutsEnabled()) { + return; + } + + // The device map is what keeps the debugger attached across a restart, so + // the shared restart — which knows nothing of it — cannot stand in here. + const restartDebugSession = (forceRebuildNativeApp: boolean): Promise => + services.$liveSyncCommandHelper.executeLiveSyncOperation( + [selectedDeviceForDebug], + services.platform, + liveSyncOptions(>{ + restartLiveSync: true, + ...(forceRebuildNativeApp ? { forceRebuildNativeApp: true } : {}), + }), + ); + + context.injector.get(KeyShortcutRegistry).add( + restartShortcut({ restart: restartDebugSession }), + restartShortcut({ + forceRebuildNativeApp: true, + restart: restartDebugSession, + }), + watcherShortcut(), + ); + + const keyShortcutService = + context.injector.get("keyShortcutService"); + if (keyShortcutService.attach({ shortcuts: [] })) { + keyShortcutService.printHint(); + } } interface IDebugApplePlatformCommandServices extends IDebugCommandServices { diff --git a/lib/commands/run.ts b/lib/commands/run.ts index 28e36a55fe..bccc99f240 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -20,7 +20,10 @@ import { IProjectData, IProjectDataService } from "../definitions/project"; import { DevicePlatformName, IKeyShortcutService, + KeyShortcut, keyShortcuts, + restartShortcut, + watcherShortcut, } from "../services/key-shortcuts"; const runCommandOptions = { @@ -137,6 +140,30 @@ export async function runRunCommand( } } +/** + * Restarting and pausing the watcher are the shortcuts a standalone run owns + * outright; the launch and clean keys belong to the parent that respawns + * things, which is why the `ns start` table is not reused here. + */ +export function runCommandShortcuts( + context: RunCommandContext, + services: IRunCommandServices, +): KeyShortcut[] { + if (process.env.NS_IS_INTERACTIVE) { + // A `ns start` child is driven over IPC through the table `run` attaches + // for itself; a second attach would replace it. + return []; + } + + const platform = services.platform; + + return [ + restartShortcut({ platform }), + restartShortcut({ platform, forceRebuildNativeApp: true }), + watcherShortcut(), + ]; +} + export const runCommandDefinition = defineCommand({ name: "run|*all", description: "Runs your project on all connected devices and emulators.", @@ -146,6 +173,7 @@ export const runCommandDefinition = defineCommand({ setup: setupRunCommand, canExecute: canExecuteRunCommand, run: runRunCommand, + shortcuts: runCommandShortcuts, }); async function canExecuteApplePlatformRunCommand( @@ -188,6 +216,7 @@ const defineApplePlatformRunCommand = ( setup: setupPlatformRunCommand(platform), canExecute: canExecuteApplePlatformRunCommand, run: runRunCommand, + shortcuts: runCommandShortcuts, }); export const iosRunCommand = defineApplePlatformRunCommand("run|ios", "iOS"); @@ -242,4 +271,5 @@ export const androidRunCommand = defineCommand({ ); }, run: runRunCommand, + shortcuts: runCommandShortcuts, }); diff --git a/lib/common/contracts/key-shortcuts.ts b/lib/common/contracts/key-shortcuts.ts new file mode 100644 index 0000000000..d2aeae1dd6 --- /dev/null +++ b/lib/common/contracts/key-shortcuts.ts @@ -0,0 +1,82 @@ +import { Contract } from "../di/contract"; +import type { Injector } from "../di/injector"; + +/** + * What every shortcut can count on. The context carries state; capabilities + * come from the injector. Callers extend it with the dimensions their own + * tables ask about — nothing in the engine inspects the context beyond handing + * it to `when` and `action`. + */ +export interface KeyContextBase { + injector: Injector; +} + +/** The half of a context its caller owns; the service provides the rest. */ +export type KeyContextExtras = Omit< + TContext, + keyof KeyContextBase +>; + +export interface KeyShortcut { + key: string; + description: string; + group?: string; + /** Availability AND help visibility — one verdict feeds both. */ + when?(ctx: TContext): boolean; + action?(ctx: TContext): void | Promise; + /** + * Suppresses the keypress banner. Set by shortcuts that hand the key to a + * child process, which announces and runs it itself. + */ + quiet?: boolean; +} + +export interface IKeyShortcutService { + /** Returns false when the terminal cannot take raw mode. */ + attach(options: { + context?: KeyContextExtras; + shortcuts: KeyShortcut[]; + }): boolean; + detach(): void; + printHelp(): void; + printHint(): void; +} + +/** What `add` hands back; the only way to take a registration out again. */ +export interface KeyShortcutRegistration { + dispose(): void; +} + +/** + * The shortcuts the running process answers to. Registrations are owned by + * whoever made them: attaching and detaching the engine disposes only the + * batch attach itself registered, so entries a lifecycle registered on its own + * survive until that lifecycle disposes them. + */ +@Contract({ name: "keyShortcutRegistry" }) +export abstract class KeyShortcutRegistry { + /** Later registrations shadow earlier ones per key; disposing restores what was shadowed. */ + abstract add(...shortcuts: KeyShortcut[]): KeyShortcutRegistration; + /** + * Every entry in registration order. The dedupe by key is the reader's, so + * that a disposal exposes what it shadowed without the registry tracking it. + */ + abstract entries(): KeyShortcut[]; +} + +const OFF_VALUES = ["0", "false", "off", "no"]; + +/** Reads an env switch by the convention `NS_KEY_SHORTCUTS` established. */ +export function envSwitchIsOn(value: string): boolean { + return value !== undefined && !OFF_VALUES.includes(value.toLowerCase()); +} + +/** + * Whether a command's declared `shortcuts` are attached when it runs. Off + * unless `NS_COMMAND_SHORTCUTS` says otherwise: a command that takes the + * terminal into raw mode and stays resident is not what a plain `ns run` or + * `ns debug` has ever done. + */ +export function commandShortcutsEnabled(): boolean { + return envSwitchIsOn(process.env.NS_COMMAND_SHORTCUTS); +} diff --git a/lib/common/define-command.ts b/lib/common/define-command.ts index 9c4e3aca83..10730417ef 100644 --- a/lib/common/define-command.ts +++ b/lib/common/define-command.ts @@ -6,6 +6,7 @@ * lib/common/services/command-definition-adapter. */ +import type { KeyShortcut } from "./contracts/key-shortcuts"; import type { Injector } from "./di/injector"; /** @@ -159,6 +160,17 @@ export interface CommandDefinition< context: CommandContext, setupResult: Awaited, ): TResult | Promise; + /** + * The keys the command answers to once `run` has resolved. Attaching keeps + * stdin resumed, which keeps the process alive: declaring shortcuts says the + * command is resident. Entries close over this command's own context and + * setup result; they are attached only for a top-level run, and only while + * `NS_COMMAND_SHORTCUTS` is on. + */ + shortcuts?( + context: CommandContext, + setupResult: Awaited, + ): KeyShortcut[]; /** Runs after `run` succeeds, with whatever `run` returned. */ postRun?( context: CommandContext, @@ -211,6 +223,7 @@ const DEFINITION_FIELDS = [ "enableHooks", "setup", "run", + "shortcuts", "postRun", ]; @@ -242,7 +255,7 @@ const OPTION_TYPES: CommandOptionType[] = [ const ACCEPTED_FORM = 'defineCommand({ name: "widget|add", run(ctx) { ... } }) — with the ' + "optional fields description, options, arguments, allowUnknownOptions, " + - "setup, canExecute, postRun, disableAnalytics and enableHooks."; + "setup, canExecute, shortcuts, postRun, disableAnalytics and enableHooks."; const describeDefinition = (definition: any): string => { const name = definition && definition.name; @@ -475,7 +488,7 @@ const validateDefinition = (definition: any): void => { } } - for (const handler of ["canExecute", "setup", "postRun"]) { + for (const handler of ["canExecute", "setup", "shortcuts", "postRun"]) { if ( definition[handler] !== undefined && typeof definition[handler] !== "function" diff --git a/lib/common/definitions/commands-service.d.ts b/lib/common/definitions/commands-service.d.ts index 26403579b2..c4e9b05fca 100644 --- a/lib/common/definitions/commands-service.d.ts +++ b/lib/common/definitions/commands-service.d.ts @@ -1,5 +1,11 @@ interface ICommandsService { currentCommandData: ICommandData; + /** + * Whether the command running right now was dispatched by + * executeCommandInProcess rather than by the command line — what tells a + * command that it is borrowing a host process instead of owning one. + */ + readonly isExecutingInProcess: boolean; allCommands(opts: { includeDevCommands: boolean }): string[]; tryExecuteCommand( commandName: string, diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index 2741193c4d..cfe0a4ca53 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -11,6 +11,11 @@ import { DeferredCommandResult, describeRejection, } from "../contracts/command-registry"; +import { + commandShortcutsEnabled, + IKeyShortcutService, + KeyShortcut, +} from "../contracts/key-shortcuts"; import { Provider } from "../di/providers"; import { ArgumentSpec, @@ -365,6 +370,46 @@ export function createCommandFromDefinition< return currentInvocation; }; + /** + * Attaching takes the terminal into raw mode and leaves stdin resumed, so it + * is confined to a top-level run: an in-process dispatch borrows the + * terminal of a host that has its own table attached, and replacing it would + * take the host's keys with it. + */ + const attachShortcuts = ( + context: CommandContext, + setupResult: Awaited, + ): void => { + if (!commandShortcutsEnabled()) { + return; + } + + const commandsService = targetInjector.get( + "commandsService", + { optional: true }, + ); + if (commandsService && commandsService.isExecutingInProcess) { + return; + } + + const shortcuts: KeyShortcut[] = runInInjectionContext(targetInjector, () => + definition.shortcuts.call(definition, context, setupResult), + ); + if (!shortcuts || !shortcuts.length) { + return; + } + + const keyShortcutService = targetInjector.get( + "keyShortcutService", + { optional: true }, + ); + if (!keyShortcutService || !keyShortcutService.attach({ shortcuts })) { + return; + } + + keyShortcutService.printHint(); + }; + return { allowedParameters: [], dashedOptions, @@ -428,6 +473,10 @@ export function createCommandFromDefinition< invocation.runResult = await runInInjectionContext(targetInjector, () => definition.run.call(definition, context, setupResult), ); + + if (definition.shortcuts) { + attachShortcuts(context, setupResult); + } }, }; } diff --git a/lib/common/services/commands-service.ts b/lib/common/services/commands-service.ts index c232cc377d..2f57a18e99 100644 --- a/lib/common/services/commands-service.ts +++ b/lib/common/services/commands-service.ts @@ -33,6 +33,11 @@ export class CommandsService implements ICommandsService { } private commands: ICommandData[] = []; + private inProcessDepth: number = 0; + + public get isExecutingInProcess(): boolean { + return this.inProcessDepth > 0; + } constructor( private $errors: IErrors, @@ -238,6 +243,7 @@ export class CommandsService implements ICommandsService { commandName: string, commandArguments: string[] = [], ): Promise { + this.inProcessDepth++; try { const command = this.$injector.resolveCommand(commandName); if (!command) { @@ -272,6 +278,8 @@ export class CommandsService implements ICommandsService { ); throw ex; + } finally { + this.inProcessDepth--; } } diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts index 925ce102c7..2a6137e2e7 100644 --- a/lib/contracts/index.ts +++ b/lib/contracts/index.ts @@ -28,6 +28,13 @@ export type { AbstractType, } from "../common/di/providers"; +export { KeyShortcutRegistry } from "../common/contracts/key-shortcuts"; +export type { + KeyContextBase, + KeyShortcut, + KeyShortcutRegistration, +} from "../common/contracts/key-shortcuts"; + export { ChildProcess } from "./child-process"; export { DevicesService } from "./devices-service"; export { DoctorService } from "./doctor-service"; diff --git a/lib/services/key-shortcut-registry.ts b/lib/services/key-shortcut-registry.ts new file mode 100644 index 0000000000..1c4e7e57d5 --- /dev/null +++ b/lib/services/key-shortcut-registry.ts @@ -0,0 +1,42 @@ +import { + KeyShortcut, + KeyShortcutRegistration, + KeyShortcutRegistry, +} from "../common/contracts/key-shortcuts"; +import { injector } from "../common/yok"; + +/** + * Ordered storage, nothing more: the replace-by-key rule lives in the + * resolution the engine runs, so a batch that shadowed a key exposes the entry + * it shadowed simply by leaving the list. + */ +export class KeyShortcutRegistryService extends KeyShortcutRegistry { + private batches: KeyShortcut[][] = []; + + public add(...shortcuts: KeyShortcut[]): KeyShortcutRegistration { + // Copied so the handle disposes exactly what was registered, whatever the + // caller does with its own array afterwards. + const batch = shortcuts.slice(); + this.batches.push(batch); + + return { + dispose: (): void => { + const at = this.batches.indexOf(batch); + if (at !== -1) { + this.batches.splice(at, 1); + } + }, + }; + } + + public entries(): KeyShortcut[] { + const entries: KeyShortcut[] = []; + for (const batch of this.batches) { + entries.push(...batch); + } + + return entries; + } +} + +injector.register("keyShortcutRegistry", KeyShortcutRegistryService); diff --git a/lib/services/key-shortcuts.ts b/lib/services/key-shortcuts.ts index c1f9bf9d5d..e3b042e802 100644 --- a/lib/services/key-shortcuts.ts +++ b/lib/services/key-shortcuts.ts @@ -2,6 +2,15 @@ import { stripVTControlCharacters } from "node:util"; import { color } from "../color"; import { IChildProcess } from "../common/declarations"; import { Injector } from "../common/di/injector"; +import { + envSwitchIsOn, + IKeyShortcutService, + KeyContextBase, + KeyContextExtras, + KeyShortcut, + KeyShortcutRegistration, + KeyShortcutRegistry, +} from "../common/contracts/key-shortcuts"; import { runCommand } from "../common/services/command-definition-adapter"; import { injector } from "../common/yok"; import { IStartService } from "../definitions/start-service"; @@ -11,45 +20,16 @@ const CTRL_C = "\u0003"; const HELP_KEY = "?"; const WORKFLOW_GROUP = "Development Workflow"; -/** - * What every shortcut can count on. The context carries state; capabilities - * come from the injector. Callers extend it with the dimensions their own - * tables ask about — nothing here inspects the context beyond handing it to - * `when` and `action`. - */ -export interface KeyContextBase { - injector: Injector; -} - -/** The half of a context its caller owns; the service provides the rest. */ -export type KeyContextExtras = Omit< - TContext, - keyof KeyContextBase ->; - -export interface KeyShortcut { - key: string; - description: string; - group?: string; - /** Availability AND help visibility — one verdict feeds both. */ - when?(ctx: TContext): boolean; - action?(ctx: TContext): void | Promise; - /** - * Suppresses the keypress banner. Set by shortcuts that hand the key to a - * child process, which announces and runs it itself. - */ - quiet?: boolean; -} - -export interface IKeyShortcutService { - /** Returns false when the terminal cannot take raw mode. */ - attach(options: { - context?: KeyContextExtras; - shortcuts: KeyShortcut[]; - }): boolean; - detach(): void; - printHelp(): void; -} +// The shortcut vocabulary lives with the registry contract, so that the +// command API can type a `shortcuts` field without reaching into this module. +export type { + IKeyShortcutService, + KeyContextBase, + KeyContextExtras, + KeyShortcut, + KeyShortcutRegistration, +}; +export { KeyShortcutRegistry }; const helpShortcut: KeyShortcut = { key: HELP_KEY, @@ -102,8 +82,6 @@ export function findShortcut( return shortcut; } -const OFF_VALUES = ["0", "false", "off", "no"]; - /** * Raw mode outlives the process that set it, so a CI runner or a redirected * stdin must never get it; `NS_KEY_SHORTCUTS=false` is the manual opt-out. @@ -111,7 +89,7 @@ const OFF_VALUES = ["0", "false", "off", "no"]; export function keyShortcutsEnabled(): boolean { const setting = process.env.NS_KEY_SHORTCUTS; if (setting !== undefined) { - return !OFF_VALUES.includes(setting.toLowerCase()); + return envSwitchIsOn(setting); } if (process.env.CI || process.env.JENKINS_HOME) { @@ -157,14 +135,16 @@ const launch = const restart = async ( ctx: NsKeyContext, + platform: DevicePlatformName, forceRebuildNativeApp: boolean, ): Promise => { const $liveSyncCommandHelper = ctx.injector.get( "liveSyncCommandHelper", ); - const devices = await $liveSyncCommandHelper.getDeviceInstances(ctx.platform); + const target = platform || ctx.platform; + const devices = await $liveSyncCommandHelper.getDeviceInstances(target); - await $liveSyncCommandHelper.executeLiveSyncOperation(devices, ctx.platform, < + await $liveSyncCommandHelper.executeLiveSyncOperation(devices, target, < ILiveSyncCommandHelperAdditionalOptions >{ restartLiveSync: true, @@ -208,6 +188,71 @@ const cleanProject = async (ctx: NsKeyContext): Promise => { }); }; +export interface RestartShortcutOptions { + /** Declares `R`, which always rebuilds the native app, rather than `r`. */ + forceRebuildNativeApp?: boolean; + /** Restricts the restart to one platform; unset takes it from the context. */ + platform?: DevicePlatformName; + /** + * Replaces the restart itself, keeping the key and its help text — for a + * caller whose restart has to carry state the shared one knows nothing of, + * such as an attached debug session. + */ + restart?(forceRebuildNativeApp: boolean): Promise; +} + +/** One half of the restart pair: `r` rebuilds only if needed, `R` always. */ +export function restartShortcut( + options: RestartShortcutOptions = {}, +): KeyShortcut { + const force = options.forceRebuildNativeApp === true; + + return { + key: force ? "R" : "r", + description: force + ? "Force rebuild native app and restart" + : "Rebuild native app if needed and restart", + group: WORKFLOW_GROUP, + action: (ctx) => + options.restart + ? options.restart(force) + : restart(ctx, options.platform, force), + }; +} + +/** Pauses and resumes the file watcher. */ +export function watcherShortcut(): KeyShortcut { + return { + key: "w", + description: "Toggle file watcher", + group: WORKFLOW_GROUP, + action: toggleFileWatcher, + }; +} + +const IDE_SHORTCUTS: { + [K in DevicePlatformName]: { key: string; description: string }; +} = { + Android: { key: "A", description: "Open project in Android Studio" }, + iOS: { key: "I", description: "Open project in Xcode" }, + visionOS: { key: "V", description: "Open project in Xcode" }, +}; + +/** Opens the platform's native project in the IDE that builds it. */ +export function openIdeShortcut( + platform: DevicePlatformName, +): KeyShortcut { + const { key, description } = IDE_SHORTCUTS[platform]; + + return { + key, + description, + group: platform, + when: onPlatform(platform), + action: () => runCommand(`open|${platform.toLowerCase()}`), + }; +} + /** * The shortcuts every interactive process shares. `ns start` appends its own * entries on top of these; the `ns run` children it spawns use them as they @@ -222,13 +267,7 @@ export function keyShortcuts(): KeyShortcut[] { when: duringStart("Android"), action: launch((startService) => startService.runAndroid()), }, - { - key: "A", - description: "Open project in Android Studio", - group: "Android", - when: onPlatform("Android"), - action: () => runCommand("open|android"), - }, + openIdeShortcut("Android"), { key: "i", description: "Run iOS app", @@ -236,13 +275,7 @@ export function keyShortcuts(): KeyShortcut[] { when: duringStart("iOS"), action: launch((startService) => startService.runIOS()), }, - { - key: "I", - description: "Open project in Xcode", - group: "iOS", - when: onPlatform("iOS"), - action: () => runCommand("open|ios"), - }, + openIdeShortcut("iOS"), { key: "v", description: "Run visionOS app", @@ -250,31 +283,10 @@ export function keyShortcuts(): KeyShortcut[] { when: duringStart("visionOS"), action: launch((startService) => startService.runVisionOS()), }, - { - key: "V", - description: "Open project in Xcode", - group: "visionOS", - when: onPlatform("visionOS"), - action: () => runCommand("open|visionos"), - }, - { - key: "r", - description: "Rebuild native app if needed and restart", - group: WORKFLOW_GROUP, - action: (ctx) => restart(ctx, false), - }, - { - key: "R", - description: "Force rebuild native app and restart", - group: WORKFLOW_GROUP, - action: (ctx) => restart(ctx, true), - }, - { - key: "w", - description: "Toggle file watcher", - group: WORKFLOW_GROUP, - action: toggleFileWatcher, - }, + openIdeShortcut("visionOS"), + restartShortcut(), + restartShortcut({ forceRebuildNativeApp: true }), + watcherShortcut(), { key: "c", description: "Clean project", @@ -291,8 +303,8 @@ export function keyShortcuts(): KeyShortcut[] { } export class KeyShortcutService implements IKeyShortcutService { - /** The table as the caller declared it; `when` is applied per read. */ - private shortcuts: KeyShortcut[] = []; + /** The batch `attach` registered, disposed when it is replaced or detached. */ + private attachedShortcuts: KeyShortcutRegistration; private context: KeyContextBase; private running: boolean = false; private attached: boolean = false; @@ -300,6 +312,7 @@ export class KeyShortcutService implements IKeyShortcutService { constructor( private $injector: Injector, private $logger: ILogger, + private $keyShortcutRegistry: KeyShortcutRegistry, ) {} public attach(options: { @@ -309,7 +322,9 @@ export class KeyShortcutService implements IKeyShortcutService { this.detach(); this.context = { ...options.context, injector: this.$injector }; - this.shortcuts = options.shortcuts; + this.attachedShortcuts = this.$keyShortcutRegistry.add( + ...options.shortcuts, + ); const stdin = process.stdin; if (!stdin.isTTY || typeof stdin.setRawMode !== "function") { @@ -321,6 +336,7 @@ export class KeyShortcutService implements IKeyShortcutService { } if (!keyShortcutsEnabled()) { + this.releaseShortcuts(); return false; } @@ -335,6 +351,8 @@ export class KeyShortcutService implements IKeyShortcutService { } public detach(): void { + this.releaseShortcuts(); + if (!this.attached) { return; } @@ -376,12 +394,31 @@ export class KeyShortcutService implements IKeyShortcutService { ); } + /** One compact line where the full table would drown the output. */ + public printHint(): void { + if (!process.stdin.isTTY) { + return; + } + + console.info(color.dim(` › press ${HELP_KEY} to list shortcuts`)); + } + /** - * Help and dispatch each read the table through this one function, so a - * `when` that changes while the process runs moves both together. + * Help and dispatch each read the registry through this one function, so a + * `when` that changes while the process runs — or an entry registered after + * the attach — moves both together. */ private resolve(): KeyShortcut[] { - return resolveShortcuts(this.shortcuts, this.context); + return resolveShortcuts(this.$keyShortcutRegistry.entries(), this.context); + } + + private releaseShortcuts(): void { + if (!this.attachedShortcuts) { + return; + } + + this.attachedShortcuts.dispose(); + this.attachedShortcuts = undefined; } private onData = (data: Buffer): void => { diff --git a/test/define-command.ts b/test/define-command.ts index a6cebf9a8b..ae7f8698cc 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -30,6 +30,7 @@ import { registerCommand, registerLazyCommand, } from "../lib/common/services/command-definition-adapter"; +import type { KeyShortcut } from "../lib/common/contracts/key-shortcuts"; const createTestInjector = (options: any = {}): IInjector => { const testInjector = new Yok(); @@ -532,6 +533,167 @@ describe("defineCommand", () => { }); }); + describe("shortcuts", () => { + let savedSetting: string; + + beforeEach(() => { + savedSetting = process.env.NS_COMMAND_SHORTCUTS; + process.env.NS_COMMAND_SHORTCUTS = "true"; + }); + + afterEach(() => { + if (savedSetting === undefined) { + delete process.env.NS_COMMAND_SHORTCUTS; + } else { + process.env.NS_COMMAND_SHORTCUTS = savedSetting; + } + }); + + const restartEntry: KeyShortcut = { + key: "r", + description: "Restart", + action: (): void => undefined, + }; + + const keyShortcutServiceStub = () => ({ + attached: [], + hints: 0, + attach(options: { shortcuts: KeyShortcut[] }): boolean { + this.attached.push(options.shortcuts.map((shortcut) => shortcut.key)); + return true; + }, + detach: (): void => undefined, + printHelp: (): void => undefined, + printHint(): void { + this.hints++; + }, + }); + + it("attaches the declared table once run resolves", async () => { + const testInjector = createTestInjector(); + const keyShortcutService = keyShortcutServiceStub(); + testInjector.register("keyShortcutService", keyShortcutService); + + let declaredWith: any[]; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-shortcuts", + setup: () => ({ platform: "iOS" }), + run: (): void => undefined, + shortcuts: (context, setupResult) => { + declaredWith = [context.args, setupResult]; + return [restartEntry]; + }, + }), + testInjector, + ); + + await command.execute(["alpha"]); + + assert.deepEqual(keyShortcutService.attached, [["r"]]); + assert.equal(keyShortcutService.hints, 1); + assert.deepEqual(declaredWith, [["alpha"], { platform: "iOS" }]); + }); + + it("attaches nothing while NS_COMMAND_SHORTCUTS is off", async () => { + delete process.env.NS_COMMAND_SHORTCUTS; + const testInjector = createTestInjector(); + const keyShortcutService = keyShortcutServiceStub(); + testInjector.register("keyShortcutService", keyShortcutService); + + let declared = false; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-shortcuts-off", + run: (): void => undefined, + shortcuts: () => { + declared = true; + return [restartEntry]; + }, + }), + testInjector, + ); + + await command.execute([]); + + assert.isFalse(declared); + assert.deepEqual(keyShortcutService.attached, []); + assert.equal(keyShortcutService.hints, 0); + }); + + it("attaches nothing when the table comes back empty", async () => { + const testInjector = createTestInjector(); + const keyShortcutService = keyShortcutServiceStub(); + testInjector.register("keyShortcutService", keyShortcutService); + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-shortcuts-empty", + run: (): void => undefined, + shortcuts: () => [], + }), + testInjector, + ); + + await command.execute([]); + + assert.deepEqual(keyShortcutService.attached, []); + }); + + it("attaches nothing when the run is an in-process dispatch", async () => { + const testInjector = new Yok(); + testInjector.register("errors", { + beginCommand: async (action: () => Promise) => action(), + failWithHelp: (message: string) => { + throw new Error(message); + }, + fail: (message: string) => { + throw new Error(message); + }, + reportCommandError: async (ex: Error) => { + throw ex; + }, + }); + testInjector.register("hooksService", HooksServiceStub); + testInjector.register("logger", LoggerStub); + testInjector.register("staticConfig", { + disableAnalytics: true, + disableCommandHooks: true, + }); + testInjector.register("extensibilityService", {}); + testInjector.register("optionsTracker", {}); + testInjector.register("options", { + validateOptions: (): void => undefined, + }); + testInjector.register("commandsService", CommandsService); + const keyShortcutService = keyShortcutServiceStub(); + testInjector.register("keyShortcutService", keyShortcutService); + + let ran = false; + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: "dctest-shortcuts-in-process", + run: () => { + ran = true; + }, + shortcuts: () => [restartEntry], + }), + ), + ); + + const commandsService: ICommandsService = + testInjector.resolve("commandsService"); + await commandsService.executeCommandInProcess( + "dctest-shortcuts-in-process", + ); + + assert.isTrue(ran); + assert.deepEqual(keyShortcutService.attached, []); + assert.isFalse(commandsService.isExecutingInProcess); + }); + }); + describe("dashedOptions", () => { it("compiles the schema into the shape the option parser expects", () => { const command = createCommandFromDefinition( diff --git a/test/services/key-shortcut-registry.ts b/test/services/key-shortcut-registry.ts new file mode 100644 index 0000000000..53783f4a31 --- /dev/null +++ b/test/services/key-shortcut-registry.ts @@ -0,0 +1,87 @@ +import { assert } from "chai"; +import { Injector } from "../../lib/common/di/injector"; +import { KeyShortcutRegistryService } from "../../lib/services/key-shortcut-registry"; +import { + KeyContextBase, + KeyShortcut, + resolveShortcuts, +} from "../../lib/services/key-shortcuts"; + +const entry = (key: string, description: string): KeyShortcut => ({ + key, + description, + action: (): void => undefined, +}); + +const context = (): KeyContextBase => ({ injector: ({}) }); + +const keysOf = (registry: KeyShortcutRegistryService): string[] => + registry.entries().map((shortcut) => shortcut.key); + +/** What a reader of the registry ends up dispatching, help entry aside. */ +const resolvedDescriptions = (registry: KeyShortcutRegistryService): string[] => + resolveShortcuts(registry.entries(), context()) + .filter((shortcut) => shortcut.key !== "?") + .map((shortcut) => shortcut.description); + +describe("KeyShortcutRegistryService", () => { + let registry: KeyShortcutRegistryService; + + beforeEach(() => { + registry = new KeyShortcutRegistryService(); + }); + + it("hands out every entry in registration order", () => { + registry.add(entry("r", "Restart"), entry("w", "Watcher")); + registry.add(entry("c", "Clean")); + + assert.deepEqual(keysOf(registry), ["r", "w", "c"]); + }); + + it("takes a batch out again when its handle is disposed", () => { + const first = registry.add(entry("r", "Restart")); + registry.add(entry("w", "Watcher")); + + first.dispose(); + + assert.deepEqual(keysOf(registry), ["w"]); + }); + + it("lets a later registration shadow an earlier one for the same key", () => { + registry.add(entry("r", "Restart")); + registry.add(entry("r", "Restart with the debugger attached")); + + assert.deepEqual(resolvedDescriptions(registry), [ + "Restart with the debugger attached", + ]); + }); + + it("restores what a batch shadowed when it is disposed", () => { + registry.add(entry("r", "Restart")); + const shadowing = registry.add(entry("r", "Restart with the debugger")); + + shadowing.dispose(); + + assert.deepEqual(resolvedDescriptions(registry), ["Restart"]); + }); + + it("does nothing on a second dispose", () => { + const first = registry.add(entry("r", "Restart")); + registry.add(entry("w", "Watcher")); + + first.dispose(); + first.dispose(); + + assert.deepEqual(keysOf(registry), ["w"]); + }); + + it("disposes exactly what was registered, whatever the caller's array does", () => { + const shortcuts = [entry("r", "Restart")]; + const registration = registry.add(...shortcuts); + shortcuts.push(entry("w", "Watcher")); + + registration.dispose(); + + assert.deepEqual(keysOf(registry), []); + }); +}); diff --git a/test/services/key-shortcuts.ts b/test/services/key-shortcuts.ts index 89c1c3fbcb..0890a3ce29 100644 --- a/test/services/key-shortcuts.ts +++ b/test/services/key-shortcuts.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "events"; import { runInInjectionContext } from "../../lib/common/di/inject"; import { Injector } from "../../lib/common/di/injector"; import { runCommand } from "../../lib/common/services/command-definition-adapter"; +import { KeyShortcutRegistryService } from "../../lib/services/key-shortcut-registry"; import { findShortcut, KeyContextBase, @@ -299,6 +300,7 @@ describe("key shortcuts", () => { let restoreConsole: () => void; let savedSetting: string; let registrations: Map; + let registry: KeyShortcutRegistryService; beforeEach(() => { savedSetting = process.env.NS_KEY_SHORTCUTS; @@ -311,11 +313,14 @@ describe("key shortcuts", () => { info = []; echoed = []; registrations = new Map(); - service = new KeyShortcutService(fakeInjector(registrations), (< - any - >{ - error: (message: string) => errors.push(message), - })); + registry = new KeyShortcutRegistryService(); + service = new KeyShortcutService( + fakeInjector(registrations), + ({ + error: (message: string) => errors.push(message), + }), + registry, + ); registrations.set("keyShortcutService", service); const originalInfo = console.info; @@ -640,6 +645,84 @@ describe("key shortcuts", () => { assert.deepEqual(ran, ["x"]); assert.deepEqual(echoed, ["x"]); }); + + it("takes the table it attached out of the registry when it detaches", () => { + service.attach({ + shortcuts: [{ key: "x", description: "Attached", action: noop }], + }); + assert.deepEqual(keysOf(registry.entries()), ["x"]); + + service.detach(); + + assert.deepEqual(registry.entries(), []); + }); + + it("replaces its own table when it attaches again", () => { + service.attach({ + shortcuts: [{ key: "x", description: "First", action: noop }], + }); + service.attach({ + shortcuts: [{ key: "y", description: "Second", action: noop }], + }); + + assert.deepEqual(keysOf(registry.entries()), ["y"]); + }); + + it("registers nothing when it declines to attach", () => { + process.env.NS_KEY_SHORTCUTS = "false"; + + assert.isFalse( + service.attach({ + shortcuts: [{ key: "x", description: "Declined", action: noop }], + }), + ); + + assert.deepEqual(registry.entries(), []); + }); + + it("dispatches and lists an entry registered after the attach", async () => { + const ran: string[] = []; + service.attach({ shortcuts: [] }); + + registry.add({ + key: "x", + description: "Registered late", + action: () => void ran.push("x"), + }); + + service.printHelp(); + await press("x"); + + assert.deepEqual(ran, ["x"]); + assert.include(info.join("\n"), "Registered late"); + }); + + it("leaves registrations it does not own alone across an attach cycle", () => { + const kept = registry.add({ + key: "x", + description: "Owned elsewhere", + action: noop, + }); + + service.attach({ + shortcuts: [{ key: "y", description: "Attached", action: noop }], + }); + service.detach(); + + assert.deepEqual(keysOf(registry.entries()), ["x"]); + + kept.dispose(); + assert.deepEqual(registry.entries(), []); + }); + + it("hints at the help key, and stays quiet without a terminal", () => { + service.printHint(); + stdin.isTTY = false; + service.printHint(); + + assert.lengthOf(info, 1); + assert.include(info[0], "press ? to list shortcuts"); + }); }); describe("runCommand", () => { diff --git a/test/stubs.ts b/test/stubs.ts index 857336ba85..4b28326a29 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -1308,6 +1308,7 @@ export class ProjectChangesService implements IProjectChangesService { export class CommandsService implements ICommandsService { public currentCommandData = { commandName: "test", commandArguments: [""] }; + public isExecutingInProcess = false; public allCommands(opts: { includeDevCommands: boolean }): string[] { return []; From 53551c71bda07cfba92673b82bf95afd2dbfd352 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 15:54:36 -0300 Subject: [PATCH 07/19] fix(bundler): tear the bundler down before starting another one Stopping a bundler only sent SIGINT and returned, so a restart could spawn a replacement while the old watcher was still alive. The stale child's exit then evicted the replacement's map entry, and a compilation finishing on it still reached the prepare controller. Await the child's exit (escalating to SIGKILL), detach its output and IPC handlers, and key every eviction on process identity. The prepare controller now keeps its compilation handler per platform, so stopping one platform no longer leaves the other's listener attached. --- lib/controllers/prepare-controller.ts | 32 ++-- .../bundler/bundler-compiler-service.ts | 105 +++++++++-- .../bundler/bundler-compiler-service.ts | 167 ++++++++++++++++++ 3 files changed, 278 insertions(+), 26 deletions(-) diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index 26e480db97..4cc149ba04 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -44,6 +44,8 @@ import { resolvePackageJSONPath } from "@rigor789/resolve-package-path"; interface IPlatformWatcherData { hasWebpackCompilerProcess: boolean; + /** Kept per platform: one process watches several of them at a time. */ + bundlerCompilerHandler: (data: any) => void; nativeFilesWatcher: FSWatcher; prepareArguments: { prepareData: IPrepareData; @@ -59,7 +61,6 @@ export class PrepareController private watchersData: IDictionary> = {}; private isInitialPrepareReady = false; private persistedData: IFilesChangeEventData[] = []; - private webpackCompilerHandler: any = null; private pausedFileWatch: boolean = false; constructor( @@ -125,14 +126,16 @@ export class PrepareController this.watchersData[projectDir][platformLowerCase] && this.watchersData[projectDir][platformLowerCase].hasWebpackCompilerProcess ) { + const watcherData = this.watchersData[projectDir][platformLowerCase]; await this.$bundlerCompilerService.stopBundlerCompiler(platformLowerCase); - this.$bundlerCompilerService.removeListener( - BUNDLER_COMPILATION_COMPLETE, - this.webpackCompilerHandler, - ); - this.watchersData[projectDir][ - platformLowerCase - ].hasWebpackCompilerProcess = false; + if (watcherData.bundlerCompilerHandler) { + this.$bundlerCompilerService.removeListener( + BUNDLER_COMPILATION_COMPLETE, + watcherData.bundlerCompilerHandler, + ); + watcherData.bundlerCompilerHandler = null; + } + watcherData.hasWebpackCompilerProcess = false; } } @@ -237,6 +240,7 @@ export class PrepareController ] = { nativeFilesWatcher: null, hasWebpackCompilerProcess: false, + bundlerCompilerHandler: null, prepareArguments: { platformData, projectData, @@ -303,15 +307,17 @@ export class PrepareController } }; - this.webpackCompilerHandler = handler.bind(this); + const watcherData = + this.watchersData[projectData.projectDir][ + platformData.platformNameLowerCase + ]; + watcherData.bundlerCompilerHandler = handler.bind(this); this.$bundlerCompilerService.on( BUNDLER_COMPILATION_COMPLETE, - this.webpackCompilerHandler, + watcherData.bundlerCompilerHandler, ); - this.watchersData[projectData.projectDir][ - platformData.platformNameLowerCase - ].hasWebpackCompilerProcess = true; + watcherData.hasWebpackCompilerProcess = true; await this.$bundlerCompilerService.compileWithWatch( platformData, projectData, diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index 04e73041ea..ccf7df4bb7 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -56,6 +56,9 @@ interface IBundlerCompilation { /* for specific bundling debugging separate from logger */ const debugLog = false; +/** Grace period a bundler child gets to honour SIGINT before it is killed. */ +const BUNDLER_STOP_TIMEOUT_MS = 5000; + export class BundlerCompilerService extends EventEmitter implements IBundlerCompilerService @@ -382,7 +385,10 @@ export class BundlerCompilerService this.$logger.trace( `Unable to start ${projectData.bundler} process in watch mode. Error is: ${err}`, ); - delete this.bundlerProcesses[platformData.platformNameLowerCase]; + this.forgetBundlerProcess( + platformData.platformNameLowerCase, + childProcess, + ); reject(err); }); @@ -399,7 +405,10 @@ export class BundlerCompilerService `Executing ${projectData.bundler} failed with exit code ${exitCode}.`, ); error.code = exitCode; - delete this.bundlerProcesses[platformData.platformNameLowerCase]; + this.forgetBundlerProcess( + platformData.platformNameLowerCase, + childProcess, + ); reject(error); }); } catch (err) { @@ -430,7 +439,10 @@ export class BundlerCompilerService this.$logger.trace( `Unable to start ${projectData.bundler} process in non-watch mode. Error is: ${err}`, ); - delete this.bundlerProcesses[platformData.platformNameLowerCase]; + this.forgetBundlerProcess( + platformData.platformNameLowerCase, + childProcess, + ); reject(err); }); @@ -441,7 +453,10 @@ export class BundlerCompilerService childProcess.pid.toString(), ); - delete this.bundlerProcesses[platformData.platformNameLowerCase]; + this.forgetBundlerProcess( + platformData.platformNameLowerCase, + childProcess, + ); const exitCode = typeof arg === "number" ? arg : arg && arg.code; if (exitCode === 0) { // Non-watch Vite builds spawn the child with stdio:"inherit" @@ -748,7 +763,9 @@ export class BundlerCompilerService await this.$cleanupService.addKillProcess(childProcess.pid.toString()); childProcess.once("exit", (code: number) => { - delete this.viteServeProcesses[key]; + if (this.viteServeProcesses[key] === childProcess) { + delete this.viteServeProcesses[key]; + } if (code) { this.$logger.warn( `Vite dev server for ${key} exited with code ${code}.`, @@ -1000,22 +1017,84 @@ export class BundlerCompilerService this.$logger.trace( `Stopping ${this.getBundler()} watch for platform ${platform}.`, ); + const bundlerProcess = this.bundlerProcesses[platform]; - await this.$cleanupService.removeKillProcess(bundlerProcess.pid.toString()); if (bundlerProcess) { - bundlerProcess.kill("SIGINT"); - delete this.bundlerProcesses[platform]; + // A compilation already in flight can still reach us between the + // kill and the exit; nothing downstream may act on output from a + // watcher the caller has torn down. + bundlerProcess.removeAllListeners("message"); + bundlerProcess.stdout?.removeAllListeners("data"); + bundlerProcess.stderr?.removeAllListeners("data"); + + await this.terminate(bundlerProcess); + this.forgetBundlerProcess(platform, bundlerProcess); } // Tear down the Vite dev server we manage alongside the build watcher. const viteServeProcess = this.viteServeProcesses[platform]; if (viteServeProcess) { - await this.$cleanupService.removeKillProcess( - viteServeProcess.pid.toString(), - ); - viteServeProcess.kill("SIGINT"); - delete this.viteServeProcesses[platform]; + await this.terminate(viteServeProcess); + if (this.viteServeProcesses[platform] === viteServeProcess) { + delete this.viteServeProcesses[platform]; + } + } + } + + /** + * Drops a platform's entry only while it still points at `childProcess`, so + * an exit arriving after a restart cannot evict the replacement watcher. + */ + private forgetBundlerProcess( + platform: string, + childProcess: child_process.ChildProcess, + ): void { + if (this.bundlerProcesses[platform] === childProcess) { + delete this.bundlerProcesses[platform]; + } + } + + /** + * Resolves once the child is gone, so a caller that restarts the bundler + * cannot spawn a replacement while the old one still holds the watch. + */ + private async terminate( + childProcess: child_process.ChildProcess, + timeoutMs: number = BUNDLER_STOP_TIMEOUT_MS, + ): Promise { + await this.$cleanupService.removeKillProcess(childProcess.pid.toString()); + + childProcess.kill("SIGINT"); + if (await this.waitForExit(childProcess, timeoutMs)) { + return; } + + this.$logger.trace( + `Process ${childProcess.pid} did not exit on SIGINT within ${timeoutMs}ms; sending SIGKILL.`, + ); + childProcess.kill("SIGKILL"); + await this.waitForExit(childProcess, timeoutMs); + } + + private waitForExit( + childProcess: child_process.ChildProcess, + timeoutMs: number, + ): Promise { + return new Promise((resolve) => { + const settle = (exited: boolean) => { + clearTimeout(timer); + childProcess.removeListener("exit", onExit); + childProcess.removeListener("close", onExit); + resolve(exited); + }; + const onExit = () => settle(true); + const timer = setTimeout(() => settle(false), timeoutMs); + // A pending timer must not be what keeps the CLI alive. + timer.unref?.(); + + childProcess.once("exit", onExit); + childProcess.once("close", onExit); + }); } private handleHMRMessage( diff --git a/test/services/bundler/bundler-compiler-service.ts b/test/services/bundler/bundler-compiler-service.ts index ba6a0880a6..1ec3a6b057 100644 --- a/test/services/bundler/bundler-compiler-service.ts +++ b/test/services/bundler/bundler-compiler-service.ts @@ -24,6 +24,34 @@ function getAllEmittedFiles(hash: string) { ]; } +type FakeChildProcess = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + pid: number; + killSignals: string[]; + kill(signal?: string): boolean; +}; + +function fakeChildProcess(pid: number): FakeChildProcess { + const childProcess = new EventEmitter() as FakeChildProcess; + childProcess.stdout = new EventEmitter(); + childProcess.stderr = new EventEmitter(); + childProcess.pid = pid; + childProcess.killSignals = []; + childProcess.kill = (signal?: string) => { + childProcess.killSignals.push(signal); + return true; + }; + + return childProcess; +} + +const flush = async (): Promise => { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } +}; + function createTestInjector( packageManager: PackageManagers = PackageManagers.npm, ): IInjector { @@ -513,4 +541,143 @@ describe("BundlerCompilerService", () => { ); }); }); + + describe("stopBundlerCompiler", () => { + const platformData = { + platformNameLowerCase: "ios", + appDestinationDirectoryPath: "/platform/app", + }; + const projectData = { + projectDir: "/project", + bundler: "vite", + bundlerConfigPath: "/project/vite.config.ts", + }; + + function registerOnStart(childProcess: FakeChildProcess): void { + (bundlerCompilerService).startBundleProcess = async () => { + (bundlerCompilerService).bundlerProcesses[ + platformData.platformNameLowerCase + ] = childProcess; + return childProcess; + }; + } + + it("does not resolve until the bundler child has exited", async () => { + const childProcess = fakeChildProcess(111); + (bundlerCompilerService).bundlerProcesses.ios = childProcess; + + let stopped = false; + const stopping = bundlerCompilerService + .stopBundlerCompiler("ios") + .then(() => (stopped = true)); + + await flush(); + assert.deepStrictEqual(childProcess.killSignals, ["SIGINT"]); + assert.isFalse(stopped); + + childProcess.emit("close", 0); + await stopping; + + assert.isTrue(stopped); + assert.isUndefined((bundlerCompilerService).bundlerProcesses.ios); + }); + + it("kills a child that ignores SIGINT", async () => { + const childProcess = fakeChildProcess(222); + + await (bundlerCompilerService).terminate(childProcess, 1); + + assert.deepStrictEqual(childProcess.killSignals, ["SIGINT", "SIGKILL"]); + }); + + it("does nothing when no bundler is running for the platform", async () => { + await bundlerCompilerService.stopBundlerCompiler("ios"); + + assert.isUndefined((bundlerCompilerService).bundlerProcesses.ios); + }); + + it("keeps a replacement watcher when the stopped child exits late", async () => { + const first = fakeChildProcess(11); + const replacement = fakeChildProcess(12); + (bundlerCompilerService).bundlerProcesses.ios = first; + + const stopping = bundlerCompilerService.stopBundlerCompiler("ios"); + await flush(); + (bundlerCompilerService).bundlerProcesses.ios = replacement; + + first.emit("close", 0); + await stopping; + + assert.strictEqual( + (bundlerCompilerService).bundlerProcesses.ios, + replacement, + ); + }); + + it("keeps a replacement watcher when a closing child runs its own handler", async () => { + const first = fakeChildProcess(21); + const replacement = fakeChildProcess(22); + + testInjector.resolve("options").hostProjectModuleName = "app"; + (bundlerCompilerService).getBundler = () => "vite"; + (bundlerCompilerService).copyViteBundleToNative = () => ({}); + registerOnStart(first); + + const compilation = bundlerCompilerService.compileWithWatch( + platformData, + projectData, + { hmr: false }, + ); + await flush(); + first.emit("message", { emittedFiles: ["bundle.mjs"], hash: "hash-1" }); + await compilation; + + (bundlerCompilerService).bundlerProcesses.ios = replacement; + first.emit("close", 1); + await flush(); + + assert.strictEqual( + (bundlerCompilerService).bundlerProcesses.ios, + replacement, + ); + }); + + it("drops compilations produced by a child it has stopped", async () => { + const childProcess = fakeChildProcess(33); + + testInjector.resolve("options").hostProjectModuleName = "app"; + (bundlerCompilerService).getBundler = () => "vite"; + (bundlerCompilerService).copyViteBundleToNative = () => ({}); + registerOnStart(childProcess); + + const emittedEvents: any[] = []; + bundlerCompilerService.on(BUNDLER_COMPILATION_COMPLETE, (data) => + emittedEvents.push(data), + ); + + const compilation = bundlerCompilerService.compileWithWatch( + platformData, + projectData, + { hmr: false }, + ); + await flush(); + childProcess.emit("message", { + emittedFiles: ["bundle.mjs"], + hash: "hash-1", + }); + await compilation; + + const stopping = bundlerCompilerService.stopBundlerCompiler("ios"); + await flush(); + childProcess.emit("close", 0); + await stopping; + + childProcess.emit("message", { + emittedFiles: ["bundle.mjs"], + hash: "hash-2", + }); + + assert.lengthOf(emittedEvents, 0); + }); + }); }); From 2b1ee61db62adfa93db350123001939255a9e704 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 16:44:52 -0300 Subject: [PATCH 08/19] feat(key-shortcuts): r/R/B restart ladder, hint reprint, session scoping r restarts the app of the running session without preparing, building or syncing; R prepares again first and rebuilds the native app only if needed; B always rebuilds it. The help hint is repeated once a burst of syncs settles, and a restart stays on the devices the session was given instead of every device attached to the platform. --- lib/commands/debug.ts | 15 +- lib/commands/run.ts | 1 + lib/controllers/run-controller.ts | 107 +++++++++ lib/definitions/livesync.d.ts | 76 +++--- lib/definitions/run.d.ts | 13 +- lib/helpers/livesync-command-helper.ts | 12 +- lib/services/key-shortcuts.ts | 131 +++++++++- lib/services/livesync-process-data-service.ts | 12 +- lib/services/start-service.ts | 1 + test/controllers/run-controller.ts | 136 +++++++++++ test/helpers/livesync-command-helper.ts | 101 ++++++++ test/services/key-shortcuts.ts | 227 +++++++++++++++++- 12 files changed, 771 insertions(+), 61 deletions(-) create mode 100644 test/helpers/livesync-command-helper.ts diff --git a/lib/commands/debug.ts b/lib/commands/debug.ts index a0cc1a5157..9da5a8aeb1 100644 --- a/lib/commands/debug.ts +++ b/lib/commands/debug.ts @@ -180,9 +180,13 @@ export async function runDebugCommand( return; } - // The device map is what keeps the debugger attached across a restart, so - // the shared restart — which knows nothing of it — cannot stand in here. - const restartDebugSession = (forceRebuildNativeApp: boolean): Promise => + // The device map is what keeps the debugger attached across a re-prepare, + // so the shared restart — which knows nothing of it — cannot stand in here. + // The plain app restart needs no stand-in: it goes through the run + // controller, whose persisted descriptor already has debugging enabled. + const restartDebugSession = ( + forceRebuildNativeApp: boolean = false, + ): Promise => services.$liveSyncCommandHelper.executeLiveSyncOperation( [selectedDeviceForDebug], services.platform, @@ -193,10 +197,11 @@ export async function runDebugCommand( ); context.injector.get(KeyShortcutRegistry).add( - restartShortcut({ restart: restartDebugSession }), + restartShortcut(), + restartShortcut({ full: true, restart: () => restartDebugSession() }), restartShortcut({ forceRebuildNativeApp: true, - restart: restartDebugSession, + restart: () => restartDebugSession(true), }), watcherShortcut(), ); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index bccc99f240..1e72107be6 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -159,6 +159,7 @@ export function runCommandShortcuts( return [ restartShortcut({ platform }), + restartShortcut({ platform, full: true }), restartShortcut({ platform, forceRebuildNativeApp: true }), watcherShortcut(), ]; diff --git a/lib/controllers/run-controller.ts b/lib/controllers/run-controller.ts index 925ffd3fc7..93496bcd66 100644 --- a/lib/controllers/run-controller.ts +++ b/lib/controllers/run-controller.ts @@ -84,6 +84,7 @@ export class RunController extends EventEmitter implements IRunController { projectDir, deviceDescriptors, platforms, + liveSyncInfo, ); const shouldStartWatcher = @@ -233,6 +234,112 @@ export class RunController extends EventEmitter implements IRunController { ); } + /** + * Restarts the application of a running session without preparing, + * building or syncing anything. Queued on the session's action chain so it + * cannot overtake a sync that is already under way, and routed through + * `refreshApplication` so a debug session gets its debugger back. + */ + public async restartApplication( + data: IRestartApplicationData, + ): Promise { + const { projectDir, deviceIdentifiers } = data; + const liveSyncProcessInfo = + this.$liveSyncProcessDataService.getPersistedData(projectDir); + + if (!liveSyncProcessInfo || liveSyncProcessInfo.isStopped) { + this.$logger.info( + "There is no running application to restart. Start a run or debug session first.", + ); + return; + } + + const deviceDescriptors = ( + liveSyncProcessInfo.deviceDescriptors || [] + ).filter( + (descriptor) => + !deviceIdentifiers || + !deviceIdentifiers.length || + _.includes(deviceIdentifiers, descriptor.identifier), + ); + + if (!deviceDescriptors.length) { + this.$logger.info("There is no device to restart the application on."); + return; + } + + const projectData = this.$projectDataService.getProjectData(projectDir); + const useHotModuleReload = + !!liveSyncProcessInfo.liveSyncInfo?.useHotModuleReload; + + const deviceAction = async (device: Mobile.IDevice) => { + const deviceDescriptor = _.find( + deviceDescriptors, + (dd) => dd.identifier === device.deviceInfo.identifier, + ); + + try { + const platformLiveSyncService = + this.$liveSyncServiceResolver.resolveLiveSyncService( + device.deviceInfo.platform, + ); + const deviceAppData = await platformLiveSyncService.getAppData({ + device, + watch: true, + projectData, + liveSyncDeviceData: deviceDescriptor, + useHotModuleReload, + }); + + await this.refreshApplication( + projectData, + { + deviceAppData, + modifiedFilesData: [], + isFullSync: false, + useHotModuleReload, + }, + // Neither a hot update nor a native change, which is what + // `refreshApplicationWithoutDebug` reads as "restart". + { + files: [], + staleFiles: [], + hasOnlyHotUpdateFiles: false, + hasNativeChanges: false, + hmrData: null, + platform: device.deviceInfo.platform.toLowerCase(), + }, + deviceDescriptor, + ); + } catch (err) { + this.$logger.warn( + `Unable to restart the application on device: ${device.deviceInfo.identifier}. Error is: ${err.message || err}.`, + ); + this.$logger.trace(err); + + this.emitCore(RunOnDeviceEvents.runOnDeviceError, { + projectDir: projectData.projectDir, + deviceIdentifier: device.deviceInfo.identifier, + applicationIdentifier: + projectData.projectIdentifiers[ + device.deviceInfo.platform.toLowerCase() + ], + error: err, + }); + } + }; + + await this.addActionToChain(projectDir, () => + this.$devicesService.execute(deviceAction, (device: Mobile.IDevice) => + _.some( + deviceDescriptors, + (deviceDescriptor) => + deviceDescriptor.identifier === device.deviceInfo.identifier, + ), + ), + ); + } + protected async refreshApplication( projectData: IProjectData, liveSyncResultInfo: ILiveSyncResultInfo, diff --git a/lib/definitions/livesync.d.ts b/lib/definitions/livesync.d.ts index faba420116..5def3b3910 100644 --- a/lib/definitions/livesync.d.ts +++ b/lib/definitions/livesync.d.ts @@ -24,6 +24,11 @@ declare global { deviceDescriptors: ILiveSyncDeviceDescriptor[]; currentSyncAction: Promise; platforms: string[]; + /** + * How the session was started, for operations that act on the running + * app without a file change to take their settings from. + */ + liveSyncInfo?: ILiveSyncInfo; } interface IOptionalOutputPath { @@ -94,10 +99,7 @@ declare global { * Describes a LiveSync operation. */ interface ILiveSyncInfo - extends IProjectDir, - IEnvOptions, - IRelease, - IHasUseHotModuleReloadOption { + extends IProjectDir, IEnvOptions, IRelease, IHasUseHotModuleReloadOption { emulator?: boolean; /** @@ -164,7 +166,7 @@ declare global { */ liveSync( deviceDescriptors: ILiveSyncDeviceDescriptor[], - liveSyncData: ILiveSyncInfo + liveSyncData: ILiveSyncInfo, ): Promise; /** @@ -177,7 +179,7 @@ declare global { stopLiveSync( projectDir: string, deviceIdentifiers?: string[], - stopOptions?: { shouldAwaitAllActions: boolean } + stopOptions?: { shouldAwaitAllActions: boolean }, ): Promise; /** @@ -188,7 +190,7 @@ declare global { * @returns {ILiveSyncDeviceDescriptor[]} Array of elements describing parameters used to start LiveSync on each device. */ getLiveSyncDeviceDescriptors( - projectDir: string + projectDir: string, ): ILiveSyncDeviceDescriptor[]; } @@ -205,8 +207,7 @@ declare global { } interface IEnableDebuggingData - extends IProjectDir, - IOptionalDebuggingOptions { + extends IProjectDir, IOptionalDebuggingOptions { deviceIdentifiers: string[]; } @@ -215,7 +216,8 @@ declare global { } interface IAttachDebuggerData - extends IProjectDir, + extends + IProjectDir, Mobile.IDeviceIdentifier, IOptionalDebuggingOptions, IIsEmulator, @@ -238,7 +240,8 @@ declare global { } interface ILiveSyncWatchInfo - extends IProjectDataComposition, + extends + IProjectDataComposition, IHasUseHotModuleReloadOption, IConnectTimeoutOption { filesToRemove: string[]; @@ -258,11 +261,11 @@ declare global { } interface IAndroidLiveSyncResultInfo - extends ILiveSyncResultInfo, - IAndroidLivesyncSyncOperationResult {} + extends ILiveSyncResultInfo, IAndroidLivesyncSyncOperationResult {} interface IFullSyncInfo - extends IProjectDataComposition, + extends + IProjectDataComposition, IHasUseHotModuleReloadOption, IConnectTimeoutOption { device: Mobile.IDevice; @@ -285,28 +288,28 @@ declare global { fullSync(syncInfo: IFullSyncInfo): Promise; liveSyncWatchAction( device: Mobile.IDevice, - liveSyncInfo: ILiveSyncWatchInfo + liveSyncInfo: ILiveSyncWatchInfo, ): Promise; tryRefreshApplication( projectData: IProjectData, - liveSyncInfo: ILiveSyncResultInfo + liveSyncInfo: ILiveSyncResultInfo, ): Promise; restartApplication( projectData: IProjectData, - liveSyncInfo: ILiveSyncResultInfo + liveSyncInfo: ILiveSyncResultInfo, ): Promise; shouldRestart( projectData: IProjectData, - liveSyncInfo: ILiveSyncResultInfo + liveSyncInfo: ILiveSyncResultInfo, ): Promise; getDeviceLiveSyncService( device: Mobile.IDevice, - projectData: IProjectData + projectData: IProjectData, ): INativeScriptDeviceLiveSyncService; getAppData(syncInfo: IFullSyncInfo): Promise; syncAfterInstall( device: Mobile.IDevice, - liveSyncInfo: ILiveSyncWatchInfo + liveSyncInfo: ILiveSyncWatchInfo, ): Promise; } @@ -325,7 +328,7 @@ declare global { */ tryRefreshApplication( projectData: IProjectData, - liveSyncInfo: ILiveSyncResultInfo + liveSyncInfo: ILiveSyncResultInfo, ): Promise; /** @@ -333,7 +336,7 @@ declare global { */ restartApplication( projectData: IProjectData, - liveSyncInfo: ILiveSyncResultInfo + liveSyncInfo: ILiveSyncResultInfo, ): Promise; /** @@ -341,7 +344,7 @@ declare global { */ shouldRestart( projectData: IProjectData, - liveSyncInfo: ILiveSyncResultInfo + liveSyncInfo: ILiveSyncResultInfo, ): Promise; /** @@ -354,7 +357,7 @@ declare global { removeFiles( deviceAppData: Mobile.IDeviceAppData, localToDevicePaths: Mobile.ILocalToDevicePathData[], - projectFilesPath?: string + projectFilesPath?: string, ): Promise; /** @@ -371,12 +374,11 @@ declare global { projectFilesPath: string, projectData: IProjectData, liveSyncDeviceData: ILiveSyncDeviceDescriptor, - options: ITransferFilesOptions + options: ITransferFilesOptions, ): Promise; } - interface IAndroidNativeScriptDeviceLiveSyncService - extends INativeScriptDeviceLiveSyncService { + interface IAndroidNativeScriptDeviceLiveSyncService extends INativeScriptDeviceLiveSyncService { /** * Guarantees all remove/update operations have finished * @param {ILiveSyncResultInfo} liveSyncInfo Describes the LiveSync operation - for which project directory is the operation and other settings. @@ -384,7 +386,7 @@ declare global { */ finalizeSync( liveSyncInfo: ILiveSyncResultInfo, - projectData: IProjectData + projectData: IProjectData, ): Promise; } @@ -441,7 +443,7 @@ declare global { * @returns {Promise} */ sendDoSyncOperation( - options?: IDoSyncOperationOptions + options?: IDoSyncOperationOptions, ): Promise; /** * Generates new operation identifier. @@ -513,7 +515,7 @@ declare global { interface IDevicePathProvider { getDeviceProjectRootPath( device: Mobile.IDevice, - options: IDeviceProjectRootOptions + options: IDeviceProjectRootOptions, ): Promise; getDeviceSyncZipPath(device: Mobile.IDevice): string; } @@ -522,8 +524,7 @@ declare global { * Describes additional options, that can be passed to LiveSyncCommandHelper. */ interface ILiveSyncCommandHelperAdditionalOptions - extends IBuildPlatformAction, - INativePrepare { + extends IBuildPlatformAction, INativePrepare { /** * A map representing devices which have debugging enabled initially. */ @@ -548,7 +549,7 @@ declare global { executeLiveSyncOperation( devices: Mobile.IDevice[], platform: string, - additionalOptions?: ILiveSyncCommandHelperAdditionalOptions + additionalOptions?: ILiveSyncCommandHelperAdditionalOptions, ): Promise; getPlatformsForOperation(platform: string): string[]; @@ -558,7 +559,7 @@ declare global { * @return {Promise} */ validatePlatform( - platform: string + platform: string, ): Promise>; /** @@ -569,12 +570,12 @@ declare global { */ executeCommandLiveSync( platform?: string, - additionalOptions?: ILiveSyncCommandHelperAdditionalOptions + additionalOptions?: ILiveSyncCommandHelperAdditionalOptions, ): Promise; createDeviceDescriptors( devices: Mobile.IDevice[], platform: string, - additionalOptions?: ILiveSyncCommandHelperAdditionalOptions + additionalOptions?: ILiveSyncCommandHelperAdditionalOptions, ): Promise; getDeviceInstances(platform?: string): Promise; getLiveSyncData(projectDir: string): ILiveSyncInfo; @@ -593,7 +594,8 @@ declare global { persistData( projectDir: string, deviceDescriptors: ILiveSyncDeviceDescriptor[], - platforms: string[] + platforms: string[], + liveSyncInfo?: ILiveSyncInfo, ): void; hasDeviceDescriptors(projectDir: string): boolean; getPlatforms(projectDir: string): string[]; diff --git a/lib/definitions/run.d.ts b/lib/definitions/run.d.ts index 1e29b16a3b..7a5af896a2 100644 --- a/lib/definitions/run.d.ts +++ b/lib/definitions/run.d.ts @@ -20,9 +20,16 @@ declare global { }; } + interface IRestartApplicationData { + projectDir: string; + /** Every device of the session when omitted or empty. */ + deviceIdentifiers?: string[]; + } + interface IRunController extends EventEmitter { run(runData: IRunData): Promise; stop(data: IStopRunData): Promise; + restartApplication(data: IRestartApplicationData): Promise; getDeviceDescriptors(data: { projectDir: string; }): ILiveSyncDeviceDescriptor[]; @@ -32,16 +39,16 @@ declare global { installOnDevice( device: Mobile.IDevice, buildData: IBuildData, - packageFile?: string + packageFile?: string, ): Promise; installOnDeviceIfNeeded( device: Mobile.IDevice, buildData: IBuildData, - packageFile?: string + packageFile?: string, ): Promise; shouldInstall( device: Mobile.IDevice, - buildData: IBuildData + buildData: IBuildData, ): Promise; } } diff --git a/lib/helpers/livesync-command-helper.ts b/lib/helpers/livesync-command-helper.ts index 432b931e76..fc1745a64e 100644 --- a/lib/helpers/livesync-command-helper.ts +++ b/lib/helpers/livesync-command-helper.ts @@ -182,8 +182,16 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { }, }); - const devices = await this.getDeviceInstances(platform); - await this.executeLiveSyncOperation(devices, platform, { + // The restart is scoped to the devices this session was given; a + // device attached since then is not part of it, one that has gone + // away is dropped. + const sessionDevices = new Set( + devices.map((device) => device.deviceInfo.identifier), + ); + const currentDevices = (await this.getDeviceInstances(platform)).filter( + (device) => sessionDevices.has(device.deviceInfo.identifier), + ); + await this.executeLiveSyncOperation(currentDevices, platform, { ...additionalOptions, restartLiveSync: false, }); diff --git a/lib/services/key-shortcuts.ts b/lib/services/key-shortcuts.ts index e3b042e802..6429dbc61b 100644 --- a/lib/services/key-shortcuts.ts +++ b/lib/services/key-shortcuts.ts @@ -1,5 +1,7 @@ +import { EventEmitter } from "events"; import { stripVTControlCharacters } from "node:util"; import { color } from "../color"; +import { RunOnDeviceEvents } from "../constants"; import { IChildProcess } from "../common/declarations"; import { Injector } from "../common/di/injector"; import { @@ -13,6 +15,7 @@ import { } from "../common/contracts/key-shortcuts"; import { runCommand } from "../common/services/command-definition-adapter"; import { injector } from "../common/yok"; +import { IProjectDataService } from "../definitions/project"; import { IStartService } from "../definitions/start-service"; /** A terminal in raw mode delivers this byte instead of raising SIGINT. */ @@ -20,6 +23,18 @@ const CTRL_C = "\u0003"; const HELP_KEY = "?"; const WORKFLOW_GROUP = "Development Workflow"; +/** + * Session events that end a burst of output; the hint is repeated after them + * so it sits below the latest sync rather than scrolled out of view. + */ +const HINT_EVENTS: string[] = [ + RunOnDeviceEvents.runOnDeviceStarted, + RunOnDeviceEvents.runOnDeviceExecuted, + RunOnDeviceEvents.runOnDeviceError, +]; +/** Several devices report the same sync within this window; print once. */ +const HINT_DEBOUNCE_MS = 200; + // The shortcut vocabulary lives with the registry contract, so that the // command API can type a `shortcuts` field without reaching into this module. export type { @@ -133,6 +148,49 @@ const launch = (ctx: NsKeyContext): Promise => run(ctx.injector.get("startService")); +/** The devices of the running session, narrowed to one platform. */ +const sessionDevicesOnPlatform = ( + ctx: NsKeyContext, + projectDir: string, + platform: DevicePlatformName, +): string[] => { + const $devicesService = + ctx.injector.get("devicesService"); + const onPlatform = $devicesService + .getDevicesForPlatform(platform) + .map((device) => device.deviceInfo.identifier); + + return ctx.injector + .get("runController") + .getDeviceDescriptors({ projectDir }) + .map((descriptor) => descriptor.identifier) + .filter((identifier) => onPlatform.includes(identifier)); +}; + +const restartApp = async ( + ctx: NsKeyContext, + platform: DevicePlatformName, +): Promise => { + const $runController = ctx.injector.get("runController"); + const { projectDir } = ctx.injector + .get("projectDataService") + .getProjectData(); + const target = platform || ctx.platform; + if (!target) { + await $runController.restartApplication({ projectDir }); + return; + } + + // An empty list would mean "every device" to the run controller. + const deviceIdentifiers = sessionDevicesOnPlatform(ctx, projectDir, target); + if (!deviceIdentifiers.length) { + console.info(`There is no ${target} device in the running session.`); + return; + } + + await $runController.restartApplication({ projectDir, deviceIdentifiers }); +}; + const restart = async ( ctx: NsKeyContext, platform: DevicePlatformName, @@ -189,7 +247,9 @@ const cleanProject = async (ctx: NsKeyContext): Promise => { }; export interface RestartShortcutOptions { - /** Declares `R`, which always rebuilds the native app, rather than `r`. */ + /** Declares `R`: prepares the project again before restarting the app. */ + full?: boolean; + /** Declares `B`: rebuilds the native app whether or not it changed. */ forceRebuildNativeApp?: boolean; /** Restricts the restart to one platform; unset takes it from the context. */ platform?: DevicePlatformName; @@ -198,25 +258,34 @@ export interface RestartShortcutOptions { * caller whose restart has to carry state the shared one knows nothing of, * such as an attached debug session. */ - restart?(forceRebuildNativeApp: boolean): Promise; + restart?(): Promise; } -/** One half of the restart pair: `r` rebuilds only if needed, `R` always. */ +/** + * One rung of the restart ladder: `r` restarts the running app and nothing + * else, `R` prepares the project again first, `B` also rebuilds the native + * app whether or not anything changed. + */ export function restartShortcut( options: RestartShortcutOptions = {}, ): KeyShortcut { const force = options.forceRebuildNativeApp === true; + const full = force || options.full === true; return { - key: force ? "R" : "r", + key: force ? "B" : full ? "R" : "r", description: force - ? "Force rebuild native app and restart" - : "Rebuild native app if needed and restart", + ? "Rebuild native app and restart" + : full + ? "Re-prepare and restart the app (rebuilds native app if needed)" + : "Restart the app", group: WORKFLOW_GROUP, action: (ctx) => options.restart - ? options.restart(force) - : restart(ctx, options.platform, force), + ? options.restart() + : full + ? restart(ctx, options.platform, force) + : restartApp(ctx, options.platform), }; } @@ -285,6 +354,7 @@ export function keyShortcuts(): KeyShortcut[] { }, openIdeShortcut("visionOS"), restartShortcut(), + restartShortcut({ full: true }), restartShortcut({ forceRebuildNativeApp: true }), watcherShortcut(), { @@ -308,6 +378,8 @@ export class KeyShortcutService implements IKeyShortcutService { private context: KeyContextBase; private running: boolean = false; private attached: boolean = false; + private hintSource: EventEmitter; + private hintTimer: NodeJS.Timeout; constructor( private $injector: Injector, @@ -346,6 +418,7 @@ export class KeyShortcutService implements IKeyShortcutService { stdin.on("data", this.onData); process.once("exit", this.onExit); this.attached = true; + this.repeatHintAfterSyncs(); return true; } @@ -357,6 +430,7 @@ export class KeyShortcutService implements IKeyShortcutService { return; } this.attached = false; + this.stopRepeatingHint(); process.off("message", this.onMessage); process.off("exit", this.onExit); @@ -403,6 +477,47 @@ export class KeyShortcutService implements IKeyShortcutService { console.info(color.dim(` › press ${HELP_KEY} to list shortcuts`)); } + /** + * The run controller is optional here: the engine also serves commands + * that never start a session, and the tests build it without one. + */ + private repeatHintAfterSyncs(): void { + const runController = this.$injector.get("runController", { + optional: true, + }); + if (!runController || typeof runController.on !== "function") { + return; + } + + this.hintSource = runController; + for (const event of HINT_EVENTS) { + runController.on(event, this.onSyncSettled); + } + } + + private stopRepeatingHint(): void { + clearTimeout(this.hintTimer); + this.hintTimer = undefined; + + if (!this.hintSource) { + return; + } + for (const event of HINT_EVENTS) { + this.hintSource.off(event, this.onSyncSettled); + } + this.hintSource = undefined; + } + + private onSyncSettled = (): void => { + clearTimeout(this.hintTimer); + this.hintTimer = setTimeout(() => { + this.hintTimer = undefined; + this.printHint(); + }, HINT_DEBOUNCE_MS); + // A pending hint must not be what keeps the CLI alive. + this.hintTimer.unref?.(); + }; + /** * Help and dispatch each read the registry through this one function, so a * `when` that changes while the process runs — or an entry registered after diff --git a/lib/services/livesync-process-data-service.ts b/lib/services/livesync-process-data-service.ts index 27b4767254..4565d99fd5 100644 --- a/lib/services/livesync-process-data-service.ts +++ b/lib/services/livesync-process-data-service.ts @@ -8,22 +8,24 @@ export class LiveSyncProcessDataService implements ILiveSyncProcessDataService { public persistData( projectDir: string, deviceDescriptors: ILiveSyncDeviceDescriptor[], - platforms: string[] + platforms: string[], + liveSyncInfo?: ILiveSyncInfo, ): void { this.processes[projectDir] = this.processes[projectDir] || Object.create(null); + this.processes[projectDir].liveSyncInfo = + liveSyncInfo || this.processes[projectDir].liveSyncInfo; this.processes[projectDir].actionsChain = this.processes[projectDir].actionsChain || Promise.resolve(); - this.processes[projectDir].currentSyncAction = this.processes[ - projectDir - ].actionsChain; + this.processes[projectDir].currentSyncAction = + this.processes[projectDir].actionsChain; this.processes[projectDir].isStopped = false; this.processes[projectDir].platforms = platforms; const currentDeviceDescriptors = this.getDeviceDescriptors(projectDir); this.processes[projectDir].deviceDescriptors = _.uniqBy( currentDeviceDescriptors.concat(deviceDescriptors), - "identifier" + "identifier", ); } diff --git a/lib/services/start-service.ts b/lib/services/start-service.ts index 0f6c4798c8..2c6db992b4 100644 --- a/lib/services/start-service.ts +++ b/lib/services/start-service.ts @@ -146,6 +146,7 @@ export default class StartService implements IStartService { forward("w"), forward("r"), forward("R"), + forward("B"), { ...findShortcut(shortcuts, "c"), quiet: true, diff --git a/test/controllers/run-controller.ts b/test/controllers/run-controller.ts index c09eb73f12..b4b3ad3d8a 100644 --- a/test/controllers/run-controller.ts +++ b/test/controllers/run-controller.ts @@ -261,6 +261,142 @@ describe("RunController", () => { }); }); + describe("restartApplication", () => { + let restartedApps: Array<{ device: string; isFullSync: boolean }> = null; + let infoMessages: string[] = null; + + beforeEach(() => { + restartedApps = []; + infoMessages = []; + + const logger = injector.resolve("logger"); + logger.info = (message: string) => infoMessages.push(message); + + for (const service of ["iOSLiveSyncService", "androidLiveSyncService"]) { + const liveSyncService = injector.resolve(service); + liveSyncService.getAppData = async (syncInfo: IFullSyncInfo) => ({ + appIdentifier, + device: syncInfo.device, + platform: syncInfo.device.deviceInfo.platform, + }); + liveSyncService.shouldRestart = async () => false; + liveSyncService.tryRefreshApplication = async () => true; + liveSyncService.restartApplication = async ( + _projectData: any, + liveSyncResultInfo: ILiveSyncResultInfo, + ) => { + restartedApps.push({ + device: + liveSyncResultInfo.deviceAppData.device.deviceInfo.identifier, + isFullSync: liveSyncResultInfo.isFullSync, + }); + }; + } + }); + + function startSession( + descriptors: ILiveSyncDeviceDescriptor[], + devices: Mobile.IDevice[], + ): void { + mockDevicesService(injector, devices); + injector.resolve("liveSyncProcessDataService").persistData( + projectDir, + descriptors, + devices.map((device) => device.deviceInfo.platform), + liveSyncInfo, + ); + } + + it("restarts the app on every device of the session", async () => { + startSession( + [iOSDeviceDescriptor, androidDeviceDescriptor], + [iOSDevice, androidDevice], + ); + + await runController.restartApplication({ projectDir }); + + assert.deepStrictEqual(restartedApps, [ + { device: "myiOSDevice", isFullSync: false }, + { device: "myAndroidDevice", isFullSync: false }, + ]); + }); + + it("restarts the app only on the devices it was asked for", async () => { + startSession( + [iOSDeviceDescriptor, androidDeviceDescriptor], + [iOSDevice, androidDevice], + ); + + await runController.restartApplication({ + projectDir, + deviceIdentifiers: ["myAndroidDevice"], + }); + + assert.deepStrictEqual(restartedApps, [ + { device: "myAndroidDevice", isFullSync: false }, + ]); + }); + + it("neither prepares nor builds", async () => { + startSession([iOSDeviceDescriptor], [iOSDevice]); + prepareData = null; + + await runController.restartApplication({ projectDir }); + + assert.isNull(prepareData); + assert.lengthOf(restartedApps, 1); + }); + + it("re-attaches the debugger of a debug session", async () => { + const attached: string[] = []; + injector.resolve( + "debugController", + ).enableDebuggingCoreWithoutWaitingCurrentAction = async ( + _projectDir: string, + deviceIdentifier: string, + ) => { + attached.push(deviceIdentifier); + }; + + startSession( + [{ ...iOSDeviceDescriptor, debuggingEnabled: true }], + [iOSDevice], + ); + + await runController.restartApplication({ projectDir }); + + assert.deepStrictEqual(attached, ["myiOSDevice"]); + }); + + it("says so rather than restarting when the session has stopped", async () => { + startSession([iOSDeviceDescriptor], [iOSDevice]); + await runController.stop({ projectDir }); + infoMessages = []; + + await runController.restartApplication({ projectDir }); + + assert.lengthOf(restartedApps, 0); + assert.deepStrictEqual(infoMessages, [ + "There is no running application to restart. Start a run or debug session first.", + ]); + }); + + it("says so rather than restarting when no device matches", async () => { + startSession([iOSDeviceDescriptor], [iOSDevice]); + infoMessages = []; + + await runController.restartApplication({ + projectDir, + deviceIdentifiers: ["someOtherDevice"], + }); + + assert.lengthOf(restartedApps, 0); + assert.deepStrictEqual(infoMessages, [ + "There is no device to restart the application on.", + ]); + }); + }); + describe("stopRunOnDevices", () => { const testCases = [ { diff --git a/test/helpers/livesync-command-helper.ts b/test/helpers/livesync-command-helper.ts new file mode 100644 index 0000000000..8eee3ce2d8 --- /dev/null +++ b/test/helpers/livesync-command-helper.ts @@ -0,0 +1,101 @@ +import { assert } from "chai"; +import { EventEmitter } from "events"; +import { InjectorStub } from "../stubs"; +import { LiveSyncCommandHelper } from "../../lib/helpers/livesync-command-helper"; + +const device = (identifier: string, platform: string = "iOS"): any => ({ + deviceInfo: { identifier, platform }, + isEmulator: false, +}); + +function createTestInjector(attached: any[]) { + const injector = new InjectorStub(); + const runController = Object.assign(new EventEmitter(), { + stops: [], + runs: [], + stop: async (data: any): Promise => + void runController.stops.push(data), + run: async (data: any): Promise => void runController.runs.push(data), + }); + + injector.register("options", { + argv: {}, + watch: true, + }); + injector.register("projectData", { projectDir: "/project" }); + injector.register("runController", runController); + injector.register("devicesService", { + initialize: async (): Promise => undefined, + getDeviceInstances: () => attached, + }); + injector.register("buildDataService", { + getBuildData: (projectDir: string, platform: string, data: any) => ({ + ...data, + projectDir, + platform, + }), + }); + injector.register("androidBundleValidatorHelper", { + validateDeviceApiLevel: (): void => undefined, + }); + injector.register("buildController", { build: async () => "/package" }); + injector.register("deployController", {}); + injector.register("iosDeviceOperations", { + setShouldDispose: (): void => undefined, + }); + injector.register("iOSSimulatorLogProvider", { + setShouldDispose: (): void => undefined, + }); + injector.register("analyticsService", { + setShouldDispose: (): void => undefined, + }); + injector.register("cleanupService", { + setShouldDispose: (): void => undefined, + }); + injector.register("mobileHelper", { + isApplePlatform: () => true, + platformNames: ["iOS", "Android"], + }); + injector.register("liveSyncCommandHelper", LiveSyncCommandHelper); + + return { injector, runController }; +} + +const identifiers = (descriptors: any[]): string[] => + descriptors.map((descriptor) => descriptor.identifier); + +describe("LiveSyncCommandHelper", () => { + describe("executeLiveSyncOperation with restartLiveSync", () => { + it("restarts only the devices the session was given, not every attached one", async () => { + const attached = [device("picked"), device("other")]; + const { injector, runController } = createTestInjector(attached); + const helper = injector.resolve("liveSyncCommandHelper"); + + await helper.executeLiveSyncOperation([attached[0]], "iOS", { + restartLiveSync: true, + }); + + assert.lengthOf(runController.stops, 1); + assert.deepEqual(runController.stops[0].deviceIdentifiers, ["picked"]); + assert.lengthOf(runController.runs, 1); + assert.deepEqual(identifiers(runController.runs[0].deviceDescriptors), [ + "picked", + ]); + }); + + it("drops a session device that is no longer attached", async () => { + const gone = device("gone"); + const attached = [device("picked")]; + const { injector, runController } = createTestInjector(attached); + const helper = injector.resolve("liveSyncCommandHelper"); + + await helper.executeLiveSyncOperation([attached[0], gone], "iOS", { + restartLiveSync: true, + }); + + assert.deepEqual(identifiers(runController.runs[0].deviceDescriptors), [ + "picked", + ]); + }); + }); +}); diff --git a/test/services/key-shortcuts.ts b/test/services/key-shortcuts.ts index 0890a3ce29..fe7a05ee1a 100644 --- a/test/services/key-shortcuts.ts +++ b/test/services/key-shortcuts.ts @@ -1,5 +1,6 @@ import { assert } from "chai"; import { EventEmitter } from "events"; +import { RunOnDeviceEvents } from "../../lib/constants"; import { runInInjectionContext } from "../../lib/common/di/inject"; import { Injector } from "../../lib/common/di/injector"; import { runCommand } from "../../lib/common/services/command-definition-adapter"; @@ -13,6 +14,7 @@ import { keyShortcutsEnabled, NsKeyContext, resolveShortcuts, + restartShortcut, } from "../../lib/services/key-shortcuts"; class FakeStdin extends EventEmitter { @@ -182,6 +184,7 @@ describe("key shortcuts", () => { "V", "r", "R", + "B", "w", "c", "n", @@ -195,7 +198,16 @@ describe("key shortcuts", () => { context({ platform: "Android", processType: "run" }), ); - assert.deepEqual(keysOf(resolved), ["A", "r", "R", "w", "c", "n", "?"]); + assert.deepEqual(keysOf(resolved), [ + "A", + "r", + "R", + "B", + "w", + "c", + "n", + "?", + ]); }); it("routes the IDE shortcuts through the open commands", async () => { @@ -229,6 +241,172 @@ describe("key shortcuts", () => { }); }); + describe("restartShortcut", () => { + const session = ( + overrides: { + descriptors?: string[]; + devicesByPlatform?: { [platform: string]: string[] }; + } = {}, + ) => { + const restarts: any[] = []; + const liveSyncOperations: any[] = []; + const registrations = new Map([ + [ + "runController", + { + restartApplication: async (data: any): Promise => + void restarts.push(data), + getDeviceDescriptors: () => + (overrides.descriptors || ["device-1"]).map((identifier) => ({ + identifier, + })), + }, + ], + [ + "projectDataService", + { getProjectData: () => ({ projectDir: "/project" }) }, + ], + [ + "devicesService", + { + getDevicesForPlatform: (platform: string) => + ((overrides.devicesByPlatform || {})[platform] || []).map( + (identifier) => ({ deviceInfo: { identifier } }), + ), + }, + ], + [ + "liveSyncCommandHelper", + { + getDeviceInstances: async (platform: string) => [{ platform }], + executeLiveSyncOperation: async ( + devices: any[], + platform: string, + options: any, + ): Promise => + void liveSyncOperations.push({ devices, platform, options }), + }, + ], + ]); + + return { + restarts, + liveSyncOperations, + ctx: (overrides: Partial = {}): NsKeyContext => ({ + injector: fakeInjector(registrations), + processType: "run", + ...overrides, + }), + }; + }; + + it("names the key and the promise each variant makes", () => { + assert.deepEqual( + [ + restartShortcut(), + restartShortcut({ full: true }), + restartShortcut({ forceRebuildNativeApp: true }), + ].map((shortcut) => [shortcut.key, shortcut.description]), + [ + ["r", "Restart the app"], + [ + "R", + "Re-prepare and restart the app (rebuilds native app if needed)", + ], + ["B", "Rebuild native app and restart"], + ], + ); + }); + + it("restarts the app on the watched platform's devices", async () => { + const { restarts, ctx } = session({ + descriptors: ["android-1", "ios-1"], + devicesByPlatform: { Android: ["android-1", "android-2"] }, + }); + + await restartShortcut().action(ctx({ platform: "Android" })); + + assert.deepEqual(restarts, [ + { projectDir: "/project", deviceIdentifiers: ["android-1"] }, + ]); + }); + + it("restarts the app on every device of the session when no platform is set", async () => { + const { restarts, ctx } = session(); + + await restartShortcut().action(ctx()); + + assert.deepEqual(restarts, [{ projectDir: "/project" }]); + }); + + it("says so instead of restarting everything when the platform has no session device", async () => { + const { restarts, ctx } = session({ + descriptors: ["android-1"], + devicesByPlatform: { Android: ["android-1"] }, + }); + const infos: string[] = []; + const originalInfo = console.info; + console.info = (message?: any) => void infos.push(String(message)); + + try { + await restartShortcut().action(ctx({ platform: "iOS" })); + } finally { + console.info = originalInfo; + } + + assert.deepEqual(restarts, []); + assert.include(infos.join("\n"), "no iOS device"); + }); + + it("re-runs the live sync for `R`, without forcing a native rebuild", async () => { + const { liveSyncOperations, ctx } = session(); + + await restartShortcut({ full: true }).action(ctx()); + + assert.deepEqual(liveSyncOperations, [ + { + devices: [{ platform: undefined }], + platform: undefined, + options: { restartLiveSync: true }, + }, + ]); + }); + + it("forces the native rebuild only when asked for it", async () => { + const { liveSyncOperations, ctx } = session(); + + await restartShortcut({ + forceRebuildNativeApp: true, + platform: "iOS", + }).action(ctx()); + + assert.deepEqual(liveSyncOperations, [ + { + devices: [{ platform: "iOS" }], + platform: "iOS", + options: { + restartLiveSync: true, + skipNativePrepare: false, + forceRebuildNativeApp: true, + }, + }, + ]); + }); + + it("hands the restart over to a caller that brought its own", async () => { + const { restarts, liveSyncOperations, ctx } = session(); + let replacements = 0; + + await restartShortcut({ + restart: async () => void replacements++, + }).action(ctx()); + + assert.equal(replacements, 1); + assert.lengthOf(restarts, 0); + assert.lengthOf(liveSyncOperations, 0); + }); + }); + describe("findShortcut", () => { it("fails loudly rather than returning a description-less entry", () => { assert.throws( @@ -723,6 +901,53 @@ describe("key shortcuts", () => { assert.lengthOf(info, 1); assert.include(info[0], "press ? to list shortcuts"); }); + + const settle = () => + new Promise((resolve) => setTimeout(resolve, 260)); + + it("repeats the hint once a burst of syncs has settled", async () => { + const runController = new EventEmitter(); + registrations.set("runController", runController); + service.attach({ shortcuts: [] }); + + runController.emit(RunOnDeviceEvents.runOnDeviceStarted); + runController.emit(RunOnDeviceEvents.runOnDeviceExecuted); + runController.emit(RunOnDeviceEvents.runOnDeviceError); + assert.lengthOf(info, 0); + + await settle(); + + assert.lengthOf(info, 1); + assert.include(info[0], "press ? to list shortcuts"); + }); + + it("stops repeating the hint once it detaches", async () => { + const runController = new EventEmitter(); + registrations.set("runController", runController); + service.attach({ shortcuts: [] }); + service.detach(); + + runController.emit(RunOnDeviceEvents.runOnDeviceExecuted); + await settle(); + + assert.lengthOf(info, 0); + assert.equal( + runController.listenerCount(RunOnDeviceEvents.runOnDeviceExecuted), + 0, + ); + }); + + it("does not listen for syncs when it attaches over IPC", () => { + const runController = new EventEmitter(); + registrations.set("runController", runController); + stdin.isTTY = false; + service.attach({ shortcuts: [] }); + + assert.equal( + runController.listenerCount(RunOnDeviceEvents.runOnDeviceExecuted), + 0, + ); + }); }); describe("runCommand", () => { From e58a9e8bb9f44a00157775d6cd67842cbfa6f581 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 17:24:11 -0300 Subject: [PATCH 09/19] refactor(commands): infer command services from setup A command's services type is now read off its setup function with ReturnType instead of being declared beside it and kept in sync by hand. --- defining-commands.md | 62 ++++++++++++++----- lib/commands/add-platform.ts | 12 ++-- lib/commands/apple-login.ts | 13 ++-- lib/commands/appstore-list.ts | 17 ++--- lib/commands/appstore-upload.ts | 20 ++---- lib/commands/build.ts | 14 +---- lib/commands/clean.ts | 16 +---- lib/commands/command-base.ts | 23 +++---- lib/commands/config.ts | 12 ++-- lib/commands/create-project.ts | 12 ++-- lib/commands/debug.ts | 30 +++------ lib/commands/embedding/embed.ts | 12 +--- .../extensibility/install-extension.ts | 11 ++-- lib/commands/extensibility/list-extensions.ts | 11 ++-- .../extensibility/uninstall-extension.ts | 11 ++-- lib/commands/fonts.ts | 11 +--- lib/commands/generate-assets.ts | 14 ++--- lib/commands/hooks/common.ts | 14 ++--- lib/commands/install.ts | 17 +---- lib/commands/list-platforms.ts | 12 ++-- lib/commands/migrate.ts | 12 +--- lib/commands/native-add.ts | 12 ++-- lib/commands/open.ts | 28 +++------ lib/commands/platform-clean.ts | 15 ++--- lib/commands/plugin/add-plugin.ts | 12 ++-- lib/commands/plugin/build-plugin.ts | 17 ++--- lib/commands/plugin/create-plugin.ts | 17 ++--- lib/commands/plugin/list-plugins.ts | 12 ++-- lib/commands/plugin/remove-plugin.ts | 13 ++-- lib/commands/plugin/update-plugin.ts | 12 ++-- lib/commands/post-install.ts | 16 ++--- lib/commands/prepare.ts | 11 +--- lib/commands/preview.ts | 12 +--- lib/commands/remove-platform.ts | 13 ++-- lib/commands/resources/resources-update.ts | 12 ++-- lib/commands/test-init.ts | 17 +---- lib/commands/test.ts | 22 +------ lib/commands/typings.ts | 16 +---- lib/commands/update-platform.ts | 15 ++--- lib/commands/update.ts | 14 +---- lib/common/commands/analytics.ts | 15 ++--- lib/common/commands/autocompletion.ts | 11 ++-- .../commands/device/device-log-stream.ts | 14 ++--- lib/common/commands/device/get-file.ts | 10 +-- .../commands/device/list-applications.ts | 11 ++-- lib/common/commands/device/list-devices.ts | 20 ++---- lib/common/commands/device/list-files.ts | 12 ++-- lib/common/commands/device/put-file.ts | 10 +-- lib/common/commands/device/run-application.ts | 12 ++-- .../commands/device/stop-application.ts | 10 +-- .../commands/device/uninstall-application.ts | 10 +-- lib/common/commands/doctor.ts | 12 +--- lib/common/commands/help.ts | 9 +-- lib/common/commands/package-manager-get.ts | 11 ++-- lib/common/commands/package-manager-set.ts | 12 ++-- lib/common/commands/preuninstall.ts | 14 ++--- lib/common/commands/proxy/proxy-base.ts | 12 ++-- lib/common/commands/proxy/proxy-set.ts | 12 +--- 58 files changed, 310 insertions(+), 549 deletions(-) diff --git a/defining-commands.md b/defining-commands.md index 49ca625488..2eeda4792e 100644 --- a/defining-commands.md +++ b/defining-commands.md @@ -169,11 +169,11 @@ options: { So a redeclaration of the same name with the same type is silent. What the CLI still warns about at registration is a redeclaration that changes what the -spelling *means*: +spelling _means_: - a declared option whose name matches a CLI-wide one but whose type differs — `verbose: stringOption()` against the CLI's boolean `--verbose`; -- an alias that belongs to a *different* CLI-wide option — `output: +- 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. @@ -449,6 +449,40 @@ top of `run`; nothing else changes. "Once per invocation" means once across `canExecute`, `run` and `postRun` together — whichever of them the CLI reaches first triggers it, and the rest reuse the value. +When several commands share a setup, or a helper outside the definition takes +the services as a parameter, lift it into a named function and derive the type +from it instead of writing the shape out by hand: + +```ts +export function setupWidgetAddCommand() { + const projectData = inject(ProjectData); + projectData.initializeProjectData(); + return { projectData, widgets: inject(WidgetService) }; +} +export type IWidgetAddCommandServices = ReturnType< + typeof setupWidgetAddCommand +>; + +export function canAddWidget(services: IWidgetAddCommandServices): boolean { + return !!services.projectData.projectDir; +} + +export default defineCommand({ + name: "widget|add", + arguments: "any", + setup: setupWidgetAddCommand, + canExecute: (ctx, services) => canAddWidget(services), + async run(ctx, { widgets }) { + await widgets.add(ctx.args); + }, +}); +``` + +Leave the setup function's return type off: the alias reads what the body +infers, so annotating the function with the alias makes the pair circular. Read +a setup curried over a parameter — `setupX(platform)` returning the setup +itself — through its inner function, `ReturnType>`. + `run`'s return value, and `postRun` ----------------------------------- @@ -504,7 +538,7 @@ all — or the definition itself, which it defines on your behalf, so registerin a command is one call. Either way the definition is validated before it reaches the registry. It claims every name the definition declares, through the `CommandRegistry` the target injector provides, and returns a -`DeferredCommandResult` — see *The owner is ambient* below. The command instance +`DeferredCommandResult` — see _The owner is ambient_ below. The command instance is built by a factory on first resolution and cached. Pass providers as the second argument to scope the command to a child injector @@ -523,7 +557,7 @@ That is how one definition serves several commands that differ only in data — the platform each one targets — instead of one command subclassing another. **Which injector it registers against is not a parameter.** It is the injector -of the current injection context — see *The owner is ambient* below — and the +of the current injection context — see _The owner is ambient_ below — and the CLI's own injector outside one. To register against some other injector, run the call in its context: @@ -722,16 +756,16 @@ A definition is compiled into an ordinary `ICommand`, so nothing downstream — the registry, the router, hooks, help, analytics — knows the difference. The mapping is: -| Definition | `ICommand` | -| --------------------------------- | --------------------------------------------------- | -| `options` | `dashedOptions` | -| `run` | `execute`, wrapped in an injection context | -| `arguments`, `canExecute` | `canExecute`: policy enforced, then the refinement | -| `setup` | — run inside `canExecute`/`execute`, memoised | -| `postRun` | `postCommandAction`, with `run`'s return value | -| `allowUnknownOptions` | `skipOptionsValidation` | -| — | `allowedParameters`, always `[]` | -| `disableAnalytics`, `enableHooks` | passed through unchanged | +| Definition | `ICommand` | +| --------------------------------- | -------------------------------------------------- | +| `options` | `dashedOptions` | +| `run` | `execute`, wrapped in an injection context | +| `arguments`, `canExecute` | `canExecute`: policy enforced, then the refinement | +| `setup` | — run inside `canExecute`/`execute`, memoised | +| `postRun` | `postCommandAction`, with `run`'s return value | +| `allowUnknownOptions` | `skipOptionsValidation` | +| — | `allowedParameters`, always `[]` | +| `disableAnalytics`, `enableHooks` | passed through unchanged | The compiled command always exposes `canExecute`, because `CommandsService` stops consulting `allowedParameters` as soon as a command has one — the adapter diff --git a/lib/commands/add-platform.ts b/lib/commands/add-platform.ts index 79faa9f01e..8ecbd166b1 100644 --- a/lib/commands/add-platform.ts +++ b/lib/commands/add-platform.ts @@ -1,7 +1,6 @@ import { canExecuteCommandBase, injectPlatformCommandServices, - IPlatformCommandServices, } from "./command-base"; import { IPlatformCommandHelper } from "../declarations"; import { IErrors } from "../common/declarations"; @@ -21,12 +20,7 @@ export type AddPlatformCommandContext = CommandContext< typeof addPlatformCommandOptions >; -export interface IAddPlatformCommandServices extends IPlatformCommandServices { - $errors: IErrors; - $platformCommandHelper: IPlatformCommandHelper; -} - -export function setupAddPlatformCommand(): IAddPlatformCommandServices { +export function setupAddPlatformCommand() { const services = { ...injectPlatformCommandServices(), $errors: inject("errors"), @@ -39,6 +33,10 @@ export function setupAddPlatformCommand(): IAddPlatformCommandServices { return services; } +export type IAddPlatformCommandServices = ReturnType< + typeof setupAddPlatformCommand +>; + export async function canExecuteAddPlatformCommand( context: AddPlatformCommandContext, services: IAddPlatformCommandServices, diff --git a/lib/commands/apple-login.ts b/lib/commands/apple-login.ts index fc0a523fda..5f87cea262 100644 --- a/lib/commands/apple-login.ts +++ b/lib/commands/apple-login.ts @@ -5,14 +5,7 @@ import { IApplePortalSessionService } from "../services/apple-portal/definitions export type AppleLoginCommandContext = CommandContext; -export interface IAppleLoginCommandServices { - $applePortalSessionService: IApplePortalSessionService; - $errors: IErrors; - $logger: ILogger; - $prompter: IPrompter; -} - -export function setupAppleLoginCommand(): IAppleLoginCommandServices { +export function setupAppleLoginCommand() { return { $applePortalSessionService: inject( "applePortalSessionService", @@ -23,6 +16,10 @@ export function setupAppleLoginCommand(): IAppleLoginCommandServices { }; } +export type IAppleLoginCommandServices = ReturnType< + typeof setupAppleLoginCommand +>; + export async function runAppleLoginCommand( context: AppleLoginCommandContext, services: IAppleLoginCommandServices, diff --git a/lib/commands/appstore-list.ts b/lib/commands/appstore-list.ts index 2417008c28..17103adfa7 100644 --- a/lib/commands/appstore-list.ts +++ b/lib/commands/appstore-list.ts @@ -22,18 +22,7 @@ export type ListiOSAppsCommandContext = CommandContext< typeof listiOSAppsCommandOptions >; -export interface IListiOSAppsCommandServices { - $applePortalApplicationService: IApplePortalApplicationService; - $applePortalSessionService: IApplePortalSessionService; - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $errors: IErrors; - $logger: ILogger; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; - $prompter: IPrompter; -} - -export function setupListiOSAppsCommand(): IListiOSAppsCommandServices { +export function setupListiOSAppsCommand() { const services = { $applePortalApplicationService: inject( "applePortalApplicationService", @@ -57,6 +46,10 @@ export function setupListiOSAppsCommand(): IListiOSAppsCommandServices { return services; } +export type IListiOSAppsCommandServices = ReturnType< + typeof setupListiOSAppsCommand +>; + export async function runListiOSAppsCommand( context: ListiOSAppsCommandContext, services: IListiOSAppsCommandServices, diff --git a/lib/commands/appstore-upload.ts b/lib/commands/appstore-upload.ts index 81eadc6281..7814e9cb2f 100644 --- a/lib/commands/appstore-upload.ts +++ b/lib/commands/appstore-upload.ts @@ -32,21 +32,7 @@ export type PublishIOSCommandContext = CommandContext< typeof publishIOSCommandOptions >; -export interface IPublishIOSCommandServices { - $applePortalSessionService: IApplePortalSessionService; - $buildController: BuildController; - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $errors: IErrors; - $hostInfo: IHostInfo; - $itmsTransporterService: IITMSTransporterService; - $logger: ILogger; - $options: IOptions; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; - $prompter: IPrompter; -} - -export function setupPublishIOSCommand(): IPublishIOSCommandServices { +export function setupPublishIOSCommand() { const services = { $applePortalSessionService: inject( "applePortalSessionService", @@ -73,6 +59,10 @@ export function setupPublishIOSCommand(): IPublishIOSCommandServices { return services; } +export type IPublishIOSCommandServices = ReturnType< + typeof setupPublishIOSCommand +>; + export function canExecutePublishIOSCommand( context: PublishIOSCommandContext, services: IPublishIOSCommandServices, diff --git a/lib/commands/build.ts b/lib/commands/build.ts index 8552f26e93..29ae6f753e 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -5,7 +5,6 @@ import { import { canExecuteCommandBase, injectPlatformCommandServices, - IPlatformCommandServices, validatePlatformOptions, } from "./command-base"; import { hasValidAndroidSigning } from "../common/helpers"; @@ -40,17 +39,6 @@ const buildCommandOptions = { keyStoreAliasPassword: stringOption(), } satisfies CommandOptionsSchema; -interface IBuildCommandServices extends IPlatformCommandServices { - platform: string; - isAndroid: boolean; - $errors: IErrors; - $logger: ILogger; - $buildController: IBuildController; - $buildDataService: IBuildDataService; - $migrateController: IMigrateController; - $androidBundleValidatorHelper: IAndroidBundleValidatorHelper; -} - const defineBuildCommand = ( name: TName, buildPlatform: BuildPlatform, @@ -60,7 +48,7 @@ const defineBuildCommand = ( description: "Builds the project for the selected target platform.", options: buildCommandOptions, arguments: "none", - setup(): IBuildCommandServices { + setup() { const devicePlatformsConstants = inject( "devicePlatformsConstants", ); diff --git a/lib/commands/clean.ts b/lib/commands/clean.ts index b193930020..28ccee4be9 100644 --- a/lib/commands/clean.ts +++ b/lib/commands/clean.ts @@ -88,19 +88,7 @@ const cleanCommandOptions = { export type CleanCommandContext = CommandContext; -export interface ICleanCommandServices { - $childProcess: IChildProcess; - $logger: ILogger; - $projectCleanupService: IProjectCleanupService; - $projectConfigService: IProjectConfigService; - $projectData: IProjectData; - $projectService: IProjectService; - $prompter: IPrompter; - $staticConfig: IStaticConfig; - $terminalSpinnerService: ITerminalSpinnerService; -} - -export function setupCleanCommand(): ICleanCommandServices { +export function setupCleanCommand() { return { $childProcess: inject("childProcess"), $logger: inject("logger"), @@ -120,6 +108,8 @@ export function setupCleanCommand(): ICleanCommandServices { }; } +export type ICleanCommandServices = ReturnType; + async function getNSProjectPathsInDirectory( services: ICleanCommandServices, dir = process.cwd(), diff --git a/lib/commands/command-base.ts b/lib/commands/command-base.ts index 748c54dcdd..48d0d2a9a6 100644 --- a/lib/commands/command-base.ts +++ b/lib/commands/command-base.ts @@ -9,20 +9,8 @@ import { import { ArgumentSpec } from "../common/define-command"; import { inject, Injector } from "../common/di"; -/** - * What the platform-validation helpers below need. A command definition's - * `setup` returns this shape (see `injectPlatformCommandServices`), so its - * result can be handed straight to them. - */ -export interface IPlatformCommandServices { - $options: IOptions; - $platformsDataService: IPlatformsDataService; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; -} - /** Callable from `setup` and from `canExecute` before their first `await`. */ -export function injectPlatformCommandServices(): IPlatformCommandServices { +export function injectPlatformCommandServices() { return { $options: inject("options"), $platformsDataService: inject( @@ -35,6 +23,15 @@ export function injectPlatformCommandServices(): IPlatformCommandServices { }; } +/** + * What the platform-validation helpers below need. A command definition's + * `setup` returns this shape (see `injectPlatformCommandServices`), so its + * result can be handed straight to them. + */ +export type IPlatformCommandServices = ReturnType< + typeof injectPlatformCommandServices +>; + /** * The declarative form of `$platformCommandParameter`. Initializing the * project data is what makes the platform check possible, so it stays part of diff --git a/lib/commands/config.ts b/lib/commands/config.ts index b3d21b8eef..41792dce04 100644 --- a/lib/commands/config.ts +++ b/lib/commands/config.ts @@ -5,13 +5,7 @@ import { CommandContext, defineCommand } from "../common/define-command"; import { inject } from "../common/di"; import { color } from "../color"; -export interface IConfigCommandServices { - $projectConfigService: IProjectConfigService; - $logger: ILogger; - $errors: IErrors; -} - -export function injectConfigCommandServices(): IConfigCommandServices { +export function injectConfigCommandServices() { return { $projectConfigService: inject( "projectConfigService", @@ -21,6 +15,10 @@ export function injectConfigCommandServices(): IConfigCommandServices { }; } +export type IConfigCommandServices = ReturnType< + typeof injectConfigCommandServices +>; + function getValueString(value: SupportedConfigValues, depth = 0): string { const indent = () => " ".repeat(depth); if (typeof value === "object") { diff --git a/lib/commands/create-project.ts b/lib/commands/create-project.ts index f3a72dca02..01067dcf05 100644 --- a/lib/commands/create-project.ts +++ b/lib/commands/create-project.ts @@ -53,13 +53,7 @@ export type CreateProjectCommandContext = CommandContext< typeof createProjectCommandOptions >; -export interface ICreateProjectCommandServices { - $projectService: IProjectService; - $logger: ILogger; - $prompter: IPrompter; -} - -export function setupCreateProjectCommand(): ICreateProjectCommandServices { +export function setupCreateProjectCommand() { return { $projectService: inject("projectService"), $logger: inject("logger"), @@ -67,6 +61,10 @@ export function setupCreateProjectCommand(): ICreateProjectCommandServices { }; } +export type ICreateProjectCommandServices = ReturnType< + typeof setupCreateProjectCommand +>; + interface ITemplateChoice { key?: string; value: string; diff --git a/lib/commands/debug.ts b/lib/commands/debug.ts index 9da5a8aeb1..0cfe058dc4 100644 --- a/lib/commands/debug.ts +++ b/lib/commands/debug.ts @@ -28,7 +28,6 @@ import { import { canExecuteCommandBase, injectPlatformCommandServices, - IPlatformCommandServices, } from "./command-base"; import * as _ from "lodash"; @@ -53,21 +52,7 @@ const debugCommandOptions = { export type DebugCommandContext = CommandContext; -export interface IDebugCommandServices extends IPlatformCommandServices { - platform: string; - $cleanupService: ICleanupService; - $debugController: IDebugController; - $debugDataService: IDebugDataService; - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $liveSyncCommandHelper: ILiveSyncCommandHelper; - $migrateController: IMigrateController; -} - -export function setupDebugCommand( - debugPlatform: DebugPlatform, -): IDebugCommandServices { +export function setupDebugCommand(debugPlatform: DebugPlatform) { const $devicePlatformsConstants = inject( "devicePlatformsConstants", ); @@ -88,6 +73,8 @@ export function setupDebugCommand( }; } +export type IDebugCommandServices = ReturnType; + export async function canExecuteDebugCommand( context: DebugCommandContext, services: IDebugCommandServices, @@ -213,13 +200,8 @@ export async function runDebugCommand( } } -interface IDebugApplePlatformCommandServices extends IDebugCommandServices { - $sysInfo: ISysInfo; -} - const setupDebugApplePlatformCommand = - (debugPlatform: "iOS" | "visionOS") => - (): IDebugApplePlatformCommandServices => { + (debugPlatform: "iOS" | "visionOS") => () => { const services = { ...setupDebugCommand(debugPlatform), $sysInfo: inject("sysInfo"), @@ -238,6 +220,10 @@ const setupDebugApplePlatformCommand = return services; }; +type IDebugApplePlatformCommandServices = ReturnType< + ReturnType +>; + function isValidTimeoutOption(timeout: string): boolean { if (!timeout) { return true; diff --git a/lib/commands/embedding/embed.ts b/lib/commands/embedding/embed.ts index d4489ddfaa..e0a245a0c3 100644 --- a/lib/commands/embedding/embed.ts +++ b/lib/commands/embedding/embed.ts @@ -7,19 +7,11 @@ import { IProjectConfigService } from "../../definitions/project"; import { platformArgument } from "../command-base"; import { canExecutePrepareCommand, - IPrepareCommandServices, prepareCommandOptions, runPrepareCommand, setupPrepareCommand, } from "../prepare"; -interface IEmbedCommandServices extends IPrepareCommandServices { - $fs: IFileSystem; - $logger: ILogger; - hostProjectPath: string; - hostProjectModuleName: string; -} - function resolveHostProjectPath( projectDir: string, hostProjectPath: string, @@ -41,14 +33,14 @@ export const embedCommandDefinition = defineCommand({ { name: "hostProjectPath" }, { name: "hostProjectModuleName" }, ], - setup(context): IEmbedCommandServices { + setup(context) { const services = setupPrepareCommand(); const $projectConfigService = inject( "projectConfigService", ); const platform = (context.args[0] || "").toLowerCase(); // embed.., falling back to embed. - const configValue = (key: string) => + const configValue = (key: string): string => $projectConfigService.getValue( `embed.${platform}.${key}`, $projectConfigService.getValue(`embed.${key}`), diff --git a/lib/commands/extensibility/install-extension.ts b/lib/commands/extensibility/install-extension.ts index d69eca8202..6133e4e5de 100644 --- a/lib/commands/extensibility/install-extension.ts +++ b/lib/commands/extensibility/install-extension.ts @@ -2,12 +2,7 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; -export interface IInstallExtensionCommandServices { - $extensibilityService: IExtensibilityService; - $logger: ILogger; -} - -export function setupInstallExtensionCommand(): IInstallExtensionCommandServices { +export function setupInstallExtensionCommand() { return { $extensibilityService: inject( "extensibilityService", @@ -16,6 +11,10 @@ export function setupInstallExtensionCommand(): IInstallExtensionCommandServices }; } +export type IInstallExtensionCommandServices = ReturnType< + typeof setupInstallExtensionCommand +>; + export const installExtensionCommandDefinition = defineCommand({ name: "extension|install", description: "Installs the specified extension.", diff --git a/lib/commands/extensibility/list-extensions.ts b/lib/commands/extensibility/list-extensions.ts index 1ae4fb8383..9dec66c7c1 100644 --- a/lib/commands/extensibility/list-extensions.ts +++ b/lib/commands/extensibility/list-extensions.ts @@ -4,12 +4,7 @@ import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; import * as helpers from "../../common/helpers"; -export interface IListExtensionsCommandServices { - $extensibilityService: IExtensibilityService; - $logger: ILogger; -} - -export function setupListExtensionsCommand(): IListExtensionsCommandServices { +export function setupListExtensionsCommand() { return { $extensibilityService: inject( "extensibilityService", @@ -18,6 +13,10 @@ export function setupListExtensionsCommand(): IListExtensionsCommandServices { }; } +export type IListExtensionsCommandServices = ReturnType< + typeof setupListExtensionsCommand +>; + export const listExtensionsCommandDefinition = defineCommand({ name: "extension|*list", description: "Lists all installed extensions.", diff --git a/lib/commands/extensibility/uninstall-extension.ts b/lib/commands/extensibility/uninstall-extension.ts index 44b26b915e..1701865848 100644 --- a/lib/commands/extensibility/uninstall-extension.ts +++ b/lib/commands/extensibility/uninstall-extension.ts @@ -2,12 +2,7 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; -export interface IUninstallExtensionCommandServices { - $extensibilityService: IExtensibilityService; - $logger: ILogger; -} - -export function setupUninstallExtensionCommand(): IUninstallExtensionCommandServices { +export function setupUninstallExtensionCommand() { return { $extensibilityService: inject( "extensibilityService", @@ -16,6 +11,10 @@ export function setupUninstallExtensionCommand(): IUninstallExtensionCommandServ }; } +export type IUninstallExtensionCommandServices = ReturnType< + typeof setupUninstallExtensionCommand +>; + export const uninstallExtensionCommandDefinition = defineCommand({ name: "extension|uninstall", description: "Uninstalls the specified extension.", diff --git a/lib/commands/fonts.ts b/lib/commands/fonts.ts index 7d4a13e4ba..edfed4dc55 100644 --- a/lib/commands/fonts.ts +++ b/lib/commands/fonts.ts @@ -7,14 +7,7 @@ import * as fontFinder from "font-finder"; import { createTable } from "../common/helpers"; import * as path from "path"; -export interface IFontsCommandServices { - $projectData: IProjectData; - $fs: IFileSystem; - $logger: ILogger; - $projectConfigService: IProjectConfigService; -} - -export function setupFontsCommand(): IFontsCommandServices { +export function setupFontsCommand() { const services = { $projectData: inject("projectData"), $fs: inject("fs"), @@ -28,6 +21,8 @@ export function setupFontsCommand(): IFontsCommandServices { return services; } +export type IFontsCommandServices = ReturnType; + export const fontsCommandDefinition = defineCommand({ name: "fonts", description: "Lists the custom fonts the project bundles.", diff --git a/lib/commands/generate-assets.ts b/lib/commands/generate-assets.ts index a299ab1e4f..559f5911db 100644 --- a/lib/commands/generate-assets.ts +++ b/lib/commands/generate-assets.ts @@ -34,15 +34,7 @@ export type GenerateAssetsCommandContext = CommandContext< typeof generateAssetsCommandOptions >; -export interface IGenerateAssetsCommandServices { - assets: GeneratedAssets; - $assetsGenerationService: IAssetsGenerationService; - $projectData: IProjectData; -} - -export function setupGenerateAssetsCommand( - assets: GeneratedAssets, -): IGenerateAssetsCommandServices { +export function setupGenerateAssetsCommand(assets: GeneratedAssets) { const services = { assets, $assetsGenerationService: inject( @@ -55,6 +47,10 @@ export function setupGenerateAssetsCommand( return services; } +export type IGenerateAssetsCommandServices = ReturnType< + typeof setupGenerateAssetsCommand +>; + export function runGenerateAssetsCommand( context: GenerateAssetsCommandContext, services: IGenerateAssetsCommandServices, diff --git a/lib/commands/hooks/common.ts b/lib/commands/hooks/common.ts index a51bf8dac1..8cfef75424 100644 --- a/lib/commands/hooks/common.ts +++ b/lib/commands/hooks/common.ts @@ -16,16 +16,8 @@ export interface OutputPlugin { hooks: OutputHook[]; } -export interface IHooksCommandServices { - $pluginsService: IPluginsService; - $projectData: IProjectData; - $errors: IErrors; - $fs: IFileSystem; - $logger: ILogger; -} - /** Callable from `setup` and from `canExecute` before their first `await`. */ -export function injectHooksCommandServices(): IHooksCommandServices { +export function injectHooksCommandServices() { const services = { $pluginsService: inject("pluginsService"), $projectData: inject("projectData"), @@ -38,6 +30,10 @@ export function injectHooksCommandServices(): IHooksCommandServices { return services; } +export type IHooksCommandServices = ReturnType< + typeof injectHooksCommandServices +>; + export function getPluginsWithHooks(plugins: IPluginData[]): IPluginData[] { const pluginsWithHooks: IPluginData[] = []; for (const plugin of plugins) { diff --git a/lib/commands/install.ts b/lib/commands/install.ts index 788a3e00b1..eacd029b4d 100644 --- a/lib/commands/install.ts +++ b/lib/commands/install.ts @@ -29,20 +29,7 @@ export type InstallCommandContext = CommandContext< typeof installCommandOptions >; -export interface IInstallCommandServices { - $options: IOptions; - $mobileHelper: Mobile.IMobileHelper; - $platformsDataService: IPlatformsDataService; - $platformCommandHelper: IPlatformCommandHelper; - $projectData: IProjectData; - $projectDataService: IProjectDataService; - $pluginsService: IPluginsService; - $logger: ILogger; - $fs: IFileSystem; - $packageManager: INodePackageManager; -} - -export function setupInstallCommand(): IInstallCommandServices { +export function setupInstallCommand() { const services = { $options: inject("options"), $mobileHelper: inject("mobileHelper"), @@ -64,6 +51,8 @@ export function setupInstallCommand(): IInstallCommandServices { return services; } +export type IInstallCommandServices = ReturnType; + async function installProjectDependencies( context: InstallCommandContext, services: IInstallCommandServices, diff --git a/lib/commands/list-platforms.ts b/lib/commands/list-platforms.ts index 2250ddb5bd..789aa25b0b 100644 --- a/lib/commands/list-platforms.ts +++ b/lib/commands/list-platforms.ts @@ -4,13 +4,7 @@ import { IPlatformCommandHelper } from "../declarations"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -export interface IListPlatformsCommandServices { - $platformCommandHelper: IPlatformCommandHelper; - $projectData: IProjectData; - $logger: ILogger; -} - -export function setupListPlatformsCommand(): IListPlatformsCommandServices { +export function setupListPlatformsCommand() { const services = { $platformCommandHelper: inject( "platformCommandHelper", @@ -23,6 +17,10 @@ export function setupListPlatformsCommand(): IListPlatformsCommandServices { return services; } +export type IListPlatformsCommandServices = ReturnType< + typeof setupListPlatformsCommand +>; + export const listPlatformsCommandDefinition = defineCommand({ name: "platform|*list", description: "Lists all platforms that the project currently targets.", diff --git a/lib/commands/migrate.ts b/lib/commands/migrate.ts index 909511e397..e3345fcd46 100644 --- a/lib/commands/migrate.ts +++ b/lib/commands/migrate.ts @@ -3,15 +3,7 @@ import { IMigrateController, IMigrationData } from "../definitions/migrate"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -export interface IMigrateCommandServices { - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $migrateController: IMigrateController; - $staticConfig: Config.IStaticConfig; - $projectData: IProjectData; - $logger: ILogger; -} - -export function setupMigrateCommand(): IMigrateCommandServices { +export function setupMigrateCommand() { const services = { $devicePlatformsConstants: inject( "devicePlatformsConstants", @@ -26,6 +18,8 @@ export function setupMigrateCommand(): IMigrateCommandServices { return services; } +export type IMigrateCommandServices = ReturnType; + export const migrateCommandDefinition = defineCommand({ name: "migrate", description: diff --git a/lib/commands/native-add.ts b/lib/commands/native-add.ts index 879c169ecb..3567ed31b4 100644 --- a/lib/commands/native-add.ts +++ b/lib/commands/native-add.ts @@ -14,17 +14,11 @@ import { IProjectData } from "../definitions/project"; */ type NativeAddLanguage = "java" | "kotlin" | "swift" | "objective-c"; -export interface INativeAddCommandServices { - $projectData: IProjectData; - $logger: ILogger; - $errors: IErrors; -} - interface INativeAddLanguageCommandServices extends INativeAddCommandServices { language: NativeAddLanguage; } -export function setupNativeAddCommand(): INativeAddCommandServices { +export function setupNativeAddCommand() { const services = { $projectData: inject("projectData"), $logger: inject("logger"), @@ -35,6 +29,10 @@ export function setupNativeAddCommand(): INativeAddCommandServices { return services; } +export type INativeAddCommandServices = ReturnType< + typeof setupNativeAddCommand +>; + function failWithUsage(services: INativeAddCommandServices): void { services.$errors.failWithHelp( "Usage: ns native add [swift|objective-c|java|kotlin] [class name]", diff --git a/lib/commands/open.ts b/lib/commands/open.ts index fe57eabf14..1bf8696964 100644 --- a/lib/commands/open.ts +++ b/lib/commands/open.ts @@ -14,23 +14,7 @@ import { IOptions } from "../declarations"; import { IProjectData } from "../definitions/project"; import type { IOSProjectService } from "../services/ios-project-service"; -export interface IOpenXcodeProjectServices { - $iOSProjectService: IOSProjectService; - $logger: ILogger; - $childProcess: IChildProcess; - $projectData: IProjectData; - $xcodeSelectService: IXcodeSelectService; - $xcodebuildArgsService: IXcodebuildArgsService; -} - -export interface IOpenAndroidStudioServices { - $logger: ILogger; - $liveSyncCommandHelper: ILiveSyncCommandHelper; - $childProcess: IChildProcess; - $projectData: IProjectData; -} - -export function injectOpenXcodeProjectServices(): IOpenXcodeProjectServices { +export function injectOpenXcodeProjectServices() { return { $iOSProjectService: inject("iOSProjectService"), $logger: inject("logger"), @@ -43,7 +27,11 @@ export function injectOpenXcodeProjectServices(): IOpenXcodeProjectServices { }; } -export function injectOpenAndroidStudioServices(): IOpenAndroidStudioServices { +export type IOpenXcodeProjectServices = ReturnType< + typeof injectOpenXcodeProjectServices +>; + +export function injectOpenAndroidStudioServices() { return { $logger: inject("logger"), $liveSyncCommandHelper: inject( @@ -54,6 +42,10 @@ export function injectOpenAndroidStudioServices(): IOpenAndroidStudioServices { }; } +export type IOpenAndroidStudioServices = ReturnType< + typeof injectOpenAndroidStudioServices +>; + export function getAndroidStudioPath(): string | null { const os = currentPlatform(); diff --git a/lib/commands/platform-clean.ts b/lib/commands/platform-clean.ts index 887dc4bdd1..521985c7ce 100644 --- a/lib/commands/platform-clean.ts +++ b/lib/commands/platform-clean.ts @@ -23,16 +23,7 @@ export type PlatformCleanCommandContext = CommandContext< typeof platformCleanCommandOptions >; -export interface IPlatformCleanCommandServices { - $errors: IErrors; - $options: IOptions; - $platformCommandHelper: IPlatformCommandHelper; - $platformValidationService: IPlatformValidationService; - $platformEnvironmentRequirements: IPlatformEnvironmentRequirements; - $projectData: IProjectData; -} - -export function setupPlatformCleanCommand(): IPlatformCleanCommandServices { +export function setupPlatformCleanCommand() { const services = { $errors: inject("errors"), $options: inject("options"), @@ -52,6 +43,10 @@ export function setupPlatformCleanCommand(): IPlatformCleanCommandServices { return services; } +export type IPlatformCleanCommandServices = ReturnType< + typeof setupPlatformCleanCommand +>; + export async function canExecutePlatformCleanCommand( context: PlatformCleanCommandContext, services: IPlatformCleanCommandServices, diff --git a/lib/commands/plugin/add-plugin.ts b/lib/commands/plugin/add-plugin.ts index fda3acf033..66287c59d7 100644 --- a/lib/commands/plugin/add-plugin.ts +++ b/lib/commands/plugin/add-plugin.ts @@ -5,13 +5,7 @@ import { IErrors } from "../../common/declarations"; import { CommandContext, defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export interface IAddPluginCommandServices { - $pluginsService: IPluginsService; - $projectData: IProjectData; - $errors: IErrors; -} - -export function setupAddPluginCommand(): IAddPluginCommandServices { +export function setupAddPluginCommand() { const services = { $pluginsService: inject("pluginsService"), $projectData: inject("projectData"), @@ -22,6 +16,10 @@ export function setupAddPluginCommand(): IAddPluginCommandServices { return services; } +export type IAddPluginCommandServices = ReturnType< + typeof setupAddPluginCommand +>; + export async function canExecuteAddPluginCommand( context: CommandContext, services: IAddPluginCommandServices, diff --git a/lib/commands/plugin/build-plugin.ts b/lib/commands/plugin/build-plugin.ts index 0e5f94c9d1..56eb6327b8 100644 --- a/lib/commands/plugin/build-plugin.ts +++ b/lib/commands/plugin/build-plugin.ts @@ -25,18 +25,7 @@ export type BuildPluginCommandContext = CommandContext< typeof buildPluginCommandOptions >; -export interface IBuildPluginCommandServices { - pluginProjectPath: string; - $androidPluginBuildService: IAndroidPluginBuildService; - $errors: IErrors; - $logger: ILogger; - $fs: IFileSystem; - $tempService: ITempService; -} - -export function setupBuildPluginCommand( - context: BuildPluginCommandContext, -): IBuildPluginCommandServices { +export function setupBuildPluginCommand(context: BuildPluginCommandContext) { return { pluginProjectPath: path.resolve(context.options.path || "."), $androidPluginBuildService: inject( @@ -49,6 +38,10 @@ export function setupBuildPluginCommand( }; } +export type IBuildPluginCommandServices = ReturnType< + typeof setupBuildPluginCommand +>; + export async function canExecuteBuildPluginCommand( context: BuildPluginCommandContext, services: IBuildPluginCommandServices, diff --git a/lib/commands/plugin/create-plugin.ts b/lib/commands/plugin/create-plugin.ts index 71bb45340a..d164ab3cc2 100644 --- a/lib/commands/plugin/create-plugin.ts +++ b/lib/commands/plugin/create-plugin.ts @@ -35,18 +35,7 @@ export type CreatePluginCommandContext = CommandContext< typeof createPluginCommandOptions >; -export interface ICreatePluginCommandServices { - $errors: IErrors; - $terminalSpinnerService: ITerminalSpinnerService; - $logger: ILogger; - $pacoteService: IPacoteService; - $fs: IFileSystem; - $childProcess: IChildProcess; - $prompter: IPrompter; - $packageManager: INodePackageManager; -} - -export function setupCreatePluginCommand(): ICreatePluginCommandServices { +export function setupCreatePluginCommand() { return { $errors: inject("errors"), $terminalSpinnerService: inject( @@ -61,6 +50,10 @@ export function setupCreatePluginCommand(): ICreatePluginCommandServices { }; } +export type ICreatePluginCommandServices = ReturnType< + typeof setupCreatePluginCommand +>; + function ensurePackageDir( services: ICreatePluginCommandServices, projectDir: string, diff --git a/lib/commands/plugin/list-plugins.ts b/lib/commands/plugin/list-plugins.ts index 771a3f595e..cd589964ed 100644 --- a/lib/commands/plugin/list-plugins.ts +++ b/lib/commands/plugin/list-plugins.ts @@ -9,13 +9,7 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { color } from "../../color"; -export interface IListPluginsCommandServices { - $pluginsService: IPluginsService; - $projectData: IProjectData; - $logger: ILogger; -} - -export function setupListPluginsCommand(): IListPluginsCommandServices { +export function setupListPluginsCommand() { const services = { $pluginsService: inject("pluginsService"), $projectData: inject("projectData"), @@ -26,6 +20,10 @@ export function setupListPluginsCommand(): IListPluginsCommandServices { return services; } +export type IListPluginsCommandServices = ReturnType< + typeof setupListPluginsCommand +>; + function createTableCells(items: IBasePluginData[]): string[][] { return items.map((item) => [item.name, item.version]); } diff --git a/lib/commands/plugin/remove-plugin.ts b/lib/commands/plugin/remove-plugin.ts index 4feb50305b..8cb0683c8e 100644 --- a/lib/commands/plugin/remove-plugin.ts +++ b/lib/commands/plugin/remove-plugin.ts @@ -5,14 +5,7 @@ import { IErrors } from "../../common/declarations"; import { CommandContext, defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export interface IRemovePluginCommandServices { - $pluginsService: IPluginsService; - $errors: IErrors; - $logger: ILogger; - $projectData: IProjectData; -} - -export function setupRemovePluginCommand(): IRemovePluginCommandServices { +export function setupRemovePluginCommand() { const services = { $pluginsService: inject("pluginsService"), $errors: inject("errors"), @@ -24,6 +17,10 @@ export function setupRemovePluginCommand(): IRemovePluginCommandServices { return services; } +export type IRemovePluginCommandServices = ReturnType< + typeof setupRemovePluginCommand +>; + export async function canExecuteRemovePluginCommand( context: CommandContext, services: IRemovePluginCommandServices, diff --git a/lib/commands/plugin/update-plugin.ts b/lib/commands/plugin/update-plugin.ts index 1180850f2f..c2672960bb 100644 --- a/lib/commands/plugin/update-plugin.ts +++ b/lib/commands/plugin/update-plugin.ts @@ -5,13 +5,7 @@ import { IErrors } from "../../common/declarations"; import { CommandContext, defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export interface IUpdatePluginCommandServices { - $pluginsService: IPluginsService; - $projectData: IProjectData; - $errors: IErrors; -} - -export function setupUpdatePluginCommand(): IUpdatePluginCommandServices { +export function setupUpdatePluginCommand() { const services = { $pluginsService: inject("pluginsService"), $projectData: inject("projectData"), @@ -22,6 +16,10 @@ export function setupUpdatePluginCommand(): IUpdatePluginCommandServices { return services; } +export type IUpdatePluginCommandServices = ReturnType< + typeof setupUpdatePluginCommand +>; + export async function canExecuteUpdatePluginCommand( context: CommandContext, services: IUpdatePluginCommandServices, diff --git a/lib/commands/post-install.ts b/lib/commands/post-install.ts index bcfdab2fd4..c7cea7d480 100644 --- a/lib/commands/post-install.ts +++ b/lib/commands/post-install.ts @@ -10,17 +10,7 @@ import { CommandContext, defineCommand } from "../common/define-command"; import { inject } from "../common/di"; import { doesCurrentNpmCommandMatch } from "../common/helpers"; -export interface IPostInstallCliCommandServices { - $fs: IFileSystem; - $commandsService: ICommandsService; - $helpService: IHelpService; - $settingsService: ISettingsService; - $analyticsService: IAnalyticsService; - $logger: ILogger; - $hostInfo: IHostInfo; -} - -export function setupPostInstallCliCommand(): IPostInstallCliCommandServices { +export function setupPostInstallCliCommand() { return { $fs: inject("fs"), $commandsService: inject("commandsService"), @@ -32,6 +22,10 @@ export function setupPostInstallCliCommand(): IPostInstallCliCommandServices { }; } +export type IPostInstallCliCommandServices = ReturnType< + typeof setupPostInstallCliCommand +>; + export async function runPostInstallCliCommand( context: CommandContext, services: IPostInstallCliCommandServices, diff --git a/lib/commands/prepare.ts b/lib/commands/prepare.ts index 18bb43e76c..a6e73d3577 100644 --- a/lib/commands/prepare.ts +++ b/lib/commands/prepare.ts @@ -1,7 +1,6 @@ import { canExecuteCommandBase, injectPlatformCommandServices, - IPlatformCommandServices, platformArgument, validatePlatformArgument, validatePlatformOptions, @@ -28,13 +27,7 @@ export type PrepareCommandContext = CommandContext< typeof prepareCommandOptions >; -export interface IPrepareCommandServices extends IPlatformCommandServices { - $prepareController: PrepareController; - $prepareDataService: PrepareDataService; - $migrateController: IMigrateController; -} - -export function setupPrepareCommand(): IPrepareCommandServices { +export function setupPrepareCommand() { const services = { ...injectPlatformCommandServices(), $prepareController: inject("prepareController"), @@ -46,6 +39,8 @@ export function setupPrepareCommand(): IPrepareCommandServices { return services; } +export type IPrepareCommandServices = ReturnType; + export async function canExecutePrepareCommand( context: PrepareCommandContext, services: IPrepareCommandServices, diff --git a/lib/commands/preview.ts b/lib/commands/preview.ts index 8e19715443..c70d667a6d 100644 --- a/lib/commands/preview.ts +++ b/lib/commands/preview.ts @@ -23,15 +23,7 @@ export type PreviewCommandContext = CommandContext< typeof previewCommandOptions >; -export interface IPreviewCommandServices { - $childProcess: IChildProcess; - $errors: IErrors; - $logger: ILogger; - $packageManager: IPackageManager; - $projectData: IProjectData; -} - -export function setupPreviewCommand(): IPreviewCommandServices { +export function setupPreviewCommand() { return { $childProcess: inject("childProcess"), $errors: inject("errors"), @@ -41,6 +33,8 @@ export function setupPreviewCommand(): IPreviewCommandServices { }; } +export type IPreviewCommandServices = ReturnType; + function getPreviewCLIPath(services: IPreviewCommandServices): string { return resolvePackagePath(PREVIEW_CLI_PACKAGE, { paths: [services.$projectData.projectDir], diff --git a/lib/commands/remove-platform.ts b/lib/commands/remove-platform.ts index 67e6805f64..f690f0455b 100644 --- a/lib/commands/remove-platform.ts +++ b/lib/commands/remove-platform.ts @@ -8,14 +8,7 @@ import { IErrors } from "../common/declarations"; import { CommandContext, defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -export interface IRemovePlatformCommandServices { - $errors: IErrors; - $platformCommandHelper: IPlatformCommandHelper; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; -} - -export function setupRemovePlatformCommand(): IRemovePlatformCommandServices { +export function setupRemovePlatformCommand() { const services = { $errors: inject("errors"), $platformCommandHelper: inject( @@ -31,6 +24,10 @@ export function setupRemovePlatformCommand(): IRemovePlatformCommandServices { return services; } +export type IRemovePlatformCommandServices = ReturnType< + typeof setupRemovePlatformCommand +>; + export async function canExecuteRemovePlatformCommand( context: CommandContext, services: IRemovePlatformCommandServices, diff --git a/lib/commands/resources/resources-update.ts b/lib/commands/resources/resources-update.ts index 0be239f3da..d958e9905f 100644 --- a/lib/commands/resources/resources-update.ts +++ b/lib/commands/resources/resources-update.ts @@ -4,13 +4,7 @@ import { IErrors } from "../../common/declarations"; import { CommandContext, defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export interface IResourcesUpdateCommandServices { - $projectData: IProjectData; - $errors: IErrors; - $androidResourcesMigrationService: IAndroidResourcesMigrationService; -} - -export function setupResourcesUpdateCommand(): IResourcesUpdateCommandServices { +export function setupResourcesUpdateCommand() { const services = { $projectData: inject("projectData"), $errors: inject("errors"), @@ -24,6 +18,10 @@ export function setupResourcesUpdateCommand(): IResourcesUpdateCommandServices { return services; } +export type IResourcesUpdateCommandServices = ReturnType< + typeof setupResourcesUpdateCommand +>; + export async function canExecuteResourcesUpdateCommand( context: CommandContext, services: IResourcesUpdateCommandServices, diff --git a/lib/commands/test-init.ts b/lib/commands/test-init.ts index 0db996dc5f..224e9e6e8f 100644 --- a/lib/commands/test-init.ts +++ b/lib/commands/test-init.ts @@ -31,20 +31,7 @@ const testInitCommandOptions = { framework: stringOption(), } satisfies CommandOptionsSchema; -interface ITestInitCommandServices { - $errors: IErrors; - $fs: IFileSystem; - $logger: ILogger; - $options: IOptions; - $packageManager: INodePackageManager; - $pluginsService: IPluginsService; - $projectData: IProjectData; - $prompter: IPrompter; - $resources: IResourceLoader; - $testInitializationService: ITestInitializationService; -} - -function setupTestInitCommand(): ITestInitCommandServices { +function setupTestInitCommand() { const services = { $errors: inject("errors"), $fs: inject("fs"), @@ -64,6 +51,8 @@ function setupTestInitCommand(): ITestInitCommandServices { return services; } +type ITestInitCommandServices = ReturnType; + /** * Android blocks cleartext traffic by default (API 28+), which would * reject the runner's ws:// connection to the host. Scope the exception diff --git a/lib/commands/test.ts b/lib/commands/test.ts index 49c528f48f..2715f37d98 100644 --- a/lib/commands/test.ts +++ b/lib/commands/test.ts @@ -51,25 +51,7 @@ const testCommandOptions = { export type TestCommandContext = CommandContext; -export interface ITestCommandServices { - platform: string; - $analyticsService: IAnalyticsService; - $cleanupService: ICleanupService; - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $liveSyncCommandHelper: ILiveSyncCommandHelper; - $logger: ILogger; - $migrateController: IMigrateController; - $options: IOptions; - $platformEnvironmentRequirements: IPlatformEnvironmentRequirements; - $projectData: IProjectData; - $testExecutionService: ITestExecutionService; - $vitestExecutionService: IVitestExecutionService; -} - -export function setupTestCommand( - testPlatform: TestPlatform, -): ITestCommandServices { +export function setupTestCommand(testPlatform: TestPlatform) { return { platform: testPlatform, $analyticsService: inject("analyticsService"), @@ -95,6 +77,8 @@ export function setupTestCommand( }; } +export type ITestCommandServices = ReturnType; + export async function canExecuteTestCommand( context: TestCommandContext, services: ITestCommandServices, diff --git a/lib/commands/typings.ts b/lib/commands/typings.ts index 79152d97de..d8c5d9175c 100644 --- a/lib/commands/typings.ts +++ b/lib/commands/typings.ts @@ -25,19 +25,7 @@ export type TypingsCommandContext = CommandContext< typeof typingsCommandOptions >; -export interface ITypingsCommandServices { - $childProcess: IChildProcess; - $fs: IFileSystem; - $hostInfo: IHostInfo; - $logger: ILogger; - $mobileHelper: Mobile.IMobileHelper; - $options: IOptions; - $projectData: IProjectData; - $prompter: IPrompter; - $staticConfig: IStaticConfig; -} - -export function setupTypingsCommand(): ITypingsCommandServices { +export function setupTypingsCommand() { return { $childProcess: inject("childProcess"), $fs: inject("fs"), @@ -51,6 +39,8 @@ export function setupTypingsCommand(): ITypingsCommandServices { }; } +export type ITypingsCommandServices = ReturnType; + async function resolveGradleDependencies( services: ITypingsCommandServices, target: string, diff --git a/lib/commands/update-platform.ts b/lib/commands/update-platform.ts index 85eb8e61f7..7eb4bd017b 100644 --- a/lib/commands/update-platform.ts +++ b/lib/commands/update-platform.ts @@ -13,16 +13,7 @@ import { IErrors } from "../common/declarations"; import { CommandContext, defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -export interface IUpdatePlatformCommandServices { - $errors: IErrors; - $options: IOptions; - $platformEnvironmentRequirements: IPlatformEnvironmentRequirements; - $platformCommandHelper: IPlatformCommandHelper; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; -} - -export function setupUpdatePlatformCommand(): IUpdatePlatformCommandServices { +export function setupUpdatePlatformCommand() { const services = { $errors: inject("errors"), $options: inject("options"), @@ -42,6 +33,10 @@ export function setupUpdatePlatformCommand(): IUpdatePlatformCommandServices { return services; } +export type IUpdatePlatformCommandServices = ReturnType< + typeof setupUpdatePlatformCommand +>; + export async function canExecuteUpdatePlatformCommand( context: CommandContext, services: IUpdatePlatformCommandServices, diff --git a/lib/commands/update.ts b/lib/commands/update.ts index 71524be88d..367736baca 100644 --- a/lib/commands/update.ts +++ b/lib/commands/update.ts @@ -21,17 +21,7 @@ const updateCommandOptions = { export type UpdateCommandContext = CommandContext; -export interface IUpdateCommandServices { - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $updateController: IUpdateController; - $migrateController: IMigrateController; - $errors: IErrors; - $logger: ILogger; - $projectData: IProjectData; - $markingModeService: IMarkingModeService; -} - -export function setupUpdateCommand(): IUpdateCommandServices { +export function setupUpdateCommand() { const services = { $devicePlatformsConstants: inject( "devicePlatformsConstants", @@ -48,6 +38,8 @@ export function setupUpdateCommand(): IUpdateCommandServices { return services; } +export type IUpdateCommandServices = ReturnType; + export async function canExecuteUpdateCommand( context: UpdateCommandContext, services: IUpdateCommandServices, diff --git a/lib/common/commands/analytics.ts b/lib/common/commands/analytics.ts index 36e39f76b8..3fee2c4041 100644 --- a/lib/common/commands/analytics.ts +++ b/lib/common/commands/analytics.ts @@ -26,16 +26,7 @@ export type AnalyticsCommandContext = CommandContext< typeof analyticsCommandOptions >; -export interface IAnalyticsCommandServices { - settingName: string; - humanReadableSettingName: string; - $analyticsService: IAnalyticsService; - $logger: ILogger; -} - -export function setupAnalyticsCommand( - setting: IAnalyticsSetting, -): IAnalyticsCommandServices { +export function setupAnalyticsCommand(setting: IAnalyticsSetting) { const $staticConfig = inject("staticConfig"); return { @@ -46,6 +37,10 @@ export function setupAnalyticsCommand( }; } +export type IAnalyticsCommandServices = ReturnType< + typeof setupAnalyticsCommand +>; + export function validateAnalyticsState(value: string): boolean | string { switch ((value || "").toLowerCase()) { case "enable": diff --git a/lib/common/commands/autocompletion.ts b/lib/common/commands/autocompletion.ts index 30d6ebed5f..774b75be67 100644 --- a/lib/common/commands/autocompletion.ts +++ b/lib/common/commands/autocompletion.ts @@ -3,12 +3,7 @@ import { IAutoCompletionService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -export interface IAutoCompleteCommandServices { - $autoCompletionService: IAutoCompletionService; - $logger: ILogger; -} - -export function injectAutoCompleteCommandServices(): IAutoCompleteCommandServices { +export function injectAutoCompleteCommandServices() { return { $autoCompletionService: inject( "autoCompletionService", @@ -17,6 +12,10 @@ export function injectAutoCompleteCommandServices(): IAutoCompleteCommandService }; } +export type IAutoCompleteCommandServices = ReturnType< + typeof injectAutoCompleteCommandServices +>; + export const autoCompleteCommandDefinition = defineCommand({ name: "autocomplete|*default", description: "Prompts to enable command-line completion for the CLI.", diff --git a/lib/common/commands/device/device-log-stream.ts b/lib/common/commands/device/device-log-stream.ts index 8c0dcda6f6..ccc7f7ff12 100644 --- a/lib/common/commands/device/device-log-stream.ts +++ b/lib/common/commands/device/device-log-stream.ts @@ -19,15 +19,7 @@ export type OpenDeviceLogStreamCommandContext = CommandContext< typeof openDeviceLogStreamCommandOptions >; -export interface IOpenDeviceLogStreamCommandServices { - $commandsService: ICommandsService; - $deviceLogProvider: Mobile.IDeviceLogProvider; - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $loggingLevels: Mobile.ILoggingLevels; -} - -export function setupOpenDeviceLogStreamCommand(): IOpenDeviceLogStreamCommandServices { +export function setupOpenDeviceLogStreamCommand() { // The log stream is the command's whole output, so neither the simulator log // provider nor the cleanup process may be torn down while it is open. The // legacy command did this from its constructor, which ran before anything @@ -46,6 +38,10 @@ export function setupOpenDeviceLogStreamCommand(): IOpenDeviceLogStreamCommandSe }; } +export type IOpenDeviceLogStreamCommandServices = ReturnType< + typeof setupOpenDeviceLogStreamCommand +>; + export async function runOpenDeviceLogStreamCommand( context: OpenDeviceLogStreamCommandContext, services: IOpenDeviceLogStreamCommandServices, diff --git a/lib/common/commands/device/get-file.ts b/lib/common/commands/device/get-file.ts index a005cd5361..7265fe5f71 100644 --- a/lib/common/commands/device/get-file.ts +++ b/lib/common/commands/device/get-file.ts @@ -17,13 +17,7 @@ export type GetFileCommandContext = CommandContext< typeof getFileCommandOptions >; -export interface IGetFileCommandServices { - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $projectData: IProjectData; -} - -export function setupGetFileCommand(): IGetFileCommandServices { +export function setupGetFileCommand() { return { $devicesService: inject("devicesService"), $errors: inject("errors"), @@ -31,6 +25,8 @@ export function setupGetFileCommand(): IGetFileCommandServices { }; } +export type IGetFileCommandServices = ReturnType; + export async function runGetFileCommand( context: GetFileCommandContext, services: IGetFileCommandServices, diff --git a/lib/common/commands/device/list-applications.ts b/lib/common/commands/device/list-applications.ts index b53f6af372..d67a9fc058 100644 --- a/lib/common/commands/device/list-applications.ts +++ b/lib/common/commands/device/list-applications.ts @@ -17,18 +17,17 @@ export type ListApplicationsCommandContext = CommandContext< typeof listApplicationsCommandOptions >; -export interface IListApplicationsCommandServices { - $devicesService: Mobile.IDevicesService; - $logger: ILogger; -} - -export function setupListApplicationsCommand(): IListApplicationsCommandServices { +export function setupListApplicationsCommand() { return { $devicesService: inject("devicesService"), $logger: inject("logger"), }; } +export type IListApplicationsCommandServices = ReturnType< + typeof setupListApplicationsCommand +>; + export async function runListApplicationsCommand( context: ListApplicationsCommandContext, services: IListApplicationsCommandServices, diff --git a/lib/common/commands/device/list-devices.ts b/lib/common/commands/device/list-devices.ts index 3564288a9e..8e18771662 100644 --- a/lib/common/commands/device/list-devices.ts +++ b/lib/common/commands/device/list-devices.ts @@ -20,15 +20,7 @@ export type ListDevicesCommandContext = CommandContext< typeof listDevicesCommandOptions >; -export interface IListDevicesCommandServices { - $devicesService: Mobile.IDevicesService; - $emulatorHelper: Mobile.IEmulatorHelper; - $errors: IErrors; - $logger: ILogger; - $mobileHelper: Mobile.IMobileHelper; -} - -export function setupListDevicesCommand(): IListDevicesCommandServices { +export function setupListDevicesCommand() { return { $devicesService: inject("devicesService"), $emulatorHelper: inject("emulatorHelper"), @@ -38,6 +30,10 @@ export function setupListDevicesCommand(): IListDevicesCommandServices { }; } +export type IListDevicesCommandServices = ReturnType< + typeof setupListDevicesCommand +>; + function printEmulators( services: IListDevicesCommandServices, emulators: Mobile.IDeviceInfo[], @@ -175,10 +171,6 @@ export const listDevicesCommandDefinition = defineCommand({ }, }); -interface IListPlatformDevicesCommandServices extends IListDevicesCommandServices { - platform: string; -} - const defineListPlatformDevicesCommand = ( name: TName, listedPlatform: "iOS" | "Android", @@ -188,7 +180,7 @@ const defineListPlatformDevicesCommand = ( description: "Lists the connected devices and emulators for one platform.", options: listDevicesCommandOptions, arguments: "none", - setup(): IListPlatformDevicesCommandServices { + setup() { const $devicePlatformsConstants = inject("devicePlatformsConstants"); diff --git a/lib/common/commands/device/list-files.ts b/lib/common/commands/device/list-files.ts index 29b02f3097..37831a6cca 100644 --- a/lib/common/commands/device/list-files.ts +++ b/lib/common/commands/device/list-files.ts @@ -16,13 +16,7 @@ export type ListFilesCommandContext = CommandContext< typeof listFilesCommandOptions >; -export interface IListFilesCommandServices { - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $projectData: IProjectData; -} - -export function setupListFilesCommand(): IListFilesCommandServices { +export function setupListFilesCommand() { return { $devicesService: inject("devicesService"), $errors: inject("errors"), @@ -30,6 +24,10 @@ export function setupListFilesCommand(): IListFilesCommandServices { }; } +export type IListFilesCommandServices = ReturnType< + typeof setupListFilesCommand +>; + export async function runListFilesCommand( context: ListFilesCommandContext, services: IListFilesCommandServices, diff --git a/lib/common/commands/device/put-file.ts b/lib/common/commands/device/put-file.ts index b2ff383305..043f4fc86d 100644 --- a/lib/common/commands/device/put-file.ts +++ b/lib/common/commands/device/put-file.ts @@ -16,13 +16,7 @@ export type PutFileCommandContext = CommandContext< typeof putFileCommandOptions >; -export interface IPutFileCommandServices { - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $projectData: IProjectData; -} - -export function setupPutFileCommand(): IPutFileCommandServices { +export function setupPutFileCommand() { return { $devicesService: inject("devicesService"), $errors: inject("errors"), @@ -30,6 +24,8 @@ export function setupPutFileCommand(): IPutFileCommandServices { }; } +export type IPutFileCommandServices = ReturnType; + export async function runPutFileCommand( context: PutFileCommandContext, services: IPutFileCommandServices, diff --git a/lib/common/commands/device/run-application.ts b/lib/common/commands/device/run-application.ts index 147060988f..b56f10c32a 100644 --- a/lib/common/commands/device/run-application.ts +++ b/lib/common/commands/device/run-application.ts @@ -15,13 +15,7 @@ export type RunApplicationOnDeviceCommandContext = CommandContext< typeof runApplicationOnDeviceCommandOptions >; -export interface IRunApplicationOnDeviceCommandServices { - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $staticConfig: Config.IStaticConfig; -} - -export function setupRunApplicationOnDeviceCommand(): IRunApplicationOnDeviceCommandServices { +export function setupRunApplicationOnDeviceCommand() { return { $devicesService: inject("devicesService"), $errors: inject("errors"), @@ -29,6 +23,10 @@ export function setupRunApplicationOnDeviceCommand(): IRunApplicationOnDeviceCom }; } +export type IRunApplicationOnDeviceCommandServices = ReturnType< + typeof setupRunApplicationOnDeviceCommand +>; + export async function runRunApplicationOnDeviceCommand( context: RunApplicationOnDeviceCommandContext, services: IRunApplicationOnDeviceCommandServices, diff --git a/lib/common/commands/device/stop-application.ts b/lib/common/commands/device/stop-application.ts index 7c9c7a0e48..d151c82538 100644 --- a/lib/common/commands/device/stop-application.ts +++ b/lib/common/commands/device/stop-application.ts @@ -14,16 +14,16 @@ export type StopApplicationOnDeviceCommandContext = CommandContext< typeof stopApplicationOnDeviceCommandOptions >; -export interface IStopApplicationOnDeviceCommandServices { - $devicesService: Mobile.IDevicesService; -} - -export function setupStopApplicationOnDeviceCommand(): IStopApplicationOnDeviceCommandServices { +export function setupStopApplicationOnDeviceCommand() { return { $devicesService: inject("devicesService"), }; } +export type IStopApplicationOnDeviceCommandServices = ReturnType< + typeof setupStopApplicationOnDeviceCommand +>; + export async function runStopApplicationOnDeviceCommand( context: StopApplicationOnDeviceCommandContext, services: IStopApplicationOnDeviceCommandServices, diff --git a/lib/common/commands/device/uninstall-application.ts b/lib/common/commands/device/uninstall-application.ts index b37cffaa2d..ca14a530e4 100644 --- a/lib/common/commands/device/uninstall-application.ts +++ b/lib/common/commands/device/uninstall-application.ts @@ -14,16 +14,16 @@ export type UninstallApplicationCommandContext = CommandContext< typeof uninstallApplicationCommandOptions >; -export interface IUninstallApplicationCommandServices { - $devicesService: Mobile.IDevicesService; -} - -export function setupUninstallApplicationCommand(): IUninstallApplicationCommandServices { +export function setupUninstallApplicationCommand() { return { $devicesService: inject("devicesService"), }; } +export type IUninstallApplicationCommandServices = ReturnType< + typeof setupUninstallApplicationCommand +>; + export async function runUninstallApplicationCommand( context: UninstallApplicationCommandContext, services: IUninstallApplicationCommandServices, diff --git a/lib/common/commands/doctor.ts b/lib/common/commands/doctor.ts index b780b0d9e5..1657545889 100644 --- a/lib/common/commands/doctor.ts +++ b/lib/common/commands/doctor.ts @@ -3,15 +3,7 @@ import { CommandName, defineCommand } from "../define-command"; import { inject } from "../di"; import { PlatformTypes } from "../../constants"; -export interface IDoctorCommandServices { - platform: PlatformTypes; - $doctorService: IDoctorService; - $projectHelper: IProjectHelper; -} - -export function setupDoctorCommand( - platform?: PlatformTypes, -): IDoctorCommandServices { +export function setupDoctorCommand(platform?: PlatformTypes) { return { platform, $doctorService: inject("doctorService"), @@ -19,6 +11,8 @@ export function setupDoctorCommand( }; } +export type IDoctorCommandServices = ReturnType; + const defineDoctorCommand = ( name: TName, platform?: PlatformTypes, diff --git a/lib/common/commands/help.ts b/lib/common/commands/help.ts index eba2fad259..25b430dced 100644 --- a/lib/common/commands/help.ts +++ b/lib/common/commands/help.ts @@ -15,18 +15,15 @@ export const helpCommandOptions = { export type HelpCommandContext = CommandContext; -export interface IHelpCommandServices { - $commandRegistry: CommandRegistry; - $helpService: IHelpService; -} - -export function setupHelpCommand(): IHelpCommandServices { +export function setupHelpCommand() { return { $commandRegistry: inject(CommandRegistry), $helpService: inject("helpService"), }; } +export type IHelpCommandServices = ReturnType; + export async function runHelpCommand( context: HelpCommandContext, services: IHelpCommandServices, diff --git a/lib/common/commands/package-manager-get.ts b/lib/common/commands/package-manager-get.ts index f47eabaf0d..95c8b20732 100644 --- a/lib/common/commands/package-manager-get.ts +++ b/lib/common/commands/package-manager-get.ts @@ -2,18 +2,17 @@ import { IUserSettingsService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -export interface IPackageManagerGetCommandServices { - $logger: ILogger; - $userSettingsService: IUserSettingsService; -} - -export function setupPackageManagerGetCommand(): IPackageManagerGetCommandServices { +export function setupPackageManagerGetCommand() { return { $logger: inject("logger"), $userSettingsService: inject("userSettingsService"), }; } +export type IPackageManagerGetCommandServices = ReturnType< + typeof setupPackageManagerGetCommand +>; + export const packageManagerGetCommandDefinition = defineCommand({ name: "package-manager|*get", description: "Prints the value of the current package manager.", diff --git a/lib/common/commands/package-manager-set.ts b/lib/common/commands/package-manager-set.ts index d192218634..9b831a6658 100644 --- a/lib/common/commands/package-manager-set.ts +++ b/lib/common/commands/package-manager-set.ts @@ -3,13 +3,7 @@ import { IErrors, IUserSettingsService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -export interface IPackageManagerSetCommandServices { - $userSettingsService: IUserSettingsService; - $errors: IErrors; - $logger: ILogger; -} - -export function setupPackageManagerSetCommand(): IPackageManagerSetCommandServices { +export function setupPackageManagerSetCommand() { return { $userSettingsService: inject("userSettingsService"), $errors: inject("errors"), @@ -17,6 +11,10 @@ export function setupPackageManagerSetCommand(): IPackageManagerSetCommandServic }; } +export type IPackageManagerSetCommandServices = ReturnType< + typeof setupPackageManagerSetCommand +>; + export const packageManagerSetCommandDefinition = defineCommand({ name: "package-manager|set", description: "Sets the package manager the CLI installs dependencies with.", diff --git a/lib/common/commands/preuninstall.ts b/lib/common/commands/preuninstall.ts index 0eaa95b4c2..f26e01636e 100644 --- a/lib/common/commands/preuninstall.ts +++ b/lib/common/commands/preuninstall.ts @@ -17,15 +17,7 @@ import { IExtensibilityService } from "../definitions/extensibility"; // disabled for now (6/24/2020) // const FEEDBACK_FORM_URL = "https://www.nativescript.org/uninstall-feedback"; -export interface IPreUninstallCommandServices { - $analyticsService: IAnalyticsService; - $extensibilityService: IExtensibilityService; - $fs: IFileSystem; - $packageInstallationManager: IPackageInstallationManager; - $settingsService: ISettingsService; -} - -export function setupPreUninstallCommand(): IPreUninstallCommandServices { +export function setupPreUninstallCommand() { return { $analyticsService: inject("analyticsService"), $extensibilityService: inject( @@ -39,6 +31,10 @@ export function setupPreUninstallCommand(): IPreUninstallCommandServices { }; } +export type IPreUninstallCommandServices = ReturnType< + typeof setupPreUninstallCommand +>; + async function handleFeedbackForm(): Promise { // disabled for now (6/24/2020) // if (isInteractive()) { diff --git a/lib/common/commands/proxy/proxy-base.ts b/lib/common/commands/proxy/proxy-base.ts index e9fbd68597..222609d044 100644 --- a/lib/common/commands/proxy/proxy-base.ts +++ b/lib/common/commands/proxy/proxy-base.ts @@ -1,13 +1,7 @@ import { IAnalyticsService, IProxyService } from "../../declarations"; import { inject } from "../../di"; -export interface IProxyCommandServices { - $analyticsService: IAnalyticsService; - $logger: ILogger; - $proxyService: IProxyService; -} - -export function injectProxyCommandServices(): IProxyCommandServices { +export function injectProxyCommandServices() { return { $analyticsService: inject("analyticsService"), $logger: inject("logger"), @@ -15,6 +9,10 @@ export function injectProxyCommandServices(): IProxyCommandServices { }; } +export type IProxyCommandServices = ReturnType< + typeof injectProxyCommandServices +>; + export async function tryTrackProxyCommandUsage( services: IProxyCommandServices, commandName: string, diff --git a/lib/common/commands/proxy/proxy-set.ts b/lib/common/commands/proxy/proxy-set.ts index fd99b24ba9..0edf26251b 100644 --- a/lib/common/commands/proxy/proxy-set.ts +++ b/lib/common/commands/proxy/proxy-set.ts @@ -17,7 +17,6 @@ import { inject } from "../../di"; import { isInteractive } from "../../helpers"; import { injectProxyCommandServices, - IProxyCommandServices, tryTrackProxyCommandUsage, } from "./proxy-base"; const { getCredentialsFromAuth } = require("proxy-lib/lib/utils"); @@ -32,14 +31,7 @@ export type ProxySetCommandContext = CommandContext< typeof proxySetCommandOptions >; -export interface IProxySetCommandServices extends IProxyCommandServices { - $errors: IErrors; - $hostInfo: IHostInfo; - $prompter: IPrompter; - $staticConfig: Config.IStaticConfig; -} - -export function setupProxySetCommand(): IProxySetCommandServices { +export function setupProxySetCommand() { return { ...injectProxyCommandServices(), $errors: inject("errors"), @@ -49,6 +41,8 @@ export function setupProxySetCommand(): IProxySetCommandServices { }; } +export type IProxySetCommandServices = ReturnType; + function isPasswordRequired(username: string, password: string): boolean { return !!(username && !password); } From c5021434ea70f6cb94823ede18b11be96e64097d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 17:49:14 -0300 Subject: [PATCH 10/19] feat(commands): provide the invocation context through an injection token The adapter now builds a child injector per invocation providing COMMAND_CONTEXT, and runs setup, canExecute, run, postRun and shortcuts under it. Handler signatures are unchanged; the token is the way a service or a field initializer reaches the context without threading it through. The provider reads the stage's own context, and nothing outside an invocation can resolve the token. --- lib/common/contracts/command-context.ts | 12 ++++ lib/common/contracts/index.ts | 1 + .../services/command-definition-adapter.ts | 49 ++++++++++--- test/define-command.ts | 71 +++++++++++++++++++ 4 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 lib/common/contracts/command-context.ts diff --git a/lib/common/contracts/command-context.ts b/lib/common/contracts/command-context.ts new file mode 100644 index 0000000000..322f9166e2 --- /dev/null +++ b/lib/common/contracts/command-context.ts @@ -0,0 +1,12 @@ +import { InjectionToken } from "../di/injection-token"; +import type { CommandContext } from "../define-command"; + +/** + * The context of the command invocation that is running. Provided by a child + * injector the adapter builds per invocation, so it resolves inside `setup`, + * `canExecute`, `run`, `postRun` and `shortcuts` — and nowhere else. A service + * registered on the root injector never sees it. + */ +export const COMMAND_CONTEXT = new InjectionToken>( + "commandContext", +); diff --git a/lib/common/contracts/index.ts b/lib/common/contracts/index.ts index 94e37d0104..6c5d9ec26f 100644 --- a/lib/common/contracts/index.ts +++ b/lib/common/contracts/index.ts @@ -14,5 +14,6 @@ export type { DeferredCommandRejection, DeferredCommandResult, } from "./command-registry"; +export { COMMAND_CONTEXT } from "./command-context"; export { ModuleRegistry } from "./module-registry"; export { PublicApiBuilder } from "./public-api-builder"; diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index cfe0a4ca53..a23e02ea69 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -5,6 +5,7 @@ import { getCurrentInjector, runInInjectionContext } from "../di/inject"; import { Injector } from "../di/injector"; import { IDictionary, IDashedOption, IErrors } from "../declarations"; import { ICommand } from "../definitions/commands"; +import { COMMAND_CONTEXT } from "../contracts/command-context"; import { COMMAND_OWNER, CommandRegistry, @@ -335,6 +336,9 @@ export function createCommandFromDefinition< // The state of one invocation. The command object itself is cached for the // process, so nothing invocation-scoped may live outside one of these. interface Invocation { + /** The context of the stage that is running; COMMAND_CONTEXT reads it. */ + context: CommandContext; + injector: Injector; setup: Promise>; hasRun: boolean; runResult?: Awaited; @@ -342,6 +346,7 @@ export function createCommandFromDefinition< const startSetup = ( context: CommandContext, + injector: Injector, ): Promise> => // The executor runs synchronously, so setup keeps its injection context // up to its first await, while a synchronous failure - ctx.fail() is one - @@ -350,7 +355,7 @@ export function createCommandFromDefinition< resolve( definition.setup ? ( - runInInjectionContext(targetInjector, () => + runInInjectionContext(injector, () => definition.setup.call(definition, context), ) ) @@ -365,9 +370,25 @@ export function createCommandFromDefinition< let currentInvocation: Invocation = null; const beginInvocation = (context: CommandContext): Invocation => { - currentInvocation = { setup: startSetup(context), hasRun: false }; + const invocation: Invocation = { + context, + // Each entry point builds its own context object, so the token reads + // the live one rather than a snapshot: a handler that injects it gets + // the very context it was handed. + injector: targetInjector.createChild([ + { + provide: COMMAND_CONTEXT, + useFactory: () => invocation.context, + shared: false, + }, + ]), + setup: undefined, + hasRun: false, + }; + invocation.setup = startSetup(context, invocation.injector); + currentInvocation = invocation; - return currentInvocation; + return invocation; }; /** @@ -377,6 +398,7 @@ export function createCommandFromDefinition< * take the host's keys with it. */ const attachShortcuts = ( + invocation: Invocation, context: CommandContext, setupResult: Awaited, ): void => { @@ -392,8 +414,9 @@ export function createCommandFromDefinition< return; } - const shortcuts: KeyShortcut[] = runInInjectionContext(targetInjector, () => - definition.shortcuts.call(definition, context, setupResult), + const shortcuts: KeyShortcut[] = runInInjectionContext( + invocation.injector, + () => definition.shortcuts.call(definition, context, setupResult), ); if (!shortcuts || !shortcuts.length) { return; @@ -428,8 +451,9 @@ export function createCommandFromDefinition< postCommandAction: async (args: string[]): Promise => { const context = buildContext(args); const invocation = currentInvocation || beginInvocation(context); + invocation.context = context; const setupResult = await invocation.setup; - await runInInjectionContext(targetInjector, () => + await runInInjectionContext(invocation.injector, () => definition.postRun.call( definition, context, @@ -446,7 +470,8 @@ export function createCommandFromDefinition< // arguments - so an argument validator can rely on it, and a command // run in the wrong place still reports that before complaining about // arity. - const setupResult = await beginInvocation(context).setup; + const invocation = beginInvocation(context); + const setupResult = await invocation.setup; await enforceArguments(context); @@ -457,7 +482,7 @@ export function createCommandFromDefinition< // Same first-await rule as execute: runInInjectionContext is // synchronous, so inject() is available up to the first await. - return await runInInjectionContext(targetInjector, () => + return await runInInjectionContext(invocation.injector, () => refine.call(definition, context, setupResult), ); }, @@ -467,15 +492,17 @@ export function createCommandFromDefinition< currentInvocation && !currentInvocation.hasRun ? currentInvocation : beginInvocation(context); + invocation.context = context; invocation.hasRun = true; const setupResult = await invocation.setup; - invocation.runResult = await runInInjectionContext(targetInjector, () => - definition.run.call(definition, context, setupResult), + invocation.runResult = await runInInjectionContext( + invocation.injector, + () => definition.run.call(definition, context, setupResult), ); if (definition.shortcuts) { - attachShortcuts(context, setupResult); + attachShortcuts(invocation, context, setupResult); } }, }; diff --git a/test/define-command.ts b/test/define-command.ts index ae7f8698cc..7c67b10271 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -8,6 +8,7 @@ import { InjectionToken, runInInjectionContext, } from "../lib/common/di"; +import { COMMAND_CONTEXT } from "../lib/common/contracts/command-context"; import { COMMAND_OWNER, CommandRegistry, @@ -2275,6 +2276,76 @@ describe("defineCommand", () => { }); }); + describe("COMMAND_CONTEXT", () => { + it("resolves to the context the handlers of the same stage receive", async () => { + const testInjector = createTestInjector(); + let injectedInSetup: any; + let injectedInRun: any; + let setupContext: any; + let runContext: any; + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-command-context", + setup: (ctx) => { + setupContext = ctx; + injectedInSetup = inject(COMMAND_CONTEXT); + }, + run: (ctx) => { + runContext = ctx; + injectedInRun = inject(COMMAND_CONTEXT); + }, + }), + testInjector, + ); + + await command.execute([]); + + assert.strictEqual(injectedInSetup, setupContext); + assert.strictEqual(injectedInRun, runContext); + }); + + it("is scoped to the invocation, so the root injector never sees it", async () => { + const testInjector = createTestInjector(); + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-command-context-scope", + run: (): void => undefined, + }), + testInjector, + ); + + await command.execute([]); + + assert.isNull(testInjector.get(COMMAND_CONTEXT, { optional: true })); + assert.throws( + () => testInjector.get(COMMAND_CONTEXT), + /unable to resolve/, + ); + }); + + it("gives each invocation a context of its own", async () => { + const testInjector = createTestInjector(); + const seen: any[] = []; + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-command-context-per-invocation", + setup: () => seen.push(inject(COMMAND_CONTEXT)), + run: (): void => undefined, + }), + testInjector, + ); + + await command.execute([]); + await command.execute([]); + + assert.lengthOf(seen, 2); + assert.notStrictEqual(seen[0], seen[1]); + }); + }); + describe("per-registration parameterization with a child injector", () => { it("registers one definition per platform and resolves the child provider", async () => { const PLATFORM = new InjectionToken("dcTestCommandPlatform"); From 2ffeb517930b284ba83fb1e3ab85acb1103c3518 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 17:59:15 -0300 Subject: [PATCH 11/19] feat(commands): add a class form built on defineCommand Command(meta) returns a base class whose static definition is a real defineCommand result: the handlers become methods, the instance is the setup result, and the adapter still only ever sees definitions. The definition is a static getter, so it resolves the subclass it is read through and caches on that constructor. Registration, the name-literal check and the extension manifest path take either form. COMMAND_CONTEXT is promoted to nativescript/contracts, which is what the base class reads in its field initializer. --- defining-commands.md | 123 +++++++++ lib/common/define-command.ts | 224 +++++++++++++++- .../services/command-definition-adapter.ts | 30 ++- lib/contracts/index.ts | 11 + lib/services/extensibility-service.ts | 7 +- test/define-command.ts | 245 ++++++++++++++++++ test/type-fixtures/define-command-types.ts | 79 +++++- 7 files changed, 698 insertions(+), 21 deletions(-) diff --git a/defining-commands.md b/defining-commands.md index 2eeda4792e..57951dcf5d 100644 --- a/defining-commands.md +++ b/defining-commands.md @@ -48,6 +48,10 @@ spread, so `{ ...baseDefinition, name: "widget|add2" }` is still recognised. `defineCommand` does not register anything by itself — see [Registering a definition](#registering-a-definition). +A command may also be written as a class, with the handlers as methods — see +[Class form](#class-form). It is sugar over `defineCommand`: everything below +describes both. + Validation happens where you can see it --------------------------------------- @@ -518,6 +522,120 @@ Other flags Both are simply passed through to the command the CLI executes; omitting them leaves the CLI's defaults in place. +Class form +---------- + +`Command(meta)` returns a base class to extend. It is sugar over +`defineCommand` and nothing more: the class carries a `static definition` built +by `defineCommand`, and that definition is the only thing the CLI ever +executes. + +```ts +import { Command, inject, stringOption } from "nativescript/contracts"; + +export class PlatformCleanCommand extends Command({ + name: "platform|clean", + description: "Removes and adds again the selected platform.", + options: { frameworkPath: stringOption() }, + arguments: "any", +}) { + private $platformCommandHelper = inject( + "platformCommandHelper", + ); + private $projectData = inject("projectData"); + + constructor() { + super(); + this.$projectData.initializeProjectData(); + } + + public async run(): Promise { + await this.$platformCommandHelper.cleanPlatforms( + this.args, + this.$projectData, + this.options.frameworkPath, + ); + } +} +``` + +`meta` is the definition minus its handlers: `name`, `description`, `options`, +`arguments`, `allowUnknownOptions`, `disableAnalytics` and `enableHooks`. The +handlers are methods instead — `run` is required, and `canExecute`, `postRun` +and `shortcuts` are optional, each with the same meaning and the same ordering +as the fields of the same name. `postRun(result)` receives what `run` returned; +`shortcuts()` returns the same table `shortcuts(ctx, setup)` does. A method the +class does not declare is left out of the definition entirely, so a class +without `postRun` gets no `postCommandAction`, exactly as an object without one +does. + +**Which form to use.** The class form is for a single named command. When a +function generates variants of one command — the `run|ios` / `run|vision` +family, one definition per platform — the object form is what fits, because +the thing being parameterized is a value and definitions are values. +Registering the same class twice under two names is not the equivalent: the +class is one definition. + +**The class is the setup.** One instance is constructed per invocation, as that +invocation's `setup`, before `canExecute` runs. So field initializers and the +constructor run inside the injection context: `inject()` in a field initializer +resolves, and a constructor — optional, and if written it must call a bare +`super()` — is where the work a legacy command did in its own constructor goes. +Because construction is the setup, `inject()` is valid throughout it; after the +first `await` inside a method, use `this.context.injector.get(token)` as +[Injection, and the first `await`](#injection-and-the-first-await) describes. + +**`this.context`, `this.options` and `this.args`** are the same context the +object form's handlers receive, typed from the `options` the meta declares: +`this.options.frameworkPath` is `string | undefined` above, and a name the +schema does not declare is a compile error. `this.context` also carries +`params`, `injector` and `fail`. + +**Per-command providers see the invocation.** The context is provided to the +invocation's own child injector under the `COMMAND_CONTEXT` token, which is how +the base class reads it. A provider registered for one command — through the +`providers` argument of `registerCommand` or `registerLazyCommand` — can inject +it too, and resolves nothing outside a running invocation. + +**Share through functions, not base classes.** Two commands that need the same +services share an `inject()`-based helper, not a common ancestor: + +```ts +export function injectPlatformCommandServices() { + const projectData = inject(ProjectData); + projectData.initializeProjectData(); + return { projectData, platformHelper: inject(PlatformCommandHelper) }; +} + +export class PlatformAddCommand extends Command({ name: "platform|add" }) { + private services = injectPlatformCommandServices(); + // ... +} +``` + +A helper composes — a command can call two of them — and it stays readable +without the reader walking a chain of files. A base class between `Command()` +and the command does not: it is the pattern the legacy `ICommand` hierarchy +used, and untangling it is most of why this API exists. + +Registration takes the class itself; see +[Registering a definition](#registering-a-definition): + +```ts +registerBuiltInCommand< + typeof import("./commands/platform-clean").PlatformCleanCommand +>( + "platform|clean", + () => require("./commands/platform-clean").PlatformCleanCommand, +); +``` + +`isCommandClass(value)` is the exported check, and `Ctor.definition` is the +definition the class stands for — derived once per class, and derived for the +subclass rather than for the base `Command()` returned. A class that implements +no `run`, or a class that did not come from `Command()`, is refused with the +same message shape a bad object gets. + Registering a definition ------------------------ @@ -533,6 +651,11 @@ registerCommand({ }); ``` +Every registration helper — `registerCommand`, `registerLazyCommand` and +`registerBuiltInCommand` — takes a [class form](#class-form) command wherever +it takes a definition, and reads the name it declares through its +`static definition`. + It takes either a `DefinedCommand` — the result of `defineCommand`, marker and all — or the definition itself, which it defines on your behalf, so registering a command is one call. Either way the definition is validated before it reaches diff --git a/lib/common/define-command.ts b/lib/common/define-command.ts index 10730417ef..43031f5d70 100644 --- a/lib/common/define-command.ts +++ b/lib/common/define-command.ts @@ -6,7 +6,9 @@ * lib/common/services/command-definition-adapter. */ +import { COMMAND_CONTEXT } from "./contracts/command-context"; import type { KeyShortcut } from "./contracts/key-shortcuts"; +import { inject } from "./di/inject"; import type { Injector } from "./di/injector"; /** @@ -255,7 +257,10 @@ const OPTION_TYPES: CommandOptionType[] = [ const ACCEPTED_FORM = 'defineCommand({ name: "widget|add", run(ctx) { ... } }) — with the ' + "optional fields description, options, arguments, allowUnknownOptions, " + - "setup, canExecute, shortcuts, postRun, disableAnalytics and enableHooks."; + "setup, canExecute, shortcuts, postRun, disableAnalytics and enableHooks. " + + 'Or the class form, class WidgetAdd extends Command({ name: "widget|add" }) ' + + "{ run() { ... } }, which declares the same fields except the handlers and " + + "implements run, and optionally canExecute, postRun and shortcuts, as methods."; const describeDefinition = (definition: any): string => { const name = definition && definition.name; @@ -556,12 +561,18 @@ export type CommandName = string | readonly string[]; * be checked against them. */ export type CommandNamesOf = TDefinition extends { - name: infer TName; + definition: infer TClassDefinition; } - ? TName extends readonly (infer TAlias)[] - ? TAlias - : TName - : never; + ? // A constructor's own `name` is Function.name, so the class form has to be + // read through its static definition before the `name` branch sees it. + CommandNamesOf + : TDefinition extends { + name: infer TName; + } + ? TName extends readonly (infer TAlias)[] + ? TAlias + : TName + : never; export function defineCommand< TSchema extends CommandOptionsSchema = {}, @@ -583,3 +594,204 @@ export function isCommandDefinition( ): value is DefinedCommand { return !!value && (value)[COMMAND_DEFINITION_MARKER] === true; } + +/** + * Marks a constructor produced by `Command()`. Same `Symbol.for` reasoning as + * COMMAND_DEFINITION_MARKER, and the same reason it is read rather than + * `instanceof`: an extension bundles its own copy of this module. + */ +export const COMMAND_CLASS_MARKER: unique symbol = Symbol.for( + "nativescript:cli:commandClass", +); + +/** The meta `Command()` was called with, inherited by every subclass. */ +const COMMAND_CLASS_META = Symbol.for("nativescript:cli:commandClassMeta"); + +/** Per-constructor cache of the derived definition; own-property only. */ +const COMMAND_CLASS_DEFINITION = Symbol.for( + "nativescript:command:classDefinition", +); + +/** + * What the class form declares up front: a definition without the handlers, + * which the class supplies as methods instead. + */ +export type CommandMeta< + TName extends CommandName = CommandName, + TSchema extends CommandOptionsSchema = {}, +> = Omit< + CommandDefinition, + "name" | "setup" | "canExecute" | "run" | "postRun" | "shortcuts" +> & { name: TName }; + +/** + * The instance side of the class form. Exported because it names the base of + * every `Command()` class — a subclass's declaration emit refers to it — not + * because anything should extend it directly. + */ +export abstract class CommandBase< + TSchema extends CommandOptionsSchema = {}, + TResult = void, +> { + /** + * 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 { + return this.context.options; + } + + protected get args(): string[] { + return this.context.args; + } + + abstract run(): Promise | TResult; + canExecute?(): Promise | boolean; + postRun?(result: Awaited): Promise | void; + shortcuts?(): KeyShortcut[]; +} + +/** + * The static side. An abstract construct signature, so the compiler still + * requires a subclass to implement `run`, and a named type, so declaration + * emit for `class X extends Command({ ... })` has something to refer to. + */ +export type CommandClass< + TName extends CommandName = CommandName, + TSchema extends CommandOptionsSchema = {}, + TResult = void, +> = (abstract new () => CommandBase) & { + readonly definition: NamedCommand< + TSchema, + TResult, + CommandBase, + TName + >; + readonly [COMMAND_CLASS_MARKER]: true; +}; + +/** Either accepted form of a command, as a registration site takes it. */ +export type RegisterableCommand = + DefinedCommand | CommandClass; + +export function isCommandClass( + value: any, +): value is CommandClass { + return ( + typeof value === "function" && (value)[COMMAND_CLASS_MARKER] === true + ); +} + +const buildClassDefinition = (ctor: any): DefinedCommand => { + const meta = ctor[COMMAND_CLASS_META]; + const prototype = ctor.prototype; + const implementsMethod = (method: string): boolean => + typeof prototype[method] === "function"; + + if (!implementsMethod("run")) { + invalid( + meta, + `the class '${ctor.name || ""}' implements no 'run' method`, + ); + } + + // The instance IS the setup result, so every handler reaches it as the + // second argument the adapter already threads through. + const definition: any = { + ...meta, + setup: () => new ctor(), + run: (context: any, instance: any) => instance.run(), + }; + + if (implementsMethod("canExecute")) { + definition.canExecute = (context: any, instance: any) => + instance.canExecute(); + } + + if (implementsMethod("postRun")) { + definition.postRun = (context: any, result: any, instance: any) => + instance.postRun(result); + } + + if (implementsMethod("shortcuts")) { + definition.shortcuts = (context: any, instance: any) => + instance.shortcuts(); + } + + return defineCommand(definition); +}; + +/** + * The definition a `Command()` class stands for, cached on the constructor it + * was read from. The cache entry is an own property so a class extending + * another command class never serves its parent's definition. + */ +export function classCommandDefinition( + ctor: any, +): DefinedCommand { + if (!isCommandClass(ctor)) { + throw new Error( + `${describeDefinition(ctor)} is not a command class: it did not come ` + + `from Command(). Accepted form: ${ACCEPTED_FORM}`, + ); + } + + const target: any = ctor; + if (Object.prototype.hasOwnProperty.call(target, COMMAND_CLASS_DEFINITION)) { + return target[COMMAND_CLASS_DEFINITION]; + } + + const definition = buildClassDefinition(target); + Object.defineProperty(target, COMMAND_CLASS_DEFINITION, { + value: definition, + }); + + return definition; +} + +/** The definition behind either form, or null for anything else. */ +export function toCommandDefinition( + value: any, +): DefinedCommand | null { + if (isCommandClass(value)) { + return classCommandDefinition(value); + } + + return isCommandDefinition(value) ? value : null; +} + +/** + * The class authoring form: sugar over defineCommand, not a second execution + * path. The returned base carries a `definition` that reads the class it is + * accessed through, so the subclass — not this base — is what `setup` + * instantiates, and registration keeps taking definitions only. + * + * export class PlatformClean extends Command({ + * name: "platform|clean", + * options: { frameworkPath: stringOption() }, + * }) { + * private $helper = inject("platformCommandHelper"); + * run() { return this.$helper.clean(this.args, this.options.frameworkPath); } + * } + */ +export function Command< + const TName extends CommandName, + TSchema extends CommandOptionsSchema = {}, + TResult = void, +>(meta: CommandMeta): CommandClass { + abstract class Base extends CommandBase { + // A getter, because `this` in a static accessor is the constructor the + // property was read through: that is the only hook that resolves the + // subclass without the subclass having to name itself. + static get definition(): DefinedCommand { + return classCommandDefinition(this); + } + } + + Object.defineProperty(Base, COMMAND_CLASS_MARKER, { value: true }); + Object.defineProperty(Base, COMMAND_CLASS_META, { value: meta }); + + return Base; +} diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index a23e02ea69..f631c9c72f 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -23,13 +23,16 @@ import { CommandArgumentValues, CommandContext, CommandDefinition, + CommandClass, + CommandName, CommandNamesOf, CommandOptionSpec, CommandOptionType, CommandOptionsSchema, DefinedCommand, + RegisterableCommand, defineCommand, - isCommandDefinition, + toCommandDefinition, } from "../define-command"; const OPTION_TYPES: IDictionary = { @@ -564,8 +567,9 @@ export async function runCommand( } /** - * Registers a command with the CLI. Takes either the result of defineCommand() - * or the definition itself, which it defines on the caller's behalf. + * Registers a command with the CLI. Takes a Command() class, the result of + * defineCommand(), or a bare definition, which it defines on the caller's + * behalf. * * Registration targets the injector of the current injection context, and * `providers` scope the command to a child of it. To register against some @@ -583,13 +587,14 @@ export function registerCommand< TSetup = any, >( definition: + | CommandClass | DefinedCommand | CommandDefinition, providers: Provider[] = [], ): DeferredCommandResult { - const defined = isCommandDefinition(definition) - ? definition - : defineCommand(>definition); + const defined = + toCommandDefinition(definition) || + defineCommand(>definition); const target = contextInjector(); const scope = providers.length ? target.createChild(providers) : target; const owner = target.get(COMMAND_OWNER, { optional: true }) || CLI_OWNER; @@ -648,7 +653,7 @@ type MissingTypeArgument = * aborts startup instead of returning a result nobody would check. */ export function registerBuiltInCommand< - TDefinition extends DefinedCommand = never, + TDefinition extends RegisterableCommand = never, >( name: [TDefinition] extends [never] ? MissingTypeArgument @@ -669,7 +674,7 @@ export function registerBuiltInCommand< } export function registerLazyCommand< - TDefinition extends DefinedCommand = never, + TDefinition extends RegisterableCommand = never, >( name: [TDefinition] extends [never] ? MissingTypeArgument @@ -684,13 +689,16 @@ export function registerLazyCommand< return registry.registerDeferredCommand(commandName, { owner: target.get(COMMAND_OWNER, { optional: true }) || CLI_OWNER, load: () => { - const definition = load(); + const loaded = load(); // The compile-time check above is only as good as the type argument the // call site passes, so the same mismatch is caught here as well. - if (!isCommandDefinition(definition)) { + const definition = toCommandDefinition(loaded); + if (!definition) { throw new Error( - "the loader did not return a defineCommand() definition", + typeof loaded === "function" + ? "the loader returned a class that did not come from Command()" + : "the loader did not return a defineCommand() definition or a Command() class", ); } diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts index 2a6137e2e7..db83a537a9 100644 --- a/lib/contracts/index.ts +++ b/lib/contracts/index.ts @@ -55,8 +55,13 @@ export { PBXPROJ_DOM_XCODE } from "./pbxproj-dom-xcode"; export { XCODE } from "./xcode"; export { + Command, + CommandBase, + COMMAND_CLASS_MARKER, defineCommand, + isCommandClass, isCommandDefinition, + toCommandDefinition, booleanOption, stringOption, numberOption, @@ -67,11 +72,14 @@ export type { ArgumentSpec, ArgumentsPolicy, CommandArgumentValues, + CommandClass, CommandDefinition, + CommandMeta, CommandName, CommandNamesOf, DefinedCommand, NamedCommand, + RegisterableCommand, CommandContext, CommandOptionSpec, DefaultedCommandOptionSpec, @@ -80,6 +88,9 @@ export type { CommandOptionType, CommandOptionValues, } from "../common/define-command"; +// 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"; export { defineHook, isHookDefinition } from "../common/define-hook"; export type { HookContext, diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index 48fc3584f6..9c2884c534 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -27,7 +27,7 @@ import { CommandRegistry, describeRejection, } from "../common/contracts"; -import { DefinedCommand, isCommandDefinition } from "../common/define-command"; +import { DefinedCommand, toCommandDefinition } from "../common/define-command"; import { registerDefinitionAs } from "../common/services/command-definition-adapter"; function isNonEmptyString(value: any): boolean { @@ -449,9 +449,10 @@ export class ExtensibilityService implements IExtensibilityService { const exported = this.loadInExtensionScope(extensionName, () => require(absoluteModulePath), ); - const candidate = (exported && exported.default) ?? exported; + const exportedValue = (exported && exported.default) ?? exported; - if (!isCommandDefinition(candidate)) { + const candidate = toCommandDefinition(exportedValue); + if (!candidate) { return; } diff --git a/test/define-command.ts b/test/define-command.ts index 7c67b10271..b833ec8f4a 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -21,13 +21,16 @@ import { LoggerStub, HooksServiceStub } from "./stubs"; import { arrayOption, booleanOption, + Command, defineCommand, + isCommandClass, isCommandDefinition, numberOption, stringOption, } from "../lib/common/define-command"; import { createCommandFromDefinition, + registerBuiltInCommand, registerCommand, registerLazyCommand, } from "../lib/common/services/command-definition-adapter"; @@ -2276,6 +2279,248 @@ describe("defineCommand", () => { }); }); + describe("class form", () => { + it("runs with the declared options and arguments", async () => { + const testInjector = createTestInjector({ release: true }); + const seen: any[] = []; + + class Widget extends Command({ + name: "dctest-class", + options: { release: booleanOption({ default: false }) }, + arguments: "any", + }) { + public run(): void { + seen.push([this.options.release, this.args, this.context.params]); + } + } + + assert.isTrue(isCommandClass(Widget)); + assert.isTrue(isCommandDefinition(Widget.definition)); + + const command = createCommandFromDefinition( + Widget.definition, + testInjector, + ); + await command.execute(["android"]); + + assert.deepEqual(seen, [[true, ["android"], {}]]); + }); + + it("derives one definition per class, not per access", () => { + class Widget extends Command({ name: "dctest-class-cached" }) { + public run(): void { + /* intentionally left blank */ + } + } + + assert.strictEqual(Widget.definition, Widget.definition); + assert.equal(Widget.definition.name, "dctest-class-cached"); + }); + + it("builds one instance per invocation, with inject() fields resolved", async () => { + const testInjector = createTestInjector(); + testInjector.register("dcTestGreeter", { greet: () => "hello" }); + const instances: any[] = []; + + class Widget extends Command({ name: "dctest-class-instances" }) { + private $greeter = inject("dcTestGreeter"); + + public run(): void { + instances.push(this); + assert.equal(this.$greeter.greet(), "hello"); + } + } + + const command = createCommandFromDefinition( + Widget.definition, + testInjector, + ); + await command.execute([]); + await command.execute([]); + + assert.lengthOf(instances, 2); + assert.notStrictEqual(instances[0], instances[1]); + assert.instanceOf(instances[0], Widget); + }); + + it("honours an optional canExecute and leaves it out when undeclared", async () => { + const testInjector = createTestInjector(); + + class Refusing extends Command({ + name: "dctest-class-refuses", + arguments: "any", + }) { + public canExecute(): boolean { + return this.args[0] === "yes"; + } + + public run(): void { + /* intentionally left blank */ + } + } + + class Plain extends Command({ name: "dctest-class-plain" }) { + public run(): void { + /* intentionally left blank */ + } + } + + assert.isUndefined(Plain.definition.canExecute); + + const refusing = createCommandFromDefinition( + Refusing.definition, + testInjector, + ); + assert.isFalse(await refusing.canExecute(["no"])); + assert.isTrue(await refusing.canExecute(["yes"])); + + const plain = createCommandFromDefinition(Plain.definition, testInjector); + assert.isTrue(await plain.canExecute([])); + }); + + it("wires postRun to the result run returned", async () => { + const testInjector = createTestInjector(); + const order: string[] = []; + + class Widget extends Command<"dctest-class-postrun", {}, number>({ + name: "dctest-class-postrun", + }) { + public run(): number { + order.push("run"); + return 7; + } + + public postRun(result: number): void { + order.push(`postRun:${result}`); + } + } + + const command = createCommandFromDefinition( + Widget.definition, + testInjector, + ); + await command.execute([]); + await command.postCommandAction([]); + + assert.deepEqual(order, ["run", "postRun:7"]); + }); + + it("wires shortcuts, and declares none when the class has no method", async () => { + const savedSetting = process.env.NS_COMMAND_SHORTCUTS; + process.env.NS_COMMAND_SHORTCUTS = "true"; + + try { + const testInjector = createTestInjector(); + const attached: string[][] = []; + testInjector.register("keyShortcutService", { + attach(options: { shortcuts: KeyShortcut[] }): boolean { + attached.push(options.shortcuts.map((shortcut) => shortcut.key)); + return true; + }, + printHint: (): void => undefined, + }); + + class Widget extends Command({ name: "dctest-class-shortcuts" }) { + public run(): void { + /* intentionally left blank */ + } + + public shortcuts(): KeyShortcut[] { + return [ + { + key: "r", + description: `Restart ${this.args[0]}`, + action: (): void => undefined, + }, + ]; + } + } + + class Plain extends Command({ name: "dctest-class-no-shortcuts" }) { + public run(): void { + /* intentionally left blank */ + } + } + + assert.isUndefined(Plain.definition.shortcuts); + + const command = createCommandFromDefinition( + Widget.definition, + testInjector, + ); + await command.execute(["ios"]); + + assert.deepEqual(attached, [["r"]]); + } finally { + if (savedSetting === undefined) { + delete process.env.NS_COMMAND_SHORTCUTS; + } else { + process.env.NS_COMMAND_SHORTCUTS = savedSetting; + } + } + }); + + it("registers through registerCommand and registerBuiltInCommand", async () => { + const testInjector = createTestInjector(); + const ran: string[] = []; + + class Direct extends Command({ name: "dctest-class-direct" }) { + public run(): void { + ran.push("direct"); + } + } + + class Lazy extends Command({ name: "dctest-class-lazy" }) { + public run(): void { + ran.push("lazy"); + } + } + + runInInjectionContext(testInjector, () => registerCommand(Direct)); + runInInjectionContext(testInjector, () => + registerBuiltInCommand( + "dctest-class-lazy", + () => Lazy, + ), + ); + + await testInjector.resolveCommand("dctest-class-direct").execute([]); + await testInjector.resolveCommand("dctest-class-lazy").execute([]); + + assert.deepEqual(ran, ["direct", "lazy"]); + }); + + it("rejects a class that implements no run", () => { + const noRun: any = Command({ name: "dctest-class-norun" }); + + assert.throws( + () => noRun.definition, + /Invalid command definition for 'dctest-class-norun'.*implements no 'run' method.*Accepted form:/s, + ); + }); + + it("reports a class Command() did not produce, through the deferred loader", () => { + const testInjector = createTestInjector(); + + class Impostor { + public run(): void { + /* intentionally left blank */ + } + } + + assert.isFalse(isCommandClass(Impostor)); + + runInInjectionContext(testInjector, () => + registerLazyCommand("dctest-class-impostor2", () => Impostor), + ); + + assert.throws( + () => testInjector.resolveCommand("dctest-class-impostor2"), + /class that did not come from Command\(\)/, + ); + }); + }); + describe("COMMAND_CONTEXT", () => { it("resolves to the context the handlers of the same stage receive", async () => { const testInjector = createTestInjector(); diff --git a/test/type-fixtures/define-command-types.ts b/test/type-fixtures/define-command-types.ts index 85fd7107d2..34cf91698a 100644 --- a/test/type-fixtures/define-command-types.ts +++ b/test/type-fixtures/define-command-types.ts @@ -9,12 +9,16 @@ import { arrayOption, booleanOption, + Command, defineCommand, numberOption, stringOption, } from "../../lib/common/define-command"; import type { CommandArgumentValues } from "../../lib/common/define-command"; -import { registerLazyCommand } from "../../lib/common/services/command-definition-adapter"; +import { + registerBuiltInCommand, + registerLazyCommand, +} from "../../lib/common/services/command-definition-adapter"; import type { Injector } from "../../lib/common/di/injector"; type IsExact = @@ -244,3 +248,76 @@ registerLazyCommand( ); declare function setupLazyCommand(): { projectDir: string }; + +// The class form types this.options, this.args and this.context off the schema +// the meta declares, exactly as the object form types ctx. +class TypefixturePlatformClean extends Command({ + name: "typefixture|class-clean", + options: { + frameworkPath: stringOption({ default: "platforms" }), + verbose: booleanOption(), + }, + arguments: "any", +}) { + run(): void { + const frameworkPath = this.options.frameworkPath; + const verbose = this.options.verbose; + const args = this.args; + const fail = this.context.fail; + + expectExactType>(); + expectExactType>(); + expectExactType>(); + expectExactType, never>>(); + + // @ts-expect-error - the schema types this.options and nothing else + this.options.undeclared; + } +} + +class TypefixtureResult extends Command<"typefixture|class-result", {}, number>( + { name: "typefixture|class-result" }, +) { + run(): number { + return 1; + } + + postRun(result: number): void { + expectExactType>(); + } +} + +// @ts-expect-error - run is abstract; a command class has to implement it +class TypefixtureNoRun extends Command({ name: "typefixture|class-no-run" }) {} + +// The static definition is what a registration site is checked against, so the +// literal name has to survive from the meta through to the call. +registerBuiltInCommand( + "typefixture|class-clean", + () => require("./commands/clean").TypefixturePlatformClean, +); + +registerBuiltInCommand( + // @ts-expect-error - the class declares 'typefixture|class-clean' + "typefixture|class-cleann", + () => require("./commands/clean").TypefixturePlatformClean, +); + +class TypefixtureAliased extends Command({ + name: ["typefixture|class-vision", "typefixture|class-visionos"], +}) { + run(): void { + return undefined; + } +} + +registerLazyCommand( + "typefixture|class-visionos", + () => require("./commands/clean").TypefixtureAliased, +); + +registerLazyCommand( + // @ts-expect-error - not one of the names the class declares + "typefixture|class-vision2", + () => require("./commands/clean").TypefixtureAliased, +); From 1aed82351321a5473db201ee3e4e1707f7dfed56 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 17:59:22 -0300 Subject: [PATCH 12/19] refactor(commands): move three commands to the class form platform|clean, update and device|*list are written as Command() classes, with their services as inject() fields and the constructor doing the initializeProjectData() work setup did. The per-platform device listings stay in the object form: they are generated from one function, which is what that form is for. --- lib/bootstrap.ts | 11 +- lib/commands/platform-clean.ts | 137 +++++++++------------ lib/commands/update.ts | 131 +++++++++----------- lib/common/bootstrap.ts | 8 +- lib/common/commands/device/list-devices.ts | 17 ++- test/platform-commands.ts | 4 +- test/update.ts | 6 +- 7 files changed, 140 insertions(+), 174 deletions(-) diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 860a46f609..adba09da3c 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -527,10 +527,10 @@ injector.require("androidToolsInfo", "./android-tools-info"); injector.require("devicePathProvider", "./device-path-provider"); registerBuiltInCommand< - typeof import("./commands/platform-clean").platformCleanCommandDefinition + typeof import("./commands/platform-clean").PlatformCleanCommand >( "platform|clean", - () => require("./commands/platform-clean").platformCleanCommandDefinition, + () => require("./commands/platform-clean").PlatformCleanCommand, ); injector.require( @@ -583,9 +583,10 @@ registerBuiltInCommand< registerBuiltInCommand< typeof import("./commands/migrate").migrateCommandDefinition >("migrate", () => require("./commands/migrate").migrateCommandDefinition); -registerBuiltInCommand< - typeof import("./commands/update").updateCommandDefinition ->("update", () => require("./commands/update").updateCommandDefinition); +registerBuiltInCommand( + "update", + () => require("./commands/update").UpdateCommand, +); injector.require("iOSLogFilter", "./services/ios-log-filter"); injector.require("logSourceMapService", "./services/log-source-map-service"); diff --git a/lib/commands/platform-clean.ts b/lib/commands/platform-clean.ts index 521985c7ce..873558cac9 100644 --- a/lib/commands/platform-clean.ts +++ b/lib/commands/platform-clean.ts @@ -8,9 +8,8 @@ import { import { IPlatformEnvironmentRequirements } from "../definitions/platform"; import { IErrors } from "../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -19,93 +18,71 @@ const platformCleanCommandOptions = { frameworkPath: stringOption(), } satisfies CommandOptionsSchema; -export type PlatformCleanCommandContext = CommandContext< - typeof platformCleanCommandOptions ->; - -export function setupPlatformCleanCommand() { - const services = { - $errors: inject("errors"), - $options: inject("options"), - $platformCommandHelper: inject( - "platformCommandHelper", - ), - $platformValidationService: inject( - "platformValidationService", - ), - $platformEnvironmentRequirements: inject( - "platformEnvironmentRequirements", - ), - $projectData: inject("projectData"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IPlatformCleanCommandServices = ReturnType< - typeof setupPlatformCleanCommand ->; +export class PlatformCleanCommand extends Command({ + name: "platform|clean", + description: "Removes and adds again the selected platform.", + options: platformCleanCommandOptions, + arguments: "any", +}) { + private $errors = inject("errors"); + private $options = inject("options"); + private $platformCommandHelper = inject( + "platformCommandHelper", + ); + private $platformValidationService = inject( + "platformValidationService", + ); + private $platformEnvironmentRequirements = + inject("platformEnvironmentRequirements"); + private $projectData = inject("projectData"); -export async function canExecutePlatformCleanCommand( - context: PlatformCleanCommandContext, - services: IPlatformCleanCommandServices, -): Promise { - const args = context.args; - if (!args || args.length === 0) { - services.$errors.failWithHelp( - "No platform specified. Please specify a platform to clean.", - ); + constructor() { + super(); + this.$projectData.initializeProjectData(); } - _.each(args, (platform) => { - services.$platformValidationService.validatePlatform( - platform, - services.$projectData, - ); - }); + public async canExecute(): Promise { + const args = this.args; + if (!args || args.length === 0) { + this.$errors.failWithHelp( + "No platform specified. Please specify a platform to clean.", + ); + } - for (const platform of args) { - services.$platformValidationService.validatePlatformInstalled( - platform, - services.$projectData, - ); + _.each(args, (platform) => { + this.$platformValidationService.validatePlatform( + platform, + this.$projectData, + ); + }); - const currentRuntimeVersion = - services.$platformCommandHelper.getCurrentPlatformVersion( + for (const platform of args) { + this.$platformValidationService.validatePlatformInstalled( platform, - services.$projectData, + this.$projectData, ); - await services.$platformEnvironmentRequirements.checkEnvironmentRequirements( - { + + const currentRuntimeVersion = + this.$platformCommandHelper.getCurrentPlatformVersion( + platform, + this.$projectData, + ); + await this.$platformEnvironmentRequirements.checkEnvironmentRequirements({ platform, - projectDir: services.$projectData.projectDir, + projectDir: this.$projectData.projectDir, runtimeVersion: currentRuntimeVersion, - options: services.$options, - }, - ); - } + options: this.$options, + }); + } - return true; -} + return true; + } -export async function runPlatformCleanCommand( - context: PlatformCleanCommandContext, - services: IPlatformCleanCommandServices, -): Promise { - await services.$platformCommandHelper.cleanPlatforms( - context.args, - services.$projectData, - context.options.frameworkPath, - ); + public async run(): Promise { + await this.$platformCommandHelper.cleanPlatforms( + this.args, + this.$projectData, + this.options.frameworkPath, + ); + } } - -export const platformCleanCommandDefinition = defineCommand({ - name: "platform|clean", - description: "Removes and adds again the selected platform.", - options: platformCleanCommandOptions, - arguments: "any", - setup: setupPlatformCleanCommand, - canExecute: canExecutePlatformCleanCommand, - run: runPlatformCleanCommand, -}); diff --git a/lib/commands/update.ts b/lib/commands/update.ts index 367736baca..589ad5ecb1 100644 --- a/lib/commands/update.ts +++ b/lib/commands/update.ts @@ -3,9 +3,8 @@ import { IMigrateController } from "../definitions/migrate"; import { IErrors } from "../common/declarations"; import { booleanOption, - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -19,84 +18,70 @@ const updateCommandOptions = { frameworkPath: stringOption(), } satisfies CommandOptionsSchema; -export type UpdateCommandContext = CommandContext; - -export function setupUpdateCommand() { - const services = { - $devicePlatformsConstants: inject( - "devicePlatformsConstants", - ), - $updateController: inject("updateController"), - $migrateController: inject("migrateController"), - $errors: inject("errors"), - $logger: inject("logger"), - $projectData: inject("projectData"), - $markingModeService: inject("markingModeService"), - }; - services.$projectData.initializeProjectData(); +export class UpdateCommand extends Command({ + name: "update", + description: + "Updates the project with the latest versions of its NativeScript dependencies.", + options: updateCommandOptions, + arguments: "any", +}) { + private $devicePlatformsConstants = inject( + "devicePlatformsConstants", + ); + private $updateController = inject("updateController"); + private $migrateController = inject("migrateController"); + private $errors = inject("errors"); + private $logger = inject("logger"); + private $projectData = inject("projectData"); + private $markingModeService = + inject("markingModeService"); - return services; -} + constructor() { + super(); + this.$projectData.initializeProjectData(); + } -export type IUpdateCommandServices = ReturnType; + public async canExecute(): Promise { + const shouldMigrate = await this.$migrateController.shouldMigrate({ + projectDir: this.$projectData.projectDir, + platforms: [ + this.$devicePlatformsConstants.Android, + this.$devicePlatformsConstants.iOS, + ], + loose: true, + }); -export async function canExecuteUpdateCommand( - context: UpdateCommandContext, - services: IUpdateCommandServices, -): Promise { - const shouldMigrate = await services.$migrateController.shouldMigrate({ - projectDir: services.$projectData.projectDir, - platforms: [ - services.$devicePlatformsConstants.Android, - services.$devicePlatformsConstants.iOS, - ], - loose: true, - }); + if (shouldMigrate) { + this.$errors.fail(SHOULD_MIGRATE_PROJECT_MESSAGE); + } - if (shouldMigrate) { - services.$errors.fail(SHOULD_MIGRATE_PROJECT_MESSAGE); + return this.args.length < 2 && this.$projectData.projectDir !== ""; } - return context.args.length < 2 && services.$projectData.projectDir !== ""; -} + public async run(): Promise { + if (this.options.markingMode) { + // ns update --markingMode + await this.$markingModeService.handleMarkingModeFullDeprecation({ + projectDir: this.$projectData.projectDir, + forceSwitch: true, + }); + return; + } -export async function runUpdateCommand( - context: UpdateCommandContext, - services: IUpdateCommandServices, -): Promise { - if (context.options.markingMode) { - // ns update --markingMode - await services.$markingModeService.handleMarkingModeFullDeprecation({ - projectDir: services.$projectData.projectDir, - forceSwitch: true, - }); - return; - } + if ( + !(await this.$updateController.shouldUpdate({ + projectDir: this.$projectData.projectDir, + version: this.args[0], + })) + ) { + this.$logger.printMarkdown(`__${PROJECT_UP_TO_DATE_MESSAGE}__`); + return; + } - if ( - !(await services.$updateController.shouldUpdate({ - projectDir: services.$projectData.projectDir, - version: context.args[0], - })) - ) { - services.$logger.printMarkdown(`__${PROJECT_UP_TO_DATE_MESSAGE}__`); - return; + await this.$updateController.update({ + projectDir: this.$projectData.projectDir, + version: this.args[0], + frameworkPath: this.options.frameworkPath, + }); } - - await services.$updateController.update({ - projectDir: services.$projectData.projectDir, - version: context.args[0], - frameworkPath: context.options.frameworkPath, - }); } - -export const updateCommandDefinition = defineCommand({ - name: "update", - description: - "Updates the project with the latest versions of its NativeScript dependencies.", - options: updateCommandOptions, - arguments: "any", - setup: setupUpdateCommand, - canExecute: canExecuteUpdateCommand, - run: runUpdateCommand, -}); diff --git a/lib/common/bootstrap.ts b/lib/common/bootstrap.ts index 4a401ec5ac..644146c909 100644 --- a/lib/common/bootstrap.ts +++ b/lib/common/bootstrap.ts @@ -91,16 +91,16 @@ registerBuiltInCommand< ); registerBuiltInCommand< - typeof import("./commands/device/list-devices").listDevicesCommandDefinition + typeof import("./commands/device/list-devices").ListDevicesCommand >( "device|*list", - () => require("./commands/device/list-devices").listDevicesCommandDefinition, + () => require("./commands/device/list-devices").ListDevicesCommand, ); registerBuiltInCommand< - typeof import("./commands/device/list-devices").listDevicesCommandDefinition + typeof import("./commands/device/list-devices").ListDevicesCommand >( "devices|*list", - () => require("./commands/device/list-devices").listDevicesCommandDefinition, + () => require("./commands/device/list-devices").ListDevicesCommand, ); registerBuiltInCommand< typeof import("./commands/device/list-devices").androidListDevicesCommand diff --git a/lib/common/commands/device/list-devices.ts b/lib/common/commands/device/list-devices.ts index 8e18771662..81feb6e834 100644 --- a/lib/common/commands/device/list-devices.ts +++ b/lib/common/commands/device/list-devices.ts @@ -3,6 +3,7 @@ import { DeviceConnectionType } from "../../../constants"; import { IErrors } from "../../declarations"; import { booleanOption, + Command, CommandContext, CommandName, CommandOptionsSchema, @@ -160,17 +161,21 @@ export async function runListDevicesCommand( } } -export const listDevicesCommandDefinition = defineCommand({ +export class ListDevicesCommand extends Command({ name: ["device|*list", "devices|*list"], description: "Lists the connected devices and emulators.", options: listDevicesCommandOptions, arguments: [{ name: "platform" }], - setup: setupListDevicesCommand, - run(context, services): Promise { - return runListDevicesCommand(context, services, context.args[0]); - }, -}); +}) { + private services = setupListDevicesCommand(); + public run(): Promise { + return runListDevicesCommand(this.context, this.services, this.args[0]); + } +} + +// One definition per platform, generated: the object form is what a family of +// commands needs, where the class form fits a single named command. const defineListPlatformDevicesCommand = ( name: TName, listedPlatform: "iOS" | "Android", diff --git a/test/platform-commands.ts b/test/platform-commands.ts index 35dd3d0836..4bc0be599b 100644 --- a/test/platform-commands.ts +++ b/test/platform-commands.ts @@ -3,7 +3,7 @@ import * as stubs from "./stubs"; import { addPlatformCommandDefinition } from "../lib/commands/add-platform"; import { removePlatformCommandDefinition } from "../lib/commands/remove-platform"; import { updatePlatformCommandDefinition } from "../lib/commands/update-platform"; -import { platformCleanCommandDefinition } from "../lib/commands/platform-clean"; +import { PlatformCleanCommand } from "../lib/commands/platform-clean"; import { registerCommand } from "../lib/common/services/command-definition-adapter"; import * as StaticConfigLib from "../lib/config"; import * as CommandsServiceLib from "../lib/common/services/commands-service"; @@ -171,7 +171,7 @@ function createTestInjector() { registerCommand(updatePlatformCommandDefinition), ); runInInjectionContext(testInjector, () => - registerCommand(platformCleanCommandDefinition), + registerCommand(PlatformCleanCommand), ); testInjector.register("resources", {}); testInjector.register("commandsService", { diff --git a/test/update.ts b/test/update.ts index 1cd0eda4fc..820ef09eef 100644 --- a/test/update.ts +++ b/test/update.ts @@ -1,6 +1,6 @@ import * as stubs from "./stubs"; import * as yok from "../lib/common/yok"; -import { updateCommandDefinition } from "../lib/commands/update"; +import { UpdateCommand } from "../lib/commands/update"; import { registerCommand } from "../lib/common/services/command-definition-adapter"; import { ICommand } from "../lib/common/definitions/commands"; import { assert } from "chai"; @@ -45,9 +45,7 @@ function createTestInjector(projectDir: string = projectFolder): IInjector { }, }); - runInInjectionContext(testInjector, () => - registerCommand(updateCommandDefinition), - ); + runInInjectionContext(testInjector, () => registerCommand(UpdateCommand)); return testInjector; } From 92002a2a631228c5341029e7146917ca7801602b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 18:41:02 -0300 Subject: [PATCH 13/19] refactor(commands): build one context per invocation canExecute opens the invocation with the context it builds; execute and postCommandAction reuse it, so every stage, the class instance and COMMAND_CONTEXT hold the same object. --- .../services/command-definition-adapter.ts | 27 ++++++--------- test/define-command.ts | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index f631c9c72f..9b4a165890 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -254,8 +254,9 @@ export function createCommandFromDefinition< return values; }; - // Read per call rather than snapshotted here: the options service only holds - // this command's parsed values once validateOptions has run for it. + // Read when an invocation opens rather than at definition time: the options + // service only holds this command's parsed values once validateOptions has + // run for it. const buildContext = (args: string[]): CommandContext => { const options: any = {}; for (const optionName of optionNames) { @@ -339,7 +340,7 @@ export function createCommandFromDefinition< // The state of one invocation. The command object itself is cached for the // process, so nothing invocation-scoped may live outside one of these. interface Invocation { - /** The context of the stage that is running; COMMAND_CONTEXT reads it. */ + /** Built once when the invocation opens; every stage and COMMAND_CONTEXT share it. */ context: CommandContext; injector: Injector; setup: Promise>; @@ -375,15 +376,8 @@ export function createCommandFromDefinition< const beginInvocation = (context: CommandContext): Invocation => { const invocation: Invocation = { context, - // Each entry point builds its own context object, so the token reads - // the live one rather than a snapshot: a handler that injects it gets - // the very context it was handed. injector: targetInjector.createChild([ - { - provide: COMMAND_CONTEXT, - useFactory: () => invocation.context, - shared: false, - }, + { provide: COMMAND_CONTEXT, useValue: context }, ]), setup: undefined, hasRun: false, @@ -452,9 +446,9 @@ export function createCommandFromDefinition< ? {} : { postCommandAction: async (args: string[]): Promise => { - const context = buildContext(args); - const invocation = currentInvocation || beginInvocation(context); - invocation.context = context; + const invocation = + currentInvocation || beginInvocation(buildContext(args)); + const context = invocation.context; const setupResult = await invocation.setup; await runInInjectionContext(invocation.injector, () => definition.postRun.call( @@ -490,12 +484,11 @@ export function createCommandFromDefinition< ); }, execute: async (args: string[]): Promise => { - const context = buildContext(args); const invocation = currentInvocation && !currentInvocation.hasRun ? currentInvocation - : beginInvocation(context); - invocation.context = context; + : beginInvocation(buildContext(args)); + const context = invocation.context; invocation.hasRun = true; const setupResult = await invocation.setup; diff --git a/test/define-command.ts b/test/define-command.ts index b833ec8f4a..3c4b43ae18 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -2550,6 +2550,40 @@ describe("defineCommand", () => { assert.strictEqual(injectedInRun, runContext); }); + it("hands one context object to every stage of an invocation", async () => { + const testInjector = createTestInjector(); + const seen: any[] = []; + + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-command-context-shared", + setup: (ctx) => { + seen.push(ctx, inject(COMMAND_CONTEXT)); + }, + canExecute: (ctx) => { + seen.push(ctx, inject(COMMAND_CONTEXT)); + return true; + }, + run: (ctx) => { + seen.push(ctx, inject(COMMAND_CONTEXT)); + }, + postRun: (ctx) => { + seen.push(ctx, inject(COMMAND_CONTEXT)); + }, + }), + testInjector, + ); + + await command.canExecute([]); + await command.execute([]); + await command.postCommandAction([]); + + assert.lengthOf(seen, 8); + for (const context of seen) { + assert.strictEqual(context, seen[0]); + } + }); + it("is scoped to the invocation, so the root injector never sees it", async () => { const testInjector = createTestInjector(); From 60d2885a263406efaa8fa71ff2287b56441af384 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 19:36:55 -0300 Subject: [PATCH 14/19] feat(commands): ask another command whether it can execute canExecuteCommand(name, args) resolves a registered command and primes its options exactly as runCommand does, then returns its own canExecute verdict. The child builds its setup from its own services, so a command can reuse another's precondition without importing its handlers. --- lib/common/definitions/commands-service.d.ts | 8 ++ .../services/command-definition-adapter.ts | 20 +++ lib/common/services/commands-service.ts | 33 +++++ test/define-command.ts | 123 ++++++++++++++++++ test/stubs.ts | 7 + 5 files changed, 191 insertions(+) diff --git a/lib/common/definitions/commands-service.d.ts b/lib/common/definitions/commands-service.d.ts index c4e9b05fca..39a1cbcd98 100644 --- a/lib/common/definitions/commands-service.d.ts +++ b/lib/common/definitions/commands-service.d.ts @@ -23,6 +23,14 @@ interface ICommandsService { commandName: string, commandArguments?: string[], ): Promise; + /** + * Asks a command whether it could run, without running it. The command + * builds its own setup from its own services. + */ + canExecuteCommandInProcess( + commandName: string, + commandArguments?: string[], + ): Promise; } /** diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index 9b4a165890..10047daf25 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -559,6 +559,26 @@ export async function runCommand( await commandsService.executeCommandInProcess(name, args); } +/** + * Asks a registered command whether it could run on `args`, without running it. + * The named command is resolved and its options primed exactly as `runCommand` + * does, and its own `canExecute` returns the verdict. + * + * This is how one command reuses another's precondition — `embed` asking + * whether `prepare` would run. The child resolves its own services, so nothing + * crosses between the two but the name and the arguments; pass only the + * arguments the child's own `arguments` policy accepts. + */ +export async function canExecuteCommand( + name: string, + args: string[] = [], +): Promise { + const commandsService = + contextInjector().get("commandsService"); + + return commandsService.canExecuteCommandInProcess(name, args); +} + /** * Registers a command with the CLI. Takes a Command() class, the result of * defineCommand(), or a bare definition, which it defines on the caller's diff --git a/lib/common/services/commands-service.ts b/lib/common/services/commands-service.ts index 2f57a18e99..eaa797860b 100644 --- a/lib/common/services/commands-service.ts +++ b/lib/common/services/commands-service.ts @@ -283,6 +283,39 @@ export class CommandsService implements ICommandsService { } } + /** + * The `canExecute` half of {@link executeCommandInProcess}: the named command + * is resolved and its options are primed the same way, and its own + * `canExecute` returns the verdict. The child builds its own setup from its + * own services — nothing is threaded in from the caller — which is what lets + * one command reuse another's precondition without importing its handlers. + */ + public async canExecuteCommandInProcess( + commandName: string, + commandArguments: string[] = [], + ): Promise { + this.inProcessDepth++; + try { + const command = this.$injector.resolveCommand(commandName); + if (!command) { + this.$errors.failWithHelp( + `Unknown command '${helpers.stringReplaceAll(commandName, "|", " ")}'.`, + ); + } + + this.commands.push({ commandName, commandArguments }); + const restoreOptions = this.primeOptions(command); + try { + return await this.canExecuteCommand(commandName, commandArguments); + } finally { + restoreOptions(); + this.commands.pop(); + } + } finally { + this.inProcessDepth--; + } + } + /** * Merging a command's options into the parser rewrites the values the host * process is still running on: a declared default replaces the CLI-wide one diff --git a/test/define-command.ts b/test/define-command.ts index 3c4b43ae18..67241c2d2b 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -29,6 +29,7 @@ import { stringOption, } from "../lib/common/define-command"; import { + canExecuteCommand, createCommandFromDefinition, registerBuiltInCommand, registerCommand, @@ -1344,6 +1345,128 @@ describe("defineCommand", () => { }); }); + describe("canExecuteCommand", () => { + const createInProcessInjector = (): IInjector => { + const testInjector = new Yok(); + testInjector.register("errors", { + beginCommand: async (action: () => Promise) => action(), + failWithHelp: (message: string) => { + throw new Error(message); + }, + fail: (message: string) => { + throw new Error(message); + }, + reportCommandError: async (ex: Error) => { + throw ex; + }, + }); + testInjector.register("hooksService", HooksServiceStub); + testInjector.register("logger", LoggerStub); + testInjector.register("staticConfig", { + disableAnalytics: true, + disableCommandHooks: true, + }); + testInjector.register("extensibilityService", {}); + testInjector.register("optionsTracker", {}); + testInjector.register("options", { + validateOptions: (): void => undefined, + }); + testInjector.register("commandsService", CommandsService); + return testInjector; + }; + + it("returns the named command's own verdict without running it", async () => { + const testInjector = createInProcessInjector(); + let ran = false; + + runInInjectionContext(testInjector, () => { + registerCommand( + defineCommand({ + name: "dctest-can-yes", + arguments: "any", + canExecute: (context) => context.args[0] === "ok", + run: () => { + ran = true; + }, + }), + ); + }); + + const verdicts = [ + await runInInjectionContext(testInjector, () => + canExecuteCommand("dctest-can-yes", ["ok"]), + ), + await runInInjectionContext(testInjector, () => + canExecuteCommand("dctest-can-yes", ["nope"]), + ), + ]; + + assert.deepEqual(verdicts, [true, false]); + assert.isFalse(ran); + }); + + it("enforces the child's arguments policy before its canExecute", async () => { + const testInjector = createInProcessInjector(); + let consulted = false; + + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: "dctest-can-none", + canExecute: () => { + consulted = true; + return true; + }, + run: (): void => undefined, + }), + ), + ); + + await assert.isRejected( + runInInjectionContext(testInjector, () => + canExecuteCommand("dctest-can-none", ["stray"]), + ), + /doesn't accept parameters/, + ); + assert.isFalse(consulted); + }); + + it("builds the child's setup from the child's own services", async () => { + const testInjector = createInProcessInjector(); + testInjector.register("gadgetService", { ready: true }); + + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: "dctest-can-setup", + setup: () => ({ + $gadgetService: inject("gadgetService"), + }), + canExecute: (context, services) => services.$gadgetService.ready, + run: (): void => undefined, + }), + ), + ); + + assert.isTrue( + await runInInjectionContext(testInjector, () => + canExecuteCommand("dctest-can-setup"), + ), + ); + }); + + it("fails by name for a command that is not registered", async () => { + const testInjector = createInProcessInjector(); + + await assert.isRejected( + runInInjectionContext(testInjector, () => + canExecuteCommand("dctest-can-missing"), + ), + /Unknown command 'dctest-can-missing'/, + ); + }); + }); + describe("positional argument specs", () => { const platformCommand = (extra: any = {}) => createCommandFromDefinition( diff --git a/test/stubs.ts b/test/stubs.ts index 4b28326a29..96d60ca966 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -1335,6 +1335,13 @@ export class CommandsService implements ICommandsService { return Promise.resolve(); } + public canExecuteCommandInProcess( + commandName: string, + commandArguments?: string[], + ): Promise { + return Promise.resolve(true); + } + public completeCommand(): Promise { return Promise.resolve(true); } From 955efb2bc63577374fea96316aad84ee9f21a2d9 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 19:37:10 -0300 Subject: [PATCH 15/19] refactor(commands): move the structured commands to the class form The commands with real internal structure - state shared between canExecute and run, values derived once per invocation, several private steps - are classes now, with one inject() field per dependency and the handlers as methods. Long handlers are split into private methods along the seams that were already there. EmbedCommand asks prepare whether it could run instead of importing its canExecute, which is what canExecuteCommand exists for. --- lib/bootstrap.ts | 71 ++-- lib/commands/add-platform.ts | 131 +++---- lib/commands/appstore-list.ts | 171 ++++---- lib/commands/appstore-upload.ts | 242 ++++++------ lib/commands/create-project.ts | 329 ++++++++-------- lib/commands/embedding/embed.ts | 99 ++--- lib/commands/plugin/build-plugin.ts | 169 ++++---- lib/commands/plugin/create-plugin.ts | 385 +++++++++--------- lib/commands/post-install.ts | 121 +++--- lib/commands/preview.ts | 112 +++--- lib/commands/test-init.ts | 319 ++++++++------- lib/commands/typings.ts | 435 ++++++++++----------- lib/commands/update-platform.ts | 138 +++---- lib/common/bootstrap.ts | 7 +- lib/common/commands/device/list-devices.ts | 86 ++-- lib/common/commands/proxy/proxy-set.ts | 296 +++++++------- test/commands/post-install.ts | 4 +- test/platform-commands.ts | 8 +- test/plugin-create.ts | 4 +- test/project-commands.ts | 4 +- test/tns-appstore-upload.ts | 4 +- 21 files changed, 1471 insertions(+), 1664 deletions(-) diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index adba09da3c..154a325910 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -176,11 +176,8 @@ injector.require( ); injector.require("platformCommandParameter", "./platform-command-param"); registerBuiltInCommand< - typeof import("./commands/create-project").createProjectCommandDefinition ->( - "create", - () => require("./commands/create-project").createProjectCommandDefinition, -); + typeof import("./commands/create-project").CreateProjectCommand +>("create", () => require("./commands/create-project").CreateProjectCommand); registerBuiltInCommand< typeof import("./commands/clean").cleanCommandDefinition >("clean", () => require("./commands/clean").cleanCommandDefinition); @@ -206,11 +203,8 @@ registerBuiltInCommand< () => require("./commands/list-platforms").listPlatformsCommandDefinition, ); registerBuiltInCommand< - typeof import("./commands/add-platform").addPlatformCommandDefinition ->( - "platform|add", - () => require("./commands/add-platform").addPlatformCommandDefinition, -); + typeof import("./commands/add-platform").AddPlatformCommand +>("platform|add", () => require("./commands/add-platform").AddPlatformCommand); registerBuiltInCommand< typeof import("./commands/remove-platform").removePlatformCommandDefinition >( @@ -218,10 +212,10 @@ registerBuiltInCommand< () => require("./commands/remove-platform").removePlatformCommandDefinition, ); registerBuiltInCommand< - typeof import("./commands/update-platform").updatePlatformCommandDefinition + typeof import("./commands/update-platform").UpdatePlatformCommand >( "platform|update", - () => require("./commands/update-platform").updatePlatformCommandDefinition, + () => require("./commands/update-platform").UpdatePlatformCommand, ); registerBuiltInCommand( "run|*all", @@ -259,13 +253,15 @@ registerBuiltInCommand( "open|vision", () => require("./commands/open").visionOpenCommand, ); -registerBuiltInCommand< - typeof import("./commands/typings").typingsCommandDefinition ->("typings", () => require("./commands/typings").typingsCommandDefinition); +registerBuiltInCommand( + "typings", + () => require("./commands/typings").TypingsCommand, +); -registerBuiltInCommand< - typeof import("./commands/preview").previewCommandDefinition ->("preview", () => require("./commands/preview").previewCommandDefinition); +registerBuiltInCommand( + "preview", + () => require("./commands/preview").PreviewCommand, +); registerBuiltInCommand( "debug|ios", @@ -312,8 +308,8 @@ registerBuiltInCommand< >("deploy", () => require("./commands/deploy").deployCommandDefinition); registerBuiltInCommand< - typeof import("./commands/embedding/embed").embedCommandDefinition ->("embed", () => require("./commands/embedding/embed").embedCommandDefinition); + typeof import("./commands/embedding/embed").EmbedCommand +>("embed", () => require("./commands/embedding/embed").EmbedCommand); injector.require("testExecutionService", "./services/test-execution-service"); injector.require( @@ -342,9 +338,10 @@ registerBuiltInCommand< "test|visionos", () => require("./commands/test").testVisionOSCommandDefinition, ); -registerBuiltInCommand< - typeof import("./commands/test-init").testInitCommandDefinition ->("test|init", () => require("./commands/test-init").testInitCommandDefinition); +registerBuiltInCommand( + "test|init", + () => require("./commands/test-init").TestInitCommand, +); registerBuiltInCommand< typeof import("./commands/generate-help").generateHelpCommandDefinition >( @@ -353,23 +350,20 @@ registerBuiltInCommand< ); registerBuiltInCommand< - typeof import("./commands/appstore-list").listiOSAppsCommandDefinition + typeof import("./commands/appstore-list").ListiOSAppsCommand >( "appstore|*list", - () => require("./commands/appstore-list").listiOSAppsCommandDefinition, + () => require("./commands/appstore-list").ListiOSAppsCommand, ); registerBuiltInCommand< - typeof import("./commands/appstore-upload").publishIOSCommandDefinition + typeof import("./commands/appstore-upload").PublishIOSCommand >( "appstore|upload", - () => require("./commands/appstore-upload").publishIOSCommandDefinition, + () => require("./commands/appstore-upload").PublishIOSCommand, ); registerBuiltInCommand< - typeof import("./commands/appstore-upload").publishIOSCommandDefinition ->( - "publish|ios", - () => require("./commands/appstore-upload").publishIOSCommandDefinition, -); + typeof import("./commands/appstore-upload").PublishIOSCommand +>("publish|ios", () => require("./commands/appstore-upload").PublishIOSCommand); registerBuiltInCommand< typeof import("./commands/apple-login").appleLoginCommandDefinition >( @@ -459,17 +453,16 @@ registerBuiltInCommand< require("./commands/plugin/update-plugin").updatePluginCommandDefinition, ); registerBuiltInCommand< - typeof import("./commands/plugin/build-plugin").buildPluginCommandDefinition + typeof import("./commands/plugin/build-plugin").BuildPluginCommand >( "plugin|build", - () => require("./commands/plugin/build-plugin").buildPluginCommandDefinition, + () => require("./commands/plugin/build-plugin").BuildPluginCommand, ); registerBuiltInCommand< - typeof import("./commands/plugin/create-plugin").createPluginCommandDefinition + typeof import("./commands/plugin/create-plugin").CreatePluginCommand >( "plugin|create", - () => - require("./commands/plugin/create-plugin").createPluginCommandDefinition, + () => require("./commands/plugin/create-plugin").CreatePluginCommand, ); registerBuiltInCommand< @@ -575,10 +568,10 @@ injector.require( injector.require("messages", "./common/messages/messages"); registerBuiltInCommand< - typeof import("./commands/post-install").postInstallCliCommandDefinition + typeof import("./commands/post-install").PostInstallCliCommand >( "post-install-cli", - () => require("./commands/post-install").postInstallCliCommandDefinition, + () => require("./commands/post-install").PostInstallCliCommand, ); registerBuiltInCommand< typeof import("./commands/migrate").migrateCommandDefinition diff --git a/lib/commands/add-platform.ts b/lib/commands/add-platform.ts index 8ecbd166b1..049978876e 100644 --- a/lib/commands/add-platform.ts +++ b/lib/commands/add-platform.ts @@ -1,13 +1,13 @@ +import { canExecuteCommandBase } from "./command-base"; import { - canExecuteCommandBase, - injectPlatformCommandServices, -} from "./command-base"; -import { IPlatformCommandHelper } from "../declarations"; + IPlatformCommandHelper, + IPlatformValidationService, +} from "../declarations"; +import { IProjectData } from "../definitions/project"; import { IErrors } from "../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -16,82 +16,63 @@ const addPlatformCommandOptions = { frameworkPath: stringOption(), } satisfies CommandOptionsSchema; -export type AddPlatformCommandContext = CommandContext< - typeof addPlatformCommandOptions ->; +export class AddPlatformCommand extends Command({ + name: "platform|add", + description: + "Configures the current project to target the selected platform.", + options: addPlatformCommandOptions, + arguments: "any", +}) { + private $errors = inject("errors"); + private $platformCommandHelper = inject( + "platformCommandHelper", + ); + private $platformValidationService = inject( + "platformValidationService", + ); + private $projectData = inject("projectData"); -export function setupAddPlatformCommand() { - const services = { - ...injectPlatformCommandServices(), - $errors: inject("errors"), - $platformCommandHelper: inject( - "platformCommandHelper", - ), - }; - services.$projectData.initializeProjectData(); + constructor() { + super(); + this.$projectData.initializeProjectData(); + } - return services; -} + public async canExecute(): Promise { + const args = this.args; + if (!args || args.length === 0) { + this.$errors.failWithHelp( + "No platform specified. Please specify a platform to add.", + ); + } -export type IAddPlatformCommandServices = ReturnType< - typeof setupAddPlatformCommand ->; + let canExecute = true; + for (const arg of args) { + this.$platformValidationService.validatePlatform(arg, this.$projectData); -export async function canExecuteAddPlatformCommand( - context: AddPlatformCommandContext, - services: IAddPlatformCommandServices, -): Promise { - const args = context.args; - if (!args || args.length === 0) { - services.$errors.failWithHelp( - "No platform specified. Please specify a platform to add.", - ); - } + if ( + !this.$platformValidationService.isPlatformSupportedForOS( + arg, + this.$projectData, + ) + ) { + this.$errors.fail( + `Applications for platform ${arg} cannot be built on this OS`, + ); + } - let canExecute = true; - for (const arg of args) { - services.$platformValidationService.validatePlatform( - arg, - services.$projectData, - ); - - if ( - !services.$platformValidationService.isPlatformSupportedForOS( - arg, - services.$projectData, - ) - ) { - services.$errors.fail( - `Applications for platform ${arg} cannot be built on this OS`, - ); + // The assignment overwrites the previous platform's verdict, so only the + // last one decides. + canExecute = await canExecuteCommandBase(this.context, arg); } - // The assignment overwrites the previous platform's verdict, so only the - // last one decides. Kept as it was. - canExecute = await canExecuteCommandBase(services, arg); + return canExecute; } - return canExecute; -} - -export async function runAddPlatformCommand( - context: AddPlatformCommandContext, - services: IAddPlatformCommandServices, -): Promise { - await services.$platformCommandHelper.addPlatforms( - context.args, - services.$projectData, - context.options.frameworkPath, - ); + public async run(): Promise { + await this.$platformCommandHelper.addPlatforms( + this.args, + this.$projectData, + this.options.frameworkPath, + ); + } } - -export const addPlatformCommandDefinition = defineCommand({ - name: "platform|add", - description: - "Configures the current project to target the selected platform.", - options: addPlatformCommandOptions, - arguments: "any", - setup: setupAddPlatformCommand, - canExecute: canExecuteAddPlatformCommand, - run: runAddPlatformCommand, -}); diff --git a/lib/commands/appstore-list.ts b/lib/commands/appstore-list.ts index 17103adfa7..1279a3b27f 100644 --- a/lib/commands/appstore-list.ts +++ b/lib/commands/appstore-list.ts @@ -1,8 +1,7 @@ import { IErrors } from "../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -18,107 +17,91 @@ const listiOSAppsCommandOptions = { appleSessionBase64: stringOption(), } satisfies CommandOptionsSchema; -export type ListiOSAppsCommandContext = CommandContext< - typeof listiOSAppsCommandOptions ->; - -export function setupListiOSAppsCommand() { - const services = { - $applePortalApplicationService: inject( - "applePortalApplicationService", - ), - $applePortalSessionService: inject( - "applePortalSessionService", - ), - $devicePlatformsConstants: inject( - "devicePlatformsConstants", - ), - $errors: inject("errors"), - $logger: inject("logger"), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - $prompter: inject("prompter"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IListiOSAppsCommandServices = ReturnType< - typeof setupListiOSAppsCommand ->; +export class ListiOSAppsCommand extends Command({ + name: "appstore|*list", + description: "Lists the applications in App Store Connect.", + options: listiOSAppsCommandOptions, + arguments: [{ name: "appleId" }, { name: "password" }], +}) { + private $applePortalApplicationService = + inject("applePortalApplicationService"); + private $applePortalSessionService = inject( + "applePortalSessionService", + ); + private $devicePlatformsConstants = inject( + "devicePlatformsConstants", + ); + private $errors = inject("errors"); + private $logger = inject("logger"); + private $platformValidationService = inject( + "platformValidationService", + ); + private $projectData = inject("projectData"); + private $prompter = inject("prompter"); -export async function runListiOSAppsCommand( - context: ListiOSAppsCommandContext, - services: IListiOSAppsCommandServices, -): Promise { - if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.$devicePlatformsConstants.iOS, - services.$projectData, - ) - ) { - services.$errors.fail( - `Applications for platform ${services.$devicePlatformsConstants.iOS} can not be built on this OS`, - ); + constructor() { + super(); + this.$projectData.initializeProjectData(); } - let username = context.args[0]; - let password = context.args[1]; + public async run(): Promise { + if ( + !this.$platformValidationService.isPlatformSupportedForOS( + this.$devicePlatformsConstants.iOS, + this.$projectData, + ) + ) { + this.$errors.fail( + `Applications for platform ${this.$devicePlatformsConstants.iOS} can not be built on this OS`, + ); + } - if (!username) { - username = await services.$prompter.getString("Apple ID", { - allowEmpty: false, - }); - } + let username = this.args[0]; + let password = this.args[1]; - if (!password) { - password = await services.$prompter.getPassword("Apple ID password"); - } + if (!username) { + username = await this.$prompter.getString("Apple ID", { + allowEmpty: false, + }); + } - const user = await services.$applePortalSessionService.createUserSession( - { username, password }, - { - sessionBase64: context.options.appleSessionBase64, - }, - ); - if (!user.areCredentialsValid) { - services.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.`, + if (!password) { + password = await this.$prompter.getPassword("Apple ID password"); + } + + const user = await this.$applePortalSessionService.createUserSession( + { username, password }, + { + sessionBase64: this.options.appleSessionBase64, + }, ); - } + if (!user.areCredentialsValid) { + this.$errors.fail( + `Invalid username and password combination. Used '${username}' as the username.`, + ); + } - const applications = - await services.$applePortalApplicationService.getApplications(user); + const applications = + await this.$applePortalApplicationService.getApplications(user); - if (!applications || !applications.length) { - services.$logger.info("Seems you don't have any applications yet."); - } else { - const table: any = createTable( - ["Application Name", "Bundle Identifier", "In Flight Version"], - applications.map((application) => { - const version = - (application && - application.versionSets && - application.versionSets.length && - application.versionSets[0].inFlightVersion && - application.versionSets[0].inFlightVersion.version) || - ""; - return [application.name, application.bundleId, version]; - }), - ); + if (!applications || !applications.length) { + this.$logger.info("Seems you don't have any applications yet."); + } else { + const table: any = createTable( + ["Application Name", "Bundle Identifier", "In Flight Version"], + applications.map((application) => { + const version = + (application && + application.versionSets && + application.versionSets.length && + application.versionSets[0].inFlightVersion && + application.versionSets[0].inFlightVersion.version) || + ""; + return [application.name, application.bundleId, version]; + }), + ); - services.$logger.info(table.toString()); + this.$logger.info(table.toString()); + } } } - -export const listiOSAppsCommandDefinition = defineCommand({ - name: "appstore|*list", - description: "Lists the applications in App Store Connect.", - options: listiOSAppsCommandOptions, - arguments: [{ name: "appleId" }, { name: "password" }], - setup: setupListiOSAppsCommand, - run: runListiOSAppsCommand, -}); diff --git a/lib/commands/appstore-upload.ts b/lib/commands/appstore-upload.ts index 7814e9cb2f..3d13e24cf8 100644 --- a/lib/commands/appstore-upload.ts +++ b/lib/commands/appstore-upload.ts @@ -2,9 +2,8 @@ import * as path from "path"; import { IErrors, IHostInfo } from "../common/declarations"; import { booleanOption, - CommandContext, + Command, CommandOptionsSchema, - defineCommand, objectOption, stringOption, } from "../common/define-command"; @@ -28,164 +27,153 @@ const publishIOSCommandOptions = { teamId: objectOption(), } satisfies CommandOptionsSchema; -export type PublishIOSCommandContext = CommandContext< - typeof publishIOSCommandOptions ->; - -export function setupPublishIOSCommand() { - const services = { - $applePortalSessionService: inject( - "applePortalSessionService", - ), - $buildController: inject("buildController"), - $devicePlatformsConstants: inject( - "devicePlatformsConstants", - ), - $errors: inject("errors"), - $hostInfo: inject("hostInfo"), - $itmsTransporterService: inject( - "itmsTransporterService", - ), - $logger: inject("logger"), - $options: inject("options"), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - $prompter: inject("prompter"), - }; - services.$projectData.initializeProjectData(); - - return services; -} +export class PublishIOSCommand extends Command({ + name: ["publish|ios", "appstore|upload"], + description: "Uploads a project to App Store Connect.", + options: publishIOSCommandOptions, + // Arguments have never been rejected here, only ignored past the third. + arguments: "any", +}) { + private $applePortalSessionService = inject( + "applePortalSessionService", + ); + private $buildController = inject("buildController"); + private $devicePlatformsConstants = inject( + "devicePlatformsConstants", + ); + private $errors = inject("errors"); + private $hostInfo = inject("hostInfo"); + private $itmsTransporterService = inject( + "itmsTransporterService", + ); + private $logger = inject("logger"); + private $options = inject("options"); + private $platformValidationService = inject( + "platformValidationService", + ); + private $projectData = inject("projectData"); + private $prompter = inject("prompter"); -export type IPublishIOSCommandServices = ReturnType< - typeof setupPublishIOSCommand ->; + constructor() { + super(); + this.$projectData.initializeProjectData(); + } -export function canExecutePublishIOSCommand( - context: PublishIOSCommandContext, - services: IPublishIOSCommandServices, -): boolean { - if (!services.$hostInfo.isDarwin) { - services.$errors.fail("iOS publishing is only available on macOS."); + public canExecute(): boolean { + if (!this.$hostInfo.isDarwin) { + this.$errors.fail("iOS publishing is only available on macOS."); + } + + if ( + !this.$platformValidationService.isPlatformSupportedForOS( + this.$devicePlatformsConstants.iOS, + this.$projectData, + ) + ) { + this.$errors.fail( + `Applications for platform ${this.$devicePlatformsConstants.iOS} can not be built on this OS`, + ); + } + + return true; } - if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.$devicePlatformsConstants.iOS, - services.$projectData, - ) - ) { - services.$errors.fail( - `Applications for platform ${services.$devicePlatformsConstants.iOS} can not be built on this OS`, + public async run(): Promise { + await this.$itmsTransporterService.validate( + this.options.appleApplicationSpecificPassword, ); - } - return true; -} + const username = + this.args[0] || + (await this.$prompter.getString("Apple ID", { allowEmpty: false })); -export async function runPublishIOSCommand( - context: PublishIOSCommandContext, - services: IPublishIOSCommandServices, -): Promise { - await services.$itmsTransporterService.validate( - context.options.appleApplicationSpecificPassword, - ); + const password = + this.args[1] || (await this.$prompter.getPassword("Apple ID password")); - const username = - context.args[0] || - (await services.$prompter.getString("Apple ID", { allowEmpty: false })); + const user = await this.createUserSession(username, password); - const password = - context.args[1] || - (await services.$prompter.getPassword("Apple ID password")); + const mobileProvisionIdentifier = this.options.provision ?? this.args[2]; - const user = await services.$applePortalSessionService.createUserSession( - { username, password }, - { - applicationSpecificPassword: - context.options.appleApplicationSpecificPassword, - sessionBase64: context.options.appleSessionBase64, - requireInteractiveConsole: true, - requireApplicationSpecificPassword: true, - }, - ); - if (!user.areCredentialsValid) { - services.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.`, - ); - } + let ipaFilePath = this.options.ipa ? path.resolve(this.options.ipa) : null; - const mobileProvisionIdentifier = - context.options.provision ?? context.args[2]; + if (!mobileProvisionIdentifier && !ipaFilePath) { + this.$logger.warn( + "No mobile provision identifier set. A default mobile provision will be used. You can set one in app/App_Resources/iOS/build.xcconfig", + ); + } - let ipaFilePath = context.options.ipa - ? path.resolve(context.options.ipa) - : null; + // The build data is spread off the parsed command line, so the flags the + // upload implies have to be set on the options service rather than on the + // context, which is a copy. + this.$options.release = true; - if (!mobileProvisionIdentifier && !ipaFilePath) { - services.$logger.warn( - "No mobile provision identifier set. A default mobile provision will be used. You can set one in app/App_Resources/iOS/build.xcconfig", - ); + if (!ipaFilePath) { + ipaFilePath = await this.buildIpa(mobileProvisionIdentifier); + } + + await this.$itmsTransporterService.upload({ + credentials: { username, password }, + user, + applicationSpecificPassword: + this.options.appleApplicationSpecificPassword, + ipaFilePath, + shouldExtractIpa: !!this.options.ipa, + verboseLogging: this.$logger.getLevel() === "TRACE", + teamId: this.options.teamId, + }); } - // The build data is spread off the parsed command line, so the flags the - // upload implies have to be set on the options service rather than on the - // context, which is a copy. - services.$options.release = true; + private async createUserSession(username: string, password: string) { + const user = await this.$applePortalSessionService.createUserSession( + { username, password }, + { + applicationSpecificPassword: + this.options.appleApplicationSpecificPassword, + sessionBase64: this.options.appleSessionBase64, + requireInteractiveConsole: true, + requireApplicationSpecificPassword: true, + }, + ); + if (!user.areCredentialsValid) { + this.$errors.fail( + `Invalid username and password combination. Used '${username}' as the username.`, + ); + } + + return user; + } - if (!ipaFilePath) { - const platform = services.$devicePlatformsConstants.iOS.toLowerCase(); + private async buildIpa(mobileProvisionIdentifier: string): Promise { + const platform = this.$devicePlatformsConstants.iOS.toLowerCase(); // No .ipa path provided, build .ipa on out own. if (mobileProvisionIdentifier) { // This is not very correct as if we build multiple targets we will try to sign all of them using the signing identity here. - services.$logger.info( + this.$logger.info( "Building .ipa with the selected mobile provision and/or certificate. " + mobileProvisionIdentifier, ); - services.$options.provision = mobileProvisionIdentifier; + this.$options.provision = mobileProvisionIdentifier; const buildData = new IOSBuildData( - services.$projectData.projectDir, + this.$projectData.projectDir, platform, - { ...services.$options.argv, buildForAppStore: true, watch: false }, + { ...this.$options.argv, buildForAppStore: true, watch: false }, ); - ipaFilePath = await services.$buildController.prepareAndBuild(buildData); + return await this.$buildController.prepareAndBuild(buildData); } else { - services.$logger.info( + this.$logger.info( "No .ipa, mobile provision or certificate set. Perfect! Now we'll build .xcarchive and let Xcode pick the distribution certificate and provisioning profile for you when exporting .ipa for AppStore submission.", ); const buildData = new IOSBuildData( - services.$projectData.projectDir, + this.$projectData.projectDir, platform, - { ...services.$options.argv, buildForAppStore: true, watch: false }, + { ...this.$options.argv, buildForAppStore: true, watch: false }, ); - ipaFilePath = await services.$buildController.prepareAndBuild(buildData); - services.$logger.info(`Export at: ${ipaFilePath}`); + const ipaFilePath = + await this.$buildController.prepareAndBuild(buildData); + this.$logger.info(`Export at: ${ipaFilePath}`); + return ipaFilePath; } } - - await services.$itmsTransporterService.upload({ - credentials: { username, password }, - user, - applicationSpecificPassword: - context.options.appleApplicationSpecificPassword, - ipaFilePath, - shouldExtractIpa: !!context.options.ipa, - verboseLogging: services.$logger.getLevel() === "TRACE", - teamId: context.options.teamId, - }); } - -export const publishIOSCommandDefinition = defineCommand({ - name: ["publish|ios", "appstore|upload"], - description: "Uploads a project to App Store Connect.", - options: publishIOSCommandOptions, - // Arguments have never been rejected here, only ignored past the third. - arguments: "any", - setup: setupPublishIOSCommand, - canExecute: canExecutePublishIOSCommand, - run: runPublishIOSCommand, -}); diff --git a/lib/commands/create-project.ts b/lib/commands/create-project.ts index 01067dcf05..321e60bb06 100644 --- a/lib/commands/create-project.ts +++ b/lib/commands/create-project.ts @@ -2,9 +2,9 @@ import * as path from "path"; import { color } from "../color"; import { booleanOption, - CommandContext, + Command, CommandOptionsSchema, - defineCommand, + CommandOptionValues, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -27,7 +27,7 @@ const TABS_TEMPLATE_KEY = "Tabs"; const TABS_TEMPLATE_DESCRIPTION = "An app with pre-built pages that uses tabs for navigation"; -export const createProjectCommandOptions = { +const createProjectCommandOptions = { js: booleanOption(), ng: booleanOption(), react: booleanOption(), @@ -49,22 +49,6 @@ export const createProjectCommandOptions = { ignoreScripts: booleanOption(), } satisfies CommandOptionsSchema; -export type CreateProjectCommandContext = CommandContext< - typeof createProjectCommandOptions ->; - -export function setupCreateProjectCommand() { - return { - $projectService: inject("projectService"), - $logger: inject("logger"), - $prompter: inject("prompter"), - }; -} - -export type ICreateProjectCommandServices = ReturnType< - typeof setupCreateProjectCommand ->; - interface ITemplateChoice { key?: string; value: string; @@ -233,7 +217,7 @@ const flavorTemplates: { [flavorName: string]: () => ITemplateChoice[] } = { /** The template a flavor flag selects, without asking anything. */ function selectTemplateFromOptions( - options: CreateProjectCommandContext["options"], + options: CommandOptionValues, ): string { if (options["vision-ng"] || (options.vision && options.ng)) { return constants.RESERVED_TEMPLATE_NAMES["vision-ng"]; @@ -298,10 +282,10 @@ function selectTemplateFromOptions( } function interactiveFlavorSelection( - services: ICreateProjectCommandServices, + $prompter: IPrompter, adverb: string, ): Promise { - return services.$prompter.promptForDetailedChoice( + return $prompter.promptForDetailedChoice( `${adverb}, which style of NativeScript project would you like to use:`, [ { @@ -338,7 +322,8 @@ function interactiveFlavorSelection( } async function interactiveTemplateSelection( - services: ICreateProjectCommandServices, + $logger: ILogger, + $prompter: IPrompter, flavorSelection: string, adverb: string, ): Promise { @@ -348,15 +333,14 @@ async function interactiveTemplateSelection( : []; if (selectedFlavorTemplates.length > 1) { - services.$logger.info(); + $logger.info(); const templateChoices = selectedFlavorTemplates.map((template) => { return { key: template.key, description: template.description }; }); - const selectedTemplateKey = - await services.$prompter.promptForDetailedChoice( - `${adverb}, which template would you like to start from:`, - templateChoices, - ); + const selectedTemplateKey = await $prompter.promptForDetailedChoice( + `${adverb}, which template would you like to start from:`, + templateChoices, + ); return selectedFlavorTemplates.find((t) => t.key === selectedTemplateKey) .value; @@ -366,164 +350,169 @@ async function interactiveTemplateSelection( } async function interactiveFlavorAndTemplateSelection( - services: ICreateProjectCommandServices, + $logger: ILogger, + $prompter: IPrompter, flavorAdverb: string, templateAdverb: string, ): Promise { const selectedFlavor = await interactiveFlavorSelection( - services, + $prompter, flavorAdverb, ); - return interactiveTemplateSelection(services, selectedFlavor, templateAdverb); + return interactiveTemplateSelection( + $logger, + $prompter, + selectedFlavor, + templateAdverb, + ); } -export async function runCreateProjectCommand( - context: CreateProjectCommandContext, - services: ICreateProjectCommandServices, -): Promise { - const options = context.options; - const interactiveAdverbs = ["First", "Next", "Finally"]; - const getNextInteractiveAdverb = () => { - return interactiveAdverbs.shift() || "Next"; - }; - - let isInteractionIntroShown = false; - const printInteractiveCreationIntroIfNeeded = () => { - if (isInteractionIntroShown) { - return; - } - - isInteractionIntroShown = true; - services.$logger.info(); - services.$logger.printMarkdown(`# Let’s create a NativeScript app!`); - services.$logger.printMarkdown(` +export class CreateProjectCommand extends Command< + "create", + typeof createProjectCommandOptions, + ICreateProjectData +>({ + name: "create", + description: "Creates a new NativeScript project.", + options: createProjectCommandOptions, + arguments: [{ name: "projectName" }], + enableHooks: false, +}) { + private $projectService = inject("projectService"); + private $logger = inject("logger"); + private $prompter = inject("prompter"); + + public async run(): Promise { + const options = this.options; + const interactiveAdverbs = ["First", "Next", "Finally"]; + const getNextInteractiveAdverb = () => { + return interactiveAdverbs.shift() || "Next"; + }; + + let isInteractionIntroShown = false; + const printInteractiveCreationIntroIfNeeded = () => { + if (isInteractionIntroShown) { + return; + } + + isInteractionIntroShown = true; + this.$logger.info(); + this.$logger.printMarkdown(`# Let’s create a NativeScript app!`); + this.$logger.printMarkdown(` Answer the following questions to help us build the right app for you. (Note: you can skip this prompt next time using the --template option, or using --ng, --react, --solid, --svelte, --vue, --ts, or --js flags.) `); - }; - - if ( - (options.tsc || - options.ng || - options.vue || - options.react || - options.solid || - options.svelte || - options.js) && - options.template - ) { - context.fail( - "You cannot use a flavor option like --ng, --vue, --react, --solid, --svelte, --tsc and --js together with --template.", - ); - } + }; + + if ( + (options.tsc || + options.ng || + options.vue || + options.react || + options.solid || + options.svelte || + options.js) && + options.template + ) { + this.context.fail( + "You cannot use a flavor option like --ng, --vue, --react, --solid, --svelte, --tsc and --js together with --template.", + ); + } - let projectName = context.args[0]; - let selectedTemplate = selectTemplateFromOptions(options); + let projectName = this.args[0]; + let selectedTemplate = selectTemplateFromOptions(options); - if (!projectName && isInteractive()) { - printInteractiveCreationIntroIfNeeded(); - projectName = await services.$prompter.getString( - `${getNextInteractiveAdverb()}, what will be the name of your app?`, - { allowEmpty: false }, - ); - services.$logger.info(); - } + if (!projectName && isInteractive()) { + printInteractiveCreationIntroIfNeeded(); + projectName = await this.$prompter.getString( + `${getNextInteractiveAdverb()}, what will be the name of your app?`, + { allowEmpty: false }, + ); + this.$logger.info(); + } - projectName = await services.$projectService.validateProjectName({ - projectName: projectName, - force: options.force, - pathToProject: options.path, - }); - - if (!selectedTemplate && isInteractive()) { - printInteractiveCreationIntroIfNeeded(); - selectedTemplate = await interactiveFlavorAndTemplateSelection( - services, - getNextInteractiveAdverb(), - getNextInteractiveAdverb(), - ); - } + projectName = await this.$projectService.validateProjectName({ + projectName: projectName, + force: options.force, + pathToProject: options.path, + }); - return services.$projectService.createProject({ - projectName: projectName, - template: selectedTemplate, - appId: options.appid, - pathToProject: options.path, - // its already validated above - force: true, - ignoreScripts: options.ignoreScripts, - }); -} + if (!selectedTemplate && isInteractive()) { + printInteractiveCreationIntroIfNeeded(); + selectedTemplate = await interactiveFlavorAndTemplateSelection( + this.$logger, + this.$prompter, + getNextInteractiveAdverb(), + getNextInteractiveAdverb(), + ); + } -export function reportCreatedProject( - context: CreateProjectCommandContext, - createdProjectData: ICreateProjectData, - services: ICreateProjectCommandServices, -): void { - const { projectDir, projectName } = createdProjectData; - const relativePath = path.relative(process.cwd(), projectDir); - - const greyDollarSign = color.grey("$"); - services.$logger.clearScreen(); - let runDebugNotes: Array = []; - if ( - context.options.vision || - context.options["vision-ng"] || - context.options["vision-react"] || - context.options["vision-solid"] || - context.options["vision-svelte"] || - context.options["vision-vue"] - ) { - runDebugNotes = [ - `Run the project on Vision Pro with:`, - "", - ` ${greyDollarSign} ${color.green("ns run visionos --no-hmr")}`, - ]; - } else { - runDebugNotes = [ - `Run the project on multiple devices:`, - "", - ` ${greyDollarSign} ${color.green("ns run ios")}`, - ` ${greyDollarSign} ${color.green("ns run android")}`, - "", - "Debug the project with Chrome DevTools:", - "", - ` ${greyDollarSign} ${color.green("ns debug ios")}`, - ` ${greyDollarSign} ${color.green("ns debug android")}`, - ]; + return this.$projectService.createProject({ + projectName: projectName, + template: selectedTemplate, + appId: options.appid, + pathToProject: options.path, + // its already validated above + force: true, + ignoreScripts: options.ignoreScripts, + }); } - services.$logger.info( - [ + + public postRun(createdProjectData: ICreateProjectData): void { + const { projectDir, projectName } = createdProjectData; + const relativePath = path.relative(process.cwd(), projectDir); + + const greyDollarSign = color.grey("$"); + this.$logger.clearScreen(); + let runDebugNotes: Array = []; + if ( + this.options.vision || + this.options["vision-ng"] || + this.options["vision-react"] || + this.options["vision-solid"] || + this.options["vision-svelte"] || + this.options["vision-vue"] + ) { + runDebugNotes = [ + `Run the project on Vision Pro with:`, + "", + ` ${greyDollarSign} ${color.green("ns run visionos --no-hmr")}`, + ]; + } else { + runDebugNotes = [ + `Run the project on multiple devices:`, + "", + ` ${greyDollarSign} ${color.green("ns run ios")}`, + ` ${greyDollarSign} ${color.green("ns run android")}`, + "", + "Debug the project with Chrome DevTools:", + "", + ` ${greyDollarSign} ${color.green("ns debug ios")}`, + ` ${greyDollarSign} ${color.green("ns debug android")}`, + ]; + } + this.$logger.info( [ - color.green(`Project`), - color.cyan(projectName), - color.green(`was successfully created.`), - ].join(" "), - "", - `Now you can navigate to your project with ${color.cyan( - `cd ${relativePath}`, - )} and then:`, - "", - ...runDebugNotes, - ``, - `For more options consult the docs or run ${color.green("ns --help")}`, - "", - ].join("\n"), - ); - // todo: add back ns preview - // this.$logger.printMarkdown( - // `After that you can preview it on device by executing \`$ ns preview\`` - // ); + [ + color.green(`Project`), + color.cyan(projectName), + color.green(`was successfully created.`), + ].join(" "), + "", + `Now you can navigate to your project with ${color.cyan( + `cd ${relativePath}`, + )} and then:`, + "", + ...runDebugNotes, + ``, + `For more options consult the docs or run ${color.green("ns --help")}`, + "", + ].join("\n"), + ); + // todo: add back ns preview + // this.$logger.printMarkdown( + // `After that you can preview it on device by executing \`$ ns preview\`` + // ); + } } - -export const createProjectCommandDefinition = defineCommand({ - name: "create", - description: "Creates a new NativeScript project.", - options: createProjectCommandOptions, - arguments: [{ name: "projectName" }], - enableHooks: false, - setup: setupCreateProjectCommand, - run: runCreateProjectCommand, - postRun: reportCreatedProject, -}); diff --git a/lib/commands/embedding/embed.ts b/lib/commands/embedding/embed.ts index e0a245a0c3..111522c98f 100644 --- a/lib/commands/embedding/embed.ts +++ b/lib/commands/embedding/embed.ts @@ -1,16 +1,13 @@ import { resolve } from "path"; import { color } from "../../color"; -import { defineCommand } from "../../common/define-command"; -import { inject } from "../../common/di"; +import { IOptions } from "../../declarations"; +import { IProjectConfigService, IProjectData } from "../../definitions/project"; +import { Command } from "../../common/define-command"; import { IFileSystem } from "../../common/declarations"; -import { IProjectConfigService } from "../../definitions/project"; +import { inject } from "../../common/di"; +import { canExecuteCommand } from "../../common/services/command-definition-adapter"; import { platformArgument } from "../command-base"; -import { - canExecutePrepareCommand, - prepareCommandOptions, - runPrepareCommand, - setupPrepareCommand, -} from "../prepare"; +import { prepareCommandOptions, runPrepareCommand } from "../prepare"; function resolveHostProjectPath( projectDir: string, @@ -23,7 +20,7 @@ function resolveHostProjectPath( return resolve(hostProjectPath); } -export const embedCommandDefinition = defineCommand({ +export class EmbedCommand extends Command({ name: "embed", description: "Prepares the project so it can be embedded into a native host project.", @@ -33,45 +30,45 @@ export const embedCommandDefinition = defineCommand({ { name: "hostProjectPath" }, { name: "hostProjectModuleName" }, ], - setup(context) { - const services = setupPrepareCommand(); - const $projectConfigService = inject( - "projectConfigService", - ); - const platform = (context.args[0] || "").toLowerCase(); - // embed.., falling back to embed. - const configValue = (key: string): string => - $projectConfigService.getValue( - `embed.${platform}.${key}`, - $projectConfigService.getValue(`embed.${key}`), - ); +}) { + private $fs = inject("fs"); + private $logger = inject("logger"); + private $options = inject("options"); + private $projectConfigService = inject( + "projectConfigService", + ); + private $projectData = inject("projectData"); + + private platform = (this.args[0] || "").toLowerCase(); + private hostProjectPath = this.args[1] || this.configValue("hostProjectPath"); + private hostProjectModuleName = + this.args[2] || this.configValue("hostProjectModuleName"); + + constructor() { + super(); + this.$projectData.initializeProjectData(); + } - return { - ...services, - $fs: inject("fs"), - $logger: inject("logger"), - hostProjectPath: context.args[1] || configValue("hostProjectPath"), - hostProjectModuleName: - context.args[2] || configValue("hostProjectModuleName"), - }; - }, - async canExecute(context, services): Promise { - if (!(await canExecutePrepareCommand(context, services))) { + public async canExecute(): Promise { + // `prepare` takes the platform alone; the host project arguments are this + // command's own and it would reject them. + if (!(await canExecuteCommand("prepare", this.args.slice(0, 1)))) { return false; } - return !!services.hostProjectPath; - }, - async run(context, services): Promise { + return !!this.hostProjectPath; + } + + public async run(): Promise { const resolvedHostProjectPath = resolveHostProjectPath( - services.$projectData.projectDir, - services.hostProjectPath, + this.$projectData.projectDir, + this.hostProjectPath, ); - if (!services.$fs.exists(resolvedHostProjectPath)) { - services.$logger.error( + if (!this.$fs.exists(resolvedHostProjectPath)) { + this.$logger.error( `The host project path ${color.yellow( - services.hostProjectPath, + this.hostProjectPath, )} (resolved to: ${color.styleText( ["yellow", "dim"], resolvedHostProjectPath, @@ -80,11 +77,19 @@ export const embedCommandDefinition = defineCommand({ return; } - services.$options.hostProjectPath = resolvedHostProjectPath; - if (services.hostProjectModuleName) { - services.$options.hostProjectModuleName = services.hostProjectModuleName; + this.$options.hostProjectPath = resolvedHostProjectPath; + if (this.hostProjectModuleName) { + this.$options.hostProjectModuleName = this.hostProjectModuleName; } - await runPrepareCommand(context, services); - }, -}); + await runPrepareCommand(this.context); + } + + /** embed.., falling back to embed.. */ + private configValue(key: string): string { + return this.$projectConfigService.getValue( + `embed.${this.platform}.${key}`, + this.$projectConfigService.getValue(`embed.${key}`), + ); + } +} diff --git a/lib/commands/plugin/build-plugin.ts b/lib/commands/plugin/build-plugin.ts index 56eb6327b8..9c43357b14 100644 --- a/lib/commands/plugin/build-plugin.ts +++ b/lib/commands/plugin/build-plugin.ts @@ -7,9 +7,8 @@ import { } from "../../definitions/android-plugin-migrator"; import { IErrors, IFileSystem } from "../../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../../common/define-command"; import { inject } from "../../common/di"; @@ -21,108 +20,88 @@ const buildPluginCommandOptions = { gradleArgs: stringOption(), } satisfies CommandOptionsSchema; -export type BuildPluginCommandContext = CommandContext< - typeof buildPluginCommandOptions ->; - -export function setupBuildPluginCommand(context: BuildPluginCommandContext) { - return { - pluginProjectPath: path.resolve(context.options.path || "."), - $androidPluginBuildService: inject( - "androidPluginBuildService", - ), - $errors: inject("errors"), - $logger: inject("logger"), - $fs: inject("fs"), - $tempService: inject("tempService"), - }; -} - -export type IBuildPluginCommandServices = ReturnType< - typeof setupBuildPluginCommand ->; +export class BuildPluginCommand extends Command({ + name: "plugin|build", + description: + "Builds the Android parts of a NativeScript plugin into an `.aar`.", + options: buildPluginCommandOptions, + arguments: "any", +}) { + private $androidPluginBuildService = inject( + "androidPluginBuildService", + ); + private $errors = inject("errors"); + private $logger = inject("logger"); + private $fs = inject("fs"); + private $tempService = inject("tempService"); + + private pluginProjectPath = path.resolve(this.options.path || "."); + + public async canExecute(): Promise { + if ( + !this.$fs.exists( + path.join( + this.pluginProjectPath, + constants.PLATFORMS_DIR_NAME, + "android", + ), + ) + ) { + this.$errors.fail( + "No plugin found at the current directory, or the plugin does not need to have its platforms/android components built into an `.aar`.", + ); + } -export async function canExecuteBuildPluginCommand( - context: BuildPluginCommandContext, - services: IBuildPluginCommandServices, -): Promise { - if ( - !services.$fs.exists( - path.join( - services.pluginProjectPath, - constants.PLATFORMS_DIR_NAME, - "android", - ), - ) - ) { - services.$errors.fail( - "No plugin found at the current directory, or the plugin does not need to have its platforms/android components built into an `.aar`.", - ); + return true; } - return true; -} - -export async function runBuildPluginCommand( - context: BuildPluginCommandContext, - services: IBuildPluginCommandServices, -): Promise { - const platformsAndroidPath = path.join( - services.pluginProjectPath, - constants.PLATFORMS_DIR_NAME, - "android", - ); - let pluginName = ""; + public async run(): Promise { + const platformsAndroidPath = path.join( + this.pluginProjectPath, + constants.PLATFORMS_DIR_NAME, + "android", + ); + let pluginName = ""; - const pluginPackageJsonPath = path.join( - services.pluginProjectPath, - constants.PACKAGE_JSON_FILE_NAME, - ); + const pluginPackageJsonPath = path.join( + this.pluginProjectPath, + constants.PACKAGE_JSON_FILE_NAME, + ); - if (services.$fs.exists(pluginPackageJsonPath)) { - const packageJsonContents = services.$fs.readJson(pluginPackageJsonPath); + if (this.$fs.exists(pluginPackageJsonPath)) { + const packageJsonContents = this.$fs.readJson(pluginPackageJsonPath); - if (packageJsonContents && packageJsonContents["name"]) { - pluginName = packageJsonContents["name"]; + if (packageJsonContents && packageJsonContents["name"]) { + pluginName = packageJsonContents["name"]; + } } - } - - const tempAndroidProject = - await services.$tempService.mkdirSync("android-project"); - - const options: IPluginBuildOptions = { - gradlePath: context.options.gradlePath, - gradleArgs: context.options.gradleArgs, - aarOutputDir: platformsAndroidPath, - platformsAndroidDirPath: platformsAndroidPath, - pluginName: pluginName, - tempPluginDirPath: tempAndroidProject, - }; - const androidPluginBuildResult = - await services.$androidPluginBuildService.buildAar(options); - - if (androidPluginBuildResult) { - services.$logger.info( - `${pluginName} successfully built aar at ${platformsAndroidPath}.${EOL}Temporary Android project can be found at ${tempAndroidProject}.`, - ); - } + const tempAndroidProject = + await this.$tempService.mkdirSync("android-project"); + + const options: IPluginBuildOptions = { + gradlePath: this.options.gradlePath, + gradleArgs: this.options.gradleArgs, + aarOutputDir: platformsAndroidPath, + platformsAndroidDirPath: platformsAndroidPath, + pluginName: pluginName, + tempPluginDirPath: tempAndroidProject, + }; + + const androidPluginBuildResult = + await this.$androidPluginBuildService.buildAar(options); + + if (androidPluginBuildResult) { + this.$logger.info( + `${pluginName} successfully built aar at ${platformsAndroidPath}.${EOL}Temporary Android project can be found at ${tempAndroidProject}.`, + ); + } - const migratedIncludeGradle = - services.$androidPluginBuildService.migrateIncludeGradle(options); + const migratedIncludeGradle = + this.$androidPluginBuildService.migrateIncludeGradle(options); - if (migratedIncludeGradle) { - services.$logger.info(`${pluginName} include gradle updated.`); + if (migratedIncludeGradle) { + this.$logger.info(`${pluginName} include gradle updated.`); + } } } - -export const buildPluginCommandDefinition = defineCommand({ - name: "plugin|build", - description: - "Builds the Android parts of a NativeScript plugin into an `.aar`.", - options: buildPluginCommandOptions, - arguments: "any", - setup: setupBuildPluginCommand, - canExecute: canExecuteBuildPluginCommand, - run: runBuildPluginCommand, -}); diff --git a/lib/commands/plugin/create-plugin.ts b/lib/commands/plugin/create-plugin.ts index d164ab3cc2..62ee77059e 100644 --- a/lib/commands/plugin/create-plugin.ts +++ b/lib/commands/plugin/create-plugin.ts @@ -3,9 +3,8 @@ import { isInteractive } from "../../common/helpers"; import { INodePackageManager } from "../../declarations"; import { IErrors, IFileSystem, IChildProcess } from "../../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../../common/define-command"; import { inject } from "../../common/di"; @@ -31,239 +30,207 @@ const createPluginCommandOptions = { includeAngularDemo: stringOption(), } satisfies CommandOptionsSchema; -export type CreatePluginCommandContext = CommandContext< - typeof createPluginCommandOptions ->; - -export function setupCreatePluginCommand() { - return { - $errors: inject("errors"), - $terminalSpinnerService: inject( - "terminalSpinnerService", - ), - $logger: inject("logger"), - $pacoteService: inject("pacoteService"), - $fs: inject("fs"), - $childProcess: inject("childProcess"), - $prompter: inject("prompter"), - $packageManager: inject("packageManager"), - }; -} - -export type ICreatePluginCommandServices = ReturnType< - typeof setupCreatePluginCommand ->; - -function ensurePackageDir( - services: ICreatePluginCommandServices, - projectDir: string, -): void { - services.$fs.createDirectory(projectDir); +export class CreatePluginCommand extends Command({ + name: "plugin|create", + description: "Creates a new project for a NativeScript plugin.", + options: createPluginCommandOptions, + arguments: "any", +}) { + private $errors = inject("errors"); + private $terminalSpinnerService = inject( + "terminalSpinnerService", + ); + private $logger = inject("logger"); + private $pacoteService = inject("pacoteService"); + private $fs = inject("fs"); + private $childProcess = inject("childProcess"); + private $prompter = inject("prompter"); + private $packageManager = inject("packageManager"); + + public canExecute(): boolean { + if (!this.args[0]) { + this.$errors.failWithHelp("You must specify the plugin repository name."); + } - if (services.$fs.exists(projectDir) && !services.$fs.isEmptyDir(projectDir)) { - services.$errors.fail(PATH_ALREADY_EXISTS_MESSAGE_TEMPLATE, projectDir); + return true; } -} -async function downloadPackage( - services: ICreatePluginCommandServices, - selectedTemplate: string, - projectDir: string, -): Promise { - if (selectedTemplate) { - services.$logger.printMarkdown( - "Make sure your custom template is compatible with the Plugin Seed at https://github.com/NativeScript/nativescript-plugin-seed/", - ); - } else { - services.$logger.printMarkdown( - "Downloading the latest version of NativeScript Plugin Seed...", + public async run(): Promise { + const pluginRepoName = this.args[0]; + const pathToProject = this.options.path; + const selectedTemplate = this.options.template; + const selectedPath = path.resolve(pathToProject || "."); + const projectDir = path.join(selectedPath, pluginRepoName); + + // Must be out of try catch block, because will throw error if folder alredy exists and we don't want to delete it. + this.ensurePackageDir(projectDir); + + try { + await this.downloadPackage(selectedTemplate, projectDir); + await this.setupSeed(projectDir, pluginRepoName); + } catch (err) { + // The call to ensurePackageDir() above will throw error if folder alredy exists, so it is safe to delete here. + this.$fs.deleteDirectory(projectDir); + throw err; + } + + this.$logger.printMarkdown( + "Solution for `%s` was successfully created.", + pluginRepoName, ); } - const spinner = services.$terminalSpinnerService.createSpinner(); - const packageToInstall = - selectedTemplate || - "https://github.com/NativeScript/nativescript-plugin-seed/archive/master.tar.gz"; - try { - spinner.start(); - await services.$pacoteService.extractPackage(packageToInstall, projectDir); - } finally { - spinner.stop(); - } -} + private ensurePackageDir(projectDir: string): void { + this.$fs.createDirectory(projectDir); -async function getGitHubUsername( - services: ICreatePluginCommandServices, - gitHubUsername: string, -): Promise { - if (!gitHubUsername) { - gitHubUsername = "NativeScriptDeveloper"; - if (isInteractive()) { - gitHubUsername = await services.$prompter.getString(USER_MESSAGE, { - allowEmpty: false, - defaultAction: () => { - return gitHubUsername; - }, - }); + if (this.$fs.exists(projectDir) && !this.$fs.isEmptyDir(projectDir)) { + this.$errors.fail(PATH_ALREADY_EXISTS_MESSAGE_TEMPLATE, projectDir); } } - return gitHubUsername; -} - -async function getPluginNameSource( - services: ICreatePluginCommandServices, - pluginNameSource: string, - pluginRepoName: string, -): Promise { - if (!pluginNameSource) { - // remove nativescript- prefix for naming plugin files - const prefix = "nativescript-"; - pluginNameSource = pluginRepoName.toLowerCase().startsWith(prefix) - ? pluginRepoName.slice(prefix.length, pluginRepoName.length) - : pluginRepoName; - if (isInteractive()) { - pluginNameSource = await services.$prompter.getString(NAME_MESSAGE, { - allowEmpty: false, - defaultAction: () => { - return pluginNameSource; - }, - }); + private async downloadPackage( + selectedTemplate: string, + projectDir: string, + ): Promise { + if (selectedTemplate) { + this.$logger.printMarkdown( + "Make sure your custom template is compatible with the Plugin Seed at https://github.com/NativeScript/nativescript-plugin-seed/", + ); + } else { + this.$logger.printMarkdown( + "Downloading the latest version of NativeScript Plugin Seed...", + ); } - } - - return pluginNameSource; -} -async function getShouldIncludeDemoResult( - services: ICreatePluginCommandServices, - includeDemoOption: string, - message: string, -): Promise { - let shouldIncludeDemo = !!includeDemoOption; - if (!includeDemoOption && isInteractive()) { - shouldIncludeDemo = await services.$prompter.confirm(message, () => { - return true; - }); + const spinner = this.$terminalSpinnerService.createSpinner(); + const packageToInstall = + selectedTemplate || + "https://github.com/NativeScript/nativescript-plugin-seed/archive/master.tar.gz"; + try { + spinner.start(); + await this.$pacoteService.extractPackage(packageToInstall, projectDir); + } finally { + spinner.stop(); + } } - return shouldIncludeDemo ? "y" : "n"; -} - -async function setupSeed( - context: CreatePluginCommandContext, - services: ICreatePluginCommandServices, - projectDir: string, - pluginRepoName: string, -): Promise { - services.$logger.printMarkdown( - "Executing initial plugin configuration script...", - ); + private async getGitHubUsername(gitHubUsername: string): Promise { + if (!gitHubUsername) { + gitHubUsername = "NativeScriptDeveloper"; + if (isInteractive()) { + gitHubUsername = await this.$prompter.getString(USER_MESSAGE, { + allowEmpty: false, + defaultAction: () => { + return gitHubUsername; + }, + }); + } + } - const config = context.options; - const spinner = services.$terminalSpinnerService.createSpinner(); - const cwd = path.join(projectDir, "src"); - try { - spinner.start(); - const npmOptions: any = { silent: true }; - await services.$packageManager.install(cwd, cwd, npmOptions); - } finally { - spinner.stop(); + return gitHubUsername; } - const gitHubUsername = await getGitHubUsername(services, config.username); - const pluginNameSource = await getPluginNameSource( - services, - config.pluginName, - pluginRepoName, - ); - const includeTypescriptDemo = await getShouldIncludeDemoResult( - services, - config.includeTypeScriptDemo, - INCLUDE_TYPESCRIPT_DEMO_MESSAGE, - ); - const includeAngularDemo = await getShouldIncludeDemoResult( - services, - config.includeAngularDemo, - INCLUDE_ANGULAR_DEMO_MESSAGE, - ); + private async getPluginNameSource( + pluginNameSource: string, + pluginRepoName: string, + ): Promise { + if (!pluginNameSource) { + // remove nativescript- prefix for naming plugin files + const prefix = "nativescript-"; + pluginNameSource = pluginRepoName.toLowerCase().startsWith(prefix) + ? pluginRepoName.slice(prefix.length, pluginRepoName.length) + : pluginRepoName; + if (isInteractive()) { + pluginNameSource = await this.$prompter.getString(NAME_MESSAGE, { + allowEmpty: false, + defaultAction: () => { + return pluginNameSource; + }, + }); + } + } - if ( - !isInteractive() && - (!config.username || - !config.pluginName || - !config.includeAngularDemo || - !config.includeTypeScriptDemo) - ) { - services.$logger.printMarkdown( - "Using default values for plugin creation options since your shell is not interactive.", - ); + return pluginNameSource; } - // run postclone script manually and kill it if it takes more than 10 sec - const pathToPostCloneScript = path.join("scripts", "postclone"); - const params = [ - pathToPostCloneScript, - `gitHubUsername=${gitHubUsername}`, - `pluginName=${pluginNameSource}`, - "initGit=y", - `includeTypeScriptDemo=${includeTypescriptDemo}`, - `includeAngularDemo=${includeAngularDemo}`, - ]; + private async getShouldIncludeDemoResult( + includeDemoOption: string, + message: string, + ): Promise { + let shouldIncludeDemo = !!includeDemoOption; + if (!includeDemoOption && isInteractive()) { + shouldIncludeDemo = await this.$prompter.confirm(message, () => { + return true; + }); + } - const outputScript = await services.$childProcess.spawnFromEvent( - process.execPath, - params, - "close", - { stdio: "inherit", cwd, timeout: 10000 }, - ); - if (outputScript && outputScript.stdout) { - services.$logger.printMarkdown(outputScript.stdout); + return shouldIncludeDemo ? "y" : "n"; } -} - -export async function runCreatePluginCommand( - context: CreatePluginCommandContext, - services: ICreatePluginCommandServices, -): Promise { - const pluginRepoName = context.args[0]; - const pathToProject = context.options.path; - const selectedTemplate = context.options.template; - const selectedPath = path.resolve(pathToProject || "."); - const projectDir = path.join(selectedPath, pluginRepoName); - // Must be out of try catch block, because will throw error if folder alredy exists and we don't want to delete it. - ensurePackageDir(services, projectDir); + private async setupSeed( + projectDir: string, + pluginRepoName: string, + ): Promise { + this.$logger.printMarkdown( + "Executing initial plugin configuration script...", + ); - try { - await downloadPackage(services, selectedTemplate, projectDir); - await setupSeed(context, services, projectDir, pluginRepoName); - } catch (err) { - // The call to ensurePackageDir() above will throw error if folder alredy exists, so it is safe to delete here. - services.$fs.deleteDirectory(projectDir); - throw err; - } + const config = this.options; + const spinner = this.$terminalSpinnerService.createSpinner(); + const cwd = path.join(projectDir, "src"); + try { + spinner.start(); + const npmOptions: any = { silent: true }; + await this.$packageManager.install(cwd, cwd, npmOptions); + } finally { + spinner.stop(); + } - services.$logger.printMarkdown( - "Solution for `%s` was successfully created.", - pluginRepoName, - ); -} + const gitHubUsername = await this.getGitHubUsername(config.username); + const pluginNameSource = await this.getPluginNameSource( + config.pluginName, + pluginRepoName, + ); + const includeTypescriptDemo = await this.getShouldIncludeDemoResult( + config.includeTypeScriptDemo, + INCLUDE_TYPESCRIPT_DEMO_MESSAGE, + ); + const includeAngularDemo = await this.getShouldIncludeDemoResult( + config.includeAngularDemo, + INCLUDE_ANGULAR_DEMO_MESSAGE, + ); -export const createPluginCommandDefinition = defineCommand({ - name: "plugin|create", - description: "Creates a new project for a NativeScript plugin.", - options: createPluginCommandOptions, - arguments: "any", - setup: setupCreatePluginCommand, - canExecute(context, services): boolean { - if (!context.args[0]) { - services.$errors.failWithHelp( - "You must specify the plugin repository name.", + if ( + !isInteractive() && + (!config.username || + !config.pluginName || + !config.includeAngularDemo || + !config.includeTypeScriptDemo) + ) { + this.$logger.printMarkdown( + "Using default values for plugin creation options since your shell is not interactive.", ); } - return true; - }, - run: runCreatePluginCommand, -}); + // run postclone script manually and kill it if it takes more than 10 sec + const pathToPostCloneScript = path.join("scripts", "postclone"); + const params = [ + pathToPostCloneScript, + `gitHubUsername=${gitHubUsername}`, + `pluginName=${pluginNameSource}`, + "initGit=y", + `includeTypeScriptDemo=${includeTypescriptDemo}`, + `includeAngularDemo=${includeAngularDemo}`, + ]; + + const outputScript = await this.$childProcess.spawnFromEvent( + process.execPath, + params, + "close", + { stdio: "inherit", cwd, timeout: 10000 }, + ); + if (outputScript && outputScript.stdout) { + this.$logger.printMarkdown(outputScript.stdout); + } + } +} diff --git a/lib/commands/post-install.ts b/lib/commands/post-install.ts index c7cea7d480..2731c152dd 100644 --- a/lib/commands/post-install.ts +++ b/lib/commands/post-install.ts @@ -6,83 +6,70 @@ import { IHostInfo, ISettingsService, } from "../common/declarations"; -import { CommandContext, defineCommand } from "../common/define-command"; +import { Command } from "../common/define-command"; import { inject } from "../common/di"; import { doesCurrentNpmCommandMatch } from "../common/helpers"; -export function setupPostInstallCliCommand() { - return { - $fs: inject("fs"), - $commandsService: inject("commandsService"), - $helpService: inject("helpService"), - $settingsService: inject("settingsService"), - $analyticsService: inject("analyticsService"), - $logger: inject("logger"), - $hostInfo: inject("hostInfo"), - }; -} - -export type IPostInstallCliCommandServices = ReturnType< - typeof setupPostInstallCliCommand ->; +export class PostInstallCliCommand extends Command({ + name: "post-install-cli", + description: "Completes the CLI installation.", + disableAnalytics: true, +}) { + private $fs = inject("fs"); + private $commandsService = inject("commandsService"); + private $helpService = inject("helpService"); + private $settingsService = inject("settingsService"); + private $analyticsService = inject("analyticsService"); + private $logger = inject("logger"); + private $hostInfo = inject("hostInfo"); -export async function runPostInstallCliCommand( - context: CommandContext, - services: IPostInstallCliCommandServices, -): Promise { - const isRunningWithSudoUser = !!process.env.SUDO_USER; + public async run(): Promise { + const isRunningWithSudoUser = !!process.env.SUDO_USER; - if (!services.$hostInfo.isWindows) { - // when running under 'sudo' we create a working dir with wrong owner (root) and - // it is no longer accessible for the user initiating the installation - // patch the owner here - if (isRunningWithSudoUser) { - // TODO: Check if this is the correct place, probably we should set this at the end of the command. - await services.$fs.setCurrentUserAsOwner( - services.$settingsService.getProfileDir(), - process.env.SUDO_USER, - ); + if (!this.$hostInfo.isWindows) { + // when running under 'sudo' we create a working dir with wrong owner (root) and + // it is no longer accessible for the user initiating the installation + // patch the owner here + if (isRunningWithSudoUser) { + // TODO: Check if this is the correct place, probably we should set this at the end of the command. + await this.$fs.setCurrentUserAsOwner( + this.$settingsService.getProfileDir(), + process.env.SUDO_USER, + ); + } } - } - const canExecutePostInstallTask = - !isRunningWithSudoUser || doesCurrentNpmCommandMatch([/^--unsafe-perm$/]); + const canExecutePostInstallTask = + !isRunningWithSudoUser || doesCurrentNpmCommandMatch([/^--unsafe-perm$/]); - if (canExecutePostInstallTask) { - await services.$helpService.generateHtmlPages(); + if (canExecutePostInstallTask) { + await this.$helpService.generateHtmlPages(); - // Explicitly ask for confirmation of usage-reporting: - await services.$analyticsService.checkConsent(); - await services.$commandsService.tryExecuteCommand("autocomplete", []); + // Explicitly ask for confirmation of usage-reporting: + await this.$analyticsService.checkConsent(); + await this.$commandsService.tryExecuteCommand("autocomplete", []); + } } -} -export function reportSuccessfulInstallation( - services: IPostInstallCliCommandServices, -): void { - services.$logger.info(""); - services.$logger.info( - color.styleText( - ["green", "bold"], - "You have successfully installed the NativeScript CLI!", - ), - ); - services.$logger.info(""); - services.$logger.info("Your next step is to create a new project:"); - services.$logger.info(color.styleText(["green", "bold"], "ns create")); + public postRun(): void { + this.reportSuccessfulInstallation(); + } - services.$logger.info(""); - services.$logger.printMarkdown( - "If you have any questions, check Stack Overflow: `https://stackoverflow.com/questions/tagged/nativescript` and our public Discord channel: `https://nativescript.org/discord`", - ); -} + private reportSuccessfulInstallation(): void { + this.$logger.info(""); + this.$logger.info( + color.styleText( + ["green", "bold"], + "You have successfully installed the NativeScript CLI!", + ), + ); + this.$logger.info(""); + this.$logger.info("Your next step is to create a new project:"); + this.$logger.info(color.styleText(["green", "bold"], "ns create")); -export const postInstallCliCommandDefinition = defineCommand({ - name: "post-install-cli", - description: "Completes the CLI installation.", - disableAnalytics: true, - setup: setupPostInstallCliCommand, - run: runPostInstallCliCommand, - postRun: (context, result, services) => - reportSuccessfulInstallation(services), -}); + this.$logger.info(""); + this.$logger.printMarkdown( + "If you have any questions, check Stack Overflow: `https://stackoverflow.com/questions/tagged/nativescript` and our public Discord channel: `https://nativescript.org/discord`", + ); + } +} diff --git a/lib/commands/preview.ts b/lib/commands/preview.ts index c70d667a6d..0ef938950b 100644 --- a/lib/commands/preview.ts +++ b/lib/commands/preview.ts @@ -4,9 +4,8 @@ import { color } from "../color"; import { IChildProcess, IErrors } from "../common/declarations"; import { booleanOption, - CommandContext, + Command, CommandOptionsSchema, - defineCommand, } from "../common/define-command"; import { inject } from "../common/di"; import { PackageManagers } from "../constants"; @@ -19,37 +18,40 @@ const previewCommandOptions = { disableNpmInstall: booleanOption(), } satisfies CommandOptionsSchema; -export type PreviewCommandContext = CommandContext< - typeof previewCommandOptions ->; +export class PreviewCommand extends Command({ + name: "preview", + description: "Runs your project with the NativeScript Preview CLI.", + options: previewCommandOptions, + // Arguments have never been rejected here, only ignored: they reach the + // preview CLI through the raw argv instead. + arguments: "any", + allowUnknownOptions: true, +}) { + private $childProcess = inject("childProcess"); + private $errors = inject("errors"); + private $logger = inject("logger"); + private $packageManager = inject("packageManager"); + private $projectData = inject("projectData"); -export function setupPreviewCommand() { - return { - $childProcess: inject("childProcess"), - $errors: inject("errors"), - $logger: inject("logger"), - $packageManager: inject("packageManager"), - $projectData: inject("projectData"), - }; -} + public async run(): Promise { + if (!this.options.disableNpmInstall) { + await this.installLatestPreviewCLI(); + } -export type IPreviewCommandServices = ReturnType; + const previewCLIPath = this.getPreviewCLIPath(); -function getPreviewCLIPath(services: IPreviewCommandServices): string { - return resolvePackagePath(PREVIEW_CLI_PACKAGE, { - paths: [services.$projectData.projectDir], - }); -} + if (!previewCLIPath) { + await this.failMissingPreviewCLI(); + } + + const previewCLIBinPath = path.resolve(previewCLIPath, "./dist/index.js"); + this.spawnPreviewCLI(previewCLIBinPath); + } -export async function runPreviewCommand( - context: PreviewCommandContext, - services: IPreviewCommandServices, -): Promise { - if (!context.options.disableNpmInstall) { - // ensure latest is installed - await services.$packageManager.install( + private async installLatestPreviewCLI(): Promise { + await this.$packageManager.install( `${PREVIEW_CLI_PACKAGE}@latest`, - services.$projectData.projectDir, + this.$projectData.projectDir, { "save-dev": true, "save-exact": true, @@ -57,11 +59,15 @@ export async function runPreviewCommand( ); } - const previewCLIPath = getPreviewCLIPath(services); + private getPreviewCLIPath(): string { + return resolvePackagePath(PREVIEW_CLI_PACKAGE, { + paths: [this.$projectData.projectDir], + }); + } - if (!previewCLIPath) { + private async failMissingPreviewCLI(): Promise { const packageManagerName = - await services.$packageManager.getPackageManagerName(); + await this.$packageManager.getPackageManagerName(); let installCommand = ""; switch (packageManagerName) { @@ -79,7 +85,7 @@ export async function runPreviewCommand( installCommand = "npm install --save-dev @nativescript/preview-cli"; break; } - services.$logger.info( + this.$logger.info( [ `Uhh ohh, no Preview CLI found.`, "", @@ -97,33 +103,21 @@ export async function runPreviewCommand( ].join("\n"), ); - services.$errors.fail("Running preview failed."); + this.$errors.fail("Running preview failed."); } - const previewCLIBinPath = path.resolve(previewCLIPath, "./dist/index.js"); - - // The preview CLI takes the command line verbatim, including flags this CLI - // does not know, so the raw process arguments are what it gets rather than - // anything the command layer parsed. - const commandIndex = process.argv.indexOf("preview"); - const commandArgs = process.argv.slice(commandIndex + 1); - services.$childProcess.spawn( - process.execPath, - [previewCLIBinPath, ...commandArgs], - { - stdio: "inherit", - }, - ); + private spawnPreviewCLI(previewCLIBinPath: string): void { + // The preview CLI takes the command line verbatim, including flags this CLI + // does not know, so the raw process arguments are what it gets rather than + // anything the command layer parsed. + const commandIndex = process.argv.indexOf("preview"); + const commandArgs = process.argv.slice(commandIndex + 1); + this.$childProcess.spawn( + process.execPath, + [previewCLIBinPath, ...commandArgs], + { + stdio: "inherit", + }, + ); + } } - -export const previewCommandDefinition = defineCommand({ - name: "preview", - description: "Runs your project with the NativeScript Preview CLI.", - options: previewCommandOptions, - // Arguments have never been rejected here, only ignored: they reach the - // preview CLI through the raw argv instead. - arguments: "any", - allowUnknownOptions: true, - setup: setupPreviewCommand, - run: runPreviewCommand, -}); diff --git a/lib/commands/test-init.ts b/lib/commands/test-init.ts index 224e9e6e8f..dc070f4e2d 100644 --- a/lib/commands/test-init.ts +++ b/lib/commands/test-init.ts @@ -9,8 +9,8 @@ import { import { INodePackageManager, IOptions } from "../declarations"; import { IPluginsService } from "../definitions/plugins"; import { + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -31,163 +31,121 @@ const testInitCommandOptions = { framework: stringOption(), } satisfies CommandOptionsSchema; -function setupTestInitCommand() { - const services = { - $errors: inject("errors"), - $fs: inject("fs"), - $logger: inject("logger"), - $options: inject("options"), - $packageManager: inject("packageManager"), - $pluginsService: inject("pluginsService"), - $projectData: inject("projectData"), - $prompter: inject("prompter"), - $resources: inject("resources"), - $testInitializationService: inject( - "testInitializationService", - ), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -type ITestInitCommandServices = ReturnType; - -/** - * Android blocks cleartext traffic by default (API 28+), which would - * reject the runner's ws:// connection to the host. Scope the exception - * to the emulator loopback alias and adb-reverse loopback only. - */ -function ensureAndroidNetworkSecurityConfig( - services: ITestInitCommandServices, - bufferedLogs: string[], -): void { - const manifestPath = path.join( - services.$projectData.appResourcesDirectoryPath, - "Android", - "src", - "main", - "AndroidManifest.xml", - ); - if (!services.$fs.exists(manifestPath)) { - bufferedLogs.push( - color.yellow( - "Could not locate App_Resources/Android/src/main/AndroidManifest.xml. For Android test runs, allow cleartext traffic to 10.0.2.2 and 127.0.0.1 via a network security config.", - ), - ); - return; - } - - const manifestContent = services.$fs.readText(manifestPath); - if (manifestContent.indexOf("networkSecurityConfig") !== -1) { - bufferedLogs.push( - color.yellow( - "AndroidManifest.xml already sets android:networkSecurityConfig — make sure it permits cleartext traffic to 10.0.2.2 and 127.0.0.1 for test runs.", - ), - ); - return; - } - - const xmlDirectory = path.join( - services.$projectData.appResourcesDirectoryPath, - "Android", - "src", - "main", - "res", - "xml", - ); - services.$fs.ensureDirectoryExists(xmlDirectory); - const securityConfigPath = path.join(xmlDirectory, "network_security.xml"); - if (!services.$fs.exists(securityConfigPath)) { - services.$fs.copyFile( - services.$resources.resolvePath("test/network_security.xml"), - securityConfigPath, - ); - bufferedLogs.push( - `Added ${color.yellow("App_Resources/Android/src/main/res/xml/network_security.xml")}`, - ); - } - - services.$fs.writeFile( - manifestPath, - manifestContent.replace( - / { - const projectDir = services.$projectData.projectDir; +}) { + private $errors = inject("errors"); + private $fs = inject("fs"); + private $logger = inject("logger"); + private $options = inject("options"); + private $packageManager = inject("packageManager"); + private $pluginsService = inject("pluginsService"); + private $projectData = inject("projectData"); + private $prompter = inject("prompter"); + private $resources = inject("resources"); + private $testInitializationService = inject( + "testInitializationService", + ); - const frameworkToInstall = - context.options.framework || - (await services.$prompter.promptForChoice( - "Select testing framework:", - TESTING_FRAMEWORKS, - )); - if (TESTING_FRAMEWORKS.indexOf(frameworkToInstall) === -1) { - services.$errors.failWithHelp( - `Unknown or unsupported unit testing framework: ${frameworkToInstall}.`, + constructor() { + super(); + this.$projectData.initializeProjectData(); + } + + /** + * Android blocks cleartext traffic by default (API 28+), which would + * reject the runner's ws:// connection to the host. Scope the exception + * to the emulator loopback alias and adb-reverse loopback only. + */ + private ensureAndroidNetworkSecurityConfig(bufferedLogs: string[]): void { + const manifestPath = path.join( + this.$projectData.appResourcesDirectoryPath, + "Android", + "src", + "main", + "AndroidManifest.xml", + ); + if (!this.$fs.exists(manifestPath)) { + bufferedLogs.push( + color.yellow( + "Could not locate App_Resources/Android/src/main/AndroidManifest.xml. For Android test runs, allow cleartext traffic to 10.0.2.2 and 127.0.0.1 via a network security config.", + ), ); + return; } - const projectFilesExtension = - services.$projectData.projectType === ProjectTypes.TsFlavorName || - services.$projectData.projectType === ProjectTypes.NgFlavorName - ? ".ts" - : ".js"; + const manifestContent = this.$fs.readText(manifestPath); + if (manifestContent.indexOf("networkSecurityConfig") !== -1) { + bufferedLogs.push( + color.yellow( + "AndroidManifest.xml already sets android:networkSecurityConfig — make sure it permits cleartext traffic to 10.0.2.2 and 127.0.0.1 for test runs.", + ), + ); + return; + } - let modulesToInstall: IDependencyInformation[] = []; - try { - modulesToInstall = - services.$testInitializationService.getDependencies(frameworkToInstall); - } catch (err) { - services.$errors.fail( - `Unable to install the unit testing dependencies. Error: '${err.message}'`, + const xmlDirectory = path.join( + this.$projectData.appResourcesDirectoryPath, + "Android", + "src", + "main", + "res", + "xml", + ); + this.$fs.ensureDirectoryExists(xmlDirectory); + const securityConfigPath = path.join(xmlDirectory, "network_security.xml"); + if (!this.$fs.exists(securityConfigPath)) { + this.$fs.copyFile( + this.$resources.resolvePath("test/network_security.xml"), + securityConfigPath, + ); + bufferedLogs.push( + `Added ${color.yellow("App_Resources/Android/src/main/res/xml/network_security.xml")}`, ); } - modulesToInstall = modulesToInstall.filter( - (moduleToInstall) => - !moduleToInstall.projectType || - moduleToInstall.projectType === projectFilesExtension, + this.$fs.writeFile( + manifestPath, + manifestContent.replace( + / { for (const mod of modulesToInstall) { let moduleToInstall = mod.name; moduleToInstall += `@${mod.version}`; - await services.$packageManager.install(moduleToInstall, projectDir, { + await this.$packageManager.install(moduleToInstall, projectDir, { // Packages with native code must land in "dependencies" — the CLI // integrates plugin platform files (pods, aars) only from there. ...(mod.saveInDependencies ? { save: true } : { "save-dev": true }), "save-exact": true, optional: false, - disableNpmInstall: services.$options.disableNpmInstall, - frameworkPath: services.$options.frameworkPath, - ignoreScripts: services.$options.ignoreScripts, - path: services.$options.path, + disableNpmInstall: this.$options.disableNpmInstall, + frameworkPath: this.$options.frameworkPath, + ignoreScripts: this.$options.ignoreScripts, + path: this.$options.path, }); const modulePath = path.join(projectDir, "node_modules", mod.name); const modulePackageJsonPath = path.join(modulePath, "package.json"); - const modulePackageJsonContent = services.$fs.readJson( - modulePackageJsonPath, - ); + const modulePackageJsonContent = this.$fs.readJson(modulePackageJsonPath); const modulePeerDependencies = modulePackageJsonContent.peerDependencies || {}; const modulePeerDependenciesMeta = modulePackageJsonContent.peerDependenciesMeta || {}; - const projectPackageJson = services.$fs.readJson( + const projectPackageJson = this.$fs.readJson( path.join(projectDir, "package.json"), ); const installedProjectDependencies = { @@ -224,50 +182,90 @@ export const testInitCommandDefinition = defineCommand({ // catch errors when a peerDependency is already installed // e.g karma is installed; karma-jasmine depends on karma and will try to install it again try { - await services.$packageManager.install( + await this.$packageManager.install( `${peerDependency}@${dependencyVersion}`, projectDir, { "save-dev": true, "save-exact": true, disableNpmInstall: false, - frameworkPath: services.$options.frameworkPath, - ignoreScripts: services.$options.ignoreScripts, - path: services.$options.path, + frameworkPath: this.$options.frameworkPath, + ignoreScripts: this.$options.ignoreScripts, + path: this.$options.path, }, ); } catch (e) { - services.$logger.error(e.message); + this.$logger.error(e.message); } } } + } + + public async run(): Promise { + const projectDir = this.$projectData.projectDir; + + const frameworkToInstall = + this.options.framework || + (await this.$prompter.promptForChoice( + "Select testing framework:", + TESTING_FRAMEWORKS, + )); + if (TESTING_FRAMEWORKS.indexOf(frameworkToInstall) === -1) { + this.$errors.failWithHelp( + `Unknown or unsupported unit testing framework: ${frameworkToInstall}.`, + ); + } + + const projectFilesExtension = + this.$projectData.projectType === ProjectTypes.TsFlavorName || + this.$projectData.projectType === ProjectTypes.NgFlavorName + ? ".ts" + : ".js"; + + let modulesToInstall: IDependencyInformation[] = []; + try { + modulesToInstall = + this.$testInitializationService.getDependencies(frameworkToInstall); + } catch (err) { + this.$errors.fail( + `Unable to install the unit testing dependencies. Error: '${err.message}'`, + ); + } + + modulesToInstall = modulesToInstall.filter( + (moduleToInstall) => + !moduleToInstall.projectType || + moduleToInstall.projectType === projectFilesExtension, + ); + + await this.installModules(modulesToInstall, projectDir); const isVitest = frameworkToInstall === "vitest"; if (!isVitest) { // The Karma client only exists in the v4 line — v5+ is Vitest-only, so // an unpinned install would break these setups once v5 is `latest`. - await services.$pluginsService.add( + await this.$pluginsService.add( "@nativescript/unit-test-runner@^4.0.0", - services.$projectData, + this.$projectData, ); } - services.$logger.clearScreen(); + this.$logger.clearScreen(); const bufferedLogs = []; - const testsDir = path.join(services.$projectData.appDirectoryPath, "tests"); + const testsDir = path.join(this.$projectData.appDirectoryPath, "tests"); const projectTestsDir = path.relative( - services.$projectData.projectDir, + this.$projectData.projectDir, testsDir, ); const relativeTestsDir = path.relative( - services.$projectData.appDirectoryPath, + this.$projectData.appDirectoryPath, testsDir, ); let shouldCreateSampleTests = true; - if (services.$fs.exists(testsDir)) { + if (this.$fs.exists(testsDir)) { const specFilenamePattern = `.spec${projectFilesExtension}`; bufferedLogs.push( color.yellow( @@ -281,18 +279,18 @@ export const testInitCommandDefinition = defineCommand({ shouldCreateSampleTests = false; } - services.$fs.ensureDirectoryExists(testsDir); + this.$fs.ensureDirectoryExists(testsDir); if (isVitest) { - const vitestConfigResourcePath = services.$resources.resolvePath( + const vitestConfigResourcePath = this.$resources.resolvePath( "test/vitest.config.mts", ); - services.$fs.copyFile( + this.$fs.copyFile( vitestConfigResourcePath, path.join(projectDir, "vitest.config.mts"), ); bufferedLogs.push(`Added/replaced ${color.yellow("vitest.config.mts")}`); - ensureAndroidNetworkSecurityConfig(services, bufferedLogs); + this.ensureAndroidNetworkSecurityConfig(bufferedLogs); } else { const frameworks = [frameworkToInstall] .concat(karmaConfigAdditionalFrameworks[frameworkToInstall] || []) @@ -301,18 +299,17 @@ export const testInitCommandDefinition = defineCommand({ const testFiles = `'${fromWindowsRelativePathToUnix( relativeTestsDir, )}/**/*${projectFilesExtension}'`; - const karmaConfTemplate = - services.$resources.readText("test/karma.conf.js"); + const karmaConfTemplate = this.$resources.readText("test/karma.conf.js"); const karmaConf = _.template(karmaConfTemplate)({ frameworks, testFiles, - basePath: services.$projectData.getAppDirectoryRelativePath(), + basePath: this.$projectData.getAppDirectoryRelativePath(), }); - services.$fs.writeFile(path.join(projectDir, "karma.conf.js"), karmaConf); + this.$fs.writeFile(path.join(projectDir, "karma.conf.js"), karmaConf); } - const exampleFilePath = services.$resources.resolvePath( + const exampleFilePath = this.$resources.resolvePath( `test/example.${frameworkToInstall}${projectFilesExtension}`, ); const targetExampleTestPath = path.join( @@ -320,8 +317,8 @@ export const testInitCommandDefinition = defineCommand({ `example.spec${projectFilesExtension}`, ); - if (shouldCreateSampleTests && services.$fs.exists(exampleFilePath)) { - services.$fs.copyFile(exampleFilePath, targetExampleTestPath); + if (shouldCreateSampleTests && this.$fs.exists(exampleFilePath)) { + this.$fs.copyFile(exampleFilePath, targetExampleTestPath); const targetExampleTestRelativePath = path.relative( projectDir, targetExampleTestPath, @@ -332,18 +329,18 @@ export const testInitCommandDefinition = defineCommand({ } // test main entry - const testMainResourcesPath = services.$resources.resolvePath( + const testMainResourcesPath = this.$resources.resolvePath( isVitest ? `test/test-main.vitest${projectFilesExtension}` : `test/test-main${projectFilesExtension}`, ); const testMainPath = path.join( - services.$projectData.appDirectoryPath, + this.$projectData.appDirectoryPath, `test${projectFilesExtension}`, ); - if (!services.$fs.exists(testMainPath)) { - services.$fs.copyFile(testMainResourcesPath, testMainPath); + if (!this.$fs.exists(testMainPath)) { + this.$fs.copyFile(testMainResourcesPath, testMainPath); const testMainRelativePath = path.relative(projectDir, testMainPath); bufferedLogs.push( `Main test entrypoint created: ${color.yellow(testMainRelativePath)}`, @@ -351,14 +348,14 @@ export const testInitCommandDefinition = defineCommand({ } if (!isVitest || projectFilesExtension === ".ts") { - const testTsConfigTemplate = services.$resources.readText( + const testTsConfigTemplate = this.$resources.readText( "test/tsconfig.spec.json", ); const testTsConfig = _.template(testTsConfigTemplate)({ - basePath: services.$projectData.getAppDirectoryRelativePath(), + basePath: this.$projectData.getAppDirectoryRelativePath(), }); - services.$fs.writeFile( + this.$fs.writeFile( path.join(projectDir, "tsconfig.spec.json"), testTsConfig, ); @@ -405,7 +402,7 @@ export const testInitCommandDefinition = defineCommand({ "", ]; - services.$logger.info( + this.$logger.info( [ [ color.green(`Tests using`), @@ -418,5 +415,5 @@ export const testInitCommandDefinition = defineCommand({ ...closingNotes, ].join("\n"), ); - }, -}); + } +} diff --git a/lib/commands/typings.ts b/lib/commands/typings.ts index d8c5d9175c..0850282161 100644 --- a/lib/commands/typings.ts +++ b/lib/commands/typings.ts @@ -5,9 +5,8 @@ import { PromptObject } from "prompts"; import { color } from "../color"; import { IChildProcess, IFileSystem, IHostInfo } from "../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -21,266 +20,236 @@ const typingsCommandOptions = { jar: stringOption(), } satisfies CommandOptionsSchema; -export type TypingsCommandContext = CommandContext< - typeof typingsCommandOptions ->; - -export function setupTypingsCommand() { - return { - $childProcess: inject("childProcess"), - $fs: inject("fs"), - $hostInfo: inject("hostInfo"), - $logger: inject("logger"), - $mobileHelper: inject("mobileHelper"), - $options: inject("options"), - $projectData: inject("projectData"), - $prompter: inject("prompter"), - $staticConfig: inject("staticConfig"), - }; -} - -export type ITypingsCommandServices = ReturnType; - -async function resolveGradleDependencies( - services: ITypingsCommandServices, - target: string, -) { - const gradleHome = path.resolve( - process.env.GRADLE_USER_HOME ?? path.join(homedir(), `/.gradle`), - ); - const gradleFiles = path.resolve(gradleHome, "caches/modules-2/files-2.1/"); - - if (!services.$fs.exists(gradleFiles)) { - services.$logger.warn("No gradle files found"); - return; +export class TypingsCommand extends Command({ + name: "typings", + description: "Generates typings for the native platform APIs.", + options: typingsCommandOptions, + // Only the first argument is read; the rest are gradle targets this command + // takes off the raw argv, so the policy must not reject them. + arguments: "any", +}) { + private $childProcess = inject("childProcess"); + private $fs = inject("fs"); + private $hostInfo = inject("hostInfo"); + private $logger = inject("logger"); + private $mobileHelper = inject("mobileHelper"); + private $options = inject("options"); + private $projectData = inject("projectData"); + private $prompter = inject("prompter"); + private $staticConfig = inject("staticConfig"); + + public canExecute(): boolean { + this.$mobileHelper.validatePlatformName(this.args[0]); + return true; } - const pattern = `${target.replaceAll(":", "/")}/**/*.{jar,aar}`; + public async run(): Promise { + const platform = this.args[0]; + let result; + if (this.$mobileHelper.isAndroidPlatform(platform)) { + result = await this.handleAndroidTypings(); + } else if (this.$mobileHelper.isiOSPlatform(platform)) { + result = await this.handleiOSTypings(); + } + let typingsFolder = "./typings"; + if (this.options.copyTo) { + this.$fs.copyFile( + path.resolve(this.$projectData.projectDir, "typings"), + this.options.copyTo, + ); + typingsFolder = this.options.copyTo; + } - const items = []; - for await (const item of glob(pattern, { - cwd: gradleFiles, - })) { - const [group, artifact, version, sha1, file] = item.split(path.sep); - items.push({ - id: sha1 + version, - group, - artifact, - version, - sha1, - file, - path: path.resolve(gradleFiles, item), - }); + if (result !== false) { + this.$logger.info( + "Typings have been generated in the following directory:", + typingsFolder, + ); + } } - if (items.length === 0) { - services.$logger.warn("No files found"); - return []; - } + private async resolveGradleDependencies(target: string) { + const gradleHome = path.resolve( + process.env.GRADLE_USER_HOME ?? path.join(homedir(), `/.gradle`), + ); + const gradleFiles = path.resolve(gradleHome, "caches/modules-2/files-2.1/"); - services.$logger.clearScreen(); + if (!this.$fs.exists(gradleFiles)) { + this.$logger.warn("No gradle files found"); + return; + } - const choices = await services.$prompter.promptForChoice( - `Select dependencies to generate typings for (${color.greenBright( - target, - )})`, - items - .sort((a, b) => { - if (a.artifact < b.artifact) return -1; - if (a.artifact > b.artifact) return 1; + const pattern = `${target.replaceAll(":", "/")}/**/*.{jar,aar}`; + + const items = []; + for await (const item of glob(pattern, { + cwd: gradleFiles, + })) { + const [group, artifact, version, sha1, file] = item.split(path.sep); + items.push({ + id: sha1 + version, + group, + artifact, + version, + sha1, + file, + path: path.resolve(gradleFiles, item), + }); + } - return a.version.localeCompare(b.version, undefined, { - numeric: true, - sensitivity: "base", - }); - }) - .map((item) => { - return { - title: `${color.white(item.group)}:${color.greenBright( - item.artifact, - )}:${color.yellow(item.version)} - ${color.styleText( - ["cyanBright", "bold"], - item.file, - )}`, - value: item.id, - }; - }), - true, - { - optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions - } as Partial, - ); + if (items.length === 0) { + this.$logger.warn("No files found"); + return []; + } - services.$logger.clearScreen(); + this.$logger.clearScreen(); + + const choices = await this.$prompter.promptForChoice( + `Select dependencies to generate typings for (${color.greenBright( + target, + )})`, + items + .sort((a, b) => { + if (a.artifact < b.artifact) return -1; + if (a.artifact > b.artifact) return 1; + + return a.version.localeCompare(b.version, undefined, { + numeric: true, + sensitivity: "base", + }); + }) + .map((item) => { + return { + title: `${color.white(item.group)}:${color.greenBright( + item.artifact, + )}:${color.yellow(item.version)} - ${color.styleText( + ["cyanBright", "bold"], + item.file, + )}`, + value: item.id, + }; + }), + true, + { + optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions + } as Partial, + ); - return items - .filter((item) => choices.includes(item.id)) - .map((item) => item.path); -} + this.$logger.clearScreen(); -async function handleAndroidTypings( - context: TypingsCommandContext, - services: ITypingsCommandServices, -) { - // The gradle targets are positional arguments this command reads off the - // raw argv rather than declaring, so that they keep working alongside the - // --jar and --aar flags. - const targets = services.$options.argv._.slice(2) ?? []; - const paths: string[] = []; + return items + .filter((item) => choices.includes(item.id)) + .map((item) => item.path); + } - if (targets.length) { - for (const target of targets) { - try { - paths.push(...(await resolveGradleDependencies(services, target))); - } catch (err) { - services.$logger.trace( - `Failed to resolve gradle dependencies for target "${target}"`, - err, - ); + private async handleAndroidTypings() { + // The gradle targets are positional arguments this command reads off the + // raw argv rather than declaring, so that they keep working alongside the + // --jar and --aar flags. + const targets = this.$options.argv._.slice(2) ?? []; + const paths: string[] = []; + + if (targets.length) { + for (const target of targets) { + try { + paths.push(...(await this.resolveGradleDependencies(target))); + } catch (err) { + this.$logger.trace( + `Failed to resolve gradle dependencies for target "${target}"`, + err, + ); + } } } - } - if (!paths.length && !(context.options.jar || context.options.aar)) { - services.$logger.warn( - [ - "No .jar or .aar file specified. Please specify at least one of the following:", - " - path to .jar file with --jar ", - " - path to .aar file with --aar ", - ].join("\n"), - ); - return false; - } - - services.$fs.ensureDirectoryExists( - path.resolve(services.$projectData.projectDir, "typings", "android"), - ); + if (!paths.length && !(this.options.jar || this.options.aar)) { + this.$logger.warn( + [ + "No .jar or .aar file specified. Please specify at least one of the following:", + " - path to .jar file with --jar ", + " - path to .aar file with --aar ", + ].join("\n"), + ); + return false; + } - const dtsGeneratorPath = path.resolve( - services.$projectData.platformsDir, - "android", - "build-tools", - "dts-generator.jar", - ); - if (!services.$fs.exists(dtsGeneratorPath)) { - services.$logger.warn( - "No platforms folder found, preparing project now...", + this.$fs.ensureDirectoryExists( + path.resolve(this.$projectData.projectDir, "typings", "android"), ); - await services.$childProcess.spawnFromEvent( - services.$hostInfo.isWindows ? "ns.cmd" : "ns", - ["prepare", "android"], - "exit", - { stdio: "inherit", shell: services.$hostInfo.isWindows }, - ); - } - const asArray = (input: string | string[]) => { - if (!input) { - return []; + const dtsGeneratorPath = path.resolve( + this.$projectData.platformsDir, + "android", + "build-tools", + "dts-generator.jar", + ); + if (!this.$fs.exists(dtsGeneratorPath)) { + this.$logger.warn("No platforms folder found, preparing project now..."); + await this.$childProcess.spawnFromEvent( + this.$hostInfo.isWindows ? "ns.cmd" : "ns", + ["prepare", "android"], + "exit", + { stdio: "inherit", shell: this.$hostInfo.isWindows }, + ); } - if (typeof input === "string") { - return [input]; - } + const asArray = (input: string | string[]) => { + if (!input) { + return []; + } - return input; - }; + if (typeof input === "string") { + return [input]; + } - const inputs: string[] = [ - ...asArray(context.options.jar), - ...asArray(context.options.aar), - ...paths, - ]; + return input; + }; - await services.$childProcess.spawnFromEvent( - "java", - [ - "-jar", - dtsGeneratorPath, - "-input", - ...inputs, - "-output", - path.resolve(services.$projectData.projectDir, "typings", "android"), - ], - "exit", - { stdio: "inherit" }, - ); -} + const inputs: string[] = [ + ...asArray(this.options.jar), + ...asArray(this.options.aar), + ...paths, + ]; -async function handleiOSTypings( - context: TypingsCommandContext, - services: ITypingsCommandServices, -) { - if (context.options.filter !== undefined) { - services.$logger.warn("--filter flag is not supported yet."); + await this.$childProcess.spawnFromEvent( + "java", + [ + "-jar", + dtsGeneratorPath, + "-input", + ...inputs, + "-output", + path.resolve(this.$projectData.projectDir, "typings", "android"), + ], + "exit", + { stdio: "inherit" }, + ); } - services.$fs.ensureDirectoryExists( - path.resolve(services.$projectData.projectDir, "typings", "ios"), - ); - - await services.$childProcess.spawnFromEvent( - "node", - [services.$staticConfig.cliBinPath, "build", "ios"], - "exit", - { - env: { - ...process.env, - TNS_TYPESCRIPT_DECLARATIONS_PATH: path.resolve( - services.$projectData.projectDir, - "typings", - "ios", - ), - }, - stdio: "inherit", - }, - ); -} - -export function canExecuteTypingsCommand( - context: TypingsCommandContext, - services: ITypingsCommandServices, -): boolean { - services.$mobileHelper.validatePlatformName(context.args[0]); - return true; -} + private async handleiOSTypings() { + if (this.options.filter !== undefined) { + this.$logger.warn("--filter flag is not supported yet."); + } -export async function runTypingsCommand( - context: TypingsCommandContext, - services: ITypingsCommandServices, -): Promise { - const platform = context.args[0]; - let result; - if (services.$mobileHelper.isAndroidPlatform(platform)) { - result = await handleAndroidTypings(context, services); - } else if (services.$mobileHelper.isiOSPlatform(platform)) { - result = await handleiOSTypings(context, services); - } - let typingsFolder = "./typings"; - if (context.options.copyTo) { - services.$fs.copyFile( - path.resolve(services.$projectData.projectDir, "typings"), - context.options.copyTo, + this.$fs.ensureDirectoryExists( + path.resolve(this.$projectData.projectDir, "typings", "ios"), ); - typingsFolder = context.options.copyTo; - } - if (result !== false) { - services.$logger.info( - "Typings have been generated in the following directory:", - typingsFolder, + await this.$childProcess.spawnFromEvent( + "node", + [this.$staticConfig.cliBinPath, "build", "ios"], + "exit", + { + env: { + ...process.env, + TNS_TYPESCRIPT_DECLARATIONS_PATH: path.resolve( + this.$projectData.projectDir, + "typings", + "ios", + ), + }, + stdio: "inherit", + }, ); } } - -export const typingsCommandDefinition = defineCommand({ - name: "typings", - description: "Generates typings for the native platform APIs.", - options: typingsCommandOptions, - // Only the first argument is read; the rest are gradle targets this command - // takes off the raw argv, so the policy must not reject them. - arguments: "any", - setup: setupTypingsCommand, - canExecute: canExecuteTypingsCommand, - run: runTypingsCommand, -}); diff --git a/lib/commands/update-platform.ts b/lib/commands/update-platform.ts index 7eb4bd017b..eec8fd1f0e 100644 --- a/lib/commands/update-platform.ts +++ b/lib/commands/update-platform.ts @@ -10,92 +10,76 @@ import { ICheckEnvironmentRequirementsInput, } from "../definitions/platform"; import { IErrors } from "../common/declarations"; -import { CommandContext, defineCommand } from "../common/define-command"; +import { Command } from "../common/define-command"; import { inject } from "../common/di"; -export function setupUpdatePlatformCommand() { - const services = { - $errors: inject("errors"), - $options: inject("options"), - $platformEnvironmentRequirements: inject( - "platformEnvironmentRequirements", - ), - $platformCommandHelper: inject( - "platformCommandHelper", - ), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IUpdatePlatformCommandServices = ReturnType< - typeof setupUpdatePlatformCommand ->; +export class UpdatePlatformCommand extends Command({ + name: "platform|update", + description: "Updates the NativeScript runtime for the specified platform.", + arguments: "any", +}) { + private $errors = inject("errors"); + private $options = inject("options"); + private $platformEnvironmentRequirements = + inject("platformEnvironmentRequirements"); + private $platformCommandHelper = inject( + "platformCommandHelper", + ); + private $platformValidationService = inject( + "platformValidationService", + ); + private $projectData = inject("projectData"); -export async function canExecuteUpdatePlatformCommand( - context: CommandContext, - services: IUpdatePlatformCommandServices, -): Promise { - const args = context.args; - if (!args || args.length === 0) { - services.$errors.failWithHelp( - "No platform specified. Please specify platforms to update.", - ); + constructor() { + super(); + this.$projectData.initializeProjectData(); } - _.each(args, (arg) => { - const platform = arg.split("@")[0]; - services.$platformValidationService.validatePlatform( - platform, - services.$projectData, - ); - }); + public async canExecute(): Promise { + const args = this.args; + if (!args || args.length === 0) { + this.$errors.failWithHelp( + "No platform specified. Please specify platforms to update.", + ); + } - for (const arg of args) { - const [platform, versionToBeInstalled] = arg.split("@"); - const checkEnvironmentRequirementsInput: ICheckEnvironmentRequirementsInput = - { + _.each(args, (arg) => { + const platform = arg.split("@")[0]; + this.$platformValidationService.validatePlatform( platform, - options: services.$options, - }; - // If version is not specified, we know the command will install the latest compatible Android runtime. - // The latest compatible Android runtime supports Java version, so we do not need to pass it here. - // Passing projectDir to the @nativescript/doctor validation will cause it to check the runtime from the current package.json - // So in this case, where we do not want to validate the runtime, just do not pass both projectDir and runtimeVersion. - if (versionToBeInstalled) { - checkEnvironmentRequirementsInput.projectDir = - services.$projectData.projectDir; - checkEnvironmentRequirementsInput.runtimeVersion = versionToBeInstalled; + this.$projectData, + ); + }); + + for (const arg of args) { + const [platform, versionToBeInstalled] = arg.split("@"); + const checkEnvironmentRequirementsInput: ICheckEnvironmentRequirementsInput = + { + platform, + options: this.$options, + }; + // If version is not specified, we know the command will install the latest compatible Android runtime. + // The latest compatible Android runtime supports Java version, so we do not need to pass it here. + // Passing projectDir to the @nativescript/doctor validation will cause it to check the runtime from the current package.json + // So in this case, where we do not want to validate the runtime, just do not pass both projectDir and runtimeVersion. + if (versionToBeInstalled) { + checkEnvironmentRequirementsInput.projectDir = + this.$projectData.projectDir; + checkEnvironmentRequirementsInput.runtimeVersion = versionToBeInstalled; + } + + await this.$platformEnvironmentRequirements.checkEnvironmentRequirements( + checkEnvironmentRequirementsInput, + ); } - await services.$platformEnvironmentRequirements.checkEnvironmentRequirements( - checkEnvironmentRequirementsInput, - ); + return true; } - return true; -} - -export async function runUpdatePlatformCommand( - context: CommandContext, - services: IUpdatePlatformCommandServices, -): Promise { - await services.$platformCommandHelper.updatePlatforms( - context.args, - services.$projectData, - ); + public async run(): Promise { + await this.$platformCommandHelper.updatePlatforms( + this.args, + this.$projectData, + ); + } } - -export const updatePlatformCommandDefinition = defineCommand({ - name: "platform|update", - description: "Updates the NativeScript runtime for the specified platform.", - arguments: "any", - setup: setupUpdatePlatformCommand, - canExecute: canExecuteUpdatePlatformCommand, - run: runUpdatePlatformCommand, -}); diff --git a/lib/common/bootstrap.ts b/lib/common/bootstrap.ts index 644146c909..ba51125a76 100644 --- a/lib/common/bootstrap.ts +++ b/lib/common/bootstrap.ts @@ -314,11 +314,8 @@ registerBuiltInCommand< () => require("./commands/proxy/proxy-get").proxyGetCommandDefinition, ); registerBuiltInCommand< - typeof import("./commands/proxy/proxy-set").proxySetCommandDefinition ->( - "proxy|set", - () => require("./commands/proxy/proxy-set").proxySetCommandDefinition, -); + typeof import("./commands/proxy/proxy-set").ProxySetCommand +>("proxy|set", () => require("./commands/proxy/proxy-set").ProxySetCommand); registerBuiltInCommand< typeof import("./commands/proxy/proxy-clear").proxyClearCommandDefinition >( diff --git a/lib/common/commands/device/list-devices.ts b/lib/common/commands/device/list-devices.ts index 81feb6e834..24ed93670d 100644 --- a/lib/common/commands/device/list-devices.ts +++ b/lib/common/commands/device/list-devices.ts @@ -17,26 +17,12 @@ const listDevicesCommandOptions = { json: booleanOption(), } satisfies CommandOptionsSchema; -export type ListDevicesCommandContext = CommandContext< +type ListDevicesCommandContext = CommandContext< typeof listDevicesCommandOptions >; -export function setupListDevicesCommand() { - return { - $devicesService: inject("devicesService"), - $emulatorHelper: inject("emulatorHelper"), - $errors: inject("errors"), - $logger: inject("logger"), - $mobileHelper: inject("mobileHelper"), - }; -} - -export type IListDevicesCommandServices = ReturnType< - typeof setupListDevicesCommand ->; - function printEmulators( - services: IListDevicesCommandServices, + $logger: ILogger, emulators: Mobile.IDeviceInfo[], ): void { const table: any = createTable( @@ -61,14 +47,22 @@ function printEmulators( ]); } - services.$logger.info(table.toString()); + $logger.info(table.toString()); } -export async function runListDevicesCommand( +async function listDevices( context: ListDevicesCommandContext, - services: IListDevicesCommandServices, platformFilter: string, ): Promise { + const $devicesService = + context.injector.get("devicesService"); + const $emulatorHelper = + context.injector.get("emulatorHelper"); + const $errors = context.injector.get("errors"); + const $logger = context.injector.get("logger"); + const $mobileHelper = + context.injector.get("mobileHelper"); + const devices: { available?: any[]; devices: any[]; @@ -77,32 +71,31 @@ export async function runListDevicesCommand( }; if (context.options.availableDevices) { - const platform = - services.$mobileHelper.normalizePlatformName(platformFilter); + const platform = $mobileHelper.normalizePlatformName(platformFilter); if (!platform && platformFilter) { - services.$errors.fail( + $errors.fail( `${platformFilter} is not a valid device platform. The valid platforms are ${formatListOfNames( - services.$mobileHelper.platformNames, + $mobileHelper.platformNames, )}`, ); } - const availableEmulatorsOutput = - await services.$devicesService.getEmulatorImages({ platform }); - const emulators = - services.$emulatorHelper.getEmulatorsFromAvailableEmulatorsOutput( - availableEmulatorsOutput, - ); + const availableEmulatorsOutput = await $devicesService.getEmulatorImages({ + platform, + }); + const emulators = $emulatorHelper.getEmulatorsFromAvailableEmulatorsOutput( + availableEmulatorsOutput, + ); devices.available = emulators; if (!context.options.json) { - services.$logger.info(color.bold("\n Available emulators")); - printEmulators(services, emulators); + $logger.info(color.bold("\n Available emulators")); + printEmulators($logger, emulators); } } let index = 1; - await services.$devicesService.initialize({ + await $devicesService.initialize({ platform: platformFilter, deviceId: null, skipInferPlatform: true, @@ -112,7 +105,7 @@ export async function runListDevicesCommand( }); if (!context.options.json) { - services.$logger.info(color.bold("\n Connected devices & emulators")); + $logger.info(color.bold("\n Connected devices & emulators")); } const table: any = createTable( @@ -148,16 +141,16 @@ export async function runListDevicesCommand( }; } - await services.$devicesService.execute(action, undefined, { + await $devicesService.execute(action, undefined, { allowNoDevices: true, }); if (context.options.json) { - return services.$logger.info(JSON.stringify(devices, null, 2)); + return $logger.info(JSON.stringify(devices, null, 2)); } if (table.length) { - services.$logger.info(table.toString()); + $logger.info(table.toString()); } } @@ -167,10 +160,8 @@ export class ListDevicesCommand extends Command({ options: listDevicesCommandOptions, arguments: [{ name: "platform" }], }) { - private services = setupListDevicesCommand(); - public run(): Promise { - return runListDevicesCommand(this.context, this.services, this.args[0]); + return listDevices(this.context, this.args[0]); } } @@ -185,17 +176,12 @@ const defineListPlatformDevicesCommand = ( description: "Lists the connected devices and emulators for one platform.", options: listDevicesCommandOptions, arguments: "none", - setup() { - const $devicePlatformsConstants = - inject("devicePlatformsConstants"); - - return { - ...setupListDevicesCommand(), - platform: $devicePlatformsConstants[listedPlatform], - }; - }, - run(context, services): Promise { - return runListDevicesCommand(context, services, services.platform); + run(context): Promise { + const platform = inject( + "devicePlatformsConstants", + )[listedPlatform]; + + return listDevices(context, platform); }, }); diff --git a/lib/common/commands/proxy/proxy-set.ts b/lib/common/commands/proxy/proxy-set.ts index 0edf26251b..cea5c96280 100644 --- a/lib/common/commands/proxy/proxy-set.ts +++ b/lib/common/commands/proxy/proxy-set.ts @@ -1,24 +1,21 @@ import { EOL, platform } from "os"; -import { parse } from "url"; +import { parse, UrlWithStringQuery } from "url"; import { HttpProtocolToPort } from "../../constants"; import { IErrors, IHostInfo, IProxyLibSettings, + IProxyService, IPrompterQuestion, } from "../../declarations"; import { booleanOption, - CommandContext, + Command, CommandOptionsSchema, - defineCommand, } from "../../define-command"; import { inject } from "../../di"; import { isInteractive } from "../../helpers"; -import { - injectProxyCommandServices, - tryTrackProxyCommandUsage, -} from "./proxy-base"; +import { tryTrackProxyCommandUsage } from "./proxy-base"; const { getCredentialsFromAuth } = require("proxy-lib/lib/utils"); const proxySetCommandName = "proxy|set"; @@ -27,22 +24,6 @@ const proxySetCommandOptions = { insecure: booleanOption(), } satisfies CommandOptionsSchema; -export type ProxySetCommandContext = CommandContext< - typeof proxySetCommandOptions ->; - -export function setupProxySetCommand() { - return { - ...injectProxyCommandServices(), - $errors: inject("errors"), - $hostInfo: inject("hostInfo"), - $prompter: inject("prompter"), - $staticConfig: inject("staticConfig"), - }; -} - -export type IProxySetCommandServices = ReturnType; - function isPasswordRequired(username: string, password: string): boolean { return !!(username && !password); } @@ -55,151 +36,178 @@ function getInvalidPortMessage(port: number): string { return `Specified port ${port} is not valid. Please enter a value between 1 and 65535.`; } -async function getPortFromUserInput( - services: IProxySetCommandServices, -): Promise { - const schemaName = "port"; - const schema: IPrompterQuestion = { - message: "Port", - type: "text", - name: schemaName, - validate: (value: any) => { - return !value || !isValidPort(value) - ? getInvalidPortMessage(value) - : true; - }, - }; - - const prompterResult = await services.$prompter.get([schema]); - return parseInt(prompterResult[schemaName]); -} - -export async function runProxySetCommand( - context: ProxySetCommandContext, - services: IProxySetCommandServices, -): Promise { - let urlString = context.args[0]; - let username = context.args[1]; - let password = context.args[2]; +export class ProxySetCommand extends Command({ + name: proxySetCommandName, + description: "Configures a proxy for the CLI to use.", + options: proxySetCommandOptions, + arguments: [{ name: "url" }, { name: "username" }, { name: "password" }], + disableAnalytics: true, +}) { + private $logger = inject("logger"); + private $proxyService = inject("proxyService"); + private $errors = inject("errors"); + private $hostInfo = inject("hostInfo"); + private $prompter = inject("prompter"); + private $staticConfig = inject("staticConfig"); + + public async run(): Promise { + let username = this.args[1]; + let password = this.args[2]; + + const { urlString, urlObj } = await this.resolveUrl(this.args[0]); + + let port = + (urlObj.port && +urlObj.port) || HttpProtocolToPort[urlObj.protocol]; + const noPort = !port || !isValidPort(port); + + const credentials = this.resolveCredentials( + urlObj.auth || "", + username, + password, + ); + username = credentials.username; + password = credentials.password; - const noUrl = !urlString; - if (noUrl) { if (!isInteractive()) { - services.$errors.failWithHelp( - "Console is not interactive - you need to supply all command parameters.", + if (noPort) { + this.$errors.fail( + `The port you have specified (${port || "none"}) is not valid.`, + ); + } else if (isPasswordRequired(username, password)) { + this.$errors.failWithHelp( + "Console is not interactive - you need to supply all command parameters.", + ); + } + } + + if (noPort) { + if (port) { + this.$logger.warn(getInvalidPortMessage(port)); + } + + port = await this.getPortFromUserInput(); + } + + if (!username) { + this.$logger.info( + "In case your proxy requires authentication, please specify username and password. If authentication is not required, just leave it empty.", ); - } else { - urlString = await services.$prompter.getString("Url", { - allowEmpty: false, + username = await this.$prompter.getString("Username", { + defaultAction: () => "", }); } - } - let urlObj = parse(urlString); - if ((!urlObj.protocol || !urlObj.hostname) && !isInteractive()) { - services.$errors.fail( - "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname.", - ); - } + if (isPasswordRequired(username, password)) { + password = await this.$prompter.getPassword("Password"); + } - while (!urlObj.protocol || !urlObj.hostname) { - services.$logger.warn( - "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname.", - ); - urlString = await services.$prompter.getString("Url", { - allowEmpty: false, + await this.saveSettings({ + proxyUrl: urlString, + username, + password, + rejectUnauthorized: !this.options.insecure, }); - urlObj = parse(urlString); } - let port = - (urlObj.port && +urlObj.port) || HttpProtocolToPort[urlObj.protocol]; - const noPort = !port || !isValidPort(port); - const authCredentials = getCredentialsFromAuth(urlObj.auth || ""); - if ( - (username && - authCredentials.username && - username !== authCredentials.username) || - (password && - authCredentials.password && - password !== authCredentials.password) - ) { - services.$errors.fail( - "The credentials you have provided in the url address mismatch those passed as command line arguments.", - ); - } - username = username || authCredentials.username; - password = password || authCredentials.password; + private async resolveUrl( + urlString: string, + ): Promise<{ urlString: string; urlObj: UrlWithStringQuery }> { + const noUrl = !urlString; + if (noUrl) { + if (!isInteractive()) { + this.$errors.failWithHelp( + "Console is not interactive - you need to supply all command parameters.", + ); + } else { + urlString = await this.$prompter.getString("Url", { + allowEmpty: false, + }); + } + } - if (!isInteractive()) { - if (noPort) { - services.$errors.fail( - `The port you have specified (${port || "none"}) is not valid.`, + let urlObj = parse(urlString); + if ((!urlObj.protocol || !urlObj.hostname) && !isInteractive()) { + this.$errors.fail( + "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname.", ); - } else if (isPasswordRequired(username, password)) { - services.$errors.failWithHelp( - "Console is not interactive - you need to supply all command parameters.", + } + + while (!urlObj.protocol || !urlObj.hostname) { + this.$logger.warn( + "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname.", ); + urlString = await this.$prompter.getString("Url", { + allowEmpty: false, + }); + urlObj = parse(urlString); } + + return { urlString, urlObj }; } - if (noPort) { - if (port) { - services.$logger.warn(getInvalidPortMessage(port)); + private resolveCredentials( + auth: string, + username: string, + password: string, + ): { username: string; password: string } { + const authCredentials = getCredentialsFromAuth(auth); + if ( + (username && + authCredentials.username && + username !== authCredentials.username) || + (password && + authCredentials.password && + password !== authCredentials.password) + ) { + this.$errors.fail( + "The credentials you have provided in the url address mismatch those passed as command line arguments.", + ); } - port = await getPortFromUserInput(services); + return { + username: username || authCredentials.username, + password: password || authCredentials.password, + }; } - if (!username) { - services.$logger.info( - "In case your proxy requires authentication, please specify username and password. If authentication is not required, just leave it empty.", - ); - username = await services.$prompter.getString("Username", { - defaultAction: () => "", - }); + private async getPortFromUserInput(): Promise { + const schemaName = "port"; + const schema: IPrompterQuestion = { + message: "Port", + type: "text", + name: schemaName, + validate: (value: any) => { + return !value || !isValidPort(value) + ? getInvalidPortMessage(value) + : true; + }, + }; + + const prompterResult = await this.$prompter.get([schema]); + return parseInt(prompterResult[schemaName]); } - if (isPasswordRequired(username, password)) { - password = await services.$prompter.getPassword("Password"); - } + private async saveSettings(settings: IProxyLibSettings): Promise { + if (!this.$hostInfo.isWindows) { + this.$logger.warn( + `Note that storing credentials is not supported on ${platform()} yet.`, + ); + } - const settings: IProxyLibSettings = { - proxyUrl: urlString, - username, - password, - rejectUnauthorized: !context.options.insecure, - }; + const clientName = this.$staticConfig.CLIENT_NAME.toLowerCase(); + const messageNote = + (clientName === "tns" + ? "Note that 'npm' and 'Gradle' need to be configured separately to work with a proxy." + : "Note that `npm` needs to be configured separately to work with a proxy.") + + EOL; - if (!services.$hostInfo.isWindows) { - services.$logger.warn( - `Note that storing credentials is not supported on ${platform()} yet.`, + this.$logger.warn( + `${messageNote}Run '${clientName} proxy set --help' for more information.`, ); - } - const clientName = services.$staticConfig.CLIENT_NAME.toLowerCase(); - const messageNote = - (clientName === "tns" - ? "Note that 'npm' and 'Gradle' need to be configured separately to work with a proxy." - : "Note that `npm` needs to be configured separately to work with a proxy.") + - EOL; - - services.$logger.warn( - `${messageNote}Run '${clientName} proxy set --help' for more information.`, - ); - - await services.$proxyService.setCache(settings); - services.$logger.info(`Successfully setup proxy.${EOL}`); - services.$logger.info(await services.$proxyService.getInfo()); - await tryTrackProxyCommandUsage(services, proxySetCommandName); + await this.$proxyService.setCache(settings); + this.$logger.info(`Successfully setup proxy.${EOL}`); + this.$logger.info(await this.$proxyService.getInfo()); + await tryTrackProxyCommandUsage(this.$logger, proxySetCommandName); + } } - -export const proxySetCommandDefinition = defineCommand({ - name: proxySetCommandName, - description: "Configures a proxy for the CLI to use.", - options: proxySetCommandOptions, - arguments: [{ name: "url" }, { name: "username" }, { name: "password" }], - disableAnalytics: true, - setup: setupProxySetCommand, - run: runProxySetCommand, -}); diff --git a/test/commands/post-install.ts b/test/commands/post-install.ts index d39cb11327..4a7b31f216 100644 --- a/test/commands/post-install.ts +++ b/test/commands/post-install.ts @@ -1,6 +1,6 @@ import { Yok } from "../../lib/common/yok"; import { assert } from "chai"; -import { postInstallCliCommandDefinition } from "../../lib/commands/post-install"; +import { PostInstallCliCommand } from "../../lib/commands/post-install"; import { registerCommand } from "../../lib/common/services/command-definition-adapter"; import { SettingsService } from "../../lib/common/test/unit-tests/stubs"; import { IInjector } from "../../lib/common/definitions/yok"; @@ -47,7 +47,7 @@ const createTestInjector = (): IInjector => { testInjector.register("settingsService", SettingsService); runInInjectionContext(testInjector, () => - registerCommand(postInstallCliCommandDefinition), + registerCommand(PostInstallCliCommand), ); testInjector.register("hostInfo", {}); diff --git a/test/platform-commands.ts b/test/platform-commands.ts index 4bc0be599b..bcdf340861 100644 --- a/test/platform-commands.ts +++ b/test/platform-commands.ts @@ -1,8 +1,8 @@ import * as yok from "../lib/common/yok"; import * as stubs from "./stubs"; -import { addPlatformCommandDefinition } from "../lib/commands/add-platform"; +import { AddPlatformCommand } from "../lib/commands/add-platform"; import { removePlatformCommandDefinition } from "../lib/commands/remove-platform"; -import { updatePlatformCommandDefinition } from "../lib/commands/update-platform"; +import { UpdatePlatformCommand } from "../lib/commands/update-platform"; import { PlatformCleanCommand } from "../lib/commands/platform-clean"; import { registerCommand } from "../lib/common/services/command-definition-adapter"; import * as StaticConfigLib from "../lib/config"; @@ -162,13 +162,13 @@ function createTestInjector() { testInjector.register("sysInfo", {}); testInjector.register("commands-service", CommandsServiceLib.CommandsService); runInInjectionContext(testInjector, () => - registerCommand(addPlatformCommandDefinition), + registerCommand(AddPlatformCommand), ); runInInjectionContext(testInjector, () => registerCommand(removePlatformCommandDefinition), ); runInInjectionContext(testInjector, () => - registerCommand(updatePlatformCommandDefinition), + registerCommand(UpdatePlatformCommand), ); runInInjectionContext(testInjector, () => registerCommand(PlatformCleanCommand), diff --git a/test/plugin-create.ts b/test/plugin-create.ts index 6c533a0ea8..0a5f66bfea 100644 --- a/test/plugin-create.ts +++ b/test/plugin-create.ts @@ -1,7 +1,7 @@ import { Yok } from "../lib/common/yok"; import * as stubs from "./stubs"; import { - createPluginCommandDefinition, + CreatePluginCommand, INCLUDE_ANGULAR_DEMO_MESSAGE, INCLUDE_TYPESCRIPT_DEMO_MESSAGE, NAME_MESSAGE, @@ -74,7 +74,7 @@ function createTestInjector() { }); runInInjectionContext(testInjector, () => - registerCommand(createPluginCommandDefinition), + registerCommand(CreatePluginCommand), ); return testInjector; diff --git a/test/project-commands.ts b/test/project-commands.ts index 567d1294dc..a7b8d49379 100644 --- a/test/project-commands.ts +++ b/test/project-commands.ts @@ -1,6 +1,6 @@ import { Yok } from "../lib/common/yok"; import * as stubs from "./stubs"; -import { createProjectCommandDefinition } from "../lib/commands/create-project"; +import { CreateProjectCommand } from "../lib/commands/create-project"; import { registerCommand } from "../lib/common/services/command-definition-adapter"; import { StringCommandParameter } from "../lib/common/command-params"; import { setIsInteractive } from "../lib/common/helpers"; @@ -171,7 +171,7 @@ function createTestInjector() { template: undefined, }); runInInjectionContext(testInjector, () => - registerCommand(createProjectCommandDefinition), + registerCommand(CreateProjectCommand), ); testInjector.register("stringParameter", StringCommandParameter); testInjector.register("prompter", PrompterStub); diff --git a/test/tns-appstore-upload.ts b/test/tns-appstore-upload.ts index e6ca259ec3..4773ce8a8c 100644 --- a/test/tns-appstore-upload.ts +++ b/test/tns-appstore-upload.ts @@ -1,4 +1,4 @@ -import { publishIOSCommandDefinition } from "../lib/commands/appstore-upload"; +import { PublishIOSCommand } from "../lib/commands/appstore-upload"; import { registerCommand } from "../lib/common/services/command-definition-adapter"; import { Injector } from "../lib/common/di"; import { @@ -104,7 +104,7 @@ class AppStore { } runInInjectionContext((this.injector), () => - registerCommand({ ...publishIOSCommandDefinition, name: "appstore" }), + registerCommand({ ...PublishIOSCommand.definition, name: "appstore" }), ); this.injector.register("projectDataService", ProjectDataServiceStub); From 0b8d65bcd4e06a27ee572ced9f4544c981c62431 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 19:37:21 -0300 Subject: [PATCH 16/19] refactor(commands): inline the simple command definitions A simple command is now one defineCommand call with its handlers written inline, where ctx is typed by inference: the exported setupX/runX/canExecuteX functions and the IXServices and XCommandContext aliases nothing else read are gone. Handlers inject what they use at their own top, before the first await. No command hands another a bag of services any more. injectPlatformCommandServices and the setupX bundles are deleted; the shared platform checks take the context and resolve through ctx.injector. A setup that survives is side-effect only - the eager initializeProjectData that has to land ahead of the arguments policy. --- lib/commands/apple-login.ts | 84 +++---- lib/commands/build.ts | 112 ++++----- lib/commands/clean.ts | 212 +++++++--------- lib/commands/command-base.ts | 110 +++----- lib/commands/config.ts | 82 +++--- lib/commands/debug.ts | 221 ++++++++--------- lib/commands/deploy.ts | 47 ++-- .../extensibility/install-extension.ts | 34 +-- lib/commands/extensibility/list-extensions.ts | 30 +-- .../extensibility/uninstall-extension.ts | 30 +-- lib/commands/fonts.ts | 44 ++-- lib/commands/generate-assets.ts | 46 ++-- lib/commands/generate-help.ts | 8 +- lib/commands/generate.ts | 13 +- lib/commands/hooks/common.ts | 55 ++-- lib/commands/hooks/hooks-lock.ts | 68 +++-- lib/commands/hooks/hooks.ts | 83 +++---- lib/commands/info.ts | 8 +- lib/commands/install.ts | 109 ++++---- lib/commands/list-platforms.ts | 57 ++--- lib/commands/migrate.ts | 46 ++-- lib/commands/native-add.ts | 168 ++++++------- lib/commands/open.ts | 145 ++++------- lib/commands/plugin/add-plugin.ts | 76 +++--- lib/commands/plugin/list-plugins.ts | 46 ++-- lib/commands/plugin/remove-plugin.ts | 89 +++---- lib/commands/plugin/update-plugin.ts | 107 ++++---- lib/commands/prepare.ts | 56 ++--- lib/commands/remove-platform.ts | 86 +++---- lib/commands/resources/resources-update.ts | 97 ++++---- lib/commands/run.ts | 234 +++++++++--------- lib/commands/setup.ts | 7 +- lib/commands/start.ts | 8 +- lib/commands/test.ts | 216 ++++++++-------- lib/commands/widget.ts | 36 ++- lib/common/commands/analytics.ts | 56 ++--- lib/common/commands/autocompletion.ts | 83 ++++--- .../commands/device/device-log-stream.ts | 82 +++--- lib/common/commands/device/get-file.ts | 93 +++---- .../commands/device/list-applications.ts | 73 +++--- lib/common/commands/device/list-files.ts | 89 +++---- lib/common/commands/device/put-file.ts | 93 +++---- lib/common/commands/device/run-application.ts | 71 ++---- .../commands/device/stop-application.ts | 53 ++-- .../commands/device/uninstall-application.ts | 43 +--- lib/common/commands/doctor.ts | 22 +- lib/common/commands/generate-messages.ts | 17 +- lib/common/commands/help.ts | 84 +++---- lib/common/commands/package-manager-get.ts | 27 +- lib/common/commands/package-manager-set.ts | 32 +-- lib/common/commands/post-install.ts | 9 +- lib/common/commands/preuninstall.ts | 55 ++-- lib/common/commands/proxy/proxy-base.ts | 25 +- lib/common/commands/proxy/proxy-clear.ts | 20 +- lib/common/commands/proxy/proxy-get.ts | 18 +- 55 files changed, 1642 insertions(+), 2273 deletions(-) diff --git a/lib/commands/apple-login.ts b/lib/commands/apple-login.ts index 5f87cea262..4002080743 100644 --- a/lib/commands/apple-login.ts +++ b/lib/commands/apple-login.ts @@ -1,59 +1,43 @@ import { IErrors } from "../common/declarations"; -import { CommandContext, defineCommand } from "../common/define-command"; +import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; import { IApplePortalSessionService } from "../services/apple-portal/definitions"; -export type AppleLoginCommandContext = CommandContext; - -export function setupAppleLoginCommand() { - return { - $applePortalSessionService: inject( - "applePortalSessionService", - ), - $errors: inject("errors"), - $logger: inject("logger"), - $prompter: inject("prompter"), - }; -} - -export type IAppleLoginCommandServices = ReturnType< - typeof setupAppleLoginCommand ->; - -export async function runAppleLoginCommand( - context: AppleLoginCommandContext, - services: IAppleLoginCommandServices, -): Promise { - let username = context.args[0]; - if (!username) { - username = await services.$prompter.getString("Apple ID", { - allowEmpty: false, - }); - } - - let password = context.args[1]; - if (!password) { - password = await services.$prompter.getPassword("Apple ID password"); - } - - const user = await services.$applePortalSessionService.createUserSession({ - username, - password, - }); - if (!user.areCredentialsValid) { - services.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.`, - ); - } - - const output = Buffer.from(user.userSessionCookie).toString("base64"); - services.$logger.info(output); -} - export const appleLoginCommandDefinition = defineCommand({ name: "apple-login", description: "Logs in to an Apple account and prints the session cookie.", arguments: [{ name: "appleId" }, { name: "password" }], - setup: setupAppleLoginCommand, - run: runAppleLoginCommand, + async run(context) { + const $applePortalSessionService = inject( + "applePortalSessionService", + ); + const $errors = inject("errors"); + const $logger = inject("logger"); + const $prompter = inject("prompter"); + + let username = context.args[0]; + if (!username) { + username = await $prompter.getString("Apple ID", { + allowEmpty: false, + }); + } + + let password = context.args[1]; + if (!password) { + password = await $prompter.getPassword("Apple ID password"); + } + + const user = await $applePortalSessionService.createUserSession({ + username, + password, + }); + if (!user.areCredentialsValid) { + $errors.fail( + `Invalid username and password combination. Used '${username}' as the username.`, + ); + } + + const output = Buffer.from(user.userSessionCookie).toString("base64"); + $logger.info(output); + }, }); diff --git a/lib/commands/build.ts b/lib/commands/build.ts index 29ae6f753e..59c25a5e5b 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -2,15 +2,16 @@ import { ANDROID_RELEASE_BUILD_ERROR_MESSAGE, AndroidAppBundleMessages, } from "../constants"; -import { - canExecuteCommandBase, - injectPlatformCommandServices, - validatePlatformOptions, -} from "./command-base"; +import { canExecuteCommandBase, validatePlatformOptions } from "./command-base"; import { hasValidAndroidSigning } from "../common/helpers"; -import { IAndroidBundleValidatorHelper } from "../declarations"; +import { + IAndroidBundleValidatorHelper, + IOptions, + IPlatformValidationService, +} from "../declarations"; import { IBuildController, IBuildDataService } from "../definitions/build"; import { IMigrateController } from "../definitions/migrate"; +import { IProjectData } from "../definitions/project"; import { IErrors } from "../common/declarations"; import { booleanOption, @@ -48,87 +49,82 @@ const defineBuildCommand = ( description: "Builds the project for the selected target platform.", options: buildCommandOptions, arguments: "none", - setup() { - const devicePlatformsConstants = inject( - "devicePlatformsConstants", + async canExecute(context): Promise { + const $devicePlatformsConstants = + inject("devicePlatformsConstants"); + const $errors = inject("errors"); + const $migrateController = + inject("migrateController"); + const $platformValidationService = inject( + "platformValidationService", ); - const platform = devicePlatformsConstants[buildPlatform]; - const isAndroid = devicePlatformsConstants.isAndroid(platform); - const services = { - ...injectPlatformCommandServices(), - platform, - isAndroid, - $errors: inject("errors"), - $logger: inject("logger"), - $buildController: inject("buildController"), - $buildDataService: inject("buildDataService"), - $migrateController: inject("migrateController"), - // Only the android build checks the runtime version. - $androidBundleValidatorHelper: isAndroid - ? inject( - "androidBundleValidatorHelper", - ) - : null, - }; - services.$projectData.initializeProjectData(); - - return services; - }, - async canExecute(context, services): Promise { - const { platform } = services; + const $projectData = inject("projectData"); + const platform = $devicePlatformsConstants[buildPlatform]; + const isAndroid = $devicePlatformsConstants.isAndroid(platform); + // Only the android build checks the runtime version. + const $androidBundleValidatorHelper = isAndroid + ? inject("androidBundleValidatorHelper") + : null; + $projectData.initializeProjectData(); if (!context.options.force) { - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, + await $migrateController.validate({ + projectDir: $projectData.projectDir, platforms: [platform], }); } - if (services.isAndroid) { - services.$androidBundleValidatorHelper.validateRuntimeVersion( - services.$projectData, - ); + if (isAndroid) { + $androidBundleValidatorHelper.validateRuntimeVersion($projectData); } else if ( - !services.$platformValidationService.isPlatformSupportedForOS( + !$platformValidationService.isPlatformSupportedForOS( platform, - services.$projectData, + $projectData, ) ) { - services.$errors.fail( + $errors.fail( `Applications for platform ${platform} can not be built on this OS`, ); } - if (!(await canExecuteCommandBase(services, platform))) { + if (!(await canExecuteCommandBase(context, platform))) { return false; } if ( - services.isAndroid && + isAndroid && context.options.release && !hasValidAndroidSigning(context.options) ) { - services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } - return validatePlatformOptions(services, platform); + return validatePlatformOptions(context, platform); }, - async run(context, services): Promise { - const buildData = services.$buildDataService.getBuildData( - services.$projectData.projectDir, - services.platform.toLowerCase(), - services.$options, + async run(context): Promise { + const $buildController = inject("buildController"); + const $buildDataService = inject("buildDataService"); + const $devicePlatformsConstants = + inject("devicePlatformsConstants"); + const $logger = inject("logger"); + const $options = inject("options"); + const $projectData = inject("projectData"); + const platform = $devicePlatformsConstants[buildPlatform]; + const isAndroid = $devicePlatformsConstants.isAndroid(platform); + $projectData.initializeProjectData(); + + const buildData = $buildDataService.getBuildData( + $projectData.projectDir, + platform.toLowerCase(), + $options, ); - const outputPath = - await services.$buildController.prepareAndBuild(buildData); + const outputPath = await $buildController.prepareAndBuild(buildData); - if (services.isAndroid && context.options.aab) { - services.$logger.info( - AndroidAppBundleMessages.ANDROID_APP_BUNDLE_DOCS_MESSAGE, - ); + if (isAndroid && context.options.aab) { + $logger.info(AndroidAppBundleMessages.ANDROID_APP_BUNDLE_DOCS_MESSAGE); if (context.options.release) { - services.$logger.info( + $logger.info( AndroidAppBundleMessages.ANDROID_APP_BUNDLE_PUBLISH_DOCS_MESSAGE, ); } diff --git a/lib/commands/clean.ts b/lib/commands/clean.ts index 28ccee4be9..c4ca161f33 100644 --- a/lib/commands/clean.ts +++ b/lib/commands/clean.ts @@ -86,32 +86,8 @@ const cleanCommandOptions = { json: booleanOption(), } satisfies CommandOptionsSchema; -export type CleanCommandContext = CommandContext; - -export function setupCleanCommand() { - return { - $childProcess: inject("childProcess"), - $logger: inject("logger"), - $projectCleanupService: inject( - "projectCleanupService", - ), - $projectConfigService: inject( - "projectConfigService", - ), - $projectData: inject("projectData"), - $projectService: inject("projectService"), - $prompter: inject("prompter"), - $staticConfig: inject("staticConfig"), - $terminalSpinnerService: inject( - "terminalSpinnerService", - ), - }; -} - -export type ICleanCommandServices = ReturnType; - async function getNSProjectPathsInDirectory( - services: ICleanCommandServices, + $logger: ILogger, dir = process.cwd(), ): Promise { let nsDirs: string[] = []; @@ -124,11 +100,7 @@ async function getNSProjectPathsInDirectory( const dirents = await readdir(dir, { withFileTypes: true }).catch( (err): any[] => { - services.$logger.trace( - 'Failed to read directory "%s". Error is:', - dir, - err, - ); + $logger.trace('Failed to read directory "%s". Error is:', dir, err); return []; }, ); @@ -162,17 +134,21 @@ async function getNSProjectPathsInDirectory( } async function cleanMultipleProjects( - context: CleanCommandContext, - services: ICleanCommandServices, + context: CommandContext, spinner: ITerminalSpinner, ) { + const $childProcess = context.injector.get("childProcess"); + const $logger = context.injector.get("logger"); + const $prompter = context.injector.get("prompter"); + const $staticConfig = context.injector.get("staticConfig"); + if (!isInteractive() || context.options.json) { // interactive terminal is required, and we can't output json in an interactive command. - services.$logger.warn("No project found in the current directory."); + $logger.warn("No project found in the current directory."); return; } - const shouldScan = await services.$prompter.confirm( + const shouldScan = await $prompter.confirm( "No project found in the current directory. Would you like to scan for all projects in sub-directories instead?", ); @@ -181,7 +157,7 @@ async function cleanMultipleProjects( } spinner.start("Scanning for projects... Please wait."); - const paths = await getNSProjectPathsInDirectory(services); + const paths = await getNSProjectPathsInDirectory($logger); spinner.succeed(`Found ${paths.length} projects.`); let computed = 0; @@ -200,9 +176,9 @@ async function cleanMultipleProjects( await promiseMap( paths, (p) => { - return services.$childProcess + return $childProcess .exec( - `node ${services.$staticConfig.cliBinPath} clean --dry-run --json --disable-analytics`, + `node ${$staticConfig.cliBinPath} clean --dry-run --json --disable-analytics`, { cwd: p, }, @@ -212,11 +188,7 @@ async function cleanMultipleProjects( return Object.values(paths).reduce((a, b) => a + b, 0); }) .catch((err) => { - services.$logger.trace( - "Failed to get project size for %s, Error is:", - p, - err, - ); + $logger.trace("Failed to get project size for %s, Error is:", p, err); return -1; }) .then((size) => { @@ -235,13 +207,13 @@ async function cleanMultipleProjects( spinner.clear(); spinner.stop(); - services.$logger.clearScreen(); + $logger.clearScreen(); const totalSize = Array.from(projects.values()) .filter((s) => s > 0) .reduce((a, b) => a + b, 0); - const pathsToClean = await services.$prompter.promptForChoice( + const pathsToClean = await $prompter.promptForChoice( `Found ${ projects.size } cleanable project(s) with a total size of: ${color.green( @@ -266,7 +238,7 @@ async function cleanMultipleProjects( optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions } as Partial, ); - services.$logger.clearScreen(); + $logger.clearScreen(); spinner.warn( `This will run "${color.yellow( @@ -278,7 +250,7 @@ async function cleanMultipleProjects( ); spinner.warn(`This action cannot be undone!`); - let confirmed = await services.$prompter.confirm( + let confirmed = await $prompter.confirm( "Are you sure you want to clean the selected projects?", ); if (!confirmed) { @@ -295,9 +267,9 @@ async function cleanMultipleProjects( `Cleaning ${color.cyan(currentPath)}... ${i + 1}/${pathsToClean.length}`, ); - const ok = await services.$childProcess + const ok = await $childProcess .exec( - `node ${services.$staticConfig.cliBinPath} clean ${ + `node ${$staticConfig.cliBinPath} clean ${ context.options.dryRun ? "--dry-run" : "" } --json --disable-analytics`, { @@ -309,11 +281,7 @@ async function cleanMultipleProjects( return cleanupRes.ok; }) .catch((err) => { - services.$logger.trace( - 'Failed to clean project "%s"', - currentPath, - err, - ); + $logger.trace('Failed to clean project "%s"', currentPath, err); return false; }); @@ -343,82 +311,88 @@ async function cleanMultipleProjects( } } -export async function runCleanCommand( - context: CleanCommandContext, - services: ICleanCommandServices, -): Promise { - const isDryRun = context.options.dryRun ?? false; - const isJSON = context.options.json ?? false; +export const cleanCommandDefinition = defineCommand({ + name: "clean", + description: "Cleans the project's build artefacts and dependencies.", + options: cleanCommandOptions, + arguments: "none", + async run(context): Promise { + const $projectCleanupService = inject( + "projectCleanupService", + ); + const $projectConfigService = inject( + "projectConfigService", + ); + const $projectData = inject("projectData"); + const $projectService = inject("projectService"); + const $terminalSpinnerService = inject( + "terminalSpinnerService", + ); - const spinner = services.$terminalSpinnerService.createSpinner({ - isSilent: isJSON, - }); + const isDryRun = context.options.dryRun ?? false; + const isJSON = context.options.json ?? false; - if (!services.$projectService.isValidNativeScriptProject()) { - return cleanMultipleProjects(context, services, spinner); - } + const spinner = $terminalSpinnerService.createSpinner({ + isSilent: isJSON, + }); - spinner.start("Cleaning project...\n"); + if (!$projectService.isValidNativeScriptProject()) { + return cleanMultipleProjects(context, spinner); + } - let pathsToClean = [ - constants.HOOKS_DIR_NAME, - services.$projectData.getBuildRelativeDirectoryPath(), - constants.NODE_MODULES_FOLDER_NAME, - ]; + spinner.start("Cleaning project...\n"); - try { - const overridePathsToClean = - services.$projectConfigService.getValue("cli.pathsToClean"); - const additionalPaths = services.$projectConfigService.getValue( - "cli.additionalPathsToClean", - ); + let pathsToClean = [ + constants.HOOKS_DIR_NAME, + $projectData.getBuildRelativeDirectoryPath(), + constants.NODE_MODULES_FOLDER_NAME, + ]; - // allow overriding default paths to clean - if (Array.isArray(overridePathsToClean)) { - pathsToClean = overridePathsToClean; - } + try { + const overridePathsToClean = + $projectConfigService.getValue("cli.pathsToClean"); + const additionalPaths = $projectConfigService.getValue( + "cli.additionalPathsToClean", + ); - if (Array.isArray(additionalPaths)) { - pathsToClean.push(...additionalPaths); + // allow overriding default paths to clean + if (Array.isArray(overridePathsToClean)) { + pathsToClean = overridePathsToClean; + } + + if (Array.isArray(additionalPaths)) { + pathsToClean.push(...additionalPaths); + } + } catch (err) { + // ignore } - } catch (err) { - // ignore - } - const res = await services.$projectCleanupService.clean(pathsToClean, { - dryRun: isDryRun, - silent: isJSON, - stats: isJSON, - }); + const res = await $projectCleanupService.clean(pathsToClean, { + dryRun: isDryRun, + silent: isJSON, + stats: isJSON, + }); - if (res.stats && isJSON) { - console.log( - JSON.stringify( - { - ok: res.ok, - dryRun: isDryRun, - stats: Object.fromEntries(res.stats.entries()), - }, - null, - 2, - ), - ); - - return; - } + if (res.stats && isJSON) { + console.log( + JSON.stringify( + { + ok: res.ok, + dryRun: isDryRun, + stats: Object.fromEntries(res.stats.entries()), + }, + null, + 2, + ), + ); - if (res.ok) { - spinner.succeed("Project successfully cleaned."); - } else { - spinner.fail(color.red("Project unsuccessfully cleaned.")); - } -} + return; + } -export const cleanCommandDefinition = defineCommand({ - name: "clean", - description: "Cleans the project's build artefacts and dependencies.", - options: cleanCommandOptions, - arguments: "none", - setup: setupCleanCommand, - run: runCleanCommand, + if (res.ok) { + spinner.succeed("Project successfully cleaned."); + } else { + spinner.fail(color.red("Project unsuccessfully cleaned.")); + } + }, }); diff --git a/lib/commands/command-base.ts b/lib/commands/command-base.ts index 48d0d2a9a6..e09dbfdf97 100644 --- a/lib/commands/command-base.ts +++ b/lib/commands/command-base.ts @@ -2,41 +2,20 @@ import { IProjectData, IValidatePlatformOutput } from "../definitions/project"; import { IOptions, IPlatformValidationService } from "../declarations"; import { IPlatformsDataService } from "../definitions/platform"; import { - ICommandParameter, ICanExecuteCommandOptions, INotConfiguredEnvOptions, } from "../common/definitions/commands"; -import { ArgumentSpec } from "../common/define-command"; -import { inject, Injector } from "../common/di"; +import { ArgumentSpec, CommandContext } from "../common/define-command"; +import { Injector } from "../common/di"; -/** Callable from `setup` and from `canExecute` before their first `await`. */ -export function injectPlatformCommandServices() { - return { - $options: inject("options"), - $platformsDataService: inject( - "platformsDataService", - ), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - }; -} - -/** - * What the platform-validation helpers below need. A command definition's - * `setup` returns this shape (see `injectPlatformCommandServices`), so its - * result can be handed straight to them. - */ -export type IPlatformCommandServices = ReturnType< - typeof injectPlatformCommandServices ->; +/** The part of a command context these helpers read. */ +type PlatformCommandContext = Pick, "injector">; /** * The declarative form of `$platformCommandParameter`. Initializing the * project data is what makes the platform check possible, so it stays part of - * validating the argument instead of moving to `setup`, which the adapter runs - * only after argument enforcement. + * validating the argument instead of moving to the command's own handlers, + * which the adapter runs only after argument enforcement. */ export function validatePlatformArgument( targetInjector: Injector, @@ -59,33 +38,38 @@ export const platformArgument: ArgumentSpec = { }; export function validatePlatformOptions( - services: IPlatformCommandServices, + context: PlatformCommandContext, platform: string, ): Promise { - return services.$platformValidationService.validateOptions( - services.$options.provision, - services.$options.teamId, - services.$projectData, - platform, - ); + const $options = context.injector.get("options"); + const $projectData = context.injector.get("projectData"); + + return context.injector + .get("platformValidationService") + .validateOptions( + $options.provision, + $options.teamId, + $projectData, + platform, + ); } async function validatePlatformBase( - services: IPlatformCommandServices, + context: PlatformCommandContext, platform: string, notConfiguredEnvOptions: INotConfiguredEnvOptions, ): Promise { - const platformData = services.$platformsDataService.getPlatformData( - platform, - services.$projectData, - ); - const platformProjectService = platformData.platformProjectService; - const result = await platformProjectService.validate( - services.$projectData, - services.$options, + const $options = context.injector.get("options"); + const $projectData = context.injector.get("projectData"); + const platformData = context.injector + .get("platformsDataService") + .getPlatformData(platform, $projectData); + + return platformData.platformProjectService.validate( + $projectData, + $options, notConfiguredEnvOptions, ); - return result; } function hasUsableEnvironment( @@ -99,12 +83,12 @@ function hasUsableEnvironment( } export async function canExecuteCommandBase( - services: IPlatformCommandServices, + context: PlatformCommandContext, platform: string, options: ICanExecuteCommandOptions = {}, ): Promise { const validatePlatformOutput = await validatePlatformBase( - services, + context, platform, options.notConfiguredEnvOptions, ); @@ -112,40 +96,8 @@ export async function canExecuteCommandBase( let result = canExecute; if (canExecute && options.validateOptions) { - result = await validatePlatformOptions(services, platform); + result = await validatePlatformOptions(context, platform); } return result; } - -/** - * @deprecated Nothing extends this any more; the exported functions beside it carry - * the same behaviour for definitions. - */ -export abstract class ValidatePlatformCommandBase { - constructor( - protected $options: IOptions, - protected $platformsDataService: IPlatformsDataService, - protected $platformValidationService: IPlatformValidationService, - protected $projectData: IProjectData, - ) {} - - abstract allowedParameters: ICommandParameter[]; - abstract execute(args: string[]): Promise; - - public canExecuteCommandBase( - platform: string, - options?: ICanExecuteCommandOptions, - ): Promise { - return canExecuteCommandBase( - { - $options: this.$options, - $platformsDataService: this.$platformsDataService, - $platformValidationService: this.$platformValidationService, - $projectData: this.$projectData, - }, - platform, - options, - ); - } -} diff --git a/lib/commands/config.ts b/lib/commands/config.ts index 41792dce04..d2ff838d02 100644 --- a/lib/commands/config.ts +++ b/lib/commands/config.ts @@ -5,20 +5,6 @@ import { CommandContext, defineCommand } from "../common/define-command"; import { inject } from "../common/di"; import { color } from "../color"; -export function injectConfigCommandServices() { - return { - $projectConfigService: inject( - "projectConfigService", - ), - $logger: inject("logger"), - $errors: inject("errors"), - }; -} - -export type IConfigCommandServices = ReturnType< - typeof injectConfigCommandServices ->; - function getValueString(value: SupportedConfigValues, depth = 0): string { const indent = () => " ".repeat(depth); if (typeof value === "object") { @@ -50,12 +36,11 @@ function getConvertedValue(v: any): any { } } -export function requireConfigKey( - context: CommandContext, - services: IConfigCommandServices, -): void { +function requireConfigKey(context: CommandContext): void { if (!context.args[0]) { - services.$errors.failWithHelp("You must specify a key. Eg: ios.id"); + context.injector + .get("errors") + .failWithHelp("You must specify a key. Eg: ios.id"); } } @@ -63,13 +48,17 @@ export const configListCommandDefinition = defineCommand({ name: "config|*list", description: "Prints the project configuration.", arguments: "none", - setup: injectConfigCommandServices, - async run(context, services): Promise { + async run(): Promise { + const $projectConfigService = inject( + "projectConfigService", + ); + const $logger = inject("logger"); + try { - const config = services.$projectConfigService.readConfig(); - services.$logger.info(getValueString(config as SupportedConfigValues)); + const config = $projectConfigService.readConfig(); + $logger.info(getValueString(config as SupportedConfigValues)); } catch (error) { - services.$logger.info("Failed to read config. Error is: ", error); + $logger.info("Failed to read config. Error is: ", error); } }, }); @@ -78,17 +67,21 @@ export const configGetCommandDefinition = defineCommand({ name: "config|get", description: "Prints the value the project configuration holds for a key.", arguments: "any", - setup: injectConfigCommandServices, - async canExecute(context, services): Promise { - requireConfigKey(context, services); + async canExecute(context): Promise { + requireConfigKey(context); return true; }, - async run(context, services): Promise { + async run(context): Promise { + const $projectConfigService = inject( + "projectConfigService", + ); + const $logger = inject("logger"); + try { const [key] = context.args; - const current = services.$projectConfigService.getValue(key); - services.$logger.info(current); + const current = $projectConfigService.getValue(key); + $logger.info(current); } catch (err) { // ignore } @@ -99,21 +92,28 @@ export const configSetCommandDefinition = defineCommand({ name: "config|set", description: "Sets a value in the project configuration.", arguments: "any", - setup: injectConfigCommandServices, - async canExecute(context, services): Promise { - requireConfigKey(context, services); + async canExecute(context): Promise { + const $errors = inject("errors"); + + requireConfigKey(context); if (!context.args[1]) { - services.$errors.failWithHelp("You must specify a value."); + $errors.failWithHelp("You must specify a value."); } return true; }, - async run(context, services): Promise { + async run(context): Promise { + const $projectConfigService = inject( + "projectConfigService", + ); + const $logger = inject("logger"); + const $errors = inject("errors"); + const [key, value] = context.args; - const current = services.$projectConfigService.getValue(key); + const current = $projectConfigService.getValue(key); if (current && typeof current === "object") { - services.$errors.fail( + $errors.fail( `Unable to change object values. Please update individual values instead.\nEg: ns config set android.codeCache true`, ); } @@ -124,17 +124,17 @@ export const configSetCommandDefinition = defineCommand({ const currentDisplay = current ? color.yellow(current) : ""; const updatedDisplay = color.cyan(convertedValue); - services.$logger.info( + $logger.info( `${existingKey ? "Updating" : "Setting"} ${keyDisplay}${ existingKey ? ` from ${currentDisplay} ` : " " }to ${updatedDisplay}`, ); try { - await services.$projectConfigService.setValue(key, convertedValue); - services.$logger.info("Done"); + await $projectConfigService.setValue(key, convertedValue); + $logger.info("Done"); } catch (error) { - services.$logger.info("Could not update conifg. Error is: ", error); + $logger.info("Could not update conifg. Error is: ", error); } }, }); diff --git a/lib/commands/debug.ts b/lib/commands/debug.ts index 0cfe058dc4..aa57de1fd7 100644 --- a/lib/commands/debug.ts +++ b/lib/commands/debug.ts @@ -11,6 +11,7 @@ import { import { inject } from "../common/di"; import { hasValidAndroidSigning } from "../common/helpers"; import { ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE } from "../constants"; +import { IOptions, IPlatformValidationService } from "../declarations"; import { ICleanupService } from "../definitions/cleanup-service"; import { IDebugController, @@ -18,6 +19,7 @@ import { IDebugOptions, } from "../definitions/debug"; import { IMigrateController } from "../definitions/migrate"; +import { IProjectData } from "../definitions/project"; import { SystemWarningsSeverity } from "../definitions/system-warnings"; import { IKeyShortcutService, @@ -25,10 +27,7 @@ import { restartShortcut, watcherShortcut, } from "../services/key-shortcuts"; -import { - canExecuteCommandBase, - injectPlatformCommandServices, -} from "./command-base"; +import { canExecuteCommandBase } from "./command-base"; import * as _ from "lodash"; /** Which `$devicePlatformsConstants` entry a command debugs. */ @@ -50,98 +49,103 @@ const debugCommandOptions = { keyStoreAliasPassword: stringOption(), } satisfies CommandOptionsSchema; -export type DebugCommandContext = CommandContext; - -export function setupDebugCommand(debugPlatform: DebugPlatform) { - const $devicePlatformsConstants = inject( - "devicePlatformsConstants", - ); - - return { - ...injectPlatformCommandServices(), - platform: $devicePlatformsConstants[debugPlatform], - $cleanupService: inject("cleanupService"), - $debugController: inject("debugController"), - $debugDataService: inject("debugDataService"), - $devicePlatformsConstants, - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $liveSyncCommandHelper: inject( - "liveSyncCommandHelper", - ), - $migrateController: inject("migrateController"), - }; -} - -export type IDebugCommandServices = ReturnType; +type DebugCommandContext = CommandContext; -export async function canExecuteDebugCommand( +async function canExecuteDebugCommand( context: DebugCommandContext, - services: IDebugCommandServices, + debugPlatform: DebugPlatform, ): Promise { + const $cleanupService = + context.injector.get("cleanupService"); + const $devicePlatformsConstants = + context.injector.get( + "devicePlatformsConstants", + ); + const $errors = context.injector.get("errors"); + const $migrateController = + context.injector.get("migrateController"); + const $platformValidationService = + context.injector.get( + "platformValidationService", + ); + const $projectData = context.injector.get("projectData"); + const platform = $devicePlatformsConstants[debugPlatform]; + // Keeping the cleanup process alive is what makes a debugger able to stay // attached, so it must not happen before the platform-specific checks that // run ahead of this function have had their chance to fail the command. - services.$cleanupService.setShouldDispose(false); + $cleanupService.setShouldDispose(false); if (!context.options.force) { - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, - platforms: [services.platform], + await $migrateController.validate({ + projectDir: $projectData.projectDir, + platforms: [platform], }); } if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.platform, - services.$projectData, - ) + !$platformValidationService.isPlatformSupportedForOS(platform, $projectData) ) { - services.$errors.fail( - `Applications for platform ${services.platform} can not be built on this OS`, + $errors.fail( + `Applications for platform ${platform} can not be built on this OS`, ); } if (context.options.release) { - services.$errors.failWithHelp( - "--release flag is not applicable to this command.", - ); + $errors.failWithHelp("--release flag is not applicable to this command."); } - return canExecuteCommandBase(services, services.platform, { + return canExecuteCommandBase(context, platform, { validateOptions: true, }); } -export async function runDebugCommand( +async function runDebugCommand( context: DebugCommandContext, - services: IDebugCommandServices, + debugPlatform: DebugPlatform, ): Promise { - await services.$devicesService.initialize({ - platform: services.platform, + const $debugController = + context.injector.get("debugController"); + const $debugDataService = + context.injector.get("debugDataService"); + const $devicePlatformsConstants = + context.injector.get( + "devicePlatformsConstants", + ); + const $devicesService = + context.injector.get("devicesService"); + const $liveSyncCommandHelper = context.injector.get( + "liveSyncCommandHelper", + ); + const $options = context.injector.get("options"); + const $projectData = context.injector.get("projectData"); + const platform = $devicePlatformsConstants[debugPlatform]; + $projectData.initializeProjectData(); + + await $devicesService.initialize({ + platform, deviceId: context.options.device, emulator: context.options.emulator, skipDeviceDetectionInterval: true, }); - const selectedDeviceForDebug = - await services.$devicesService.pickSingleDevice({ - onlyEmulators: context.options.emulator, - onlyDevices: context.options.forDevice, - deviceId: context.options.device, - }); + const selectedDeviceForDebug = await $devicesService.pickSingleDevice({ + onlyEmulators: context.options.emulator, + onlyDevices: context.options.forDevice, + deviceId: context.options.device, + }); if (context.options.start) { // The debug services read the whole parsed command line, including flags // no command declares, so the raw argv is what they get. - const debugOptions = _.cloneDeep(services.$options.argv); - const debugData = services.$debugDataService.getDebugData( + const debugOptions = _.cloneDeep($options.argv); + const debugData = $debugDataService.getDebugData( selectedDeviceForDebug.deviceInfo.identifier, - services.$projectData, + $projectData, debugOptions, ); - await services.$debugController.printDebugInformation( - await services.$debugController.startDebug(debugData), + await $debugController.printDebugInformation( + await $debugController.startDebug(debugData), ); return; } @@ -157,9 +161,9 @@ export async function runDebugCommand( ...additional, }); - await services.$liveSyncCommandHelper.executeLiveSyncOperation( + await $liveSyncCommandHelper.executeLiveSyncOperation( [selectedDeviceForDebug], - services.platform, + platform, liveSyncOptions({}), ); @@ -174,9 +178,9 @@ export async function runDebugCommand( const restartDebugSession = ( forceRebuildNativeApp: boolean = false, ): Promise => - services.$liveSyncCommandHelper.executeLiveSyncOperation( + $liveSyncCommandHelper.executeLiveSyncOperation( [selectedDeviceForDebug], - services.platform, + platform, liveSyncOptions(>{ restartLiveSync: true, ...(forceRebuildNativeApp ? { forceRebuildNativeApp: true } : {}), @@ -200,30 +204,6 @@ export async function runDebugCommand( } } -const setupDebugApplePlatformCommand = - (debugPlatform: "iOS" | "visionOS") => () => { - const services = { - ...setupDebugCommand(debugPlatform), - $sysInfo: inject("sysInfo"), - }; - services.$projectData.initializeProjectData(); - - // Do not dispose ios-device-lib, so the process will remain alive and the debug application (NativeScript Inspector or Chrome DevTools) will be able to connect to the socket. - // In case we dispose ios-device-lib, the socket will be closed and the code will fail when the debug application tries to read/send data to device socket. - // That's why the `$ ns debug ios --justlaunch` command will not release the terminal. - // In case we do not set it to false, the dispose will be called once the command finishes its execution, which will prevent the debugging. - inject("iosDeviceOperations").setShouldDispose(false); - inject( - "iOSSimulatorLogProvider", - ).setShouldDispose(false); - - return services; - }; - -type IDebugApplePlatformCommandServices = ReturnType< - ReturnType ->; - function isValidTimeoutOption(timeout: string): boolean { if (!timeout) { return true; @@ -252,43 +232,61 @@ const defineApplePlatformDebugCommand = ( options: debugCommandOptions, // Arguments have never been rejected here, only ignored. arguments: "any", - setup: setupDebugApplePlatformCommand(debugPlatform), - async canExecute( - context: DebugCommandContext, - services: IDebugApplePlatformCommandServices, - ): Promise { + async canExecute(context): Promise { + const $devicePlatformsConstants = + inject("devicePlatformsConstants"); + const $errors = inject("errors"); + const $platformValidationService = inject( + "platformValidationService", + ); + const $projectData = inject("projectData"); + const $sysInfo = inject("sysInfo"); + const platform = $devicePlatformsConstants[debugPlatform]; + $projectData.initializeProjectData(); + + // Do not dispose ios-device-lib, so the process will remain alive and the debug application (NativeScript Inspector or Chrome DevTools) will be able to connect to the socket. + // In case we dispose ios-device-lib, the socket will be closed and the code will fail when the debug application tries to read/send data to device socket. + // That's why the `$ ns debug ios --justlaunch` command will not release the terminal. + // In case we do not set it to false, the dispose will be called once the command finishes its execution, which will prevent the debugging. + inject("iosDeviceOperations").setShouldDispose( + false, + ); + inject( + "iOSSimulatorLogProvider", + ).setShouldDispose(false); + if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.platform, - services.$projectData, + !$platformValidationService.isPlatformSupportedForOS( + platform, + $projectData, ) ) { - services.$errors.fail( - `Applications for platform ${services.platform} can not be built on this OS`, + $errors.fail( + `Applications for platform ${platform} can not be built on this OS`, ); } if (!isValidTimeoutOption(context.options.timeout)) { - services.$errors.fail( + $errors.fail( `Timeout option specifies the seconds NativeScript CLI will wait to find the inspector socket port from device's logs. Must be a number.`, ); } if (context.options.inspector) { - const macOSWarning = await services.$sysInfo.getMacOSWarningMessage(); + const macOSWarning = await $sysInfo.getMacOSWarningMessage(); if ( macOSWarning && macOSWarning.severity === SystemWarningsSeverity.high ) { - services.$errors.fail( + $errors.fail( `You cannot use NativeScript Inspector on this OS. To use it, please update your OS.`, ); } } - return canExecuteDebugCommand(context, services); + return canExecuteDebugCommand(context, debugPlatform); }, - run: runDebugCommand, + run: (context) => runDebugCommand(context, debugPlatform), }); export const iosDebugCommand = defineApplePlatformDebugCommand( @@ -306,24 +304,19 @@ export const androidDebugCommand = defineCommand({ description: "Debugs your project on a connected Android device or emulator.", options: debugCommandOptions, arguments: "any", - setup(): IDebugCommandServices { - const services = setupDebugCommand("Android"); - services.$projectData.initializeProjectData(); + async canExecute(context): Promise { + const $errors = inject("errors"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); - return services; - }, - async canExecute( - context: DebugCommandContext, - services: IDebugCommandServices, - ): Promise { - const canExecuteBase = await canExecuteDebugCommand(context, services); + const canExecuteBase = await canExecuteDebugCommand(context, "Android"); if (canExecuteBase) { if (context.options.aab && !hasValidAndroidSigning(context.options)) { - services.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } return canExecuteBase; }, - run: runDebugCommand, + run: (context) => runDebugCommand(context, "Android"), }); diff --git a/lib/commands/deploy.ts b/lib/commands/deploy.ts index d96166600f..acda8ab0ef 100644 --- a/lib/commands/deploy.ts +++ b/lib/commands/deploy.ts @@ -2,14 +2,11 @@ import { ANDROID_RELEASE_BUILD_ERROR_MESSAGE, ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, } from "../constants"; -import { - canExecuteCommandBase, - injectPlatformCommandServices, - platformArgument, -} from "./command-base"; +import { canExecuteCommandBase, platformArgument } from "./command-base"; import { DeployCommandHelper } from "../helpers/deploy-command-helper"; import { hasValidAndroidSigning } from "../common/helpers"; import { IMigrateController } from "../definitions/migrate"; +import { IProjectData } from "../definitions/project"; import { IErrors } from "../common/declarations"; import { booleanOption, @@ -36,24 +33,18 @@ export const deployCommandDefinition = defineCommand({ description: "Builds and deploys the project to a connected device.", options: deployCommandOptions, arguments: [platformArgument], - setup() { - const services = { - ...injectPlatformCommandServices(), - $errors: inject("errors"), - $mobileHelper: inject("mobileHelper"), - $deployCommandHelper: inject("deployCommandHelper"), - $migrateController: inject("migrateController"), - }; - services.$projectData.initializeProjectData(); + async canExecute(context): Promise { + const $errors = inject("errors"); + const $migrateController = inject("migrateController"); + const $mobileHelper = inject("mobileHelper"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); - return services; - }, - async canExecute(context, services): Promise { const platform = context.args[0]; if (!context.options.force) { - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, + await $migrateController.validate({ + projectDir: $projectData.projectDir, platforms: [platform], }); } @@ -63,22 +54,28 @@ export const deployCommandDefinition = defineCommand({ } if ( - services.$mobileHelper.isAndroidPlatform(platform) && + $mobileHelper.isAndroidPlatform(platform) && (context.options.release || context.options.aab) && !hasValidAndroidSigning(context.options) ) { if (context.options.release) { - services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - services.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } - return canExecuteCommandBase(services, platform, { + return canExecuteCommandBase(context, platform, { validateOptions: true, }); }, - async run(context, services): Promise { - await services.$deployCommandHelper.deploy(context.args[0]); + async run(context): Promise { + const $deployCommandHelper = inject( + "deployCommandHelper", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + await $deployCommandHelper.deploy(context.args[0]); }, }); diff --git a/lib/commands/extensibility/install-extension.ts b/lib/commands/extensibility/install-extension.ts index 6133e4e5de..20483bf22e 100644 --- a/lib/commands/extensibility/install-extension.ts +++ b/lib/commands/extensibility/install-extension.ts @@ -2,19 +2,6 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; -export function setupInstallExtensionCommand() { - return { - $extensibilityService: inject( - "extensibilityService", - ), - $logger: inject("logger"), - }; -} - -export type IInstallExtensionCommandServices = ReturnType< - typeof setupInstallExtensionCommand ->; - export const installExtensionCommandDefinition = defineCommand({ name: "extension|install", description: "Installs the specified extension.", @@ -26,22 +13,21 @@ export const installExtensionCommandDefinition = defineCommand({ "You have to provide a valid name for extension that you want to install.", }, ], - setup: setupInstallExtensionCommand, - async run( - context, - services: IInstallExtensionCommandServices, - ): Promise { - const extensionData = await services.$extensibilityService.installExtension( + async run(context): Promise { + const $extensibilityService = inject( + "extensibilityService", + ); + const $logger = inject("logger"); + + const extensionData = await $extensibilityService.installExtension( context.args[0], ); - services.$logger.info( + $logger.info( `Successfully installed extension ${extensionData.extensionName}.`, ); - await services.$extensibilityService.loadExtension( - extensionData.extensionName, - ); - services.$logger.info( + await $extensibilityService.loadExtension(extensionData.extensionName); + $logger.info( `Successfully loaded extension ${extensionData.extensionName}.`, ); }, diff --git a/lib/commands/extensibility/list-extensions.ts b/lib/commands/extensibility/list-extensions.ts index 9dec66c7c1..35d7ab81c8 100644 --- a/lib/commands/extensibility/list-extensions.ts +++ b/lib/commands/extensibility/list-extensions.ts @@ -4,36 +4,26 @@ import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; import * as helpers from "../../common/helpers"; -export function setupListExtensionsCommand() { - return { - $extensibilityService: inject( - "extensibilityService", - ), - $logger: inject("logger"), - }; -} - -export type IListExtensionsCommandServices = ReturnType< - typeof setupListExtensionsCommand ->; - export const listExtensionsCommandDefinition = defineCommand({ name: "extension|*list", description: "Lists all installed extensions.", - setup: setupListExtensionsCommand, - run(context, services: IListExtensionsCommandServices): void { - const installedExtensions = - services.$extensibilityService.getInstalledExtensions(); + run(): void { + const $extensibilityService = inject( + "extensibilityService", + ); + const $logger = inject("logger"); + + const installedExtensions = $extensibilityService.getInstalledExtensions(); if (_.keys(installedExtensions).length) { - services.$logger.info("Installed extensions:"); + $logger.info("Installed extensions:"); const data = _.map(installedExtensions, (version, name) => { return [name, version]; }); const table = helpers.createTable(["Name", "Version"], data); - services.$logger.info(table.toString()); + $logger.info(table.toString()); } else { - services.$logger.info("No extensions installed."); + $logger.info("No extensions installed."); } }, }); diff --git a/lib/commands/extensibility/uninstall-extension.ts b/lib/commands/extensibility/uninstall-extension.ts index 1701865848..369de3ad07 100644 --- a/lib/commands/extensibility/uninstall-extension.ts +++ b/lib/commands/extensibility/uninstall-extension.ts @@ -2,19 +2,6 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; -export function setupUninstallExtensionCommand() { - return { - $extensibilityService: inject( - "extensibilityService", - ), - $logger: inject("logger"), - }; -} - -export type IUninstallExtensionCommandServices = ReturnType< - typeof setupUninstallExtensionCommand ->; - export const uninstallExtensionCommandDefinition = defineCommand({ name: "extension|uninstall", description: "Uninstalls the specified extension.", @@ -26,15 +13,14 @@ export const uninstallExtensionCommandDefinition = defineCommand({ "You have to provide a valid name for extension that you want to uninstall.", }, ], - setup: setupUninstallExtensionCommand, - async run( - context, - services: IUninstallExtensionCommandServices, - ): Promise { - const extensionName = context.args[0]; - await services.$extensibilityService.uninstallExtension(extensionName); - services.$logger.info( - `Successfully uninstalled extension ${extensionName}`, + async run(context): Promise { + const $extensibilityService = inject( + "extensibilityService", ); + const $logger = inject("logger"); + + const extensionName = context.args[0]; + await $extensibilityService.uninstallExtension(extensionName); + $logger.info(`Successfully uninstalled extension ${extensionName}`); }, }); diff --git a/lib/commands/fonts.ts b/lib/commands/fonts.ts index edfed4dc55..289f899ba5 100644 --- a/lib/commands/fonts.ts +++ b/lib/commands/fonts.ts @@ -7,49 +7,43 @@ import * as fontFinder from "font-finder"; import { createTable } from "../common/helpers"; import * as path from "path"; -export function setupFontsCommand() { - const services = { - $projectData: inject("projectData"), - $fs: inject("fs"), - $logger: inject("logger"), - $projectConfigService: inject( - "projectConfigService", - ), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IFontsCommandServices = ReturnType; - export const fontsCommandDefinition = defineCommand({ name: "fonts", description: "Lists the custom fonts the project bundles.", arguments: "none", - setup: setupFontsCommand, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(): Promise { + const $projectData = inject("projectData"); + const $fs = inject("fs"); + const $logger = inject("logger"); + const $projectConfigService = inject( + "projectConfigService", + ); const supportedExtensions = [".ttf", ".otf"]; const defaultFontsFolderPaths = [ path.join( - services.$projectConfigService.getValue("appPath") ?? "", + $projectConfigService.getValue("appPath") ?? "", constants.FONTS_DIR, ), path.join(constants.APP_FOLDER_NAME, constants.FONTS_DIR), path.join(constants.SRC_DIR, constants.FONTS_DIR), - ].map((entry) => path.resolve(services.$projectData.projectDir, entry)); + ].map((entry) => path.resolve($projectData.projectDir, entry)); const fontsFolderPath = defaultFontsFolderPaths.find((entry) => - services.$fs.exists(entry), + $fs.exists(entry), ); if (!fontsFolderPath) { - services.$logger.warn("No fonts folder found."); + $logger.warn("No fonts folder found."); return; } - const files = services.$fs + const files = $fs .readDirectory(fontsFolderPath) .map((entry) => path.parse(entry)) .filter((entry) => { @@ -57,7 +51,7 @@ export const fontsCommandDefinition = defineCommand({ }); if (!files.length) { - services.$logger.warn("No custom fonts found."); + $logger.warn("No custom fonts found."); return; } @@ -71,6 +65,6 @@ export const fontsCommandDefinition = defineCommand({ ]); } - services.$logger.info(table.toString()); + $logger.info(table.toString()); }, }); diff --git a/lib/commands/generate-assets.ts b/lib/commands/generate-assets.ts index 559f5911db..c7ce072d81 100644 --- a/lib/commands/generate-assets.ts +++ b/lib/commands/generate-assets.ts @@ -5,12 +5,12 @@ import { defineCommand, stringOption, } from "../common/define-command"; -import { inject } from "../common/di"; import { IAssetsGenerationService, IResourceGenerationData, } from "../declarations"; import { IProjectData } from "../definitions/project"; +import { inject } from "../common/di"; /** Which set of assets a command generates from the source image. */ type GeneratedAssets = "icons" | "splashes"; @@ -26,39 +26,21 @@ const generators: Record< splashes: (service, data) => service.generateSplashScreens(data), }; -export const generateAssetsCommandOptions = { +const generateAssetsCommandOptions = { background: stringOption(), } satisfies CommandOptionsSchema; -export type GenerateAssetsCommandContext = CommandContext< - typeof generateAssetsCommandOptions ->; - -export function setupGenerateAssetsCommand(assets: GeneratedAssets) { - const services = { - assets, - $assetsGenerationService: inject( - "assetsGenerationService", - ), - $projectData: inject("projectData"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IGenerateAssetsCommandServices = ReturnType< - typeof setupGenerateAssetsCommand ->; - -export function runGenerateAssetsCommand( - context: GenerateAssetsCommandContext, - services: IGenerateAssetsCommandServices, +function runGenerateAssetsCommand( + context: CommandContext, + assets: GeneratedAssets, ): Promise { - return generators[services.assets](services.$assetsGenerationService, { + const $assetsGenerationService = + context.injector.get("assetsGenerationService"); + const $projectData = context.injector.get("projectData"); + return generators[assets]($assetsGenerationService, { imagePath: context.args[0], background: context.options.background, - projectDir: services.$projectData.projectDir, + projectDir: $projectData.projectDir, }); } @@ -79,8 +61,12 @@ const defineGenerateAssetsCommand = ( "You have to provide path to image to generate other images based on it.", }, ], - setup: () => setupGenerateAssetsCommand(assets), - run: runGenerateAssetsCommand, + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a missing image path reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + run: (context) => runGenerateAssetsCommand(context, assets), }); export const generateIconsCommand = defineGenerateAssetsCommand( diff --git a/lib/commands/generate-help.ts b/lib/commands/generate-help.ts index ca0c64ae79..476b185388 100644 --- a/lib/commands/generate-help.ts +++ b/lib/commands/generate-help.ts @@ -6,10 +6,8 @@ export const generateHelpCommandDefinition = defineCommand({ name: "dev-generate-help", description: "Generates the HTML help pages from the man pages.", arguments: "none", - setup: () => ({ - $helpService: inject("helpService"), - }), - run(context, services): Promise { - return services.$helpService.generateHtmlPages(); + run(): Promise { + const $helpService = inject("helpService"); + return $helpService.generateHtmlPages(); }, }); diff --git a/lib/commands/generate.ts b/lib/commands/generate.ts index 29caa6ece4..70c173a8ef 100644 --- a/lib/commands/generate.ts +++ b/lib/commands/generate.ts @@ -7,18 +7,17 @@ export const generateCommandDefinition = defineCommand({ name: "generate", description: "Executes a schematic in the project.", arguments: "any", - setup: () => ({ - $logger: inject("logger"), - $errors: inject("errors"), - }), - async run(context, services): Promise { + async run(): Promise { + const $logger = inject("logger"); + const $errors = inject("errors"); + try { - services.$logger.info( + $logger.info( "If you have ideas for this command, please discuss at https://nativescript.org/discord", ); // await run(this.executionOptions); } catch (error) { - services.$errors.fail(error.message); + $errors.fail(error.message); } }, }); diff --git a/lib/commands/hooks/common.ts b/lib/commands/hooks/common.ts index 8cfef75424..51d9e00cbd 100644 --- a/lib/commands/hooks/common.ts +++ b/lib/commands/hooks/common.ts @@ -1,7 +1,6 @@ -import { IProjectData } from "../../definitions/project"; -import { IPluginData, IPluginsService } from "../../definitions/plugins"; +import { IPluginData } from "../../definitions/plugins"; import { IErrors, IFileSystem } from "../../common/declarations"; -import { inject } from "../../common/di"; +import { CommandContext } from "../../common/define-command"; import path = require("path"); import * as crypto from "crypto"; @@ -16,24 +15,6 @@ export interface OutputPlugin { hooks: OutputHook[]; } -/** Callable from `setup` and from `canExecute` before their first `await`. */ -export function injectHooksCommandServices() { - const services = { - $pluginsService: inject("pluginsService"), - $projectData: inject("projectData"), - $errors: inject("errors"), - $fs: inject("fs"), - $logger: inject("logger"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IHooksCommandServices = ReturnType< - typeof injectHooksCommandServices ->; - export function getPluginsWithHooks(plugins: IPluginData[]): IPluginData[] { const pluginsWithHooks: IPluginData[] = []; for (const plugin of plugins) { @@ -46,18 +27,22 @@ export function getPluginsWithHooks(plugins: IPluginData[]): IPluginData[] { } export async function verifyHooksLock( - services: IHooksCommandServices, + context: CommandContext, plugins: IPluginData[], hooksLockPath: string, ): Promise { + const $errors = context.injector.get("errors"); + const $fs = context.injector.get("fs"); + const $logger = context.injector.get("logger"); + let lockFileContent: string; let hooksLock: OutputPlugin[]; try { - lockFileContent = services.$fs.readText(hooksLockPath, "utf8"); + lockFileContent = $fs.readText(hooksLockPath, "utf8"); hooksLock = JSON.parse(lockFileContent); } catch (err) { - services.$errors.fail( + $errors.fail( `❌ Failed to read or parse ${LOCK_FILE_NAME} at ${hooksLockPath}`, ); } @@ -78,7 +63,7 @@ export async function verifyHooksLock( const pluginLockHooks = lockMap.get(plugin.name); if (!pluginLockHooks) { - services.$logger.error( + $logger.error( `❌ Plugin '${plugin.name}' not found in ${LOCK_FILE_NAME}`, ); isValid = false; @@ -89,7 +74,7 @@ export async function verifyHooksLock( const expectedHash = pluginLockHooks.get(hook.type); if (!expectedHash) { - services.$logger.error( + $logger.error( `❌ Missing hook '${hook.type}' for plugin '${plugin.name}' in ${LOCK_FILE_NAME}`, ); isValid = false; @@ -99,11 +84,9 @@ export async function verifyHooksLock( let fileContent: string | Buffer; try { - fileContent = services.$fs.readFile( - path.join(plugin.fullPath, hook.script), - ); + fileContent = $fs.readFile(path.join(plugin.fullPath, hook.script)); } catch (err) { - services.$logger.error( + $logger.error( `❌ Cannot read script file '${hook.script}' for hook '${hook.type}' in plugin '${plugin.name}'`, ); isValid = false; @@ -116,21 +99,19 @@ export async function verifyHooksLock( .digest("hex"); if (actualHash !== expectedHash) { - services.$logger.error( + $logger.error( `❌ Hash mismatch for '${hook.script}' (${hook.type} in ${plugin.name}):`, ); - services.$logger.error(` Expected: ${expectedHash}`); - services.$logger.error(` Actual: ${actualHash}`); + $logger.error(` Expected: ${expectedHash}`); + $logger.error(` Actual: ${actualHash}`); isValid = false; } } } if (isValid) { - services.$logger.info( - "✅ All hooks verified successfully. No issues found.", - ); + $logger.info("✅ All hooks verified successfully. No issues found."); } else { - services.$errors.fail("❌ One or more hooks failed verification."); + $errors.fail("❌ One or more hooks failed verification."); } } diff --git a/lib/commands/hooks/hooks-lock.ts b/lib/commands/hooks/hooks-lock.ts index 18142e8a92..7272aec725 100644 --- a/lib/commands/hooks/hooks-lock.ts +++ b/lib/commands/hooks/hooks-lock.ts @@ -1,11 +1,12 @@ -import { IPluginData } from "../../definitions/plugins"; -import { defineCommand } from "../../common/define-command"; +import { IProjectData } from "../../definitions/project"; +import { IPluginData, IPluginsService } from "../../definitions/plugins"; +import { IErrors, IFileSystem } from "../../common/declarations"; +import { CommandContext, defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; import path = require("path"); import * as crypto from "crypto"; import { getPluginsWithHooks, - IHooksCommandServices, - injectHooksCommandServices, LOCK_FILE_NAME, OutputHook, OutputPlugin, @@ -13,10 +14,13 @@ import { } from "./common"; async function writeHooksLockFile( - services: IHooksCommandServices, + context: CommandContext, plugins: IPluginData[], outputDir: string, ): Promise { + const $errors = context.injector.get("errors"); + const $fs = context.injector.get("fs"); + const $logger = context.injector.get("logger"); const output: OutputPlugin[] = []; for (const plugin of plugins) { @@ -24,7 +28,7 @@ async function writeHooksLockFile( for (const hook of plugin.nativescript?.hooks || []) { try { - const fileContent = services.$fs.readFile( + const fileContent = $fs.readFile( path.join(plugin.fullPath, hook.script), ); const hash = crypto @@ -37,7 +41,7 @@ async function writeHooksLockFile( hash, }); } catch (err) { - services.$logger.warn( + $logger.warn( `Warning: Failed to read script '${hook.script}' for plugin '${plugin.name}'. Skipping this hook.`, ); continue; @@ -50,10 +54,10 @@ async function writeHooksLockFile( const filePath = path.resolve(outputDir, LOCK_FILE_NAME); try { - services.$fs.writeFile(filePath, JSON.stringify(output, null, 2), "utf8"); - services.$logger.info(`✅ ${LOCK_FILE_NAME} written to: ${filePath}`); + $fs.writeFile(filePath, JSON.stringify(output, null, 2), "utf8"); + $logger.info(`✅ ${LOCK_FILE_NAME} written to: ${filePath}`); } catch (err) { - services.$errors.fail(`❌ Failed to write ${LOCK_FILE_NAME}: ${err}`); + $errors.fail(`❌ Failed to write ${LOCK_FILE_NAME}: ${err}`); } } @@ -62,20 +66,26 @@ export const hooksLockCommandDefinition = defineCommand({ description: "Records a hash of every plugin hook in the project's lock file.", arguments: "any", - setup: injectHooksCommandServices, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + const $logger = inject("logger"); + const plugins: IPluginData[] = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); + await $pluginsService.getAllInstalledPlugins($projectData); if (plugins && plugins.length > 0) { await writeHooksLockFile( - services, + context, getPluginsWithHooks(plugins), - services.$projectData.projectDir, + $projectData.projectDir, ); } else { - services.$logger.info("No plugins with hooks found."); + $logger.info("No plugins with hooks found."); } }, }); @@ -85,20 +95,26 @@ export const hooksVerifyCommandDefinition = defineCommand({ description: "Checks every plugin hook against the hashes in the project's lock file.", arguments: "any", - setup: injectHooksCommandServices, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + const $logger = inject("logger"); + const plugins: IPluginData[] = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); + await $pluginsService.getAllInstalledPlugins($projectData); if (plugins && plugins.length > 0) { await verifyHooksLock( - services, + context, getPluginsWithHooks(plugins), - path.join(services.$projectData.projectDir, LOCK_FILE_NAME), + path.join($projectData.projectDir, LOCK_FILE_NAME), ); } else { - services.$logger.info("No plugins with hooks found."); + $logger.info("No plugins with hooks found."); } }, }); diff --git a/lib/commands/hooks/hooks.ts b/lib/commands/hooks/hooks.ts index afe451f6d9..313f9c8153 100644 --- a/lib/commands/hooks/hooks.ts +++ b/lib/commands/hooks/hooks.ts @@ -1,21 +1,15 @@ -import { IPluginData } from "../../definitions/plugins"; +import { IProjectData } from "../../definitions/project"; +import { IPluginData, IPluginsService } from "../../definitions/plugins"; +import { IErrors, IFileSystem } from "../../common/declarations"; import { CommandContext, defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; import path = require("path"); import { HOOKS_DIR_NAME } from "../../constants"; import { createTable } from "../../common/helpers"; import nsHooks = require("@nativescript/hook"); -import { - getPluginsWithHooks, - IHooksCommandServices, - injectHooksCommandServices, - LOCK_FILE_NAME, - verifyHooksLock, -} from "./common"; +import { getPluginsWithHooks, LOCK_FILE_NAME, verifyHooksLock } from "./common"; -function listHooks( - services: IHooksCommandServices, - pluginsWithHooks: IPluginData[], -): void { +function listHooks($logger: ILogger, pluginsWithHooks: IPluginData[]): void { const headers: string[] = ["Plugin", "HookName", "HookPath"]; const hookDataData: string[][] = pluginsWithHooks.flatMap((plugin) => plugin.nativescript.hooks.map((hook: { type: string; script: string }) => { @@ -23,31 +17,26 @@ function listHooks( }), ); const hookDataTable: any = createTable(headers, hookDataData); - services.$logger.info("Hooks:"); - services.$logger.info(hookDataTable.toString()); + $logger.info("Hooks:"); + $logger.info(hookDataTable.toString()); } async function installHooks( - services: IHooksCommandServices, + context: CommandContext, + projectDir: string, pluginsWithHooks: IPluginData[], ): Promise { - const hooksDir = path.join(services.$projectData.projectDir, HOOKS_DIR_NAME); + const $fs = context.injector.get("fs"); + const hooksDir = path.join(projectDir, HOOKS_DIR_NAME); + const hooksLockPath = path.join(projectDir, LOCK_FILE_NAME); - if ( - services.$fs.exists( - path.join(services.$projectData.projectDir, LOCK_FILE_NAME), - ) - ) { - await verifyHooksLock( - services, - pluginsWithHooks, - path.join(services.$projectData.projectDir, LOCK_FILE_NAME), - ); + if ($fs.exists(hooksLockPath)) { + await verifyHooksLock(context, pluginsWithHooks, hooksLockPath); } if (pluginsWithHooks.length === 0) { - if (!services.$fs.exists(hooksDir)) { - services.$fs.createDirectory(hooksDir); + if (!$fs.exists(hooksDir)) { + $fs.createDirectory(hooksDir); } } for (const plugin of pluginsWithHooks) { @@ -55,31 +44,35 @@ async function installHooks( } } -export async function runHooksCommand( - services: IHooksCommandServices, +async function runHooksCommand( + context: CommandContext, isList: boolean, ): Promise { + const $pluginsService = + context.injector.get("pluginsService"); + const $projectData = context.injector.get("projectData"); + $projectData.initializeProjectData(); + const plugins: IPluginData[] = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); + await $pluginsService.getAllInstalledPlugins($projectData); if (plugins && plugins.length > 0) { const pluginsWithHooks = getPluginsWithHooks(plugins); if (isList) { - listHooks(services, pluginsWithHooks); + listHooks(context.injector.get("logger"), pluginsWithHooks); } else { - await installHooks(services, pluginsWithHooks); + await installHooks(context, $projectData.projectDir, pluginsWithHooks); } } } -export function canExecuteHooksCommand( - context: CommandContext, - services: IHooksCommandServices, -): boolean { +function canExecuteHooksCommand(context: CommandContext): boolean { + // A hooks command only makes sense inside a project, and reporting a missing + // one takes precedence over the argument check. + inject("projectData").initializeProjectData(); + if (context.args.length > 0 && context.args[0] !== "list") { - services.$errors.failWithHelp( + inject("errors").failWithHelp( `Invalid argument ${context.args[0]}. Supported argument is "list".`, ); } @@ -90,10 +83,9 @@ export const hooksInstallCommandDefinition = defineCommand({ name: "hooks|install", description: "Runs the postinstall hook of every installed plugin.", arguments: "any", - setup: injectHooksCommandServices, canExecute: canExecuteHooksCommand, - run(context, services): Promise { - return runHooksCommand(services, context.args[0] === "list"); + run(context): Promise { + return runHooksCommand(context, context.args[0] === "list"); }, }); @@ -101,10 +93,9 @@ export const hooksListCommandDefinition = defineCommand({ name: "hooks|*list", description: "Lists the hooks every installed plugin contributes.", arguments: "any", - setup: injectHooksCommandServices, // The name accepts "list" as its only argument, and lists either way. canExecute: canExecuteHooksCommand, - run(context, services): Promise { - return runHooksCommand(services, true); + run(context): Promise { + return runHooksCommand(context, true); }, }); diff --git a/lib/commands/info.ts b/lib/commands/info.ts index 008ad002e4..f2dd0021a8 100644 --- a/lib/commands/info.ts +++ b/lib/commands/info.ts @@ -6,10 +6,8 @@ export const infoCommandDefinition = defineCommand({ name: "info", description: "Displays version information about the CLI and its components.", arguments: "none", - setup: () => ({ - $infoService: inject("infoService"), - }), - run(context, services): Promise { - return services.$infoService.printComponentsInfo(); + run(): Promise { + const $infoService = inject("infoService"); + return $infoService.printComponentsInfo(); }, }); diff --git a/lib/commands/install.ts b/lib/commands/install.ts index eacd029b4d..69ab52f2b2 100644 --- a/lib/commands/install.ts +++ b/lib/commands/install.ts @@ -18,71 +18,53 @@ import { IPlatformsDataService } from "../definitions/platform"; import { IPluginsService } from "../definitions/plugins"; import { IProjectData, IProjectDataService } from "../definitions/project"; -export const installCommandOptions = { +const installCommandOptions = { frameworkPath: stringOption(), disableNpmInstall: booleanOption(), ignoreScripts: booleanOption(), path: stringOption(), } satisfies CommandOptionsSchema; -export type InstallCommandContext = CommandContext< - typeof installCommandOptions ->; - -export function setupInstallCommand() { - const services = { - $options: inject("options"), - $mobileHelper: inject("mobileHelper"), - $platformsDataService: inject( - "platformsDataService", - ), - $platformCommandHelper: inject( - "platformCommandHelper", - ), - $projectData: inject("projectData"), - $projectDataService: inject("projectDataService"), - $pluginsService: inject("pluginsService"), - $logger: inject("logger"), - $fs: inject("fs"), - $packageManager: inject("packageManager"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IInstallCommandServices = ReturnType; - async function installProjectDependencies( - context: InstallCommandContext, - services: IInstallCommandServices, + context: CommandContext, ): Promise { + const $options = context.injector.get("options"); + const $mobileHelper = + context.injector.get("mobileHelper"); + const $platformsDataService = context.injector.get( + "platformsDataService", + ); + const $platformCommandHelper = context.injector.get( + "platformCommandHelper", + ); + const $projectData = context.injector.get("projectData"); + const $projectDataService = + context.injector.get("projectDataService"); + const $pluginsService = + context.injector.get("pluginsService"); + const $logger = context.injector.get("logger"); + let error: string = ""; - await services.$pluginsService.ensureAllDependenciesAreInstalled( - services.$projectData, - ); + await $pluginsService.ensureAllDependenciesAreInstalled($projectData); - for (const platform of services.$mobileHelper.platformNames) { - const platformData = services.$platformsDataService.getPlatformData( + for (const platform of $mobileHelper.platformNames) { + const platformData = $platformsDataService.getPlatformData( platform, - services.$projectData, + $projectData, ); - const frameworkPackageData = services.$projectDataService.getRuntimePackage( - services.$projectData.projectDir, + const frameworkPackageData = $projectDataService.getRuntimePackage( + $projectData.projectDir, platformData.platformNameLowerCase, ); if (frameworkPackageData && frameworkPackageData.version) { try { const platformProjectService = platformData.platformProjectService; - await platformProjectService.validate( - services.$projectData, - services.$options, - ); + await platformProjectService.validate($projectData, $options); - await services.$platformCommandHelper.addPlatforms( + await $platformCommandHelper.addPlatforms( [`${platform}@${frameworkPackageData.version}`], - services.$projectData, + $projectData, context.options.frameworkPath, ); } catch (err) { @@ -92,23 +74,27 @@ async function installProjectDependencies( } if (error) { - services.$logger.error(error); + $logger.error(error); } } async function installModule( - context: InstallCommandContext, - services: IInstallCommandServices, + context: CommandContext, moduleName: string, ): Promise { - const projectDir = services.$projectData.projectDir; + const $projectData = context.injector.get("projectData"); + const $fs = context.injector.get("fs"); + const $packageManager = + context.injector.get("packageManager"); + + const projectDir = $projectData.projectDir; const devPrefix = "nativescript-dev-"; - if (!services.$fs.exists(moduleName) && moduleName.indexOf(devPrefix) !== 0) { + if (!$fs.exists(moduleName) && moduleName.indexOf(devPrefix) !== 0) { moduleName = devPrefix + moduleName; } - await services.$packageManager.install(moduleName, projectDir, { + await $packageManager.install(moduleName, projectDir, { "save-dev": true, disableNpmInstall: context.options.disableNpmInstall, frameworkPath: context.options.frameworkPath, @@ -117,15 +103,6 @@ async function installModule( }); } -export function runInstallCommand( - context: InstallCommandContext, - services: IInstallCommandServices, -): Promise { - return context.args[0] - ? installModule(context, services, context.args[0]) - : installProjectDependencies(context, services); -} - export const installCommandDefinition = defineCommand({ name: "install", description: @@ -133,6 +110,14 @@ export const installCommandDefinition = defineCommand({ options: installCommandOptions, arguments: [{ name: "moduleName" }], enableHooks: false, - setup: setupInstallCommand, - run: runInstallCommand, + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + run(context): Promise { + return context.args[0] + ? installModule(context, context.args[0]) + : installProjectDependencies(context); + }, }); diff --git a/lib/commands/list-platforms.ts b/lib/commands/list-platforms.ts index 789aa25b0b..9af91cf5d2 100644 --- a/lib/commands/list-platforms.ts +++ b/lib/commands/list-platforms.ts @@ -4,66 +4,47 @@ import { IPlatformCommandHelper } from "../declarations"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -export function setupListPlatformsCommand() { - const services = { - $platformCommandHelper: inject( - "platformCommandHelper", - ), - $projectData: inject("projectData"), - $logger: inject("logger"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IListPlatformsCommandServices = ReturnType< - typeof setupListPlatformsCommand ->; - export const listPlatformsCommandDefinition = defineCommand({ name: "platform|*list", description: "Lists all platforms that the project currently targets.", arguments: "none", - setup: setupListPlatformsCommand, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(): Promise { + const $platformCommandHelper = inject( + "platformCommandHelper", + ); + const $projectData = inject("projectData"); + const $logger = inject("logger"); const installedPlatforms = - services.$platformCommandHelper.getInstalledPlatforms( - services.$projectData, - ); + $platformCommandHelper.getInstalledPlatforms($projectData); if (installedPlatforms.length > 0) { const preparedPlatforms = - services.$platformCommandHelper.getPreparedPlatforms( - services.$projectData, - ); + $platformCommandHelper.getPreparedPlatforms($projectData); if (preparedPlatforms.length > 0) { - services.$logger.info( + $logger.info( "The project is prepared for: ", helpers.formatListOfNames(preparedPlatforms, "and"), ); } else { - services.$logger.info("The project is not prepared for any platform"); + $logger.info("The project is not prepared for any platform"); } - services.$logger.info( + $logger.info( "Installed platforms: ", helpers.formatListOfNames(installedPlatforms, "and"), ); } else { const formattedPlatformsList = helpers.formatListOfNames( - services.$platformCommandHelper.getAvailablePlatforms( - services.$projectData, - ), + $platformCommandHelper.getAvailablePlatforms($projectData), "and", ); - services.$logger.info( - "Available platforms for this OS: ", - formattedPlatformsList, - ); - services.$logger.info( - "No installed platforms found. Use $ ns platform add", - ); + $logger.info("Available platforms for this OS: ", formattedPlatformsList); + $logger.info("No installed platforms found. Use $ ns platform add"); } }, }); diff --git a/lib/commands/migrate.ts b/lib/commands/migrate.ts index e3345fcd46..9fb9c88213 100644 --- a/lib/commands/migrate.ts +++ b/lib/commands/migrate.ts @@ -3,48 +3,42 @@ import { IMigrateController, IMigrationData } from "../definitions/migrate"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -export function setupMigrateCommand() { - const services = { - $devicePlatformsConstants: inject( - "devicePlatformsConstants", - ), - $migrateController: inject("migrateController"), - $staticConfig: inject("staticConfig"), - $projectData: inject("projectData"), - $logger: inject("logger"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IMigrateCommandServices = ReturnType; - export const migrateCommandDefinition = defineCommand({ name: "migrate", description: "Migrates the project's dependencies to the ones the current CLI supports.", arguments: "none", - setup: setupMigrateCommand, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(): Promise { + const $devicePlatformsConstants = inject( + "devicePlatformsConstants", + ); + const $migrateController = inject("migrateController"); + const $staticConfig = inject("staticConfig"); + const $projectData = inject("projectData"); + const $logger = inject("logger"); const migrationData: IMigrationData = { - projectDir: services.$projectData.projectDir, + projectDir: $projectData.projectDir, platforms: [ - services.$devicePlatformsConstants.Android, - services.$devicePlatformsConstants.iOS, + $devicePlatformsConstants.Android, + $devicePlatformsConstants.iOS, ], }; const shouldMigrateResult = - await services.$migrateController.shouldMigrate(migrationData); + await $migrateController.shouldMigrate(migrationData); if (!shouldMigrateResult) { - const cliVersion = services.$staticConfig.version; - services.$logger.printMarkdown( + const cliVersion = $staticConfig.version; + $logger.printMarkdown( `__Project is compatible with NativeScript \`v${cliVersion}\`__`, ); return; } - await services.$migrateController.migrate(migrationData); + await $migrateController.migrate(migrationData); }, }); diff --git a/lib/commands/native-add.ts b/lib/commands/native-add.ts index 3567ed31b4..6b995934aa 100644 --- a/lib/commands/native-add.ts +++ b/lib/commands/native-add.ts @@ -2,7 +2,11 @@ import * as fs from "fs"; import { EOL } from "os"; import * as path from "path"; import { IErrors } from "../common/declarations"; -import { CommandName, defineCommand } from "../common/define-command"; +import { + CommandContext, + CommandName, + defineCommand, +} from "../common/define-command"; import { inject } from "../common/di"; import { capitalizeFirstLetter } from "../common/utils"; import { IProjectData } from "../definitions/project"; @@ -14,38 +18,19 @@ import { IProjectData } from "../definitions/project"; */ type NativeAddLanguage = "java" | "kotlin" | "swift" | "objective-c"; -interface INativeAddLanguageCommandServices extends INativeAddCommandServices { - language: NativeAddLanguage; -} - -export function setupNativeAddCommand() { - const services = { - $projectData: inject("projectData"), - $logger: inject("logger"), - $errors: inject("errors"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type INativeAddCommandServices = ReturnType< - typeof setupNativeAddCommand ->; - -function failWithUsage(services: INativeAddCommandServices): void { - services.$errors.failWithHelp( +function failWithUsage($errors: IErrors): void { + $errors.failWithHelp( "Usage: ns native add [swift|objective-c|java|kotlin] [class name]", ); } -function getIosSourcePathBase(services: INativeAddCommandServices): string { - const resources = services.$projectData.getAppResourcesDirectoryPath(); +function getIosSourcePathBase($projectData: IProjectData): string { + const resources = $projectData.getAppResourcesDirectoryPath(); return path.join(resources, "iOS", "src"); } -function getAndroidSourcePathBase(services: INativeAddCommandServices): string { - const resources = services.$projectData.getAppResourcesDirectoryPath(); +function getAndroidSourcePathBase($projectData: IProjectData): string { + const resources = $projectData.getAppResourcesDirectoryPath(); return path.join(resources, "Android", "src", "main", "java"); } @@ -102,10 +87,11 @@ class ${classSimpleName} { ); } -function checkAndUpdateGradleProperties( - services: INativeAddCommandServices, -): boolean { - const resources = services.$projectData.getAppResourcesDirectoryPath(); +function checkAndUpdateGradleProperties(ctx: CommandContext): boolean { + const $projectData = ctx.injector.get("projectData"); + const $logger = ctx.injector.get("logger"); + const $errors = ctx.injector.get("errors"); + const resources = $projectData.getAppResourcesDirectoryPath(); const filePath = path.join(resources, "Android", "gradle.properties"); @@ -118,7 +104,7 @@ function checkAndUpdateGradleProperties( const useKotlin = match[1]; if (useKotlin === "false") { - services.$errors.failWithHelp( + $errors.failWithHelp( "The useKotlin property is set to false. Stopping processing. Kotlin must be enabled in gradle.properties to use.", ); return false; @@ -129,41 +115,38 @@ function checkAndUpdateGradleProperties( } } else { fs.appendFileSync(filePath, `${EOL}useKotlin=true${EOL}`); - services.$logger.info( - 'Added "useKotlin=true" property to gradle.properties.', - ); + $logger.info('Added "useKotlin=true" property to gradle.properties.'); } } else { fs.writeFileSync(filePath, `useKotlin=true${EOL}`); - services.$logger.info( - 'Created gradle.properties with "useKotlin=true" property.', - ); + $logger.info('Created gradle.properties with "useKotlin=true" property.'); } return true; } -export function generateJavaKotlin( - services: INativeAddCommandServices, +function generateJavaKotlin( + ctx: CommandContext, className: string, extension: string, ): void { + const $projectData = ctx.injector.get("projectData"); + const $logger = ctx.injector.get("logger"); + const $errors = ctx.injector.get("errors"); const fileExt = extension == "java" ? extension : "kt"; const packageName = getPackageName(className); const classSimpleName = getClassSimpleName(className); const packagePath = path.join( - getAndroidSourcePathBase(services), + getAndroidSourcePathBase($projectData), ...packageName.split("."), ); const filePath = path.join(packagePath, `${classSimpleName}.${fileExt}`); if (fs.existsSync(filePath)) { - services.$errors.failWithHelp( - `${extension} file '${filePath}' already exists.`, - ); + $errors.failWithHelp(`${extension} file '${filePath}' already exists.`); return; } - if (extension == "kotlin" && !checkAndUpdateGradleProperties(services)) { + if (extension == "kotlin" && !checkAndUpdateGradleProperties(ctx)) { return; } @@ -174,7 +157,7 @@ export function generateJavaKotlin( fs.mkdirSync(packagePath, { recursive: true }); fs.writeFileSync(filePath, fileContent); - services.$logger.info( + $logger.info( `${capitalizeFirstLetter( extension, )} file '${filePath}' generated successfully.`, @@ -182,7 +165,7 @@ export function generateJavaKotlin( } function generateOrUpdateModuleMap( - services: INativeAddCommandServices, + $logger: ILogger, headerFileName: string, moduleMapPath: string, ): void { @@ -201,7 +184,7 @@ function generateOrUpdateModuleMap( // Module declaration already exists in the module map if (moduleMapContent.includes(headerDeclaration)) { // Header is already present in the module map - services.$logger.warn( + $logger.warn( `Header '${headerFileName}' is already added to the module map.`, ); return; @@ -221,28 +204,27 @@ function generateOrUpdateModuleMap( fs.writeFileSync(moduleMapPath, moduleMapContent); } - services.$logger.info( + $logger.info( `Module map '${moduleMapPath}' has been updated with the header '${headerFileName}'.`, ); } function generateObjectiveCFiles( - services: INativeAddCommandServices, + ctx: CommandContext, className: string, classFilePath: string, interfaceFilePath: string, ): boolean { + const $logger = ctx.injector.get("logger"); + const $errors = ctx.injector.get("errors"); + if (fs.existsSync(classFilePath)) { - services.$errors.failWithHelp( - `Error: File '${classFilePath}' already exists.`, - ); + $errors.failWithHelp(`Error: File '${classFilePath}' already exists.`); return false; } if (fs.existsSync(interfaceFilePath)) { - services.$errors.failWithHelp( - `Error: File '${interfaceFilePath}' already exists.`, - ); + $errors.failWithHelp(`Error: File '${interfaceFilePath}' already exists.`); return false; } @@ -267,32 +249,29 @@ function generateObjectiveCFiles( `; fs.writeFileSync(classFilePath, classContent); - services.$logger.trace( + $logger.trace( `Objective-C class file '${classFilePath}' generated successfully.`, ); fs.writeFileSync(interfaceFilePath, interfaceContent); - services.$logger.trace( + $logger.trace( `Objective-C interface file '${interfaceFilePath}' generated successfully.`, ); return true; } -export function generateObjectiveC( - services: INativeAddCommandServices, - className: string, -): void { - const iosSourceBase = getIosSourcePathBase(services); +function generateObjectiveC(ctx: CommandContext, className: string): void { + const $projectData = ctx.injector.get("projectData"); + const $logger = ctx.injector.get("logger"); + const iosSourceBase = getIosSourcePathBase($projectData); const classFilePath = path.join(iosSourceBase, `${className}.m`); const headerFilePath = path.join(iosSourceBase, `${className}.h`); - if ( - generateObjectiveCFiles(services, className, classFilePath, headerFilePath) - ) { + if (generateObjectiveCFiles(ctx, className, classFilePath, headerFilePath)) { // Modify/Generate moduleMap generateOrUpdateModuleMap( - services, + $logger, `${className}.h`, path.join(iosSourceBase, "module.modulemap"), ); @@ -300,19 +279,21 @@ export function generateObjectiveC( } function generateSwiftFile( - services: INativeAddCommandServices, + ctx: CommandContext, className: string, filePath: string, ): void { + const $logger = ctx.injector.get("logger"); + const $errors = ctx.injector.get("errors"); const directory = path.dirname(filePath); if (!fs.existsSync(directory)) { fs.mkdirSync(directory, { recursive: true }); - services.$logger.trace(`Created directory: '${directory}'.`); + $logger.trace(`Created directory: '${directory}'.`); } if (fs.existsSync(filePath)) { - services.$errors.failWithHelp(`Error: File '${filePath}' already exists.`); + $errors.failWithHelp(`Error: File '${filePath}' already exists.`); return; } @@ -326,26 +307,22 @@ import os; }`; fs.writeFileSync(filePath, content); - services.$logger.info(`Swift file '${filePath}' generated successfully.`); + $logger.info(`Swift file '${filePath}' generated successfully.`); } -export function generateSwift( - services: INativeAddCommandServices, - className: string, -): void { - const iosSourceBase = getIosSourcePathBase(services); +function generateSwift(ctx: CommandContext, className: string): void { + const $projectData = ctx.injector.get("projectData"); + const iosSourceBase = getIosSourcePathBase($projectData); const swiftFilePath = path.join(iosSourceBase, `${className}.swift`); - generateSwiftFile(services, className, swiftFilePath); + generateSwiftFile(ctx, className, swiftFilePath); } const generators: Record< NativeAddLanguage, - (services: INativeAddCommandServices, className: string) => void + (ctx: CommandContext, className: string) => void > = { - java: (services, className) => - generateJavaKotlin(services, className, "java"), - kotlin: (services, className) => - generateJavaKotlin(services, className, "kotlin"), + java: (ctx, className) => generateJavaKotlin(ctx, className, "java"), + kotlin: (ctx, className) => generateJavaKotlin(ctx, className, "kotlin"), swift: generateSwift, "objective-c": generateObjectiveC, }; @@ -355,13 +332,15 @@ export const nativeAddCommandDefinition = defineCommand({ description: "Commands to add native files to the application placing them in the correct directory.", arguments: "any", - setup: setupNativeAddCommand, - canExecute(context, services: INativeAddCommandServices): boolean { - failWithUsage(services); + setup() { + inject("projectData").initializeProjectData(); + }, + canExecute(): boolean { + failWithUsage(inject("errors")); return false; }, - run(context, services: INativeAddCommandServices): void { - failWithUsage(services); + run(): void { + failWithUsage(inject("errors")); }, }); @@ -375,21 +354,20 @@ const defineNativeAddLanguageCommand = ( // The one usage message answers both too few and too many arguments; a // declared argument spec would report them with two different ones. arguments: "any", - setup(): INativeAddLanguageCommandServices { - return { - ...setupNativeAddCommand(), - language, - }; + setup() { + inject("projectData").initializeProjectData(); }, - canExecute(context, services: INativeAddLanguageCommandServices): boolean { + canExecute(context): boolean { + const $errors = inject("errors"); + if (context.args.length !== 1) { - failWithUsage(services); + failWithUsage($errors); } return true; }, - run(context, services: INativeAddLanguageCommandServices): void { - generators[services.language](services, context.args[0]); + run(context): void { + generators[language](context, context.args[0]); }, }); diff --git a/lib/commands/open.ts b/lib/commands/open.ts index 1bf8696964..9d614ab2ba 100644 --- a/lib/commands/open.ts +++ b/lib/commands/open.ts @@ -4,6 +4,7 @@ import * as path from "path"; import { IChildProcess, IXcodeSelectService } from "../common/declarations"; import { booleanOption, + CommandContext, CommandOptionsSchema, defineCommand, } from "../common/define-command"; @@ -14,39 +15,7 @@ import { IOptions } from "../declarations"; import { IProjectData } from "../definitions/project"; import type { IOSProjectService } from "../services/ios-project-service"; -export function injectOpenXcodeProjectServices() { - return { - $iOSProjectService: inject("iOSProjectService"), - $logger: inject("logger"), - $childProcess: inject("childProcess"), - $projectData: inject("projectData"), - $xcodeSelectService: inject("xcodeSelectService"), - $xcodebuildArgsService: inject( - "xcodebuildArgsService", - ), - }; -} - -export type IOpenXcodeProjectServices = ReturnType< - typeof injectOpenXcodeProjectServices ->; - -export function injectOpenAndroidStudioServices() { - return { - $logger: inject("logger"), - $liveSyncCommandHelper: inject( - "liveSyncCommandHelper", - ), - $childProcess: inject("childProcess"), - $projectData: inject("projectData"), - }; -} - -export type IOpenAndroidStudioServices = ReturnType< - typeof injectOpenAndroidStudioServices ->; - -export function getAndroidStudioPath(): string | null { +function getAndroidStudioPath(): string | null { const os = currentPlatform(); if (os === "darwin") { @@ -79,14 +48,21 @@ export function getAndroidStudioPath(): string | null { * while `ns run` owns stdin and has to hand it back after `prepare` consumed * it, a one-shot CLI command exits instead. */ -export async function openAndroidStudioProject( - services: IOpenAndroidStudioServices, +async function openAndroidStudioProject( + context: CommandContext, platform: string, isInteractive: boolean, ): Promise { - services.$liveSyncCommandHelper.validatePlatform(platform); - services.$projectData.initializeProjectData(); - const androidDir = `${services.$projectData.platformsDir}/android`; + const $childProcess = context.injector.get("childProcess"); + const $liveSyncCommandHelper = context.injector.get( + "liveSyncCommandHelper", + ); + const $logger = context.injector.get("logger"); + const $projectData = context.injector.get("projectData"); + + $liveSyncCommandHelper.validatePlatform(platform); + $projectData.initializeProjectData(); + const androidDir = `${$projectData.platformsDir}/android`; if (!fs.existsSync(androidDir)) { const prepareCommand = injector.resolveCommand("prepare") as ICommand; @@ -104,7 +80,7 @@ export async function openAndroidStudioProject( studioPath = getAndroidStudioPath(); if (!studioPath) { - services.$logger.error( + $logger.error( "Android Studio is not installed, or is not in a standard location. Use NATIVESCRIPT_ANDROID_STUDIO_PATH.", ); return; @@ -113,34 +89,42 @@ export async function openAndroidStudioProject( const os = currentPlatform(); if (os === "darwin") { - services.$childProcess.exec(`open -a "${studioPath}" ${androidDir}`); + $childProcess.exec(`open -a "${studioPath}" ${androidDir}`); } else if (os === "win32") { - const child = services.$childProcess.spawn(studioPath, [androidDir], { + const child = $childProcess.spawn(studioPath, [androidDir], { detached: true, stdio: "ignore", }); child.unref(); } else if (os === "linux") { - services.$childProcess.exec(`${studioPath} ${androidDir}`); + $childProcess.exec(`${studioPath} ${androidDir}`); } } -export async function openXcodeProject( - services: IOpenXcodeProjectServices, +async function openXcodeProject( + context: CommandContext, platformDirName: string, isInteractive: boolean, ): Promise { + const $childProcess = context.injector.get("childProcess"); + const $iOSProjectService = + context.injector.get("iOSProjectService"); + const $logger = context.injector.get("logger"); + const $projectData = context.injector.get("projectData"); + const $xcodeSelectService = + context.injector.get("xcodeSelectService"); + const $xcodebuildArgsService = context.injector.get( + "xcodebuildArgsService", + ); + const os = currentPlatform(); if (os !== "darwin") { - services.$logger.error("Opening a project in XCode requires macOS."); + $logger.error("Opening a project in XCode requires macOS."); return; } - services.$projectData.initializeProjectData(); - const platformDir = path.resolve( - services.$projectData.platformsDir, - platformDirName, - ); + $projectData.initializeProjectData(); + const platformDir = path.resolve($projectData.platformsDir, platformDirName); if (!fs.existsSync(platformDir)) { const prepareCommand = injector.resolveCommand("prepare") as ICommand; @@ -150,33 +134,31 @@ export async function openXcodeProject( process.stdin.resume(); } } - const platformData = services.$iOSProjectService.getPlatformData( - services.$projectData, - ); - const xcprojectFile = services.$xcodebuildArgsService.getXcodeProjectArgs( + const platformData = $iOSProjectService.getPlatformData($projectData); + const xcprojectFile = $xcodebuildArgsService.getXcodeProjectArgs( platformData, - services.$projectData, + $projectData, )[1]; if (fs.existsSync(xcprojectFile)) { - services.$xcodeSelectService + $xcodeSelectService .getDeveloperDirectoryPath() - .then(() => services.$childProcess.exec(`open ${xcprojectFile}`, {})) + .then(() => $childProcess.exec(`open ${xcprojectFile}`, {})) .catch((e) => { - services.$logger.error(e.message); + $logger.error(e.message); }); } else { - services.$logger.error(`Unable to open project file: ${xcprojectFile}`); + $logger.error(`Unable to open project file: ${xcprojectFile}`); } } -export async function openVisionOSProject( - services: IOpenXcodeProjectServices, +async function openVisionOSProject( + context: CommandContext, $options: IOptions, isInteractive: boolean, ): Promise { $options.platformOverride = "visionOS"; - await openXcodeProject(services, "visionos", isInteractive); + await openXcodeProject(context, "visionos", isInteractive); $options.platformOverride = null; } @@ -208,16 +190,9 @@ export const iosOpenCommand = defineCommand({ description: "Opens the project in Xcode.", options: openCommandOptions, arguments: "none", - setup() { - return { - ...injectOpenXcodeProjectServices(), - $options: inject("options"), - }; - }, - async run(context, services): Promise { - await withoutWatch(services.$options, () => - openXcodeProject(services, "ios", false), - ); + async run(context): Promise { + const $options = inject("options"); + await withoutWatch($options, () => openXcodeProject(context, "ios", false)); }, }); @@ -226,15 +201,10 @@ export const visionOpenCommand = defineCommand({ description: "Opens the visionOS project in Xcode.", options: openCommandOptions, arguments: "none", - setup() { - return { - ...injectOpenXcodeProjectServices(), - $options: inject("options"), - }; - }, - async run(context, services): Promise { - await withoutWatch(services.$options, () => - openVisionOSProject(services, services.$options, false), + async run(context): Promise { + const $options = inject("options"); + await withoutWatch($options, () => + openVisionOSProject(context, $options, false), ); }, }); @@ -244,15 +214,10 @@ export const androidOpenCommand = defineCommand({ description: "Opens the project in Android Studio.", options: openCommandOptions, arguments: "none", - setup() { - return { - ...injectOpenAndroidStudioServices(), - $options: inject("options"), - }; - }, - async run(context, services): Promise { - await withoutWatch(services.$options, () => - openAndroidStudioProject(services, "Android", false), + async run(context): Promise { + const $options = inject("options"); + await withoutWatch($options, () => + openAndroidStudioProject(context, "Android", false), ); }, }); diff --git a/lib/commands/plugin/add-plugin.ts b/lib/commands/plugin/add-plugin.ts index 66287c59d7..96b3d9dab4 100644 --- a/lib/commands/plugin/add-plugin.ts +++ b/lib/commands/plugin/add-plugin.ts @@ -2,56 +2,42 @@ import * as _ from "lodash"; import { IProjectData } from "../../definitions/project"; import { IPluginsService, IPluginData } from "../../definitions/plugins"; import { IErrors } from "../../common/declarations"; -import { CommandContext, defineCommand } from "../../common/define-command"; +import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export function setupAddPluginCommand() { - const services = { - $pluginsService: inject("pluginsService"), - $projectData: inject("projectData"), - $errors: inject("errors"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IAddPluginCommandServices = ReturnType< - typeof setupAddPluginCommand ->; - -export async function canExecuteAddPluginCommand( - context: CommandContext, - services: IAddPluginCommandServices, -): Promise { - if (!context.args[0]) { - services.$errors.failWithHelp("You must specify plugin name."); - } - - const installedPlugins = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); - const pluginName = context.args[0].toLowerCase(); - if ( - _.some( - installedPlugins, - (plugin: IPluginData) => plugin.name.toLowerCase() === pluginName, - ) - ) { - services.$errors.fail(`Plugin "${pluginName}" is already installed.`); - } - - return true; -} - export const addPluginCommandDefinition = defineCommand({ name: ["plugin|add", "plugin|install"], description: "Installs the specified plugin and its dependencies.", arguments: "any", - setup: setupAddPluginCommand, - canExecute: canExecuteAddPluginCommand, - run(context, services): Promise { - return services.$pluginsService.add(context.args[0], services.$projectData); + async canExecute(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + const $errors = inject("errors"); + $projectData.initializeProjectData(); + + if (!context.args[0]) { + $errors.failWithHelp("You must specify plugin name."); + } + + const installedPlugins = + await $pluginsService.getAllInstalledPlugins($projectData); + const pluginName = context.args[0].toLowerCase(); + if ( + _.some( + installedPlugins, + (plugin: IPluginData) => plugin.name.toLowerCase() === pluginName, + ) + ) { + $errors.fail(`Plugin "${pluginName}" is already installed.`); + } + + return true; + }, + run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + return $pluginsService.add(context.args[0], $projectData); }, }); diff --git a/lib/commands/plugin/list-plugins.ts b/lib/commands/plugin/list-plugins.ts index cd589964ed..4341028f76 100644 --- a/lib/commands/plugin/list-plugins.ts +++ b/lib/commands/plugin/list-plugins.ts @@ -9,21 +9,6 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { color } from "../../color"; -export function setupListPluginsCommand() { - const services = { - $pluginsService: inject("pluginsService"), - $projectData: inject("projectData"), - $logger: inject("logger"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IListPluginsCommandServices = ReturnType< - typeof setupListPluginsCommand ->; - function createTableCells(items: IBasePluginData[]): string[][] { return items.map((item) => [item.name, item.version]); } @@ -32,12 +17,17 @@ export const listPluginsCommandDefinition = defineCommand({ name: "plugin|*list", description: "Lists all installed plugins.", arguments: "none", - setup: setupListPluginsCommand, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + const $logger = inject("logger"); const installedPlugins: IPackageJsonDepedenciesResult = - services.$pluginsService.getDependenciesFromPackageJson( - services.$projectData.projectDir, - ); + $pluginsService.getDependenciesFromPackageJson($projectData.projectDir); const headers: string[] = ["Plugin", "Version"]; const dependenciesData: string[][] = createTableCells( @@ -45,8 +35,8 @@ export const listPluginsCommandDefinition = defineCommand({ ); const dependenciesTable: any = createTable(headers, dependenciesData); - services.$logger.info("Dependencies:"); - services.$logger.info(dependenciesTable.toString()); + $logger.info("Dependencies:"); + $logger.info(dependenciesTable.toString()); if ( installedPlugins.devDependencies && @@ -61,10 +51,10 @@ export const listPluginsCommandDefinition = defineCommand({ devDependenciesData, ); - services.$logger.info("Dev Dependencies:"); - services.$logger.info(devDependenciesTable.toString()); + $logger.info("Dev Dependencies:"); + $logger.info(devDependenciesTable.toString()); } else { - services.$logger.info("There are no dev dependencies."); + $logger.info("There are no dev dependencies."); } const viewDependenciesCommand: string = color.cyan( @@ -74,11 +64,11 @@ export const listPluginsCommandDefinition = defineCommand({ "npm view grep devDependencies", ); - services.$logger.warn("NOTE:"); - services.$logger.warn( + $logger.warn("NOTE:"); + $logger.warn( `If you want to check the dependencies of installed plugin use ${viewDependenciesCommand}`, ); - services.$logger.warn( + $logger.warn( `If you want to check the dev dependencies of installed plugin use ${viewDevDependenciesCommand}`, ); }, diff --git a/lib/commands/plugin/remove-plugin.ts b/lib/commands/plugin/remove-plugin.ts index 8cb0683c8e..a2cfe193df 100644 --- a/lib/commands/plugin/remove-plugin.ts +++ b/lib/commands/plugin/remove-plugin.ts @@ -2,64 +2,47 @@ import * as _ from "lodash"; import { IProjectData } from "../../definitions/project"; import { IPluginsService } from "../../definitions/plugins"; import { IErrors } from "../../common/declarations"; -import { CommandContext, defineCommand } from "../../common/define-command"; +import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export function setupRemovePluginCommand() { - const services = { - $pluginsService: inject("pluginsService"), - $errors: inject("errors"), - $logger: inject("logger"), - $projectData: inject("projectData"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IRemovePluginCommandServices = ReturnType< - typeof setupRemovePluginCommand ->; - -export async function canExecuteRemovePluginCommand( - context: CommandContext, - services: IRemovePluginCommandServices, -): Promise { - if (!context.args[0]) { - services.$errors.failWithHelp("You must specify plugin name."); - } - - let pluginNames: string[] = []; - try { - // try installing the plugins, so we can get information from node_modules about their native code, libs, etc. - const installedPlugins = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); - pluginNames = installedPlugins.map((pl) => pl.name); - } catch (err) { - services.$logger.trace("Error while installing plugins. Error is:", err); - pluginNames = _.keys(services.$projectData.dependencies); - } - - const pluginName = context.args[0].toLowerCase(); - if (!_.some(pluginNames, (name) => name.toLowerCase() === pluginName)) { - services.$errors.fail(`Plugin "${pluginName}" is not installed.`); - } - - return true; -} - export const removePluginCommandDefinition = defineCommand({ name: "plugin|remove", description: "Uninstalls the specified plugin and its dependencies.", arguments: "any", - setup: setupRemovePluginCommand, - canExecute: canExecuteRemovePluginCommand, - run(context, services): Promise { - return services.$pluginsService.remove( - context.args[0], - services.$projectData, - ); + async canExecute(context): Promise { + const $pluginsService = inject("pluginsService"); + const $errors = inject("errors"); + const $logger = inject("logger"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + if (!context.args[0]) { + $errors.failWithHelp("You must specify plugin name."); + } + + let pluginNames: string[] = []; + try { + // try installing the plugins, so we can get information from node_modules about their native code, libs, etc. + const installedPlugins = + await $pluginsService.getAllInstalledPlugins($projectData); + pluginNames = installedPlugins.map((pl) => pl.name); + } catch (err) { + $logger.trace("Error while installing plugins. Error is:", err); + pluginNames = _.keys($projectData.dependencies); + } + + const pluginName = context.args[0].toLowerCase(); + if (!_.some(pluginNames, (name) => name.toLowerCase() === pluginName)) { + $errors.fail(`Plugin "${pluginName}" is not installed.`); + } + + return true; + }, + run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + return $pluginsService.remove(context.args[0], $projectData); }, }); diff --git a/lib/commands/plugin/update-plugin.ts b/lib/commands/plugin/update-plugin.ts index c2672960bb..44de94e80d 100644 --- a/lib/commands/plugin/update-plugin.ts +++ b/lib/commands/plugin/update-plugin.ts @@ -2,74 +2,55 @@ import * as _ from "lodash"; import { IProjectData } from "../../definitions/project"; import { IPluginsService } from "../../definitions/plugins"; import { IErrors } from "../../common/declarations"; -import { CommandContext, defineCommand } from "../../common/define-command"; +import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export function setupUpdatePluginCommand() { - const services = { - $pluginsService: inject("pluginsService"), - $projectData: inject("projectData"), - $errors: inject("errors"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IUpdatePluginCommandServices = ReturnType< - typeof setupUpdatePluginCommand ->; - -export async function canExecuteUpdatePluginCommand( - context: CommandContext, - services: IUpdatePluginCommandServices, -): Promise { - const args = context.args; - if (!args || args.length === 0) { - return true; - } - - const installedPlugins = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); - const installedPluginNames: string[] = installedPlugins.map((pl) => pl.name); - - const pluginName = args[0].toLowerCase(); - if ( - !_.some(installedPluginNames, (name) => name.toLowerCase() === pluginName) - ) { - services.$errors.fail(`Plugin "${pluginName}" is not installed.`); - } - - return true; -} +export const updatePluginCommandDefinition = defineCommand({ + name: "plugin|update", + description: "Uninstalls and installs the specified plugin(s).", + arguments: "any", + async canExecute(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + const $errors = inject("errors"); + $projectData.initializeProjectData(); -export async function runUpdatePluginCommand( - context: CommandContext, - services: IUpdatePluginCommandServices, -): Promise { - let pluginNames = context.args; + const args = context.args; + if (!args || args.length === 0) { + return true; + } - if (!pluginNames || context.args.length === 0) { const installedPlugins = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); - pluginNames = installedPlugins.map((p) => p.name); - } + await $pluginsService.getAllInstalledPlugins($projectData); + const installedPluginNames: string[] = installedPlugins.map( + (pl) => pl.name, + ); - for (const pluginName of pluginNames) { - await services.$pluginsService.remove(pluginName, services.$projectData); - await services.$pluginsService.add(pluginName, services.$projectData); - } -} + const pluginName = args[0].toLowerCase(); + if ( + !_.some(installedPluginNames, (name) => name.toLowerCase() === pluginName) + ) { + $errors.fail(`Plugin "${pluginName}" is not installed.`); + } -export const updatePluginCommandDefinition = defineCommand({ - name: "plugin|update", - description: "Uninstalls and installs the specified plugin(s).", - arguments: "any", - setup: setupUpdatePluginCommand, - canExecute: canExecuteUpdatePluginCommand, - run: runUpdatePluginCommand, + return true; + }, + async run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + let pluginNames = context.args; + + if (!pluginNames || context.args.length === 0) { + const installedPlugins = + await $pluginsService.getAllInstalledPlugins($projectData); + pluginNames = installedPlugins.map((p) => p.name); + } + + for (const pluginName of pluginNames) { + await $pluginsService.remove(pluginName, $projectData); + await $pluginsService.add(pluginName, $projectData); + } + }, }); diff --git a/lib/commands/prepare.ts b/lib/commands/prepare.ts index a6e73d3577..29780ef800 100644 --- a/lib/commands/prepare.ts +++ b/lib/commands/prepare.ts @@ -1,6 +1,5 @@ import { canExecuteCommandBase, - injectPlatformCommandServices, platformArgument, validatePlatformArgument, validatePlatformOptions, @@ -15,6 +14,8 @@ import { defineCommand, } from "../common/define-command"; import { inject } from "../common/di"; +import { IOptions } from "../declarations"; +import { IProjectData } from "../definitions/project"; export const prepareCommandOptions = { watch: booleanOption({ default: false }), @@ -23,28 +24,15 @@ export const prepareCommandOptions = { force: booleanOption(), } satisfies CommandOptionsSchema; -export type PrepareCommandContext = CommandContext< - typeof prepareCommandOptions ->; +type PrepareCommandContext = CommandContext; -export function setupPrepareCommand() { - const services = { - ...injectPlatformCommandServices(), - $prepareController: inject("prepareController"), - $prepareDataService: inject("prepareDataService"), - $migrateController: inject("migrateController"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IPrepareCommandServices = ReturnType; - -export async function canExecutePrepareCommand( +async function canExecutePrepareCommand( context: PrepareCommandContext, - services: IPrepareCommandServices, ): Promise { + const $migrateController = + context.injector.get("migrateController"); + const $projectData = context.injector.get("projectData"); + const platform = context.args[0]; if (!platform) { // The declared argument validates only a platform that was passed; an @@ -52,11 +40,11 @@ export async function canExecutePrepareCommand( validatePlatformArgument(context.injector, platform); } - const result = await validatePlatformOptions(services, platform); + const result = await validatePlatformOptions(context, platform); if (!context.options.force) { - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, + await $migrateController.validate({ + projectDir: $projectData.projectDir, platforms: [platform], }); } @@ -65,19 +53,25 @@ export async function canExecutePrepareCommand( return false; } - return canExecuteCommandBase(services, platform); + return canExecuteCommandBase(context, platform); } export async function runPrepareCommand( context: PrepareCommandContext, - services: IPrepareCommandServices, ): Promise { - const prepareData = services.$prepareDataService.getPrepareData( - services.$projectData.projectDir, + const $options = context.injector.get("options"); + const $prepareController = + context.injector.get("prepareController"); + const $prepareDataService = + context.injector.get("prepareDataService"); + const $projectData = context.injector.get("projectData"); + + const prepareData = $prepareDataService.getPrepareData( + $projectData.projectDir, context.args[0], - services.$options, + $options, ); - await services.$prepareController.prepare(prepareData); + await $prepareController.prepare(prepareData); } export const prepareCommandDefinition = defineCommand({ @@ -85,7 +79,9 @@ export const prepareCommandDefinition = defineCommand({ description: "Copies common and platform-specific content to the platform.", options: prepareCommandOptions, arguments: [platformArgument], - setup: setupPrepareCommand, + setup() { + inject("projectData").initializeProjectData(); + }, canExecute: canExecutePrepareCommand, run: runPrepareCommand, }); diff --git a/lib/commands/remove-platform.ts b/lib/commands/remove-platform.ts index f690f0455b..77b36f829d 100644 --- a/lib/commands/remove-platform.ts +++ b/lib/commands/remove-platform.ts @@ -5,66 +5,42 @@ import { IPlatformValidationService, } from "../declarations"; import { IErrors } from "../common/declarations"; -import { CommandContext, defineCommand } from "../common/define-command"; +import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -export function setupRemovePlatformCommand() { - const services = { - $errors: inject("errors"), - $platformCommandHelper: inject( - "platformCommandHelper", - ), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IRemovePlatformCommandServices = ReturnType< - typeof setupRemovePlatformCommand ->; - -export async function canExecuteRemovePlatformCommand( - context: CommandContext, - services: IRemovePlatformCommandServices, -): Promise { - const args = context.args; - if (!args || args.length === 0) { - services.$errors.failWithHelp( - "No platform specified. Please specify a platform to remove.", - ); - } - - _.each(args, (platform) => { - services.$platformValidationService.validatePlatform( - platform, - services.$projectData, - ); - }); - - return true; -} - -export function runRemovePlatformCommand( - context: CommandContext, - services: IRemovePlatformCommandServices, -): Promise { - return services.$platformCommandHelper.removePlatforms( - context.args, - services.$projectData, - ); -} - export const removePlatformCommandDefinition = defineCommand({ name: "platform|remove", description: "Removes the selected platform from the platforms that the project currently targets.", arguments: "any", - setup: setupRemovePlatformCommand, - canExecute: canExecuteRemovePlatformCommand, - run: runRemovePlatformCommand, + async canExecute(context): Promise { + const $errors = inject("errors"); + const $platformValidationService = inject( + "platformValidationService", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + const args = context.args; + if (!args || args.length === 0) { + $errors.failWithHelp( + "No platform specified. Please specify a platform to remove.", + ); + } + + _.each(args, (platform) => { + $platformValidationService.validatePlatform(platform, $projectData); + }); + + return true; + }, + run(context): Promise { + const $platformCommandHelper = inject( + "platformCommandHelper", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + return $platformCommandHelper.removePlatforms(context.args, $projectData); + }, }); diff --git a/lib/commands/resources/resources-update.ts b/lib/commands/resources/resources-update.ts index d958e9905f..4dfcf765cd 100644 --- a/lib/commands/resources/resources-update.ts +++ b/lib/commands/resources/resources-update.ts @@ -1,69 +1,60 @@ import { IProjectData } from "../../definitions/project"; import { IAndroidResourcesMigrationService } from "../../declarations"; import { IErrors } from "../../common/declarations"; -import { CommandContext, defineCommand } from "../../common/define-command"; +import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export function setupResourcesUpdateCommand() { - const services = { - $projectData: inject("projectData"), - $errors: inject("errors"), - $androidResourcesMigrationService: +export const resourcesUpdateCommandDefinition = defineCommand({ + name: "resources|update", + description: + "Updates the App_Resources directory to the structure the current Android runtime expects.", + arguments: "any", + async canExecute(context): Promise { + const $androidResourcesMigrationService = inject( "androidResourcesMigrationService", - ), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export type IResourcesUpdateCommandServices = ReturnType< - typeof setupResourcesUpdateCommand ->; - -export async function canExecuteResourcesUpdateCommand( - context: CommandContext, - services: IResourcesUpdateCommandServices, -): Promise { - let args = context.args; - if (!args || args.length === 0) { - // Command defaults to migrating the Android App_Resources, unless explicitly specified. - // The default reaches this check only; the migration itself ignores the arguments. - args = ["android"]; - } - - for (const platform of args) { - if (!services.$androidResourcesMigrationService.canMigrate(platform)) { - services.$errors.fail( - `The ${platform} does not need to have its resources updated.`, ); + const $errors = inject("errors"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + let args = context.args; + if (!args || args.length === 0) { + // Command defaults to migrating the Android App_Resources, unless explicitly specified. + // The default reaches this check only; the migration itself ignores the arguments. + args = ["android"]; } - if ( - services.$androidResourcesMigrationService.hasMigrated( - services.$projectData.getAppResourcesDirectoryPath(), - ) - ) { - services.$errors.fail( - "The App_Resources have already been updated for the Android platform.", - ); + for (const platform of args) { + if (!$androidResourcesMigrationService.canMigrate(platform)) { + $errors.fail( + `The ${platform} does not need to have its resources updated.`, + ); + } + + if ( + $androidResourcesMigrationService.hasMigrated( + $projectData.getAppResourcesDirectoryPath(), + ) + ) { + $errors.fail( + "The App_Resources have already been updated for the Android platform.", + ); + } } - } - return true; -} + return true; + }, + async run(): Promise { + const $androidResourcesMigrationService = + inject( + "androidResourcesMigrationService", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); -export const resourcesUpdateCommandDefinition = defineCommand({ - name: "resources|update", - description: - "Updates the App_Resources directory to the structure the current Android runtime expects.", - arguments: "any", - setup: setupResourcesUpdateCommand, - canExecute: canExecuteResourcesUpdateCommand, - async run(context, services): Promise { - await services.$androidResourcesMigrationService.migrate( - services.$projectData.getAppResourcesDirectoryPath(), + await $androidResourcesMigrationService.migrate( + $projectData.getAppResourcesDirectoryPath(), ); }, }); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index 1e72107be6..4d21a898ac 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -36,103 +36,78 @@ const runCommandOptions = { keyStoreAliasPassword: stringOption(), } satisfies CommandOptionsSchema; -export type RunCommandContext = CommandContext; - -export interface IRunCommandServices { - /** - * Undefined for `run|*all`, which targets every platform. `canExecute` - * narrows it to Android off macOS, and `run` reads whatever it settled on. - */ - platform: string; - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $errors: IErrors; - $hostInfo: IHostInfo; - $keyShortcutService: IKeyShortcutService; - $liveSyncCommandHelper: ILiveSyncCommandHelper; - $migrateController: IMigrateController; - $options: IOptions; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; - $projectDataService: IProjectDataService; -} - -export function setupRunCommand(): IRunCommandServices { - return { - platform: undefined, - $devicePlatformsConstants: inject( - "devicePlatformsConstants", - ), - $errors: inject("errors"), - $hostInfo: inject("hostInfo"), - $keyShortcutService: inject("keyShortcutService"), - $liveSyncCommandHelper: inject( - "liveSyncCommandHelper", - ), - $migrateController: inject("migrateController"), - $options: inject("options"), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - $projectDataService: inject("projectDataService"), - }; -} +type RunCommandContext = CommandContext; +/** + * Which `$devicePlatformsConstants` entry a command runs for. The constants + * stay the source of truth for the platform spelling. + */ type RunPlatform = "iOS" | "Android" | "visionOS"; -const setupPlatformRunCommand = - (platform: RunPlatform) => (): IRunCommandServices => { - const services = setupRunCommand(); - services.platform = services.$devicePlatformsConstants[platform]; - - return services; - }; +const runPlatformName = ( + context: RunCommandContext, + platform: RunPlatform, +): string => + context.injector.get( + "devicePlatformsConstants", + )[platform]; -export async function canExecuteRunCommand( +async function canExecuteRunCommand( context: RunCommandContext, - services: IRunCommandServices, + platform: string, ): Promise { - if (context.args.length) { - services.$errors.failWithHelp(ERROR_NO_VALID_SUBCOMMAND_FORMAT, "run"); - } + const $devicePlatformsConstants = + context.injector.get( + "devicePlatformsConstants", + ); + const $errors = context.injector.get("errors"); + const $liveSyncCommandHelper = context.injector.get( + "liveSyncCommandHelper", + ); + const $migrateController = + context.injector.get("migrateController"); + const $projectData = context.injector.get("projectData"); - if (!services.platform && !services.$hostInfo.isDarwin) { - services.platform = services.$devicePlatformsConstants.Android; + if (context.args.length) { + $errors.failWithHelp(ERROR_NO_VALID_SUBCOMMAND_FORMAT, "run"); } - services.$projectData.initializeProjectData(); - const platforms = services.platform - ? [services.platform] - : [ - services.$devicePlatformsConstants.Android, - services.$devicePlatformsConstants.iOS, - ]; + $projectData.initializeProjectData(); + const platforms = platform + ? [platform] + : [$devicePlatformsConstants.Android, $devicePlatformsConstants.iOS]; if (!context.options.force) { - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, + await $migrateController.validate({ + projectDir: $projectData.projectDir, platforms, }); } - await services.$liveSyncCommandHelper.validatePlatform(services.platform); + await $liveSyncCommandHelper.validatePlatform(platform); return true; } -export async function runRunCommand( +async function runRunCommand( context: RunCommandContext, - services: IRunCommandServices, + platform: string, ): Promise { - await services.$liveSyncCommandHelper.executeCommandLiveSync( - services.platform, + const $keyShortcutService = + context.injector.get("keyShortcutService"); + const $liveSyncCommandHelper = context.injector.get( + "liveSyncCommandHelper", + ); + + await $liveSyncCommandHelper.executeCommandLiveSync( + platform, {}, ); if (process.env.NS_IS_INTERACTIVE) { - services.$keyShortcutService.attach({ + $keyShortcutService.attach({ context: { - platform: services.platform, + platform: platform, processType: "run", }, shortcuts: keyShortcuts(), @@ -145,9 +120,9 @@ export async function runRunCommand( * outright; the launch and clean keys belong to the parent that respawns * things, which is why the `ns start` table is not reused here. */ -export function runCommandShortcuts( +function runCommandShortcuts( context: RunCommandContext, - services: IRunCommandServices, + platform: string, ): KeyShortcut[] { if (process.env.NS_IS_INTERACTIVE) { // A `ns start` child is driven over IPC through the table `run` attaches @@ -155,12 +130,13 @@ export function runCommandShortcuts( return []; } - const platform = services.platform; - return [ - restartShortcut({ platform }), - restartShortcut({ platform, full: true }), - restartShortcut({ platform, forceRebuildNativeApp: true }), + restartShortcut({ platform: platform }), + restartShortcut({ platform: platform, full: true }), + restartShortcut({ + platform: platform, + forceRebuildNativeApp: true, + }), watcherShortcut(), ]; } @@ -171,7 +147,16 @@ export const runCommandDefinition = defineCommand({ options: runCommandOptions, // The base rejects arguments itself, with the sub-command message. arguments: "any", - setup: setupRunCommand, + /** + * Undefined for `run|*all`, which targets every platform, except off macOS + * where only Android can be built. It is settled here, once per invocation, + * because `canExecute` and `run` have to agree on the platform. + */ + setup(context: RunCommandContext): string { + const $hostInfo = inject("hostInfo"); + + return $hostInfo.isDarwin ? undefined : runPlatformName(context, "Android"); + }, canExecute: canExecuteRunCommand, run: runRunCommand, shortcuts: runCommandShortcuts, @@ -179,28 +164,34 @@ export const runCommandDefinition = defineCommand({ async function canExecuteApplePlatformRunCommand( context: RunCommandContext, - services: IRunCommandServices, + platform: string, ): Promise { - const projectData = services.$projectDataService.getProjectData(); + const $errors = context.injector.get("errors"); + const $options = context.injector.get("options"); + const $platformValidationService = + context.injector.get( + "platformValidationService", + ); + const $projectDataService = + context.injector.get("projectDataService"); + + const projectData = $projectDataService.getProjectData(); if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.platform, - projectData, - ) + !$platformValidationService.isPlatformSupportedForOS(platform, projectData) ) { - services.$errors.fail( - `Applications for platform ${services.platform} can not be built on this OS`, + $errors.fail( + `Applications for platform ${platform} can not be built on this OS`, ); } const result = - (await canExecuteRunCommand(context, services)) && - (await services.$platformValidationService.validateOptions( - services.$options.provision, - services.$options.teamId, + (await canExecuteRunCommand(context, platform)) && + (await $platformValidationService.validateOptions( + $options.provision, + $options.teamId, projectData, - services.platform.toLowerCase(), + platform.toLowerCase(), )); return result; } @@ -214,10 +205,15 @@ const defineApplePlatformRunCommand = ( description: "Runs your project on a connected Apple device or simulator.", options: runCommandOptions, arguments: "any", - setup: setupPlatformRunCommand(platform), - canExecute: canExecuteApplePlatformRunCommand, - run: runRunCommand, - shortcuts: runCommandShortcuts, + canExecute: (context: RunCommandContext) => + canExecuteApplePlatformRunCommand( + context, + runPlatformName(context, platform), + ), + run: (context: RunCommandContext) => + runRunCommand(context, runPlatformName(context, platform)), + shortcuts: (context: RunCommandContext) => + runCommandShortcuts(context, runPlatformName(context, platform)), }); export const iosRunCommand = defineApplePlatformRunCommand("run|ios", "iOS"); @@ -232,24 +228,28 @@ export const androidRunCommand = defineCommand({ description: "Runs your project on a connected Android device or emulator.", options: runCommandOptions, arguments: "any", - setup: setupPlatformRunCommand("Android"), - async canExecute( - context: RunCommandContext, - services: IRunCommandServices, - ): Promise { + async canExecute(context: RunCommandContext): Promise { + const $errors = inject("errors"); + const $options = inject("options"); + const $platformValidationService = inject( + "platformValidationService", + ); + const $projectData = inject("projectData"); + const platform = runPlatformName(context, "Android"); + // The base verdict is dropped rather than combined with the checks below; // the base only ever returns true or throws, so the Android command has // always relied on it for its side effects alone. - await canExecuteRunCommand(context, services); + await canExecuteRunCommand(context, platform); if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.$devicePlatformsConstants.Android, - services.$projectData, + !$platformValidationService.isPlatformSupportedForOS( + platform, + $projectData, ) ) { - services.$errors.fail( - `Applications for platform ${services.$devicePlatformsConstants.Android} can not be built on this OS`, + $errors.fail( + `Applications for platform ${platform} can not be built on this OS`, ); } @@ -258,19 +258,21 @@ export const androidRunCommand = defineCommand({ !hasValidAndroidSigning(context.options) ) { if (context.options.release) { - services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - services.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } - return services.$platformValidationService.validateOptions( - services.$options.provision, - services.$options.teamId, - services.$projectData, - services.$devicePlatformsConstants.Android.toLowerCase(), + return $platformValidationService.validateOptions( + $options.provision, + $options.teamId, + $projectData, + platform.toLowerCase(), ); }, - run: runRunCommand, - shortcuts: runCommandShortcuts, + run: (context: RunCommandContext) => + runRunCommand(context, runPlatformName(context, "Android")), + shortcuts: (context: RunCommandContext) => + runCommandShortcuts(context, runPlatformName(context, "Android")), }); diff --git a/lib/commands/setup.ts b/lib/commands/setup.ts index 69e21a161e..17879c24db 100644 --- a/lib/commands/setup.ts +++ b/lib/commands/setup.ts @@ -7,10 +7,7 @@ export const setupCommandDefinition = defineCommand({ description: "Run the setup script to try to automatically configure your environment.", arguments: "none", - setup: () => ({ - $doctorService: inject("doctorService"), - }), - run(context, services): Promise { - return services.$doctorService.runSetupScript(); + run(): Promise { + return inject("doctorService").runSetupScript(); }, }); diff --git a/lib/commands/start.ts b/lib/commands/start.ts index d1ac27f997..9070b4770f 100644 --- a/lib/commands/start.ts +++ b/lib/commands/start.ts @@ -7,13 +7,11 @@ export const startCommandDefinition = defineCommand({ name: "start", description: "Starts the NativeScript interactive command line.", arguments: "any", - setup: () => ({ - $startService: inject("startService"), - }), - async run(context, services): Promise { + async run(): Promise { + const $startService = inject("startService"); printHeader(); // Left unawaited: the command returns while the service keeps running. - services.$startService.start(); + $startService.start(); return; }, }); diff --git a/lib/commands/test.ts b/lib/commands/test.ts index 2715f37d98..0829bb97ec 100644 --- a/lib/commands/test.ts +++ b/lib/commands/test.ts @@ -49,79 +49,66 @@ const testCommandOptions = { keyStoreAliasPassword: stringOption(), } satisfies CommandOptionsSchema; -export type TestCommandContext = CommandContext; +type TestCommandContext = CommandContext; -export function setupTestCommand(testPlatform: TestPlatform) { - return { - platform: testPlatform, - $analyticsService: inject("analyticsService"), - $cleanupService: inject("cleanupService"), - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $liveSyncCommandHelper: inject( - "liveSyncCommandHelper", - ), - $logger: inject("logger"), - $migrateController: inject("migrateController"), - $options: inject("options"), - $platformEnvironmentRequirements: inject( - "platformEnvironmentRequirements", - ), - $projectData: inject("projectData"), - $testExecutionService: inject( - "testExecutionService", - ), - $vitestExecutionService: inject( - "vitestExecutionService", - ), - }; -} - -export type ITestCommandServices = ReturnType; - -export async function canExecuteTestCommand( +async function canExecuteTestCommand( context: TestCommandContext, - services: ITestCommandServices, + platform: TestPlatform, ): Promise { + const $analyticsService = + context.injector.get("analyticsService"); + const $cleanupService = + context.injector.get("cleanupService"); + const $errors = context.injector.get("errors"); + const $migrateController = + context.injector.get("migrateController"); + const $options = context.injector.get("options"); + const $platformEnvironmentRequirements = + context.injector.get( + "platformEnvironmentRequirements", + ); + const $projectData = context.injector.get("projectData"); + const $testExecutionService = context.injector.get( + "testExecutionService", + ); + const $vitestExecutionService = context.injector.get( + "vitestExecutionService", + ); + if (!context.options.force) { if (context.options.hmr) { // With HMR we are not restarting after LiveSync which is causing a 30 seconds app start on Android // because the Runtime does not watch for the `/data/local/tmp-livesync-in-progress` file deletion. // The App is closing itself after each test execution and the bug will be reproducible on each LiveSync. - services.$errors.fail( - "The `--hmr` option is not supported for this command.", - ); + $errors.fail("The `--hmr` option is not supported for this command."); } - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, - platforms: [services.platform], + await $migrateController.validate({ + projectDir: $projectData.projectDir, + platforms: [platform], }); } - services.$projectData.initializeProjectData(); - services.$analyticsService.setShouldDispose( + $projectData.initializeProjectData(); + $analyticsService.setShouldDispose( context.options.justlaunch || !context.options.watch, ); - services.$cleanupService.setShouldDispose( + $cleanupService.setShouldDispose( context.options.justlaunch || !context.options.watch, ); const output = - await services.$platformEnvironmentRequirements.checkEnvironmentRequirements( - { - platform: services.platform, - projectDir: services.$projectData.projectDir, - options: services.$options, - }, - ); + await $platformEnvironmentRequirements.checkEnvironmentRequirements({ + platform, + projectDir: $projectData.projectDir, + options: $options, + }); - if (services.$vitestExecutionService.isVitestProject(services.$projectData)) { - const canStartTestRun = services.$vitestExecutionService.canStartTestRun( - services.$projectData, - ); + if ($vitestExecutionService.isVitestProject($projectData)) { + const canStartTestRun = + $vitestExecutionService.canStartTestRun($projectData); if (!canStartTestRun) { - services.$errors.fail({ + $errors.fail({ formatStr: "Error: In order to run unit tests, your project must already be configured by running $ ns test init.", errorCode: ErrorCodes.TESTS_INIT_REQUIRED, @@ -131,11 +118,9 @@ export async function canExecuteTestCommand( } const canStartKarmaServer = - await services.$testExecutionService.canStartKarmaServer( - services.$projectData, - ); + await $testExecutionService.canStartKarmaServer($projectData); if (!canStartKarmaServer) { - services.$errors.fail({ + $errors.fail({ formatStr: "Error: In order to run unit tests, your project must already be configured by running $ ns test init.", errorCode: ErrorCodes.TESTS_INIT_REQUIRED, @@ -145,57 +130,66 @@ export async function canExecuteTestCommand( return output.canExecute && canStartKarmaServer; } -export async function runTestCommand( +async function runTestCommand( context: TestCommandContext, - services: ITestCommandServices, + platform: TestPlatform, ): Promise { - if (services.$vitestExecutionService.isVitestProject(services.$projectData)) { - await services.$vitestExecutionService.startTestRun( - services.platform, - services.$projectData, - ); + const $devicesService = + context.injector.get("devicesService"); + const $liveSyncCommandHelper = context.injector.get( + "liveSyncCommandHelper", + ); + const $logger = context.injector.get("logger"); + const $options = context.injector.get("options"); + const $projectData = context.injector.get("projectData"); + const $testExecutionService = context.injector.get( + "testExecutionService", + ); + const $vitestExecutionService = context.injector.get( + "vitestExecutionService", + ); + + if ($vitestExecutionService.isVitestProject($projectData)) { + await $vitestExecutionService.startTestRun(platform, $projectData); process.exit(0); } - services.$logger.warn( + $logger.warn( "Karma-based unit testing is deprecated and will be removed in a future release. " + "Re-initialize your tests with '$ ns test init --framework vitest' to migrate.", ); let devices = []; if (context.options.debugBrk) { - await services.$devicesService.initialize({ - platform: services.platform, + await $devicesService.initialize({ + platform, deviceId: context.options.device, emulator: context.options.emulator, - skipInferPlatform: !services.platform, + skipInferPlatform: !platform, sdk: context.options.sdk, }); - const selectedDeviceForDebug = - await services.$devicesService.pickSingleDevice({ - onlyEmulators: context.options.emulator, - onlyDevices: context.options.forDevice, - deviceId: context.options.device, - }); + const selectedDeviceForDebug = await $devicesService.pickSingleDevice({ + onlyEmulators: context.options.emulator, + onlyDevices: context.options.forDevice, + deviceId: context.options.device, + }); devices = [selectedDeviceForDebug]; // const debugData = this.getDebugData(platform, projectData, deployOptions, { device: selectedDeviceForDebug.deviceInfo.identifier }); // await this.$debugService.debug(debugData, this.$options); } else { - devices = await services.$liveSyncCommandHelper.getDeviceInstances( - services.platform, - ); + devices = await $liveSyncCommandHelper.getDeviceInstances(platform); } // The bundler reads unitTesting off the shared options service, so the flag // is set there rather than on the command's own snapshot. - if (!services.$options.env) { - services.$options.env = {}; + if (!$options.env) { + $options.env = {}; } - services.$options.env.unitTesting = true; + $options.env.unitTesting = true; - const liveSyncInfo = services.$liveSyncCommandHelper.getLiveSyncData( - services.$projectData.projectDir, + const liveSyncInfo = $liveSyncCommandHelper.getLiveSyncData( + $projectData.projectDir, ); const deviceDebugMap: IDictionary = {}; @@ -205,14 +199,12 @@ export async function runTestCommand( ); const deviceDescriptors = - await services.$liveSyncCommandHelper.createDeviceDescriptors( - devices, - services.platform, - { deviceDebugMap }, - ); + await $liveSyncCommandHelper.createDeviceDescriptors(devices, platform, < + any + >{ deviceDebugMap }); - await services.$testExecutionService.startKarmaServer( - services.platform, + await $testExecutionService.startKarmaServer( + platform, liveSyncInfo, deviceDescriptors, ); @@ -226,9 +218,9 @@ export const testCommandDefinition = defineCommand({ options: testCommandOptions, // Arguments have never been rejected here, only ignored. arguments: "any", - setup: () => setupTestCommand("iOS"), - canExecute: canExecuteTestCommand, - run: runTestCommand, + canExecute: (context: TestCommandContext) => + canExecuteTestCommand(context, "iOS"), + run: (context: TestCommandContext) => runTestCommand(context, "iOS"), }); export const testAndroidCommandDefinition = defineCommand({ @@ -237,30 +229,26 @@ export const testAndroidCommandDefinition = defineCommand({ "Runs the tests in your project on connected Android devices or Android emulators.", options: testCommandOptions, arguments: "any", - setup: () => setupTestCommand("android"), - async canExecute( - context: TestCommandContext, - services: ITestCommandServices, - ): Promise { - const canExecuteBase = await canExecuteTestCommand(context, services); + async canExecute(context: TestCommandContext): Promise { + const $errors = inject("errors"); + + const canExecuteBase = await canExecuteTestCommand(context, "android"); if (canExecuteBase) { if ( (context.options.release || context.options.aab) && !hasValidAndroidSigning(context.options) ) { if (context.options.release) { - services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - services.$errors.failWithHelp( - ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, - ); + $errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } } return canExecuteBase; }, - run: runTestCommand, + run: (context: TestCommandContext) => runTestCommand(context, "android"), }); export const testVisionOSCommandDefinition = defineCommand({ @@ -269,23 +257,23 @@ export const testVisionOSCommandDefinition = defineCommand({ "Runs the tests in your project in the visionOS Simulator or on connected Apple Vision Pro devices.", options: testCommandOptions, arguments: "any", - setup: () => setupTestCommand("visionOS"), - async canExecute( - context: TestCommandContext, - services: ITestCommandServices, - ): Promise { - services.$projectData.initializeProjectData(); + async canExecute(context: TestCommandContext): Promise { + const $errors = inject("errors"); + const $projectData = inject("projectData"); + const $vitestExecutionService = inject( + "vitestExecutionService", + ); + + $projectData.initializeProjectData(); // The Karma runner (v4 line) never supported visionOS — only the Vitest // path can drive it. - if ( - !services.$vitestExecutionService.isVitestProject(services.$projectData) - ) { - services.$errors.fail( + if (!$vitestExecutionService.isVitestProject($projectData)) { + $errors.fail( "visionOS unit testing requires the Vitest runner. Run '$ ns test init --framework vitest' to configure your project.", ); } - return canExecuteTestCommand(context, services); + return canExecuteTestCommand(context, "visionOS"); }, - run: runTestCommand, + run: (context: TestCommandContext) => runTestCommand(context, "visionOS"), }); diff --git a/lib/commands/widget.ts b/lib/commands/widget.ts index 033d6942ff..1347e3ba4e 100644 --- a/lib/commands/widget.ts +++ b/lib/commands/widget.ts @@ -894,35 +894,29 @@ declare class AppleWidgetUtils extends NSObject { } } -interface IWidgetCommandServices { - generator: IOSWidgetGenerator; -} - // No flat "widget": the subcommand registration below synthesizes the parent // dispatcher. export const widgetIOSCommandDefinition = defineCommand({ name: "widget|ios", description: "Generates an iOS widget extension for the project.", arguments: "any", - setup(): IWidgetCommandServices { - const $projectData = inject("projectData"); - $projectData.initializeProjectData(); - - return { - generator: new IOSWidgetGenerator( - $projectData, - inject("projectConfigService"), - inject("logger"), - inject("errors"), - ), - }; - }, - canExecute(): boolean { - return true; + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); }, - run(context, services: IWidgetCommandServices): void { + run(ctx): void { + const $projectData = inject("projectData"); + + const generator = new IOSWidgetGenerator( + $projectData, + inject("projectConfigService"), + inject("logger"), + inject("errors"), + ); + // Not awaited: the command has always reported completion before the // prompts it opens are answered. - services.generator.startPrompt(context.args); + generator.startPrompt(ctx.args); }, }); diff --git a/lib/common/commands/analytics.ts b/lib/common/commands/analytics.ts index 3fee2c4041..ee1a4c50f7 100644 --- a/lib/common/commands/analytics.ts +++ b/lib/common/commands/analytics.ts @@ -18,30 +18,13 @@ interface IAnalyticsSetting { humanReadableSettingName: string; } -export const analyticsCommandOptions = { +const analyticsCommandOptions = { json: booleanOption(), } satisfies CommandOptionsSchema; -export type AnalyticsCommandContext = CommandContext< - typeof analyticsCommandOptions ->; +type AnalyticsCommandContext = CommandContext; -export function setupAnalyticsCommand(setting: IAnalyticsSetting) { - const $staticConfig = inject("staticConfig"); - - return { - settingName: $staticConfig[setting.staticConfigKey], - humanReadableSettingName: setting.humanReadableSettingName, - $analyticsService: inject("analyticsService"), - $logger: inject("logger"), - }; -} - -export type IAnalyticsCommandServices = ReturnType< - typeof setupAnalyticsCommand ->; - -export function validateAnalyticsState(value: string): boolean | string { +function validateAnalyticsState(value: string): boolean | string { switch ((value || "").toLowerCase()) { case "enable": case "disable": @@ -53,33 +36,35 @@ export function validateAnalyticsState(value: string): boolean | string { } } -export async function runAnalyticsCommand( +async function runAnalyticsCommand( context: AnalyticsCommandContext, - services: IAnalyticsCommandServices, + setting: IAnalyticsSetting, ): Promise { + const $analyticsService = inject("analyticsService"); + const $logger = inject("logger"); + const $staticConfig = inject("staticConfig"); + const settingName = $staticConfig[setting.staticConfigKey]; + const { humanReadableSettingName } = setting; + const arg = context.args[0] || ""; switch (arg.toLowerCase()) { case "enable": - await services.$analyticsService.setStatus(services.settingName, true); + await $analyticsService.setStatus(settingName, true); // TODO(Analytics): await this.$analyticsService.track(this.settingName, "enabled"); - services.$logger.info( - `${services.humanReadableSettingName} is now enabled.`, - ); + $logger.info(`${humanReadableSettingName} is now enabled.`); break; case "disable": // TODO(Analytics): await this.$analyticsService.track(this.settingName, "disabled"); - await services.$analyticsService.setStatus(services.settingName, false); - services.$logger.info( - `${services.humanReadableSettingName} is now disabled.`, - ); + await $analyticsService.setStatus(settingName, false); + $logger.info(`${humanReadableSettingName} is now disabled.`); break; case "status": case "": - services.$logger.info( - await services.$analyticsService.getStatusMessage( - services.settingName, + $logger.info( + await $analyticsService.getStatusMessage( + settingName, context.options.json, - services.humanReadableSettingName, + humanReadableSettingName, ), ); break; @@ -96,8 +81,7 @@ const defineAnalyticsCommand = ( options: analyticsCommandOptions, arguments: [{ name: "state", validate: validateAnalyticsState }], disableAnalytics: true, - setup: () => setupAnalyticsCommand(setting), - run: runAnalyticsCommand, + run: (context) => runAnalyticsCommand(context, setting), }); export const usageReportingCommand = defineAnalyticsCommand("usage-reporting", { diff --git a/lib/common/commands/autocompletion.ts b/lib/common/commands/autocompletion.ts index 774b75be67..1e47462689 100644 --- a/lib/common/commands/autocompletion.ts +++ b/lib/common/commands/autocompletion.ts @@ -3,52 +3,41 @@ import { IAutoCompletionService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -export function injectAutoCompleteCommandServices() { - return { - $autoCompletionService: inject( - "autoCompletionService", - ), - $logger: inject("logger"), - }; -} - -export type IAutoCompleteCommandServices = ReturnType< - typeof injectAutoCompleteCommandServices ->; - export const autoCompleteCommandDefinition = defineCommand({ name: "autocomplete|*default", description: "Prompts to enable command-line completion for the CLI.", arguments: "none", disableAnalytics: true, - setup: () => ({ - ...injectAutoCompleteCommandServices(), - $prompter: inject("prompter"), - }), - async run(context, services): Promise { + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); + const $prompter = inject("prompter"); + if (helpers.isInteractive()) { - if (services.$autoCompletionService.isAutoCompletionEnabled()) { - if (services.$autoCompletionService.isObsoleteAutoCompletionEnabled()) { + if ($autoCompletionService.isAutoCompletionEnabled()) { + if ($autoCompletionService.isObsoleteAutoCompletionEnabled()) { // obsolete autocompletion is enabled, update it to the new one: - await services.$autoCompletionService.enableAutoCompletion(); + await $autoCompletionService.enableAutoCompletion(); } else { - services.$logger.info("Autocompletion is already enabled"); + $logger.info("Autocompletion is already enabled"); } } else { - services.$logger.info( + $logger.info( "If you are using bash or zsh, you can enable command-line completion.", ); const message = "Do you want to enable it now?"; - const autoCompetionStatus = await services.$prompter.confirm( + const autoCompetionStatus = await $prompter.confirm( message, () => true, ); if (autoCompetionStatus) { - await services.$autoCompletionService.enableAutoCompletion(); + await $autoCompletionService.enableAutoCompletion(); } else { // make sure we've removed all autocompletion code from all shell profiles - services.$autoCompletionService.disableAutoCompletion(); + $autoCompletionService.disableAutoCompletion(); } } } @@ -60,12 +49,16 @@ export const disableAutoCompleteCommandDefinition = defineCommand({ description: "Disables command-line completion for the CLI.", arguments: "none", disableAnalytics: true, - setup: injectAutoCompleteCommandServices, - async run(context, services): Promise { - if (services.$autoCompletionService.isAutoCompletionEnabled()) { - services.$autoCompletionService.disableAutoCompletion(); + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); + + if ($autoCompletionService.isAutoCompletionEnabled()) { + $autoCompletionService.disableAutoCompletion(); } else { - services.$logger.info("Autocompletion is already disabled."); + $logger.info("Autocompletion is already disabled."); } }, }); @@ -75,12 +68,16 @@ export const enableAutoCompleteCommandDefinition = defineCommand({ description: "Enables command-line completion for the CLI.", arguments: "none", disableAnalytics: true, - setup: injectAutoCompleteCommandServices, - async run(context, services): Promise { - if (services.$autoCompletionService.isAutoCompletionEnabled()) { - services.$logger.info("Autocompletion is already enabled."); + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); + + if ($autoCompletionService.isAutoCompletionEnabled()) { + $logger.info("Autocompletion is already enabled."); } else { - await services.$autoCompletionService.enableAutoCompletion(); + await $autoCompletionService.enableAutoCompletion(); } }, }); @@ -90,12 +87,16 @@ export const autoCompleteStatusCommandDefinition = defineCommand({ description: "Prints whether command-line completion is enabled.", arguments: "none", disableAnalytics: true, - setup: injectAutoCompleteCommandServices, - async run(context, services): Promise { - if (services.$autoCompletionService.isAutoCompletionEnabled()) { - services.$logger.info("Autocompletion is enabled."); + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); + + if ($autoCompletionService.isAutoCompletionEnabled()) { + $logger.info("Autocompletion is enabled."); } else { - services.$logger.info("Autocompletion is disabled."); + $logger.info("Autocompletion is disabled."); } }, }); diff --git a/lib/common/commands/device/device-log-stream.ts b/lib/common/commands/device/device-log-stream.ts index ccc7f7ff12..730458e4c6 100644 --- a/lib/common/commands/device/device-log-stream.ts +++ b/lib/common/commands/device/device-log-stream.ts @@ -1,7 +1,6 @@ import { ICleanupService } from "../../../definitions/cleanup-service"; import { IErrors } from "../../declarations"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -15,58 +14,41 @@ const openDeviceLogStreamCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type OpenDeviceLogStreamCommandContext = CommandContext< - typeof openDeviceLogStreamCommandOptions ->; - -export function setupOpenDeviceLogStreamCommand() { - // The log stream is the command's whole output, so neither the simulator log - // provider nor the cleanup process may be torn down while it is open. The - // legacy command did this from its constructor, which ran before anything - // looked at the command line. - inject( - "iOSSimulatorLogProvider", - ).setShouldDispose(false); - inject("cleanupService").setShouldDispose(false); - - return { - $commandsService: inject("commandsService"), - $deviceLogProvider: inject("deviceLogProvider"), - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $loggingLevels: inject("loggingLevels"), - }; -} - -export type IOpenDeviceLogStreamCommandServices = ReturnType< - typeof setupOpenDeviceLogStreamCommand ->; - -export async function runOpenDeviceLogStreamCommand( - context: OpenDeviceLogStreamCommandContext, - services: IOpenDeviceLogStreamCommandServices, -): Promise { - services.$deviceLogProvider.setLogLevel(services.$loggingLevels.full); - - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - - if (services.$devicesService.deviceCount > 1) { - await services.$commandsService.tryExecuteCommand("device", []); - services.$errors.failWithHelp(NOT_SPECIFIED_DEVICE_ERROR_MESSAGE); - } - - const action = (device: Mobile.IiOSDevice) => device.openDeviceLogStream(); - await services.$devicesService.execute(action); -} - export const openDeviceLogStreamCommandDefinition = defineCommand({ name: ["device|log", "devices|log"], description: "Opens the device log stream for a connected device.", options: openDeviceLogStreamCommandOptions, arguments: "none", - setup: setupOpenDeviceLogStreamCommand, - run: runOpenDeviceLogStreamCommand, + // The log stream is the command's whole output, so neither the simulator log + // provider nor the cleanup process may be torn down while it is open. In + // setup, so the flags are set at the point in the invocation they always were. + setup(): void { + inject( + "iOSSimulatorLogProvider", + ).setShouldDispose(false); + inject("cleanupService").setShouldDispose(false); + }, + async run(context): Promise { + const $commandsService = inject("commandsService"); + const $deviceLogProvider = + inject("deviceLogProvider"); + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $loggingLevels = inject("loggingLevels"); + + $deviceLogProvider.setLogLevel($loggingLevels.full); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + + if ($devicesService.deviceCount > 1) { + await $commandsService.tryExecuteCommand("device", []); + $errors.failWithHelp(NOT_SPECIFIED_DEVICE_ERROR_MESSAGE); + } + + const action = (device: Mobile.IiOSDevice) => device.openDeviceLogStream(); + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/device/get-file.ts b/lib/common/commands/device/get-file.ts index 7265fe5f71..c6c2a2d760 100644 --- a/lib/common/commands/device/get-file.ts +++ b/lib/common/commands/device/get-file.ts @@ -1,7 +1,6 @@ import { IProjectData } from "../../../definitions/project"; import { IErrors } from "../../declarations"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -13,63 +12,47 @@ const getFileCommandOptions = { file: stringOption(), } satisfies CommandOptionsSchema; -export type GetFileCommandContext = CommandContext< - typeof getFileCommandOptions ->; - -export function setupGetFileCommand() { - return { - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $projectData: inject("projectData"), - }; -} - -export type IGetFileCommandServices = ReturnType; - -export async function runGetFileCommand( - context: GetFileCommandContext, - services: IGetFileCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - let appIdentifier = context.args[1]; - - if (!appIdentifier) { - try { - services.$projectData.initializeProjectData(); - } catch (err) { - // ignore the error - } - if (!services.$projectData.projectIdentifiers) { - services.$errors.fail( - "Please enter application identifier or execute this command in project.", - ); - } - } - - const action = async (device: Mobile.IDevice) => { - appIdentifier = - appIdentifier || - services.$projectData.projectIdentifiers[ - device.deviceInfo.platform.toLowerCase() - ]; - await device.fileSystem.getFile( - context.args[0], - appIdentifier, - context.options.file, - ); - }; - await services.$devicesService.execute(action); -} - export const getFileCommandDefinition = defineCommand({ name: ["device|get-file", "devices|get-file"], description: "Downloads a file from a connected device.", options: getFileCommandOptions, arguments: [{ name: "path" }, { name: "appId" }], - setup: setupGetFileCommand, - run: runGetFileCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $projectData = inject("projectData"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + let appIdentifier = context.args[1]; + + if (!appIdentifier) { + try { + $projectData.initializeProjectData(); + } catch (err) { + // ignore the error + } + if (!$projectData.projectIdentifiers) { + $errors.fail( + "Please enter application identifier or execute this command in project.", + ); + } + } + + const action = async (device: Mobile.IDevice) => { + appIdentifier = + appIdentifier || + $projectData.projectIdentifiers[ + device.deviceInfo.platform.toLowerCase() + ]; + await device.fileSystem.getFile( + context.args[0], + appIdentifier, + context.options.file, + ); + }; + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/device/list-applications.ts b/lib/common/commands/device/list-applications.ts index d67a9fc058..2b2a375353 100644 --- a/lib/common/commands/device/list-applications.ts +++ b/lib/common/commands/device/list-applications.ts @@ -2,7 +2,6 @@ import * as _ from "lodash"; import { EOL } from "os"; import * as util from "util"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -13,53 +12,37 @@ const listApplicationsCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type ListApplicationsCommandContext = CommandContext< - typeof listApplicationsCommandOptions ->; - -export function setupListApplicationsCommand() { - return { - $devicesService: inject("devicesService"), - $logger: inject("logger"), - }; -} - -export type IListApplicationsCommandServices = ReturnType< - typeof setupListApplicationsCommand ->; - -export async function runListApplicationsCommand( - context: ListApplicationsCommandContext, - services: IListApplicationsCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - const output: string[] = []; - - const action = async (device: Mobile.IDevice) => { - const applications = - await device.applicationManager.getInstalledApplications(); - output.push( - util.format( - "%s=====Installed applications on device with UDID '%s' are:", - EOL, - device.deviceInfo.identifier, - ), - ); - _.each(applications, (applicationId: string) => output.push(applicationId)); - }; - await services.$devicesService.execute(action); - - services.$logger.info(output.join(EOL)); -} - export const listApplicationsCommandDefinition = defineCommand({ name: ["device|list-applications", "devices|list-applications"], description: "Lists the installed applications on all connected devices.", options: listApplicationsCommandOptions, arguments: "none", - setup: setupListApplicationsCommand, - run: runListApplicationsCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $logger = inject("logger"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + const output: string[] = []; + + const action = async (device: Mobile.IDevice) => { + const applications = + await device.applicationManager.getInstalledApplications(); + output.push( + util.format( + "%s=====Installed applications on device with UDID '%s' are:", + EOL, + device.deviceInfo.identifier, + ), + ); + _.each(applications, (applicationId: string) => + output.push(applicationId), + ); + }; + await $devicesService.execute(action); + + $logger.info(output.join(EOL)); + }, }); diff --git a/lib/common/commands/device/list-files.ts b/lib/common/commands/device/list-files.ts index 37831a6cca..c20e591cd9 100644 --- a/lib/common/commands/device/list-files.ts +++ b/lib/common/commands/device/list-files.ts @@ -1,7 +1,6 @@ import { IProjectData } from "../../../definitions/project"; import { IErrors } from "../../declarations"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -12,62 +11,44 @@ const listFilesCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type ListFilesCommandContext = CommandContext< - typeof listFilesCommandOptions ->; - -export function setupListFilesCommand() { - return { - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $projectData: inject("projectData"), - }; -} - -export type IListFilesCommandServices = ReturnType< - typeof setupListFilesCommand ->; - -export async function runListFilesCommand( - context: ListFilesCommandContext, - services: IListFilesCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - const pathToList = context.args[0]; - let appIdentifier = context.args[1]; - - if (!appIdentifier) { - try { - services.$projectData.initializeProjectData(); - } catch (err) { - // ignore the error - } - if (!services.$projectData.projectIdentifiers) { - services.$errors.fail( - "Please enter application identifier or execute this command in project.", - ); - } - } - - const action = async (device: Mobile.IDevice) => { - appIdentifier = - appIdentifier || - services.$projectData.projectIdentifiers[ - device.deviceInfo.platform.toLowerCase() - ]; - await device.fileSystem.listFiles(pathToList, appIdentifier); - }; - await services.$devicesService.execute(action); -} - export const listFilesCommandDefinition = defineCommand({ name: ["device|list-files", "devices|list-files"], description: "Lists the files in a directory on a connected device.", options: listFilesCommandOptions, arguments: [{ name: "path" }, { name: "appId" }], - setup: setupListFilesCommand, - run: runListFilesCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $projectData = inject("projectData"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + const pathToList = context.args[0]; + let appIdentifier = context.args[1]; + + if (!appIdentifier) { + try { + $projectData.initializeProjectData(); + } catch (err) { + // ignore the error + } + if (!$projectData.projectIdentifiers) { + $errors.fail( + "Please enter application identifier or execute this command in project.", + ); + } + } + + const action = async (device: Mobile.IDevice) => { + appIdentifier = + appIdentifier || + $projectData.projectIdentifiers[ + device.deviceInfo.platform.toLowerCase() + ]; + await device.fileSystem.listFiles(pathToList, appIdentifier); + }; + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/device/put-file.ts b/lib/common/commands/device/put-file.ts index 043f4fc86d..bd658db719 100644 --- a/lib/common/commands/device/put-file.ts +++ b/lib/common/commands/device/put-file.ts @@ -1,7 +1,6 @@ import { IProjectData } from "../../../definitions/project"; import { IErrors } from "../../declarations"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -12,63 +11,47 @@ const putFileCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type PutFileCommandContext = CommandContext< - typeof putFileCommandOptions ->; - -export function setupPutFileCommand() { - return { - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $projectData: inject("projectData"), - }; -} - -export type IPutFileCommandServices = ReturnType; - -export async function runPutFileCommand( - context: PutFileCommandContext, - services: IPutFileCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - let appIdentifier = context.args[2]; - - if (!appIdentifier) { - try { - services.$projectData.initializeProjectData(); - } catch (err) { - // ignore the error - } - if (!services.$projectData.projectIdentifiers) { - services.$errors.fail( - "Please enter application identifier or execute this command in project.", - ); - } - } - - const action = async (device: Mobile.IDevice) => { - appIdentifier = - appIdentifier || - services.$projectData.projectIdentifiers[ - device.deviceInfo.platform.toLowerCase() - ]; - await device.fileSystem.putFile( - context.args[0], - context.args[1], - appIdentifier, - ); - }; - await services.$devicesService.execute(action); -} - export const putFileCommandDefinition = defineCommand({ name: ["device|put-file", "devices|put-file"], description: "Uploads a file to a connected device.", options: putFileCommandOptions, arguments: [{ name: "localPath" }, { name: "devicePath" }, { name: "appId" }], - setup: setupPutFileCommand, - run: runPutFileCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $projectData = inject("projectData"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + let appIdentifier = context.args[2]; + + if (!appIdentifier) { + try { + $projectData.initializeProjectData(); + } catch (err) { + // ignore the error + } + if (!$projectData.projectIdentifiers) { + $errors.fail( + "Please enter application identifier or execute this command in project.", + ); + } + } + + const action = async (device: Mobile.IDevice) => { + appIdentifier = + appIdentifier || + $projectData.projectIdentifiers[ + device.deviceInfo.platform.toLowerCase() + ]; + await device.fileSystem.putFile( + context.args[0], + context.args[1], + appIdentifier, + ); + }; + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/device/run-application.ts b/lib/common/commands/device/run-application.ts index b56f10c32a..78b97ed2ad 100644 --- a/lib/common/commands/device/run-application.ts +++ b/lib/common/commands/device/run-application.ts @@ -1,6 +1,5 @@ import { IErrors } from "../../declarations"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -11,53 +10,35 @@ const runApplicationOnDeviceCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type RunApplicationOnDeviceCommandContext = CommandContext< - typeof runApplicationOnDeviceCommandOptions ->; - -export function setupRunApplicationOnDeviceCommand() { - return { - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $staticConfig: inject("staticConfig"), - }; -} - -export type IRunApplicationOnDeviceCommandServices = ReturnType< - typeof setupRunApplicationOnDeviceCommand ->; - -export async function runRunApplicationOnDeviceCommand( - context: RunApplicationOnDeviceCommandContext, - services: IRunApplicationOnDeviceCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - - if (services.$devicesService.deviceCount > 1) { - services.$errors.failWithHelp( - "More than one device found. Specify device explicitly with --device option. To discover device ID, use $%s device command.", - services.$staticConfig.CLIENT_NAME.toLowerCase(), - ); - } - - await services.$devicesService.execute( - async (device: Mobile.IDevice) => - await device.applicationManager.startApplication({ - appId: context.args[0], - projectName: context.args[1], - projectDir: null, - }), - ); -} - export const runApplicationOnDeviceCommandDefinition = defineCommand({ name: ["device|run", "devices|run"], description: "Runs the selected application on a connected device.", options: runApplicationOnDeviceCommandOptions, arguments: [{ name: "appId" }, { name: "projectName" }], - setup: setupRunApplicationOnDeviceCommand, - run: runRunApplicationOnDeviceCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $staticConfig = inject("staticConfig"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + + if ($devicesService.deviceCount > 1) { + $errors.failWithHelp( + "More than one device found. Specify device explicitly with --device option. To discover device ID, use $%s device command.", + $staticConfig.CLIENT_NAME.toLowerCase(), + ); + } + + await $devicesService.execute( + async (device: Mobile.IDevice) => + await device.applicationManager.startApplication({ + appId: context.args[0], + projectName: context.args[1], + projectDir: null, + }), + ); + }, }); diff --git a/lib/common/commands/device/stop-application.ts b/lib/common/commands/device/stop-application.ts index d151c82538..e0463ca2df 100644 --- a/lib/common/commands/device/stop-application.ts +++ b/lib/common/commands/device/stop-application.ts @@ -1,5 +1,4 @@ import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -10,44 +9,26 @@ const stopApplicationOnDeviceCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type StopApplicationOnDeviceCommandContext = CommandContext< - typeof stopApplicationOnDeviceCommandOptions ->; - -export function setupStopApplicationOnDeviceCommand() { - return { - $devicesService: inject("devicesService"), - }; -} - -export type IStopApplicationOnDeviceCommandServices = ReturnType< - typeof setupStopApplicationOnDeviceCommand ->; - -export async function runStopApplicationOnDeviceCommand( - context: StopApplicationOnDeviceCommandContext, - services: IStopApplicationOnDeviceCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - platform: context.args[1], - }); - - const action = (device: Mobile.IDevice) => - device.applicationManager.stopApplication({ - appId: context.args[0], - projectName: context.args[2], - projectDir: null, - }); - await services.$devicesService.execute(action); -} - export const stopApplicationOnDeviceCommandDefinition = defineCommand({ name: ["device|stop", "devices|stop"], description: "Stops the selected application on a connected device.", options: stopApplicationOnDeviceCommandOptions, arguments: [{ name: "appId" }, { name: "platform" }, { name: "projectName" }], - setup: setupStopApplicationOnDeviceCommand, - run: runStopApplicationOnDeviceCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + platform: context.args[1], + }); + + const action = (device: Mobile.IDevice) => + device.applicationManager.stopApplication({ + appId: context.args[0], + projectName: context.args[2], + projectDir: null, + }); + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/device/uninstall-application.ts b/lib/common/commands/device/uninstall-application.ts index ca14a530e4..9d90e88f53 100644 --- a/lib/common/commands/device/uninstall-application.ts +++ b/lib/common/commands/device/uninstall-application.ts @@ -1,5 +1,4 @@ import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -10,39 +9,21 @@ const uninstallApplicationCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type UninstallApplicationCommandContext = CommandContext< - typeof uninstallApplicationCommandOptions ->; - -export function setupUninstallApplicationCommand() { - return { - $devicesService: inject("devicesService"), - }; -} - -export type IUninstallApplicationCommandServices = ReturnType< - typeof setupUninstallApplicationCommand ->; - -export async function runUninstallApplicationCommand( - context: UninstallApplicationCommandContext, - services: IUninstallApplicationCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - - const action = (device: Mobile.IDevice) => - device.applicationManager.uninstallApplication(context.args[0]); - await services.$devicesService.execute(action); -} - export const uninstallApplicationCommandDefinition = defineCommand({ name: ["device|uninstall", "devices|uninstall"], description: "Uninstalls an application from all connected devices.", options: uninstallApplicationCommandOptions, arguments: [{ name: "appId" }], - setup: setupUninstallApplicationCommand, - run: runUninstallApplicationCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + + const action = (device: Mobile.IDevice) => + device.applicationManager.uninstallApplication(context.args[0]); + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/doctor.ts b/lib/common/commands/doctor.ts index 1657545889..5d0b04fe3d 100644 --- a/lib/common/commands/doctor.ts +++ b/lib/common/commands/doctor.ts @@ -3,16 +3,6 @@ import { CommandName, defineCommand } from "../define-command"; import { inject } from "../di"; import { PlatformTypes } from "../../constants"; -export function setupDoctorCommand(platform?: PlatformTypes) { - return { - platform, - $doctorService: inject("doctorService"), - $projectHelper: inject("projectHelper"), - }; -} - -export type IDoctorCommandServices = ReturnType; - const defineDoctorCommand = ( name: TName, platform?: PlatformTypes, @@ -22,13 +12,15 @@ const defineDoctorCommand = ( description: "Checks the local environment for configuration issues, and prints what it finds.", arguments: "none", - setup: () => setupDoctorCommand(platform), - run(context, services): Promise { - return services.$doctorService.printWarnings({ + run(): Promise { + const $doctorService = inject("doctorService"); + const $projectHelper = inject("projectHelper"); + + return $doctorService.printWarnings({ trackResult: false, - projectDir: services.$projectHelper.projectDir, + projectDir: $projectHelper.projectDir, forceCheck: true, - ...(services.platform ? { platform: services.platform } : {}), + ...(platform ? { platform } : {}), }); }, }); diff --git a/lib/common/commands/generate-messages.ts b/lib/common/commands/generate-messages.ts index 80cc52dfb5..e508491a86 100644 --- a/lib/common/commands/generate-messages.ts +++ b/lib/common/commands/generate-messages.ts @@ -19,14 +19,13 @@ export const generateMessagesCommandDefinition = defineCommand({ description: "Regenerates the CLI's message contracts.", options: generateMessagesCommandOptions, arguments: "none", - setup: () => ({ - $fs: inject("fs"), - $messageContractGenerator: inject( + async run(context): Promise { + const $fs = inject("fs"); + const $messageContractGenerator = inject( "messageContractGenerator", - ), - }), - async run(context, services): Promise { - const result = await services.$messageContractGenerator.generate(); + ); + + const result = await $messageContractGenerator.generate(); const innerMessagesDirectory = path.join(__dirname, "../messages"); const outerMessagesDirectory = path.join(__dirname, "../.."); let interfaceFilePath: string; @@ -52,7 +51,7 @@ export const generateMessagesCommandDefinition = defineCommand({ ); } - services.$fs.writeFile(interfaceFilePath, result.interfaceFile); - services.$fs.writeFile(implementationFilePath, result.implementationFile); + $fs.writeFile(interfaceFilePath, result.interfaceFile); + $fs.writeFile(implementationFilePath, result.implementationFile); }, }); diff --git a/lib/common/commands/help.ts b/lib/common/commands/help.ts index 25b430dced..f04f04dccb 100644 --- a/lib/common/commands/help.ts +++ b/lib/common/commands/help.ts @@ -1,66 +1,44 @@ import * as _ from "lodash"; import { CommandRegistry } from "../contracts/command-registry"; import { IHelpService } from "../declarations"; -import { - booleanOption, - CommandContext, - CommandOptionsSchema, - defineCommand, -} from "../define-command"; +import { booleanOption, defineCommand } from "../define-command"; import { inject } from "../di"; -export const helpCommandOptions = { - help: booleanOption(), -} satisfies CommandOptionsSchema; - -export type HelpCommandContext = CommandContext; - -export function setupHelpCommand() { - return { - $commandRegistry: inject(CommandRegistry), - $helpService: inject("helpService"), - }; -} - -export type IHelpCommandServices = ReturnType; - -export async function runHelpCommand( - context: HelpCommandContext, - services: IHelpCommandServices, -): Promise { - const args = context.args; - let commandName = (args[0] || "").toLowerCase(); - let commandArguments = _.tail(args); - const hierarchicalCommand = - services.$commandRegistry.buildHierarchicalCommand( - args[0], - commandArguments, - ); - if (hierarchicalCommand) { - commandName = hierarchicalCommand.commandName; - commandArguments = hierarchicalCommand.remainingArguments; - } - - const commandData: ICommandData = { - commandName, - commandArguments, - }; - - if (context.options.help) { - await services.$helpService.showCommandLineHelp(commandData); - } else { - await services.$helpService.openHelpForCommandInBrowser(commandData); - } -} - export const helpCommandDefinition = defineCommand({ name: ["help", "/?"], description: "Shows the help for a command.", - options: helpCommandOptions, + options: { + help: booleanOption(), + }, // The command names whatever command it explains, so every argument after // the first is that command's own. arguments: "any", enableHooks: false, - setup: setupHelpCommand, - run: runHelpCommand, + async run(context): Promise { + const $commandRegistry = inject(CommandRegistry); + const $helpService = inject("helpService"); + + const args = context.args; + let commandName = (args[0] || "").toLowerCase(); + let commandArguments = _.tail(args); + const hierarchicalCommand = $commandRegistry.buildHierarchicalCommand( + args[0], + commandArguments, + ); + if (hierarchicalCommand) { + commandName = hierarchicalCommand.commandName; + commandArguments = hierarchicalCommand.remainingArguments; + } + + const commandData: ICommandData = { + commandName, + commandArguments, + }; + + if (context.options.help) { + await $helpService.showCommandLineHelp(commandData); + } else { + await $helpService.openHelpForCommandInBrowser(commandData); + } + }, }); diff --git a/lib/common/commands/package-manager-get.ts b/lib/common/commands/package-manager-get.ts index 95c8b20732..47c95460b1 100644 --- a/lib/common/commands/package-manager-get.ts +++ b/lib/common/commands/package-manager-get.ts @@ -2,28 +2,17 @@ import { IUserSettingsService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -export function setupPackageManagerGetCommand() { - return { - $logger: inject("logger"), - $userSettingsService: inject("userSettingsService"), - }; -} - -export type IPackageManagerGetCommandServices = ReturnType< - typeof setupPackageManagerGetCommand ->; - export const packageManagerGetCommandDefinition = defineCommand({ name: "package-manager|*get", description: "Prints the value of the current package manager.", - setup: setupPackageManagerGetCommand, - async run( - context, - services: IPackageManagerGetCommandServices, - ): Promise { - const result = - await services.$userSettingsService.getSettingValue("packageManager"); - services.$logger.printMarkdown( + async run(): Promise { + const $logger = inject("logger"); + const $userSettingsService = inject( + "userSettingsService", + ); + + const result = await $userSettingsService.getSettingValue("packageManager"); + $logger.printMarkdown( `Your current package manager is \`${result || "npm"}\`.`, ); }, diff --git a/lib/common/commands/package-manager-set.ts b/lib/common/commands/package-manager-set.ts index 9b831a6658..64d09c5a8a 100644 --- a/lib/common/commands/package-manager-set.ts +++ b/lib/common/commands/package-manager-set.ts @@ -3,46 +3,36 @@ import { IErrors, IUserSettingsService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -export function setupPackageManagerSetCommand() { - return { - $userSettingsService: inject("userSettingsService"), - $errors: inject("errors"), - $logger: inject("logger"), - }; -} - -export type IPackageManagerSetCommandServices = ReturnType< - typeof setupPackageManagerSetCommand ->; - export const packageManagerSetCommandDefinition = defineCommand({ name: "package-manager|set", description: "Sets the package manager the CLI installs dependencies with.", arguments: [{ name: "packageManager" }], - setup: setupPackageManagerSetCommand, - async run( - context, - services: IPackageManagerSetCommandServices, - ): Promise { + async run(context): Promise { + const $userSettingsService = inject( + "userSettingsService", + ); + const $errors = inject("errors"); + const $logger = inject("logger"); + const packageManagerName = context.args[0]; const supportedPackageManagers = Object.keys(PackageManagers); if (supportedPackageManagers.indexOf(packageManagerName) === -1) { - services.$errors.fail( + $errors.fail( `${packageManagerName} is not a valid package manager. Supported values are: ${supportedPackageManagers.join( ", ", )}.`, ); } - await services.$userSettingsService.saveSetting( + await $userSettingsService.saveSetting( "packageManager", packageManagerName, ); - services.$logger.printMarkdown( + $logger.printMarkdown( `Please ensure you have the directory containing \`${packageManagerName}\` executable available in your PATH.`, ); - services.$logger.printMarkdown( + $logger.printMarkdown( `You've successfully set \`${packageManagerName}\` as your package manager.`, ); }, diff --git a/lib/common/commands/post-install.ts b/lib/common/commands/post-install.ts index dfbdfe571d..fe027b6dd7 100644 --- a/lib/common/commands/post-install.ts +++ b/lib/common/commands/post-install.ts @@ -7,11 +7,10 @@ export const postInstallCommandDefinition = defineCommand({ description: "Deprecated; use `ns dev-post-install-cli`.", arguments: "none", disableAnalytics: true, - setup: () => ({ - $errors: inject("errors"), - }), - async run(context, services): Promise { - services.$errors.fail( + async run(): Promise { + const $errors = inject("errors"); + + $errors.fail( "This command is deprecated. Use `ns dev-post-install-cli` instead", ); }, diff --git a/lib/common/commands/preuninstall.ts b/lib/common/commands/preuninstall.ts index f26e01636e..7a1b823fb5 100644 --- a/lib/common/commands/preuninstall.ts +++ b/lib/common/commands/preuninstall.ts @@ -17,24 +17,6 @@ import { IExtensibilityService } from "../definitions/extensibility"; // disabled for now (6/24/2020) // const FEEDBACK_FORM_URL = "https://www.nativescript.org/uninstall-feedback"; -export function setupPreUninstallCommand() { - return { - $analyticsService: inject("analyticsService"), - $extensibilityService: inject( - "extensibilityService", - ), - $fs: inject("fs"), - $packageInstallationManager: inject( - "packageInstallationManager", - ), - $settingsService: inject("settingsService"), - }; -} - -export type IPreUninstallCommandServices = ReturnType< - typeof setupPreUninstallCommand ->; - async function handleFeedbackForm(): Promise { // disabled for now (6/24/2020) // if (isInteractive()) { @@ -44,10 +26,11 @@ async function handleFeedbackForm(): Promise { } async function handleIntentionalUninstall( - services: IPreUninstallCommandServices, + $extensibilityService: IExtensibilityService, + $packageInstallationManager: IPackageInstallationManager, ): Promise { - services.$extensibilityService.removeAllExtensions(); - services.$packageInstallationManager.clearInspectorCache(); + $extensibilityService.removeAllExtensions(); + $packageInstallationManager.clearInspectorCache(); await handleFeedbackForm(); } @@ -55,8 +38,17 @@ export const preUninstallCommandDefinition = defineCommand({ name: "dev-preuninstall", description: "Runs the CLI's own uninstall bookkeeping.", arguments: "none", - setup: setupPreUninstallCommand, - async run(context, services): Promise { + async run(): Promise { + const $analyticsService = inject("analyticsService"); + const $extensibilityService = inject( + "extensibilityService", + ); + const $fs = inject("fs"); + const $packageInstallationManager = inject( + "packageInstallationManager", + ); + const $settingsService = inject("settingsService"); + const isIntentionalUninstall = doesCurrentNpmCommandMatch([ /^uninstall$/, /^remove$/, @@ -66,22 +58,21 @@ export const preUninstallCommandDefinition = defineCommand({ /^unlink$/, ]); - await services.$analyticsService.trackEventActionInGoogleAnalytics({ + await $analyticsService.trackEventActionInGoogleAnalytics({ action: TrackActionNames.UninstallCLI, additionalData: `isIntentionalUninstall${AnalyticsEventLabelDelimiter}${isIntentionalUninstall}${AnalyticsEventLabelDelimiter}isInteractive${AnalyticsEventLabelDelimiter}${!!isInteractive()}`, }); if (isIntentionalUninstall) { - await handleIntentionalUninstall(services); + await handleIntentionalUninstall( + $extensibilityService, + $packageInstallationManager, + ); } - services.$fs.deleteFile( - path.join( - services.$settingsService.getProfileDir(), - "KillSwitches", - "cli", - ), + $fs.deleteFile( + path.join($settingsService.getProfileDir(), "KillSwitches", "cli"), ); - await services.$analyticsService.finishTracking(); + await $analyticsService.finishTracking(); }, }); diff --git a/lib/common/commands/proxy/proxy-base.ts b/lib/common/commands/proxy/proxy-base.ts index 222609d044..f7908b51a1 100644 --- a/lib/common/commands/proxy/proxy-base.ts +++ b/lib/common/commands/proxy/proxy-base.ts @@ -1,29 +1,14 @@ -import { IAnalyticsService, IProxyService } from "../../declarations"; -import { inject } from "../../di"; - -export function injectProxyCommandServices() { - return { - $analyticsService: inject("analyticsService"), - $logger: inject("logger"), - $proxyService: inject("proxyService"), - }; -} - -export type IProxyCommandServices = ReturnType< - typeof injectProxyCommandServices ->; - export async function tryTrackProxyCommandUsage( - services: IProxyCommandServices, + $logger: ILogger, commandName: string, ): Promise { try { // TODO(Analytics): Check why we have set the `disableAnalytics` to true and we track the command as separate one // instead of tracking it through the commandsService. - services.$logger.trace(commandName); - // await services.$analyticsService.trackFeature(commandName); + $logger.trace(commandName); + // await $analyticsService.trackFeature(commandName); } catch (ex) { - services.$logger.trace("Error in trying to track proxy command usage:"); - services.$logger.trace(ex); + $logger.trace("Error in trying to track proxy command usage:"); + $logger.trace(ex); } } diff --git a/lib/common/commands/proxy/proxy-clear.ts b/lib/common/commands/proxy/proxy-clear.ts index a48655f22f..80624762ab 100644 --- a/lib/common/commands/proxy/proxy-clear.ts +++ b/lib/common/commands/proxy/proxy-clear.ts @@ -1,9 +1,7 @@ +import { IProxyService } from "../../declarations"; import { defineCommand } from "../../define-command"; -import { - injectProxyCommandServices, - IProxyCommandServices, - tryTrackProxyCommandUsage, -} from "./proxy-base"; +import { inject } from "../../di"; +import { tryTrackProxyCommandUsage } from "./proxy-base"; const proxyClearCommandName = "proxy|clear"; @@ -12,10 +10,12 @@ export const proxyClearCommandDefinition = defineCommand({ description: "Clears the currently configured proxy settings.", arguments: "none", disableAnalytics: true, - setup: injectProxyCommandServices, - async run(context, services: IProxyCommandServices): Promise { - await services.$proxyService.clearCache(); - services.$logger.info("Successfully cleared proxy."); - await tryTrackProxyCommandUsage(services, proxyClearCommandName); + async run(): Promise { + const $logger = inject("logger"); + const $proxyService = inject("proxyService"); + + await $proxyService.clearCache(); + $logger.info("Successfully cleared proxy."); + await tryTrackProxyCommandUsage($logger, proxyClearCommandName); }, }); diff --git a/lib/common/commands/proxy/proxy-get.ts b/lib/common/commands/proxy/proxy-get.ts index 7325648ed2..263a1e70c2 100644 --- a/lib/common/commands/proxy/proxy-get.ts +++ b/lib/common/commands/proxy/proxy-get.ts @@ -1,9 +1,7 @@ +import { IProxyService } from "../../declarations"; import { defineCommand } from "../../define-command"; -import { - injectProxyCommandServices, - IProxyCommandServices, - tryTrackProxyCommandUsage, -} from "./proxy-base"; +import { inject } from "../../di"; +import { tryTrackProxyCommandUsage } from "./proxy-base"; const proxyGetCommandName = "proxy|*get"; @@ -12,9 +10,11 @@ export const proxyGetCommandDefinition = defineCommand({ description: "Prints the current proxy settings.", arguments: "none", disableAnalytics: true, - setup: injectProxyCommandServices, - async run(context, services: IProxyCommandServices): Promise { - services.$logger.info(await services.$proxyService.getInfo()); - await tryTrackProxyCommandUsage(services, proxyGetCommandName); + async run(): Promise { + const $logger = inject("logger"); + const $proxyService = inject("proxyService"); + + $logger.info(await $proxyService.getInfo()); + await tryTrackProxyCommandUsage($logger, proxyGetCommandName); }, }); From 0158722190ed92aa5743a4d7dce773d39451b750 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 19:37:28 -0300 Subject: [PATCH 17/19] docs(commands): describe where a handler gets its services Says that a handler resolves its own dependencies at its top, that services are never bundled or shared between commands, and that setup is optional sugar for one command. Documents canExecuteCommand as the way to reuse another command's precondition, and sharpens which authoring form fits which command. --- defining-commands.md | 192 +++++++++++++++++++++++++++---------------- 1 file changed, 120 insertions(+), 72 deletions(-) diff --git a/defining-commands.md b/defining-commands.md index 57951dcf5d..e7983816b2 100644 --- a/defining-commands.md +++ b/defining-commands.md @@ -419,73 +419,69 @@ resolves providers a child scope supplied — see [Registering a definition](#registering-a-definition). The same guidance, and the reasoning behind it, is in `dependency-injection.md`. -`setup` — hoisting work out of `run` ------------------------------------- +Where a handler gets its services +--------------------------------- -`setup(ctx)` runs once per invocation, before `canExecute`, and its return -value is handed to `canExecute`, `run` and `postRun` as their second argument: +A handler resolves what it needs itself, at the top of its own body: ```ts export default defineCommand({ name: "widget|add", arguments: "any", - setup() { + async run(ctx) { + const widgets = inject(WidgetService); const projectData = inject(ProjectData); + projectData.initializeProjectData(); - return { projectData, widgets: inject(WidgetService) }; - }, - canExecute(ctx, { projectData }) { - return !!projectData.projectDir; - }, - async run(ctx, { widgets }) { await widgets.add(ctx.args); }, }); ``` -It exists for two reasons. It is the place to inject services before the first -`await` when several handlers need them, and it is where the work a command -class used to do in its constructor goes — most often -`$projectData.initializeProjectData()`. - -`setup` is sugar. A command may ignore it entirely and call `inject()` at the -top of `run`; nothing else changes. "Once per invocation" means once across -`canExecute`, `run` and `postRun` together — whichever of them the CLI reaches -first triggers it, and the rest reuse the value. - -When several commands share a setup, or a helper outside the definition takes -the services as a parameter, lift it into a named function and derive the type -from it instead of writing the shape out by hand: - -```ts -export function setupWidgetAddCommand() { - const projectData = inject(ProjectData); - projectData.initializeProjectData(); - return { projectData, widgets: inject(WidgetService) }; -} -export type IWidgetAddCommandServices = ReturnType< - typeof setupWidgetAddCommand ->; - -export function canAddWidget(services: IWidgetAddCommandServices): boolean { - return !!services.projectData.projectDir; -} +The injection context is synchronous, so the `inject()` calls belong **above +the first `await`** — see [Injection, and the first +`await`](#injection-and-the-first-await). Resolve everything the handler needs +there and the rule never bites; for anything that genuinely has to wait — +resolved after an `await`, or inside a helper called later — use +`ctx.injector.get(token)`, which works at any point. + +**Services are never bundled.** There is no `setupXCommand()` returning an +object of injected services for another command to spread, and no +`IXCommandServices` type travelling between commands. A dependency is named +where it is used, so reading a handler tells you exactly what it touches. +Sharing is either of two things, and neither of them is a bag: + +- **Shared logic** — a plain function taking the typed `ctx` and plain values, + resolving its own services through `ctx.injector.get(...)`: + + ```ts + export async function canBuildFor( + ctx: CommandContext, + platform: string, + ): Promise { + const validation = ctx.injector.get(PlatformValidationService); + return validation.canBuild(platform); + } + ``` + +- **A whole command's precondition** — `canExecuteCommand(name, args)`, which + asks that command itself; see [Asking another + command](#asking-another-command). + +### `setup`, when a command has one -export default defineCommand({ - name: "widget|add", - arguments: "any", - setup: setupWidgetAddCommand, - canExecute: (ctx, services) => canAddWidget(services), - async run(ctx, { widgets }) { - await widgets.add(ctx.args); - }, -}); -``` - -Leave the setup function's return type off: the alias reads what the body -infers, so annotating the function with the alias makes the pair circular. Read -a setup curried over a parameter — `setupX(platform)` returning the setup -itself — through its inner function, `ReturnType>`. +`setup(ctx)` runs once per invocation, before `canExecute`, and its return +value is handed to `canExecute`, `run` and `postRun` as their second argument. +"Once per invocation" means once across the three together — whichever the CLI +reaches first triggers it, and the rest reuse the value. + +It is optional sugar for **one** command's own handlers, for the case where +`canExecute` and `run` would otherwise repeat the same per-invocation +derivation. It is never a place to assemble services for anything but the +command it belongs to, and a command with a single handler does not need it at +all. When a command has enough structure to want one, the +[class form](#class-form) usually says the same thing better: the instance *is* +the setup, and each dependency is a field. `run`'s return value, and `postRun` ----------------------------------- @@ -569,12 +565,19 @@ class does not declare is left out of the definition entirely, so a class without `postRun` gets no `postCommandAction`, exactly as an object without one does. -**Which form to use.** The class form is for a single named command. When a -function generates variants of one command — the `run|ios` / `run|vision` -family, one definition per platform — the object form is what fits, because -the thing being parameterized is a value and definitions are values. -Registering the same class twice under two names is not the equivalent: the -class is one definition. +**Which form to use.** The class form is for a single named command with +internal structure: state shared between `canExecute` and `run`, values derived +once per invocation, several private steps, or enough collaborators that +`this.$service` reads better than a local in every handler. Everything simpler +— a handful of services and a short handler — is an object definition with its +handlers written inline, where `ctx` is typed by inference and there is nothing +to name. + +When a function generates variants of one command — the `run|ios` / +`run|vision` family, one definition per platform — the object form is what +fits, because the thing being parameterized is a value and definitions are +values. Registering the same class twice under two names is not the +equivalent: the class is one definition. **The class is the setup.** One instance is constructed per invocation, as that invocation's `setup`, before `canExecute` runs. So field initializers and the @@ -597,26 +600,37 @@ the base class reads it. A provider registered for one command — through the `providers` argument of `registerCommand` or `registerLazyCommand` — can inject it too, and resolves nothing outside a running invocation. -**Share through functions, not base classes.** Two commands that need the same -services share an `inject()`-based helper, not a common ancestor: +**One field per dependency.** Each service the class uses is its own field, +read as `this.$x`: ```ts -export function injectPlatformCommandServices() { - const projectData = inject(ProjectData); - projectData.initializeProjectData(); - return { projectData, platformHelper: inject(PlatformCommandHelper) }; -} - export class PlatformAddCommand extends Command({ name: "platform|add" }) { - private services = injectPlatformCommandServices(); + private $projectData = inject("projectData"); + private $platformHelper = inject( + "platformCommandHelper", + ); + + constructor() { + super(); + this.$projectData.initializeProjectData(); + } // ... } ``` -A helper composes — a command can call two of them — and it stays readable -without the reader walking a chain of files. A base class between `Command()` -and the command does not: it is the pattern the legacy `ICommand` hierarchy -used, and untangling it is most of why this API exists. +Never a `private services = injectSomething()` holding a bag — the fields are +the point, and a bag puts the dependency list back behind one more hop. Two +commands needing the same four services restate those four lines; that +duplication is cheaper than a shared shape neither of them owns. + +**Share logic, not base classes and not services.** What two commands genuinely +have in common is a check or a step, so share a function that takes +`this.context` and plain values and resolves its own services — see [Where a +handler gets its services](#where-a-handler-gets-its-services). To reuse +another command's precondition whole, ask that command: [Asking another +command](#asking-another-command). A base class between `Command()` and the +command is the pattern the legacy `ICommand` hierarchy used, and untangling it +is most of why this API exists. Registration takes the class itself; see [Registering a definition](#registering-a-definition): @@ -848,6 +862,40 @@ the injector of the current injection context, and the CLI's own outside one. `runCommand` is a thin call onto `CommandsService.executeCommandInProcess`, where the pipeline itself lives. +### Asking another command + +`canExecuteCommand(name, args)` asks a registered command whether it *could* +run, without running it: + +```ts +import { canExecuteCommand } from "../common/services/command-definition-adapter"; + +async canExecute(): Promise { + if (!(await canExecuteCommand("prepare", [this.args[0]]))) { + return false; + } + + return !!this.hostProjectPath; +} +``` + +This is how one command builds on another's precondition. `embed` prepares the +project, so "could `embed` run" starts with "could `prepare` run" — and the way +to ask that is to ask `prepare`, not to import its `canExecute` and hand it +services. The named command is resolved and its options primed exactly as +`runCommand` does, then its own `canExecute` returns the verdict. It builds its +own setup from its own services; nothing crosses between the two commands but +the name and the arguments. + +Pass only the arguments the child's own `arguments` policy accepts. The child +enforces that policy before its `canExecute`, so forwarding a caller's whole +argument list to a child that declares fewer is a rejection, not a wider check. + +`canExecuteCommand` is a thin call onto +`CommandsService.canExecuteCommandInProcess`, and follows `runCommand` in +everything else: the same injector rule, the same option priming and +restoration. + ### Key shortcuts The interactive keys `ns start` and `ns run` offer are the CLI's own caller. A From 25f15ec381c996635a64bf4fc14bf28eb33c7e95 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 20:16:52 -0300 Subject: [PATCH 18/19] feat(commands): make the in-process dispatcher a contract CommandsService is the API a command or plugin runs or consults another command through: runCommand and canExecuteCommand take the registered name or the definition or class it was registered from. The free helpers stay as convenience over it; the *InProcess methods are deprecated. --- defining-commands.md | 28 ++++++-- lib/commands/embedding/embed.ts | 13 +++- lib/commands/post-install.ts | 5 +- .../commands/device/device-log-stream.ts | 5 +- lib/common/contracts/commands-service.ts | 45 +++++++++++++ lib/common/contracts/index.ts | 1 + lib/common/define-command.ts | 22 ++++++ lib/common/definitions/commands-service.d.ts | 14 +++- .../services/command-definition-adapter.ts | 43 +++++------- lib/common/services/commands-service.ts | 60 ++++++++++++----- lib/contracts/index.ts | 3 + test/commands/post-install.ts | 6 +- test/define-command.ts | 67 +++++++++++++++++++ test/services/key-shortcuts.ts | 17 ++--- test/stubs.ts | 18 ++++- 15 files changed, 277 insertions(+), 70 deletions(-) create mode 100644 lib/common/contracts/commands-service.ts diff --git a/defining-commands.md b/defining-commands.md index e7983816b2..f015b19f2d 100644 --- a/defining-commands.md +++ b/defining-commands.md @@ -824,12 +824,20 @@ per-platform command subclasses a shared base to override one field. Running a command in process ---------------------------- -`runCommand` dispatches a registered command from inside the process that is -already running: +The `CommandsService` contract dispatches a registered command from inside the +process that is already running. A class command injects it like any other +service; an inline handler or a key shortcut may use the `runCommand` +convenience, which only resolves the contract from the current context: ```ts +import { CommandsService } from "../common/contracts/commands-service"; import { runCommand } from "../common/services/command-definition-adapter"; +// in a class command +private $commandsService = inject(CommandsService); +await this.$commandsService.runCommand("autocomplete"); + +// in an inline handler or a shortcut action await runCommand("open|ios"); await runCommand("install", ["lodash"]); ``` @@ -857,14 +865,20 @@ declarations into it rewrites the values the host process is still running on — `open|ios` declares `watch: false`, which would otherwise leave an `ns start` out of watch mode for the rest of its life. -Which injector it dispatches through follows the rule `registerCommand` does: -the injector of the current injection context, and the CLI's own outside one. -`runCommand` is a thin call onto `CommandsService.executeCommandInProcess`, -where the pipeline itself lives. +Which injector `runCommand` dispatches through follows the rule +`registerCommand` does: the injector of the current injection context, and the +CLI's own outside one. The pipeline itself lives on the contract, so a plugin +that holds an injector can call `CommandsService.runCommand` directly. ### Asking another command -`canExecuteCommand(name, args)` asks a registered command whether it *could* +Both methods take the command's registered name, or — the typed way — the +definition or `Command()` class it was registered from, whose first name is +used: `runCommand(prepareCommandDefinition)` cannot go stale the way a string +can. + +`CommandsService.canExecuteCommand(command, args)` — or the +`canExecuteCommand` convenience — asks a registered command whether it *could* run, without running it: ```ts diff --git a/lib/commands/embedding/embed.ts b/lib/commands/embedding/embed.ts index 111522c98f..a39b361c19 100644 --- a/lib/commands/embedding/embed.ts +++ b/lib/commands/embedding/embed.ts @@ -7,7 +7,11 @@ import { IFileSystem } from "../../common/declarations"; import { inject } from "../../common/di"; import { canExecuteCommand } from "../../common/services/command-definition-adapter"; import { platformArgument } from "../command-base"; -import { prepareCommandOptions, runPrepareCommand } from "../prepare"; +import { + prepareCommandDefinition, + prepareCommandOptions, + runPrepareCommand, +} from "../prepare"; function resolveHostProjectPath( projectDir: string, @@ -52,7 +56,12 @@ export class EmbedCommand extends Command({ public async canExecute(): Promise { // `prepare` takes the platform alone; the host project arguments are this // command's own and it would reject them. - if (!(await canExecuteCommand("prepare", this.args.slice(0, 1)))) { + if ( + !(await canExecuteCommand( + prepareCommandDefinition, + this.args.slice(0, 1), + )) + ) { return false; } diff --git a/lib/commands/post-install.ts b/lib/commands/post-install.ts index 2731c152dd..8206cdec7d 100644 --- a/lib/commands/post-install.ts +++ b/lib/commands/post-install.ts @@ -6,6 +6,7 @@ import { IHostInfo, ISettingsService, } from "../common/declarations"; +import { CommandsService } from "../common/contracts/commands-service"; import { Command } from "../common/define-command"; import { inject } from "../common/di"; import { doesCurrentNpmCommandMatch } from "../common/helpers"; @@ -16,7 +17,7 @@ export class PostInstallCliCommand extends Command({ disableAnalytics: true, }) { private $fs = inject("fs"); - private $commandsService = inject("commandsService"); + private $commandsService = inject(CommandsService); private $helpService = inject("helpService"); private $settingsService = inject("settingsService"); private $analyticsService = inject("analyticsService"); @@ -47,7 +48,7 @@ export class PostInstallCliCommand extends Command({ // Explicitly ask for confirmation of usage-reporting: await this.$analyticsService.checkConsent(); - await this.$commandsService.tryExecuteCommand("autocomplete", []); + await this.$commandsService.runCommand("autocomplete"); } } diff --git a/lib/common/commands/device/device-log-stream.ts b/lib/common/commands/device/device-log-stream.ts index 730458e4c6..ad165f6bc7 100644 --- a/lib/common/commands/device/device-log-stream.ts +++ b/lib/common/commands/device/device-log-stream.ts @@ -1,4 +1,5 @@ import { ICleanupService } from "../../../definitions/cleanup-service"; +import { CommandsService } from "../../contracts/commands-service"; import { IErrors } from "../../declarations"; import { CommandOptionsSchema, @@ -29,7 +30,7 @@ export const openDeviceLogStreamCommandDefinition = defineCommand({ inject("cleanupService").setShouldDispose(false); }, async run(context): Promise { - const $commandsService = inject("commandsService"); + const $commandsService = inject(CommandsService); const $deviceLogProvider = inject("deviceLogProvider"); const $devicesService = inject("devicesService"); @@ -44,7 +45,7 @@ export const openDeviceLogStreamCommandDefinition = defineCommand({ }); if ($devicesService.deviceCount > 1) { - await $commandsService.tryExecuteCommand("device", []); + await $commandsService.runCommand("device"); $errors.failWithHelp(NOT_SPECIFIED_DEVICE_ERROR_MESSAGE); } diff --git a/lib/common/contracts/commands-service.ts b/lib/common/contracts/commands-service.ts new file mode 100644 index 0000000000..d19dfb379a --- /dev/null +++ b/lib/common/contracts/commands-service.ts @@ -0,0 +1,45 @@ +import { Contract } from "../di/contract"; +import type { CommandReference } from "../define-command"; + +/** + * Dispatches commands inside the running process: the surface a command, a + * key shortcut or a plugin uses to run or consult another command. The command + * line's own entry points into the dispatcher are not part of it. + */ +@Contract({ name: "commandsService" }) +export abstract class CommandsService { + /** + * Whether the command running now was dispatched in process rather than by + * the command line — what tells a command it is borrowing a host process + * instead of owning one. + */ + abstract readonly isExecutingInProcess: boolean; + + /** + * Runs a registered command in the current process. The command gets what a + * typed command line gives it — its declared options primed with their + * defaults, the arguments policy, `canExecute`, hooks and `postRun` — and a + * failure throws instead of exiting, so a process that has to keep running + * can catch it. Analytics do not fire: this is not a new CLI invocation. + * + * `command` is the registered name, or the definition or `Command()` class + * it was registered from — the typed way to refer to a command. + */ + abstract runCommand( + command: CommandReference, + args?: string[], + ): Promise; + + /** + * Asks a registered command whether it could run on `args`, without running + * it. The command is resolved and its options primed exactly as for + * `runCommand`, and its own `canExecute` returns the verdict. The child + * builds its own setup from its own services, so nothing crosses between + * the two but the name and the arguments; pass only the arguments the + * child's own `arguments` policy accepts. + */ + abstract canExecuteCommand( + command: CommandReference, + args?: string[], + ): Promise; +} diff --git a/lib/common/contracts/index.ts b/lib/common/contracts/index.ts index 6c5d9ec26f..3267f1d173 100644 --- a/lib/common/contracts/index.ts +++ b/lib/common/contracts/index.ts @@ -15,5 +15,6 @@ export type { DeferredCommandResult, } from "./command-registry"; export { COMMAND_CONTEXT } from "./command-context"; +export { CommandsService } from "./commands-service"; export { ModuleRegistry } from "./module-registry"; export { PublicApiBuilder } from "./public-api-builder"; diff --git a/lib/common/define-command.ts b/lib/common/define-command.ts index 43031f5d70..9e600064b5 100644 --- a/lib/common/define-command.ts +++ b/lib/common/define-command.ts @@ -762,6 +762,28 @@ export function toCommandDefinition( return isCommandDefinition(value) ? value : null; } +/** + * What a dispatcher accepts in place of a command name: the name itself, or + * the definition or class it was registered from, whose first name is used. + */ +export type CommandReference = string | RegisterableCommand; + +export function commandNameOf(command: CommandReference): string { + if (typeof command === "string") { + return command; + } + + const definition = toCommandDefinition(command); + if (!definition) { + throw new Error( + `${describeDefinition(command)} is neither a command name, a ` + + `defineCommand() definition nor a Command() class.`, + ); + } + + return Array.isArray(definition.name) ? definition.name[0] : definition.name; +} + /** * The class authoring form: sugar over defineCommand, not a second execution * path. The returned base carries a `definition` that reads the class it is diff --git a/lib/common/definitions/commands-service.d.ts b/lib/common/definitions/commands-service.d.ts index 39a1cbcd98..f6ed426658 100644 --- a/lib/common/definitions/commands-service.d.ts +++ b/lib/common/definitions/commands-service.d.ts @@ -19,14 +19,24 @@ interface ICommandsService { * Runs a command inside the running process, throwing on failure rather * than exiting, so a long-lived host survives it. */ - executeCommandInProcess( - commandName: string, + runCommand( + command: import("../define-command").CommandReference, commandArguments?: string[], ): Promise; /** * Asks a command whether it could run, without running it. The command * builds its own setup from its own services. */ + canExecuteCommand( + command: import("../define-command").CommandReference, + commandArguments?: string[], + ): Promise; + /** @deprecated Use `runCommand`. */ + executeCommandInProcess( + commandName: string, + commandArguments?: string[], + ): Promise; + /** @deprecated Use `canExecuteCommand`. */ canExecuteCommandInProcess( commandName: string, commandArguments?: string[], diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index 10047daf25..b967131d1a 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -6,6 +6,7 @@ import { Injector } from "../di/injector"; import { IDictionary, IDashedOption, IErrors } from "../declarations"; import { ICommand } from "../definitions/commands"; import { COMMAND_CONTEXT } from "../contracts/command-context"; +import { CommandsService } from "../contracts/commands-service"; import { COMMAND_OWNER, CommandRegistry, @@ -29,6 +30,7 @@ import { CommandOptionSpec, CommandOptionType, CommandOptionsSchema, + CommandReference, DefinedCommand, RegisterableCommand, defineCommand, @@ -403,10 +405,9 @@ export function createCommandFromDefinition< return; } - const commandsService = targetInjector.get( - "commandsService", - { optional: true }, - ); + const commandsService = targetInjector.get(CommandsService, { + optional: true, + }); if (commandsService && commandsService.isExecutingInProcess) { return; } @@ -543,40 +544,28 @@ const contextInjector = (): Injector => getCurrentInjector() || (getRootInjector()); /** - * Runs a registered command in the current process. The command gets what a - * typed command line gives it — its declared options primed with their - * defaults, the arguments policy, `canExecute`, hooks and `postRun` — and a - * failure throws instead of exiting, so a process that has to keep running - * (`ns start`, dispatching a key shortcut) can catch it. + * Convenience over `CommandsService.runCommand` for code that has no injected + * service at hand, such as a key shortcut action or an inline handler; the + * contract is the API, this only resolves it from the current context. */ export async function runCommand( - name: string, + command: CommandReference, args: string[] = [], ): Promise { - const commandsService = - contextInjector().get("commandsService"); - - await commandsService.executeCommandInProcess(name, args); + await contextInjector().get(CommandsService).runCommand(command, args); } /** - * Asks a registered command whether it could run on `args`, without running it. - * The named command is resolved and its options primed exactly as `runCommand` - * does, and its own `canExecute` returns the verdict. - * - * This is how one command reuses another's precondition — `embed` asking - * whether `prepare` would run. The child resolves its own services, so nothing - * crosses between the two but the name and the arguments; pass only the - * arguments the child's own `arguments` policy accepts. + * Convenience over `CommandsService.canExecuteCommand`, resolved from the + * current context the way `runCommand` is. */ export async function canExecuteCommand( - name: string, + command: CommandReference, args: string[] = [], ): Promise { - const commandsService = - contextInjector().get("commandsService"); - - return commandsService.canExecuteCommandInProcess(name, args); + return contextInjector() + .get(CommandsService) + .canExecuteCommand(command, args); } /** diff --git a/lib/common/services/commands-service.ts b/lib/common/services/commands-service.ts index eaa797860b..432215e585 100644 --- a/lib/common/services/commands-service.ts +++ b/lib/common/services/commands-service.ts @@ -10,6 +10,8 @@ import { IInjector } from "../definitions/yok"; import { injector } from "../yok"; import { IExtensibilityService } from "../definitions/extensibility"; import { IGoogleAnalyticsPageviewData } from "../definitions/google-analytics"; +import { CommandsService as CommandsServiceContract } from "../contracts/commands-service"; +import { CommandReference, commandNameOf } from "../define-command"; import { ICommandParameter, ICommand, @@ -27,7 +29,10 @@ class CommandArgumentsValidationHelper { public remainingArguments: string[]; } -export class CommandsService implements ICommandsService { +export class CommandsService + extends CommandsServiceContract + implements ICommandsService +{ public get currentCommandData(): ICommandData { return _.last(this.commands); } @@ -48,7 +53,9 @@ export class CommandsService implements ICommandsService { private $staticConfig: Config.IStaticConfig, private $extensibilityService: IExtensibilityService, private $optionsTracker: IOptionsTracker, - ) {} + ) { + super(); + } public allCommands(opts: { includeDevCommands: boolean }): string[] { const commands = this.$injector.getRegisteredCommandsNames( @@ -191,7 +198,7 @@ export class CommandsService implements ICommandsService { ); } - return this.canExecuteCommand(commandName, commandArguments); + return this.canExecuteResolvedCommand(commandName, commandArguments); } public async tryExecuteCommand( @@ -239,10 +246,11 @@ export class CommandsService implements ICommandsService { * Analytics stay out of it: this is not a new CLI invocation, and * `checkConsent` may prompt on a terminal the caller has put in raw mode. */ - public async executeCommandInProcess( - commandName: string, + public async runCommand( + command: CommandReference, commandArguments: string[] = [], ): Promise { + const commandName = commandNameOf(command); this.inProcessDepth++; try { const command = this.$injector.resolveCommand(commandName); @@ -255,7 +263,9 @@ export class CommandsService implements ICommandsService { this.commands.push({ commandName, commandArguments }); const restoreOptions = this.primeOptions(command); try { - if (!(await this.canExecuteCommand(commandName, commandArguments))) { + if ( + !(await this.canExecuteResolvedCommand(commandName, commandArguments)) + ) { let commandWithArgs = commandName; if (commandArguments && commandArguments.length) { commandWithArgs += ` ${commandArguments.join(" ")}`; @@ -284,16 +294,17 @@ export class CommandsService implements ICommandsService { } /** - * The `canExecute` half of {@link executeCommandInProcess}: the named command - * is resolved and its options are primed the same way, and its own - * `canExecute` returns the verdict. The child builds its own setup from its - * own services — nothing is threaded in from the caller — which is what lets - * one command reuse another's precondition without importing its handlers. + * The `canExecute` half of {@link runCommand}: the named command is resolved + * and its options are primed the same way, and its own `canExecute` returns + * the verdict. The child builds its own setup from its own services — + * nothing is threaded in from the caller — which is what lets one command + * reuse another's precondition without importing its handlers. */ - public async canExecuteCommandInProcess( - commandName: string, + public async canExecuteCommand( + command: CommandReference, commandArguments: string[] = [], ): Promise { + const commandName = commandNameOf(command); this.inProcessDepth++; try { const command = this.$injector.resolveCommand(commandName); @@ -306,7 +317,10 @@ export class CommandsService implements ICommandsService { this.commands.push({ commandName, commandArguments }); const restoreOptions = this.primeOptions(command); try { - return await this.canExecuteCommand(commandName, commandArguments); + return await this.canExecuteResolvedCommand( + commandName, + commandArguments, + ); } finally { restoreOptions(); this.commands.pop(); @@ -316,6 +330,22 @@ export class CommandsService implements ICommandsService { } } + /** @deprecated Use {@link runCommand}. */ + public executeCommandInProcess( + commandName: string, + commandArguments: string[] = [], + ): Promise { + return this.runCommand(commandName, commandArguments); + } + + /** @deprecated Use {@link canExecuteCommand}. */ + public canExecuteCommandInProcess( + commandName: string, + commandArguments: string[] = [], + ): Promise { + return this.canExecuteCommand(commandName, commandArguments); + } + /** * Merging a command's options into the parser rewrites the values the host * process is still running on: a declared default replaces the CLI-wide one @@ -341,7 +371,7 @@ export class CommandsService implements ICommandsService { }; } - private async canExecuteCommand( + private async canExecuteResolvedCommand( commandName: string, commandArguments: string[], isDynamicCommand?: boolean, diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts index db83a537a9..54aac0ffa3 100644 --- a/lib/contracts/index.ts +++ b/lib/contracts/index.ts @@ -91,6 +91,9 @@ export type { // 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"; +// The in-process dispatcher a command or plugin runs or consults other +// commands through. +export { CommandsService } from "../common/contracts/commands-service"; export { defineHook, isHookDefinition } from "../common/define-hook"; export type { HookContext, diff --git a/test/commands/post-install.ts b/test/commands/post-install.ts index 4a7b31f216..b0dd9ca476 100644 --- a/test/commands/post-install.ts +++ b/test/commands/post-install.ts @@ -17,7 +17,7 @@ const createTestInjector = (): IInjector => { testInjector.register("staticConfig", {}); testInjector.register("commandsService", { - tryExecuteCommand: async ( + runCommand: async ( commandName: string, commandArguments: string[], ): Promise => undefined, @@ -85,7 +85,7 @@ describe("post-install command", () => { const commandsService = testInjector.resolve("commandsService"); let isTryExecuteCommandCalled = false; - commandsService.tryExecuteCommand = async (): Promise => { + commandsService.runCommand = async (): Promise => { isTryExecuteCommandCalled = true; }; @@ -110,7 +110,7 @@ describe("post-install command", () => { assert.equal( isTryExecuteCommandCalled, opts.shouldCallMethod, - `post-install-cli command must ${hasNotInMsg} call commandsService.tryExecuteCommand`, + `post-install-cli command must ${hasNotInMsg} call commandsService.runCommand`, ); }; diff --git a/test/define-command.ts b/test/define-command.ts index 67241c2d2b..6eb18c2030 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -14,6 +14,7 @@ import { CommandRegistry, DeferredCommandResult, } from "../lib/common/contracts/command-registry"; +import { CommandsService as CommandsServiceContract } from "../lib/common/contracts/commands-service"; import { CommandsService } from "../lib/common/services/commands-service"; import { Options } from "../lib/options"; import { Errors } from "../lib/common/errors"; @@ -1405,6 +1406,72 @@ describe("defineCommand", () => { assert.isFalse(ran); }); + it("is the CommandsService contract's method, resolved by the registered name", async () => { + const testInjector = createInProcessInjector(); + let ran = false; + + runInInjectionContext(testInjector, () => { + registerCommand( + defineCommand({ + name: "dctest-can-contract", + arguments: "any", + canExecute: (context) => context.args[0] === "ok", + run: () => { + ran = true; + }, + }), + ); + }); + + const service = testInjector.get(CommandsServiceContract); + assert.instanceOf(service, CommandsServiceContract); + assert.isTrue( + await service.canExecuteCommand("dctest-can-contract", ["ok"]), + ); + assert.isFalse(ran); + + await service.runCommand("dctest-can-contract", ["ok"]); + assert.isTrue(ran); + }); + + it("takes the definition or class in place of the name", async () => { + const testInjector = createInProcessInjector(); + const runs: string[] = []; + const definition = defineCommand({ + name: ["dctest-ref-primary", "dctest-ref-alias"], + arguments: "any", + canExecute: (context) => context.args[0] === "ok", + run: () => { + runs.push("definition"); + }, + }); + class RefCommand extends Command({ + name: "dctest-ref-class", + arguments: "any", + }) { + run(): void { + runs.push("class"); + } + } + + runInInjectionContext(testInjector, () => { + registerCommand(definition); + registerCommand(RefCommand); + }); + const service = testInjector.get(CommandsServiceContract); + + assert.isTrue(await service.canExecuteCommand(definition, ["ok"])); + assert.isFalse(await service.canExecuteCommand(definition, ["no"])); + await service.runCommand(definition, ["ok"]); + await service.runCommand(RefCommand); + assert.deepEqual(runs, ["definition", "class"]); + + await assert.isRejected( + service.runCommand({ name: "not-a-definition" }), + /neither a command name/, + ); + }); + it("enforces the child's arguments policy before its canExecute", async () => { const testInjector = createInProcessInjector(); let consulted = false; diff --git a/test/services/key-shortcuts.ts b/test/services/key-shortcuts.ts index fe7a05ee1a..4be80230ae 100644 --- a/test/services/key-shortcuts.ts +++ b/test/services/key-shortcuts.ts @@ -1,6 +1,7 @@ import { assert } from "chai"; import { EventEmitter } from "events"; import { RunOnDeviceEvents } from "../../lib/constants"; +import { getContractName } from "../../lib/common/di/contract"; import { runInInjectionContext } from "../../lib/common/di/inject"; import { Injector } from "../../lib/common/di/injector"; import { runCommand } from "../../lib/common/services/command-definition-adapter"; @@ -41,8 +42,10 @@ class FakeStdin extends EventEmitter { const fakeInjector = ( registrations: Map = new Map(), -): Injector => - ({ get: (token: any) => registrations.get(token) }); +): Injector => ({ + get: (token: any) => + registrations.get(token) ?? registrations.get(getContractName(token)), + }); const baseContext = (): KeyContextBase => ({ injector: fakeInjector() }); @@ -217,7 +220,7 @@ describe("key shortcuts", () => { [ "commandsService", { - executeCommandInProcess: async (name: string): Promise => + runCommand: async (name: string): Promise => void invoked.push(name), }, ], @@ -958,10 +961,8 @@ describe("key shortcuts", () => { [ "commandsService", { - executeCommandInProcess: async ( - name: string, - args: string[], - ): Promise => void dispatched.push({ name, args }), + runCommand: async (name: string, args: string[]): Promise => + void dispatched.push({ name, args }), }, ], ]), @@ -984,7 +985,7 @@ describe("key shortcuts", () => { [ "commandsService", { - executeCommandInProcess: async (): Promise => { + runCommand: async (): Promise => { throw new Error("Unable to execute command 'open ios'."); }, }, diff --git a/test/stubs.ts b/test/stubs.ts index 96d60ca966..8011d1c0be 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -1328,20 +1328,34 @@ export class CommandsService implements ICommandsService { return Promise.resolve(true); } - public executeCommandInProcess( + public runCommand( commandName: string, commandArguments?: string[], ): Promise { return Promise.resolve(); } - public canExecuteCommandInProcess( + public canExecuteCommand( commandName: string, commandArguments?: string[], ): Promise { return Promise.resolve(true); } + public executeCommandInProcess( + commandName: string, + commandArguments?: string[], + ): Promise { + return this.runCommand(commandName, commandArguments); + } + + public canExecuteCommandInProcess( + commandName: string, + commandArguments?: string[], + ): Promise { + return this.canExecuteCommand(commandName, commandArguments); + } + public completeCommand(): Promise { return Promise.resolve(true); } From 71c4324c90feedc53adb611de777b371fb2963d8 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 14 Sep 2026 20:46:55 -0300 Subject: [PATCH 19/19] fix(commands): run a definition passed to the dispatcher as given A name is looked up in the registry; a definition or Command() class is built and run as the caller holds it, registered or not, its first name serving only hooks and reporting. --- defining-commands.md | 9 +-- lib/common/contracts/commands-service.ts | 5 +- lib/common/define-command.ts | 20 +------ lib/common/services/commands-service.ts | 76 ++++++++++++++++++------ test/define-command.ts | 21 +++++-- 5 files changed, 82 insertions(+), 49 deletions(-) diff --git a/defining-commands.md b/defining-commands.md index f015b19f2d..6821842d08 100644 --- a/defining-commands.md +++ b/defining-commands.md @@ -872,10 +872,11 @@ that holds an injector can call `CommandsService.runCommand` directly. ### Asking another command -Both methods take the command's registered name, or — the typed way — the -definition or `Command()` class it was registered from, whose first name is -used: `runCommand(prepareCommandDefinition)` cannot go stale the way a string -can. +Both methods take a registered name, or — the typed way — a definition or +`Command()` class. A name is looked up in the registry; a definition runs as +given, whether or not it is registered, so `runCommand(prepareCommandDefinition)` +runs exactly what you hold and cannot go stale the way a string can. Its first +name still identifies it for hooks and reporting. `CommandsService.canExecuteCommand(command, args)` — or the `canExecuteCommand` convenience — asks a registered command whether it *could* diff --git a/lib/common/contracts/commands-service.ts b/lib/common/contracts/commands-service.ts index d19dfb379a..0cb64462db 100644 --- a/lib/common/contracts/commands-service.ts +++ b/lib/common/contracts/commands-service.ts @@ -22,8 +22,9 @@ export abstract class CommandsService { * failure throws instead of exiting, so a process that has to keep running * can catch it. Analytics do not fire: this is not a new CLI invocation. * - * `command` is the registered name, or the definition or `Command()` class - * it was registered from — the typed way to refer to a command. + * `command` is a registered name, looked up in the registry, or a + * definition or `Command()` class, which runs as given whether or not it is + * registered — the typed way to refer to a command. */ abstract runCommand( command: CommandReference, diff --git a/lib/common/define-command.ts b/lib/common/define-command.ts index 9e600064b5..0f985e449a 100644 --- a/lib/common/define-command.ts +++ b/lib/common/define-command.ts @@ -763,27 +763,11 @@ export function toCommandDefinition( } /** - * What a dispatcher accepts in place of a command name: the name itself, or - * the definition or class it was registered from, whose first name is used. + * What a dispatcher accepts: a registered command's name, or a definition or + * class to run as given. */ export type CommandReference = string | RegisterableCommand; -export function commandNameOf(command: CommandReference): string { - if (typeof command === "string") { - return command; - } - - const definition = toCommandDefinition(command); - if (!definition) { - throw new Error( - `${describeDefinition(command)} is neither a command name, a ` + - `defineCommand() definition nor a Command() class.`, - ); - } - - return Array.isArray(definition.name) ? definition.name[0] : definition.name; -} - /** * The class authoring form: sugar over defineCommand, not a second execution * path. The returned base carries a `definition` that reads the class it is diff --git a/lib/common/services/commands-service.ts b/lib/common/services/commands-service.ts index 432215e585..f66ac774b4 100644 --- a/lib/common/services/commands-service.ts +++ b/lib/common/services/commands-service.ts @@ -11,7 +11,8 @@ import { injector } from "../yok"; import { IExtensibilityService } from "../definitions/extensibility"; import { IGoogleAnalyticsPageviewData } from "../definitions/google-analytics"; import { CommandsService as CommandsServiceContract } from "../contracts/commands-service"; -import { CommandReference, commandNameOf } from "../define-command"; +import { CommandReference, toCommandDefinition } from "../define-command"; +import { createCommandFromDefinition } from "./command-definition-adapter"; import { ICommandParameter, ICommand, @@ -247,24 +248,28 @@ export class CommandsService * `checkConsent` may prompt on a terminal the caller has put in raw mode. */ public async runCommand( - command: CommandReference, + reference: CommandReference, commandArguments: string[] = [], ): Promise { - const commandName = commandNameOf(command); + // Known before the lookup, so a failure to resolve reports under the name + // the caller used. + let commandName = typeof reference === "string" ? reference : undefined; this.inProcessDepth++; try { - const command = this.$injector.resolveCommand(commandName); - if (!command) { - this.$errors.failWithHelp( - `Unknown command '${helpers.stringReplaceAll(commandName, "|", " ")}'.`, - ); - } + const resolved = this.resolveReference(reference); + const command = resolved.command; + commandName = resolved.commandName; this.commands.push({ commandName, commandArguments }); const restoreOptions = this.primeOptions(command); try { if ( - !(await this.canExecuteResolvedCommand(commandName, commandArguments)) + !(await this.canExecuteResolvedCommand( + commandName, + commandArguments, + undefined, + command, + )) ) { let commandWithArgs = commandName; if (commandArguments && commandArguments.length) { @@ -301,18 +306,12 @@ export class CommandsService * reuse another's precondition without importing its handlers. */ public async canExecuteCommand( - command: CommandReference, + reference: CommandReference, commandArguments: string[] = [], ): Promise { - const commandName = commandNameOf(command); this.inProcessDepth++; try { - const command = this.$injector.resolveCommand(commandName); - if (!command) { - this.$errors.failWithHelp( - `Unknown command '${helpers.stringReplaceAll(commandName, "|", " ")}'.`, - ); - } + const { commandName, command } = this.resolveReference(reference); this.commands.push({ commandName, commandArguments }); const restoreOptions = this.primeOptions(command); @@ -320,6 +319,8 @@ export class CommandsService return await this.canExecuteResolvedCommand( commandName, commandArguments, + undefined, + command, ); } finally { restoreOptions(); @@ -352,6 +353,42 @@ export class CommandsService * and the host keeps reading the replacement long after the command is * done. An in-process dispatch has to put the parser back where it found it. */ + /** + * A name is looked up in the registry; a definition or class is run as the + * caller holds it, registered or not, so what runs is what was referenced. + * Its first name still identifies it for hooks and reporting. + */ + private resolveReference(reference: CommandReference): { + commandName: string; + command: ICommand; + } { + if (typeof reference === "string") { + const command = this.$injector.resolveCommand(reference); + if (!command) { + this.$errors.failWithHelp( + `Unknown command '${helpers.stringReplaceAll(reference, "|", " ")}'.`, + ); + } + + return { commandName: reference, command }; + } + + const definition = toCommandDefinition(reference); + if (!definition) { + throw new Error( + "Expected a command name, a defineCommand() definition or a " + + "Command() class to run.", + ); + } + + return { + commandName: Array.isArray(definition.name) + ? definition.name[0] + : definition.name, + command: createCommandFromDefinition(definition, this.$injector), + }; + } + private primeOptions(command: ICommand): () => void { if (command.isHierarchicalCommand) { return () => undefined; @@ -375,8 +412,9 @@ export class CommandsService commandName: string, commandArguments: string[], isDynamicCommand?: boolean, + resolved?: ICommand, ): Promise { - const command = this.$injector.resolveCommand(commandName); + const command = resolved || this.$injector.resolveCommand(commandName); const beautifiedName = helpers.stringReplaceAll(commandName, "|", " "); if (command) { // Verify command is enabled diff --git a/test/define-command.ts b/test/define-command.ts index 6eb18c2030..1f28916dd7 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -1434,7 +1434,7 @@ describe("defineCommand", () => { assert.isTrue(ran); }); - it("takes the definition or class in place of the name", async () => { + it("runs a definition or class as given, registered or not", async () => { const testInjector = createInProcessInjector(); const runs: string[] = []; const definition = defineCommand({ @@ -1453,10 +1453,18 @@ describe("defineCommand", () => { runs.push("class"); } } - + // Registered under the same name as the definition, to show the + // definition wins over the lookup. runInInjectionContext(testInjector, () => { - registerCommand(definition); - registerCommand(RefCommand); + registerCommand( + defineCommand({ + name: "dctest-ref-primary", + arguments: "any", + run: () => { + runs.push("registered"); + }, + }), + ); }); const service = testInjector.get(CommandsServiceContract); @@ -1464,11 +1472,12 @@ describe("defineCommand", () => { assert.isFalse(await service.canExecuteCommand(definition, ["no"])); await service.runCommand(definition, ["ok"]); await service.runCommand(RefCommand); - assert.deepEqual(runs, ["definition", "class"]); + await service.runCommand("dctest-ref-primary"); + assert.deepEqual(runs, ["definition", "class", "registered"]); await assert.isRejected( service.runCommand({ name: "not-a-definition" }), - /neither a command name/, + /Expected a command name/, ); });