diff --git a/defining-commands.md b/defining-commands.md index ef7fc024c1..6821842d08 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 --------------------------------------- @@ -59,10 +63,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 +137,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 +156,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`: + +```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. -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`. +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 +205,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.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.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. +- `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.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.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. ### `canExecute` refines, it does not replace @@ -230,8 +338,14 @@ The run context - `ctx.args` — `string[]`, the positional arguments left after the command name (including any subcommand segments) has been consumed. +- `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 + [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 +379,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 +398,115 @@ 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`. + +Where a handler gets its services +--------------------------------- + +A handler resolves what it needs itself, at the top of its own body: + +```ts +export default defineCommand({ + name: "widget|add", + arguments: "any", + async run(ctx) { + const widgets = inject(WidgetService); + const projectData = inject(ProjectData); + + projectData.initializeProjectData(); + await widgets.add(ctx.args); + }, +}); +``` + +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 + +`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` +----------------------------------- + +`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.params.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 ----------- @@ -296,37 +518,423 @@ 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 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 +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. + +**One field per dependency.** Each service the class uses is its own field, +read as `this.$x`: + +```ts +export class PlatformAddCommand extends Command({ name: "platform|add" }) { + private $projectData = inject("projectData"); + private $platformHelper = inject( + "platformCommandHelper", + ); + + constructor() { + super(); + this.$projectData.initializeProjectData(); + } + // ... +} +``` + +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): + +```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 ------------------------ -Inside the CLI, a definition is registered with `registerCommandDefinition`: +Inside the CLI, a definition is registered with `registerCommand`: + +```ts +import { registerCommand } from "../common/services/command-definition-adapter"; + +registerCommand({ + name: "widget|add", + options: { force: booleanOption({ default: false }) }, + run: async (ctx) => { … }, +}); +``` + +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 +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 -import { registerCommandDefinition } from "../common/services/command-definition-adapter"; -import addWidgetCommand from "./add-widget"; +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. -registerCommandDefinition(addWidgetCommand); +**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 with providers that +carry the value: + +```ts +const PLATFORM = new InjectionToken("commandPlatform"); + +for (const platform of ["android", "ios"]) { + registerCommand({ ...definition, name: `run|${platform}` }, [ + { 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. + +Running a command in process +---------------------------- + +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"]); +``` + +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 `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 + +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* +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 +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` -------------------------- @@ -339,14 +947,20 @@ mapping is: | `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/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 634c916e47..154a325910 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,64 +175,209 @@ 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").CreateProjectCommand +>("create", () => require("./commands/create-project").CreateProjectCommand); +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").AddPlatformCommand +>("platform|add", () => require("./commands/add-platform").AddPlatformCommand); +registerBuiltInCommand< + typeof import("./commands/remove-platform").removePlatformCommandDefinition +>( + "platform|remove", + () => require("./commands/remove-platform").removePlatformCommandDefinition, +); +registerBuiltInCommand< + typeof import("./commands/update-platform").UpdatePlatformCommand +>( + "platform|update", + () => require("./commands/update-platform").UpdatePlatformCommand, +); +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( + "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( + "typings", + () => require("./commands/typings").TypingsCommand, +); + +registerBuiltInCommand( + "preview", + () => require("./commands/preview").PreviewCommand, +); + +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").EmbedCommand +>("embed", () => require("./commands/embedding/embed").EmbedCommand); injector.require("testExecutionService", "./services/test-execution-service"); 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"); -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( + "test|init", + () => require("./commands/test-init").TestInitCommand, +); +registerBuiltInCommand< + typeof import("./commands/generate-help").generateHelpCommandDefinition +>( + "dev-generate-help", + () => require("./commands/generate-help").generateHelpCommandDefinition, +); + +registerBuiltInCommand< + typeof import("./commands/appstore-list").ListiOSAppsCommand +>( + "appstore|*list", + () => require("./commands/appstore-list").ListiOSAppsCommand, +); +registerBuiltInCommand< + typeof import("./commands/appstore-upload").PublishIOSCommand +>( + "appstore|upload", + () => require("./commands/appstore-upload").PublishIOSCommand, +); +registerBuiltInCommand< + typeof import("./commands/appstore-upload").PublishIOSCommand +>("publish|ios", () => require("./commands/appstore-upload").PublishIOSCommand); +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"); @@ -233,13 +385,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( @@ -260,44 +420,111 @@ 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").BuildPluginCommand +>( + "plugin|build", + () => require("./commands/plugin/build-plugin").BuildPluginCommand, +); +registerBuiltInCommand< + typeof import("./commands/plugin/create-plugin").CreatePluginCommand +>( + "plugin|create", + () => require("./commands/plugin/create-plugin").CreatePluginCommand, +); + +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").PlatformCleanCommand +>( + "platform|clean", + () => require("./commands/platform-clean").PlatformCleanCommand, +); injector.require( "androidBundleValidatorHelper", @@ -340,9 +567,19 @@ 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").PostInstallCliCommand +>( + "post-install-cli", + () => require("./commands/post-install").PostInstallCliCommand, +); +registerBuiltInCommand< + typeof import("./commands/migrate").migrateCommandDefinition +>("migrate", () => require("./commands/migrate").migrateCommandDefinition); +registerBuiltInCommand( + "update", + () => require("./commands/update").UpdateCommand, +); injector.require("iOSLogFilter", "./services/ios-log-filter"); injector.require("logSourceMapService", "./services/log-source-map-service"); @@ -355,17 +592,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", @@ -386,13 +635,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,19 +715,43 @@ injector.require("tempService", "./services/temp-service"); injector.require("sharedEventBus", "./shared-event-bus"); -injector.require("keyCommandHelper", "./helpers/key-command-helper"); +injector.require("keyShortcutRegistry", "./services/key-shortcut-registry"); +injector.require("keyShortcutService", "./services/key-shortcuts"); -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"); -require("./key-commands/bootstrap"); +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); diff --git a/lib/commands/add-platform.ts b/lib/commands/add-platform.ts index fdf6b93eb4..049978876e 100644 --- a/lib/commands/add-platform.ts +++ b/lib/commands/add-platform.ts @@ -1,50 +1,47 @@ -import { ValidatePlatformCommandBase } from "./command-base"; -import { IProjectData } from "../definitions/project"; +import { canExecuteCommandBase } from "./command-base"; import { - IOptions, IPlatformCommandHelper, IPlatformValidationService, } from "../declarations"; -import { IPlatformsDataService } from "../definitions/platform"; -import { ICommandParameter, ICommand } from "../common/definitions/commands"; +import { IProjectData } from "../definitions/project"; import { IErrors } from "../common/declarations"; -import { injector } from "../common/yok"; +import { + Command, + CommandOptionsSchema, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; -export class AddPlatformCommand - extends ValidatePlatformCommandBase - implements ICommand -{ - public allowedParameters: ICommandParameter[] = []; +const addPlatformCommandOptions = { + frameworkPath: stringOption(), +} satisfies CommandOptionsSchema; - constructor( - $options: IOptions, - private $platformCommandHelper: IPlatformCommandHelper, - $platformValidationService: IPlatformValidationService, - $projectData: IProjectData, - $platformsDataService: IPlatformsDataService, - private $errors: IErrors - ) { - super( - $options, - $platformsDataService, - $platformValidationService, - $projectData - ); - this.$projectData.initializeProjectData(); - } +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"); - public async execute(args: string[]): Promise { - await this.$platformCommandHelper.addPlatforms( - args, - this.$projectData, - this.$options.frameworkPath - ); + constructor() { + super(); + this.$projectData.initializeProjectData(); } - public async canExecute(args: string[]): Promise { + 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." + "No platform specified. Please specify a platform to add.", ); } @@ -55,19 +52,27 @@ export class AddPlatformCommand if ( !this.$platformValidationService.isPlatformSupportedForOS( arg, - this.$projectData + this.$projectData, ) ) { this.$errors.fail( - `Applications for platform ${arg} cannot be built on this OS` + `Applications for platform ${arg} cannot be built on this OS`, ); } - canExecute = await super.canExecuteCommandBase(arg); + // The assignment overwrites the previous platform's verdict, so only the + // last one decides. + canExecute = await canExecuteCommandBase(this.context, arg); } return canExecute; } -} -injector.registerCommand("platform|add", AddPlatformCommand); + public async run(): Promise { + await this.$platformCommandHelper.addPlatforms( + this.args, + this.$projectData, + this.options.frameworkPath, + ); + } +} diff --git a/lib/commands/apple-login.ts b/lib/commands/apple-login.ts index c6e45c5326..4002080743 100644 --- a/lib/commands/apple-login.ts +++ b/lib/commands/apple-login.ts @@ -1,49 +1,43 @@ -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 { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; import { IApplePortalSessionService } from "../services/apple-portal/definitions"; -export class AppleLogin implements ICommand { - public allowedParameters: ICommandParameter[] = [ - new StringCommandParameter(this.$injector), - new StringCommandParameter(this.$injector), - ]; +export const appleLoginCommandDefinition = defineCommand({ + name: "apple-login", + description: "Logs in to an Apple account and prints the session cookie.", + arguments: [{ name: "appleId" }, { name: "password" }], + async run(context) { + const $applePortalSessionService = inject( + "applePortalSessionService", + ); + const $errors = inject("errors"); + const $logger = inject("logger"); + const $prompter = inject("prompter"); - 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]; + let username = context.args[0]; if (!username) { - username = await this.$prompter.getString("Apple ID", { + username = await $prompter.getString("Apple ID", { allowEmpty: false, }); } - let password = args[1]; + let password = context.args[1]; if (!password) { - password = await this.$prompter.getPassword("Apple ID password"); + password = await $prompter.getPassword("Apple ID password"); } - const user = await this.$applePortalSessionService.createUserSession({ + const user = await $applePortalSessionService.createUserSession({ username, password, }); if (!user.areCredentialsValid) { - this.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.` + $errors.fail( + `Invalid username and password combination. Used '${username}' as the username.`, ); } const output = Buffer.from(user.userSessionCookie).toString("base64"); - this.$logger.info(output); - } -} -injector.registerCommand("apple-login", AppleLogin); + $logger.info(output); + }, +}); diff --git a/lib/commands/appstore-list.ts b/lib/commands/appstore-list.ts index 910d038b57..1279a3b27f 100644 --- a/lib/commands/appstore-list.ts +++ b/lib/commands/appstore-list.ts @@ -1,51 +1,63 @@ +import { IErrors } from "../common/declarations"; +import { + Command, + CommandOptionsSchema, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; import { createTable } from "../common/helpers"; -import { StringCommandParameter } from "../common/command-params"; +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; - 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 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"); + + constructor() { + super(); this.$projectData.initializeProjectData(); } - public async execute(args: string[]): Promise { + public async run(): Promise { if ( !this.$platformValidationService.isPlatformSupportedForOS( this.$devicePlatformsConstants.iOS, - this.$projectData + this.$projectData, ) ) { this.$errors.fail( - `Applications for platform ${this.$devicePlatformsConstants.iOS} can not be built on this OS` + `Applications for platform ${this.$devicePlatformsConstants.iOS} can not be built on this OS`, ); } - let username = args[0]; - let password = args[1]; + let username = this.args[0]; + let password = this.args[1]; if (!username) { username = await this.$prompter.getString("Apple ID", { @@ -60,18 +72,17 @@ export class ListiOSApps implements ICommand { const user = await this.$applePortalSessionService.createUserSession( { username, password }, { - sessionBase64: this.$options.appleSessionBase64, - } + sessionBase64: this.options.appleSessionBase64, + }, ); if (!user.areCredentialsValid) { this.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.` + `Invalid username and password combination. Used '${username}' as the username.`, ); } - const applications = await this.$applePortalApplicationService.getApplications( - user - ); + const applications = + await this.$applePortalApplicationService.getApplications(user); if (!applications || !applications.length) { this.$logger.info("Seems you don't have any applications yet."); @@ -87,12 +98,10 @@ export class ListiOSApps implements ICommand { application.versionSets[0].inFlightVersion.version) || ""; return [application.name, application.bundleId, version]; - }) + }), ); this.$logger.info(table.toString()); } } } - -injector.registerCommand("appstore|*list", ListiOSApps); diff --git a/lib/commands/appstore-upload.ts b/lib/commands/appstore-upload.ts index 1d80a11715..3d13e24cf8 100644 --- a/lib/commands/appstore-upload.ts +++ b/lib/commands/appstore-upload.ts @@ -1,148 +1,179 @@ import * as path from "path"; -import { StringCommandParameter } from "../common/command-params"; +import { IErrors, IHostInfo } from "../common/declarations"; +import { + booleanOption, + Command, + CommandOptionsSchema, + objectOption, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; 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 - ) { +const publishIOSCommandOptions = { + appleApplicationSpecificPassword: stringOption(), + appleSessionBase64: stringOption(), + ipa: stringOption(), + provision: objectOption(), + release: booleanOption(), + teamId: objectOption(), +} satisfies CommandOptionsSchema; + +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"); + + constructor() { + super(); this.$projectData.initializeProjectData(); } - public async execute(args: string[]): Promise { + 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; + } + + public async run(): Promise { await this.$itmsTransporterService.validate( - this.$options.appleApplicationSpecificPassword + this.options.appleApplicationSpecificPassword, ); const username = - args[0] || + this.args[0] || (await this.$prompter.getString("Apple ID", { allowEmpty: false })); const password = - args[1] || (await this.$prompter.getPassword("Apple ID password")); + this.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, - } - ); - if (!user.areCredentialsValid) { - this.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.` - ); - } + const user = await this.createUserSession(username, password); - const mobileProvisionIdentifier = this.$options.provision ?? args[2]; + const mobileProvisionIdentifier = this.options.provision ?? this.args[2]; - let ipaFilePath = this.$options.ipa - ? path.resolve(this.$options.ipa) - : null; + let ipaFilePath = this.options.ipa ? path.resolve(this.options.ipa) : null; 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" + "No mobile provision identifier set. A default mobile provision will be used. You can set one in app/App_Resources/iOS/build.xcconfig", ); } + // 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 (!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}`); - } + ipaFilePath = await this.buildIpa(mobileProvisionIdentifier); } await this.$itmsTransporterService.upload({ credentials: { username, password }, user, applicationSpecificPassword: - this.$options.appleApplicationSpecificPassword, + this.options.appleApplicationSpecificPassword, ipaFilePath, - shouldExtractIpa: !!this.$options.ipa, + shouldExtractIpa: !!this.options.ipa, verboseLogging: this.$logger.getLevel() === "TRACE", - teamId: this.$options.teamId, + teamId: this.options.teamId, }); } - public async canExecute(args: string[]): Promise { - if (!this.$hostInfo.isDarwin) { - this.$errors.fail("iOS publishing is only available on macOS."); - } - - if ( - !this.$platformValidationService.isPlatformSupportedForOS( - this.$devicePlatformsConstants.iOS, - this.$projectData - ) - ) { + 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( - `Applications for platform ${this.$devicePlatformsConstants.iOS} can not be built on this OS` + `Invalid username and password combination. Used '${username}' as the username.`, ); } - return true; + return user; } -} -injector.registerCommand(["publish|ios", "appstore|upload"], PublishIOS); + 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. + 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 }, + ); + return 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 }, + ); + const ipaFilePath = + await this.$buildController.prepareAndBuild(buildData); + this.$logger.info(`Export at: ${ipaFilePath}`); + return ipaFilePath; + } + } +} diff --git a/lib/commands/build.ts b/lib/commands/build.ts index 7216e8a3fc..59c25a5e5b 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -2,279 +2,146 @@ import { ANDROID_RELEASE_BUILD_ERROR_MESSAGE, AndroidAppBundleMessages, } from "../constants"; -import { ValidatePlatformCommandBase } from "./command-base"; +import { canExecuteCommandBase, validatePlatformOptions } from "./command-base"; import { hasValidAndroidSigning } from "../common/helpers"; -import { IProjectData } from "../definitions/project"; import { + IAndroidBundleValidatorHelper, IOptions, IPlatformValidationService, - IAndroidBundleValidatorHelper, } from "../declarations"; -import { IPlatformsDataService } from "../definitions/platform"; import { IBuildController, IBuildDataService } from "../definitions/build"; import { IMigrateController } from "../definitions/migrate"; +import { IProjectData } from "../definitions/project"; import { IErrors } from "../common/declarations"; -import { OptionType } from "../common/enums"; -import { ICommandParameter, ICommand } from "../common/definitions/commands"; -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 }, - }; - - public async executeCore(args: string[]): Promise { - const platform = args[0].toLowerCase(); - const buildData = this.$buildDataService.getBuildData( - this.$projectData.projectDir, - platform, - this.$options, - ); - const outputPath = await this.$buildController.prepareAndBuild(buildData); - - return outputPath; - } - - protected validatePlatform(platform: string): void { - if ( - !this.$platformValidationService.isPlatformSupportedForOS( - platform, - this.$projectData, - ) - ) { - this.$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.`, - ); - } - - 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); - } - - 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, - ); - } - - public async execute(args: string[]): Promise { - await this.executeCore([ - this.$devicePlatformsConstants.Android.toLowerCase(), - ]); - - if (this.$options.aab) { - this.$logger.info( - AndroidAppBundleMessages.ANDROID_APP_BUNDLE_DOCS_MESSAGE, +import { + booleanOption, + CommandName, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; + +/** + * Which `$devicePlatformsConstants` entry a command builds for. The constants + * stay the source of truth for the platform spelling. + */ +type BuildPlatform = "iOS" | "Android" | "visionOS"; + +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; + +const defineBuildCommand = ( + name: TName, + buildPlatform: BuildPlatform, +) => + defineCommand({ + name, + description: "Builds the project for the selected target platform.", + options: buildCommandOptions, + arguments: "none", + async canExecute(context): Promise { + const $devicePlatformsConstants = + inject("devicePlatformsConstants"); + const $errors = inject("errors"); + const $migrateController = + inject("migrateController"); + const $platformValidationService = inject( + "platformValidationService", ); + 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 $migrateController.validate({ + projectDir: $projectData.projectDir, + platforms: [platform], + }); + } - if (this.$options.release) { - this.$logger.info( - AndroidAppBundleMessages.ANDROID_APP_BUNDLE_PUBLISH_DOCS_MESSAGE, + if (isAndroid) { + $androidBundleValidatorHelper.validateRuntimeVersion($projectData); + } else if ( + !$platformValidationService.isPlatformSupportedForOS( + platform, + $projectData, + ) + ) { + $errors.fail( + `Applications for platform ${platform} can not be built on this OS`, ); } - } - } - 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); + if (!(await canExecuteCommandBase(context, platform))) { + return false; } - canExecute = await super.validateArgs(args, platform); - } - - return canExecute; - } -} - -injector.registerCommand("build|android", BuildAndroidCommand); + if ( + isAndroid && + context.options.release && + !hasValidAndroidSigning(context.options) + ) { + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + } -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, - ); - } + return validatePlatformOptions(context, platform); + }, + 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 $buildController.prepareAndBuild(buildData); - public async execute(args: string[]): Promise { - await this.executeCore([ - this.$devicePlatformsConstants.visionOS.toLowerCase(), - ]); - } + if (isAndroid && context.options.aab) { + $logger.info(AndroidAppBundleMessages.ANDROID_APP_BUNDLE_DOCS_MESSAGE); - 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], - }); - } + if (context.options.release) { + $logger.info( + AndroidAppBundleMessages.ANDROID_APP_BUNDLE_PUBLISH_DOCS_MESSAGE, + ); + } + } - super.validatePlatform(platform); + return outputPath; + }, + }); - let canExecute = await super.canExecuteCommandBase(platform); - if (canExecute) { - canExecute = await super.validateArgs(args, platform); - } +export const iosBuildCommand = defineBuildCommand("build|ios", "iOS"); - return canExecute; - } -} +export const androidBuildCommand = defineBuildCommand( + "build|android", + "Android", +); -injector.registerCommand("build|vision", BuildVisionOsCommand); -injector.registerCommand("build|visionos", BuildVisionOsCommand); +export const visionBuildCommand = defineBuildCommand( + ["build|vision", "build|visionos"], + "visionOS", +); diff --git a/lib/commands/clean.ts b/lib/commands/clean.ts index d891ead677..c4ca161f33 100644 --- a/lib/commands/clean.ts +++ b/lib/commands/clean.ts @@ -1,7 +1,19 @@ +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 * as constants from "../constants"; +import { IStaticConfig } from "../declarations"; import { IProjectCleanupResult, IProjectCleanupService, @@ -9,19 +21,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,46 +81,277 @@ 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({ +const cleanCommandOptions = { + dryRun: booleanOption(), + json: booleanOption(), +} satisfies CommandOptionsSchema; + +async function getNSProjectPathsInDirectory( + $logger: ILogger, + dir = process.cwd(), +): Promise { + let nsDirs: string[] = []; + + const getFiles = async (dir: string) => { + if (dir.includes("node_modules")) { + // skip traversing node_modules + return; + } + + const dirents = await readdir(dir, { withFileTypes: true }).catch( + (err): any[] => { + $logger.trace('Failed to read directory "%s". Error is:', dir, err); + return []; + }, + ); + + 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; + } + + await Promise.all( + dirents.map((dirent: any) => { + const res = resolve(dir, dirent.name); + + if (dirent.isDirectory()) { + return getFiles(res); + } + }), + ); + }; + + await getFiles(dir); + + return nsDirs; +} + +async function cleanMultipleProjects( + 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. + $logger.warn("No project found in the current directory."); + return; + } + + const shouldScan = await $prompter.confirm( + "No project found in the current directory. Would you like to scan for all projects in sub-directories instead?", + ); + + if (!shouldScan) { + return; + } + + spinner.start("Scanning for projects... Please wait."); + const paths = await getNSProjectPathsInDirectory($logger); + spinner.succeed(`Found ${paths.length} projects.`); + + let computed = 0; + const updateProgress = () => { + const current = color.grey(`${computed}/${paths.length}`); + spinner.start( + `Gathering cleanable sizes. This may take a while... ${current}`, + ); + }; + + // update the progress initially + updateProgress(); + + const projects = new Map(); + + await promiseMap( + paths, + (p) => { + return $childProcess + .exec( + `node ${$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) => { + $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, + ); + + spinner.clear(); + spinner.stop(); + + $logger.clearScreen(); + + const totalSize = Array.from(projects.values()) + .filter((s) => s > 0) + .reduce((a, b) => a + b, 0); + + const pathsToClean = await $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)}`; + } + + return { + title: `${p}${color.grey(description)}`, + value: p, + }; + }), + true, + { + optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions + } as Partial, + ); + $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 $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}`, + ); + + const ok = await $childProcess + .exec( + `node ${$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) => { + $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.', + ); + } +} + +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 isDryRun = context.options.dryRun ?? false; + const isJSON = context.options.json ?? false; + + const spinner = $terminalSpinnerService.createSpinner({ isSilent: isJSON, }); - if (!this.$projectService.isValidNativeScriptProject()) { - return this.cleanMultipleProjects(spinner); + if (!$projectService.isValidNativeScriptProject()) { + return cleanMultipleProjects(context, spinner); } spinner.start("Cleaning project...\n"); let pathsToClean = [ constants.HOOKS_DIR_NAME, - this.$projectData.getBuildRelativeDirectoryPath(), + $projectData.getBuildRelativeDirectoryPath(), constants.NODE_MODULES_FOLDER_NAME, ]; try { const overridePathsToClean = - this.$projectConfigService.getValue("cli.pathsToClean"); - const additionalPaths = this.$projectConfigService.getValue( + $projectConfigService.getValue("cli.pathsToClean"); + const additionalPaths = $projectConfigService.getValue( "cli.additionalPathsToClean", ); @@ -133,7 +367,7 @@ export class CleanCommand implements ICommand { // ignore } - const res = await this.$projectCleanupService.clean(pathsToClean, { + const res = await $projectCleanupService.clean(pathsToClean, { dryRun: isDryRun, silent: isJSON, stats: isJSON, @@ -160,231 +394,5 @@ export class CleanCommand implements ICommand { } 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; - } - - 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 (!shouldScan) { - return; - } - - spinner.start("Scanning for projects... Please wait."); - const paths = await this.getNSProjectPathsInDirectory(); - spinner.succeed(`Found ${paths.length} projects.`); - - let computed = 0; - const updateProgress = () => { - const current = color.grey(`${computed}/${paths.length}`); - spinner.start( - `Gathering cleanable sizes. This may take a while... ${current}`, - ); - }; - - // 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, - ); - - 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)}`; - } - - 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!`); - - let confirmed = await this.$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}`, - ); - - const ok = await this.$childProcess - .exec( - `node ${this.$staticConfig.cliBinPath} clean ${ - this.$options.dryRun ? "--dry-run" : "" - } --json --disable-analytics`, - { - cwd: currentPath, - }, - ) - .then((res) => { - const cleanupRes = JSON.parse(res) as IProjectCleanupResult; - return cleanupRes.ok; - }) - .catch((err) => { - this.$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 (this.$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[] = []; - - const getFiles = async (dir: string) => { - if (dir.includes("node_modules")) { - // skip traversing node_modules - return; - } - - const dirents = await readdir(dir, { withFileTypes: true }).catch( - (err): any[] => { - this.$logger.trace( - 'Failed to read directory "%s". Error is:', - dir, - err, - ); - return []; - }, - ); - - 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; - } - - await Promise.all( - dirents.map((dirent: any) => { - const res = resolve(dir, dirent.name); - - if (dirent.isDirectory()) { - return getFiles(res); - } - }), - ); - }; - - await getFiles(dir); - - return nsDirs; - } -} - -injector.registerCommand("clean", CleanCommand); + }, +}); diff --git a/lib/commands/command-base.ts b/lib/commands/command-base.ts index 0f4e1f834c..e09dbfdf97 100644 --- a/lib/commands/command-base.ts +++ b/lib/commands/command-base.ts @@ -2,70 +2,102 @@ 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, CommandContext } from "../common/define-command"; +import { Injector } from "../common/di"; -export abstract class ValidatePlatformCommandBase { - constructor( - protected $options: IOptions, - protected $platformsDataService: IPlatformsDataService, - protected $platformValidationService: IPlatformValidationService, - protected $projectData: IProjectData - ) {} +/** The part of a command context these helpers read. */ +type PlatformCommandContext = Pick, "injector">; - abstract allowedParameters: ICommandParameter[]; - abstract execute(args: string[]): Promise; +/** + * 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 the command's own handlers, + * 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; + }, +}; - public async canExecuteCommandBase( - platform: string, - options?: ICanExecuteCommandOptions - ): Promise { - options = options || {}; - const validatePlatformOutput = await this.validatePlatformBase( +export function validatePlatformOptions( + context: PlatformCommandContext, + platform: string, +): Promise { + const $options = context.injector.get("options"); + const $projectData = context.injector.get("projectData"); + + return context.injector + .get("platformValidationService") + .validateOptions( + $options.provision, + $options.teamId, + $projectData, 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 - ); - } +async function validatePlatformBase( + context: PlatformCommandContext, + platform: string, + notConfiguredEnvOptions: INotConfiguredEnvOptions, +): Promise { + const $options = context.injector.get("options"); + const $projectData = context.injector.get("projectData"); + const platformData = context.injector + .get("platformsDataService") + .getPlatformData(platform, $projectData); - return result; - } + return platformData.platformProjectService.validate( + $projectData, + $options, + notConfiguredEnvOptions, + ); +} - private async validatePlatformBase( - platform: string, - notConfiguredEnvOptions: INotConfiguredEnvOptions - ): Promise { - const platformData = this.$platformsDataService.getPlatformData( - platform, - this.$projectData - ); - const platformProjectService = platformData.platformProjectService; - const result = await platformProjectService.validate( - this.$projectData, - this.$options, - notConfiguredEnvOptions - ); - return result; - } +function hasUsableEnvironment( + validatePlatformOutput: IValidatePlatformOutput, +): boolean { + return ( + validatePlatformOutput && + validatePlatformOutput.checkEnvironmentRequirementsOutput && + validatePlatformOutput.checkEnvironmentRequirementsOutput.canExecute + ); +} - private canExecuteCommand( - validatePlatformOutput: IValidatePlatformOutput - ): boolean { - return ( - validatePlatformOutput && - validatePlatformOutput.checkEnvironmentRequirementsOutput && - validatePlatformOutput.checkEnvironmentRequirementsOutput.canExecute - ); +export async function canExecuteCommandBase( + context: PlatformCommandContext, + platform: string, + options: ICanExecuteCommandOptions = {}, +): Promise { + const validatePlatformOutput = await validatePlatformBase( + context, + platform, + options.notConfiguredEnvOptions, + ); + const canExecute = hasUsableEnvironment(validatePlatformOutput); + let result = canExecute; + + if (canExecute && options.validateOptions) { + result = await validatePlatformOptions(context, platform); } + + return result; } diff --git a/lib/commands/config.ts b/lib/commands/config.ts index a37506bf47..d2ff838d02 100644 --- a/lib/commands/config.ts +++ b/lib/commands/config.ts @@ -1,136 +1,140 @@ -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 { color } from "../color"; -export class ConfigListCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - private $projectConfigService: IProjectConfigService, - private $logger: ILogger, - ) {} +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(), + ); + } +} - 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 getConvertedValue(v: any): any { + try { + return JSON.parse(v); + } catch (e) { + // just treat it as a string + return `${v}`; } +} - 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 requireConfigKey(context: CommandContext): void { + if (!context.args[0]) { + context.injector + .get("errors") + .failWithHelp("You must specify a key. Eg: ios.id"); } } -export class ConfigGetCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export const configListCommandDefinition = defineCommand({ + name: "config|*list", + description: "Prints the project configuration.", + arguments: "none", + async run(): Promise { + const $projectConfigService = inject( + "projectConfigService", + ); + const $logger = inject("logger"); + + try { + const config = $projectConfigService.readConfig(); + $logger.info(getValueString(config as SupportedConfigValues)); + } catch (error) { + $logger.info("Failed to read config. Error is: ", error); + } + }, +}); - constructor( - private $projectConfigService: IProjectConfigService, - private $logger: ILogger, - private $errors: IErrors, - ) {} +export const configGetCommandDefinition = defineCommand({ + name: "config|get", + description: "Prints the value the project configuration holds for a key.", + arguments: "any", + async canExecute(context): Promise { + requireConfigKey(context); + + return true; + }, + async run(context): Promise { + const $projectConfigService = inject( + "projectConfigService", + ); + const $logger = inject("logger"); - public async execute(args: string[]): Promise { try { - const [key] = args; - const current = this.$projectConfigService.getValue(key); - this.$logger.info(current); + const [key] = context.args; + const current = $projectConfigService.getValue(key); + $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", + async canExecute(context): Promise { + const $errors = inject("errors"); - return true; - } -} + requireConfigKey(context); -export class ConfigSetCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; + if (!context.args[1]) { + $errors.failWithHelp("You must specify a value."); + } - constructor( - private $projectConfigService: IProjectConfigService, - private $logger: ILogger, - private $errors: IErrors, - ) {} + return true; + }, + async run(context): Promise { + const $projectConfigService = inject( + "projectConfigService", + ); + const $logger = inject("logger"); + const $errors = inject("errors"); - public async execute(args: string[]): Promise { - const [key, value] = args; - const current = this.$projectConfigService.getValue(key); + const [key, value] = context.args; + const current = $projectConfigService.getValue(key); if (current && typeof current === "object") { - this.$errors.fail( + $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( + $logger.info( `${existingKey ? "Updating" : "Setting"} ${keyDisplay}${ existingKey ? ` from ${currentDisplay} ` : " " }to ${updatedDisplay}`, ); try { - await this.$projectConfigService.setValue(key, convertedValue); - this.$logger.info("Done"); + await $projectConfigService.setValue(key, convertedValue); + $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."); - } - - return true; - } - - private getConvertedValue(v: any): any { - try { - return JSON.parse(v); - } catch (e) { - // just treat it as a string - return `${v}`; + $logger.info("Could not update conifg. Error is: ", error); } - } -} - -injector.registerCommand("config|*list", ConfigListCommand); -injector.registerCommand("config|get", ConfigGetCommand); -injector.registerCommand("config|set", ConfigSetCommand); + }, +}); diff --git a/lib/commands/create-project.ts b/lib/commands/create-project.ts index c817d1e188..321e60bb06 100644 --- a/lib/commands/create-project.ts +++ b/lib/commands/create-project.ts @@ -1,449 +1,478 @@ -import * as constants from "../constants"; import * as path from "path"; +import { color } from "../color"; +import { + booleanOption, + Command, + CommandOptionsSchema, + CommandOptionValues, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; import { isInteractive } from "../common/helpers"; +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"; - }; +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"; + +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; + +interface ITemplateChoice { + key?: string; + value: string; + description?: string; +} - 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." - ); - } +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, + }, + ]; +} - 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; - } +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, + }, + ]; +} - if (!projectName && isInteractive()) { - this.printInteractiveCreationIntroIfNeeded(); - projectName = await this.$prompter.getString( - `${getNextInteractiveAdverb()}, what will be the name of your app?`, - { allowEmpty: false } - ); - this.$logger.info(); - } +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, + }, + ]; +} - projectName = await this.$projectService.validateProjectName({ - projectName: projectName, - force: this.$options.force, - pathToProject: this.$options.path, - }); +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, + }, + ]; +} - if (!selectedTemplate && isInteractive()) { - this.printInteractiveCreationIntroIfNeeded(); - selectedTemplate = await this.interactiveFlavorAndTemplateSelection( - getNextInteractiveAdverb(), - getNextInteractiveAdverb() - ); - } +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, + }, + ]; +} - 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 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, + }, + ]; +} - private async interactiveFlavorAndTemplateSelection( - flavorAdverb: string, - templateAdverb: string - ) { - const selectedFlavor = await this.interactiveFlavorSelection(flavorAdverb); - const selectedTemplate: string = await this.interactiveTemplateSelection( - selectedFlavor, - templateAdverb - ); +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, + }, + ]; +} - return selectedTemplate; +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: CommandOptionValues, +): 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( + $prompter: IPrompter, + adverb: string, +): Promise { + return $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( + $logger: ILogger, + $prompter: IPrompter, + flavorSelection: string, + adverb: string, +): Promise { + const getTemplates = flavorTemplates[flavorSelection]; + const selectedFlavorTemplates: ITemplateChoice[] = getTemplates + ? getTemplates() + : []; + + if (selectedFlavorTemplates.length > 1) { + $logger.info(); + const templateChoices = selectedFlavorTemplates.map((template) => { + return { key: template.key, description: template.description }; + }); + const selectedTemplateKey = await $prompter.promptForDetailedChoice( + `${adverb}, which template would you like to start from:`, + templateChoices, + ); + + return selectedFlavorTemplates.find((t) => t.key === selectedTemplateKey) + .value; + } + + return selectedFlavorTemplates[0].value; +} + +async function interactiveFlavorAndTemplateSelection( + $logger: ILogger, + $prompter: IPrompter, + flavorAdverb: string, + templateAdverb: string, +): Promise { + const selectedFlavor = await interactiveFlavorSelection( + $prompter, + flavorAdverb, + ); + + return interactiveTemplateSelection( + $logger, + $prompter, + selectedFlavor, + templateAdverb, + ); +} + +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 + ) { + this.context.fail( + "You cannot use a flavor option like --ng, --vue, --react, --solid, --svelte, --tsc and --js together with --template.", + ); + } + + let projectName = this.args[0]; + let selectedTemplate = selectTemplateFromOptions(options); + + if (!projectName && isInteractive()) { + printInteractiveCreationIntroIfNeeded(); + projectName = await this.$prompter.getString( + `${getNextInteractiveAdverb()}, what will be the name of your app?`, + { allowEmpty: false }, + ); + this.$logger.info(); + } - return templates; + projectName = await this.$projectService.validateProjectName({ + projectName: projectName, + force: options.force, + pathToProject: options.path, + }); + + if (!selectedTemplate && isInteractive()) { + printInteractiveCreationIntroIfNeeded(); + selectedTemplate = await interactiveFlavorAndTemplateSelection( + this.$logger, + this.$prompter, + getNextInteractiveAdverb(), + getNextInteractiveAdverb(), + ); + } + + return this.$projectService.createProject({ + projectName: projectName, + template: selectedTemplate, + appId: options.appid, + pathToProject: options.path, + // its already validated above + force: true, + ignoreScripts: options.ignoreScripts, + }); } - public async postCommandAction(args: string[]): Promise { - const { projectDir, projectName } = this.createdProjectData; + 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"] + 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:`, @@ -472,14 +501,14 @@ can skip this prompt next time using the --template option, or using --ng, --rea ].join(" "), "", `Now you can navigate to your project with ${color.cyan( - `cd ${relativePath}` + `cd ${relativePath}`, )} and then:`, "", ...runDebugNotes, ``, `For more options consult the docs or run ${color.green("ns --help")}`, "", - ].join("\n") + ].join("\n"), ); // todo: add back ns preview // this.$logger.printMarkdown( @@ -487,5 +516,3 @@ can skip this prompt next time using the --template option, or using --ng, --rea // ); } } - -injector.registerCommand("create", CreateProjectCommand); diff --git a/lib/commands/debug.ts b/lib/commands/debug.ts index bd894e9019..aa57de1fd7 100644 --- a/lib/commands/debug.ts +++ b/lib/commands/debug.ts @@ -1,251 +1,322 @@ -import { cache } from "../common/decorators"; -import { ValidatePlatformCommandBase } from "./command-base"; +import { IErrors, ISysInfo } from "../common/declarations"; +import { commandShortcutsEnabled } from "../common/contracts/key-shortcuts"; +import { + booleanOption, + CommandContext, + CommandName, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; import { hasValidAndroidSigning } from "../common/helpers"; 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 { IOptions, IPlatformValidationService } from "../declarations"; +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 { IProjectData } from "../definitions/project"; import { SystemWarningsSeverity } from "../definitions/system-warnings"; +import { + IKeyShortcutService, + KeyShortcutRegistry, + restartShortcut, + watcherShortcut, +} from "../services/key-shortcuts"; +import { canExecuteCommandBase } from "./command-base"; +import * as _ from "lodash"; + +/** Which `$devicePlatformsConstants` entry a command debugs. */ +type DebugPlatform = "iOS" | "Android" | "visionOS"; + +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; + +type DebugCommandContext = CommandContext; -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, +async function canExecuteDebugCommand( + context: DebugCommandContext, + 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. + $cleanupService.setShouldDispose(false); + + if (!context.options.force) { + await $migrateController.validate({ + projectDir: $projectData.projectDir, + platforms: [platform], + }); + } + + if ( + !$platformValidationService.isPlatformSupportedForOS(platform, $projectData) ) { - super( - $options, - $platformsDataService, - $platformValidationService, - $projectData, + $errors.fail( + `Applications for platform ${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) { + $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(context, 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; - } +async function runDebugCommand( + context: DebugCommandContext, + debugPlatform: DebugPlatform, +): Promise { + 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 this.$liveSyncCommandHelper.executeLiveSyncOperation( - [selectedDeviceForDebug], - this.platform, - { - deviceDebugMap: { - [selectedDeviceForDebug.deviceInfo.identifier]: true, - }, - buildPlatform: undefined, - skipNativePrepare: false, - }, + await $devicesService.initialize({ + platform, + deviceId: context.options.device, + emulator: context.options.emulator, + skipDeviceDetectionInterval: true, + }); + + 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($options.argv); + const debugData = $debugDataService.getDebugData( + selectedDeviceForDebug.deviceInfo.identifier, + $projectData, + debugOptions, ); + await $debugController.printDebugInformation( + await $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], - }); - } + const liveSyncOptions = ( + additional: Partial, + ): ILiveSyncCommandHelperAdditionalOptions => ({ + deviceDebugMap: { + [selectedDeviceForDebug.deviceInfo.identifier]: true, + }, + buildPlatform: undefined, + skipNativePrepare: false, + ...additional, + }); - if ( - !this.$platformValidationService.isPlatformSupportedForOS( - this.platform, - this.$projectData, - ) - ) { - this.$errors.fail( - `Applications for platform ${this.platform} can not be built on this OS`, - ); - } + await $liveSyncCommandHelper.executeLiveSyncOperation( + [selectedDeviceForDebug], + platform, + liveSyncOptions({}), + ); - if (this.$options.release) { - this.$errors.failWithHelp( - "--release flag is not applicable to this command.", - ); - } + if (!commandShortcutsEnabled()) { + return; + } - const result = await super.canExecuteCommandBase(this.platform, { - validateOptions: true, - }); - return result; + // 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 => + $liveSyncCommandHelper.executeLiveSyncOperation( + [selectedDeviceForDebug], + platform, + liveSyncOptions(>{ + restartLiveSync: true, + ...(forceRebuildNativeApp ? { forceRebuildNativeApp: true } : {}), + }), + ); + + context.injector.get(KeyShortcutRegistry).add( + restartShortcut(), + restartShortcut({ full: true, restart: () => restartDebugSession() }), + restartShortcut({ + forceRebuildNativeApp: true, + restart: () => restartDebugSession(true), + }), + watcherShortcut(), + ); + + const keyShortcutService = + context.injector.get("keyShortcutService"); + if (keyShortcutService.attach({ shortcuts: [] })) { + keyShortcutService.printHint(); } } -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 { - 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; +} + +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", + 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(); - const isValidTimeoutOption = this.isValidTimeoutOption(); - if (!isValidTimeoutOption) { - this.$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.`, + // 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 (this.$options.inspector) { - const macOSWarning = await this.$sysInfo.getMacOSWarningMessage(); if ( - macOSWarning && - macOSWarning.severity === SystemWarningsSeverity.high + !$platformValidationService.isPlatformSupportedForOS( + platform, + $projectData, + ) ) { - this.$errors.fail( - `You cannot use NativeScript Inspector on this OS. To use it, please update your OS.`, + $errors.fail( + `Applications for platform ${platform} can not be built on this 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 (!isValidTimeoutOption(context.options.timeout)) { + $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 (!timeout) { - return false; - } + if (context.options.inspector) { + const macOSWarning = await $sysInfo.getMacOSWarningMessage(); + if ( + macOSWarning && + macOSWarning.severity === SystemWarningsSeverity.high + ) { + $errors.fail( + `You cannot use NativeScript Inspector on this OS. To use it, please update your OS.`, + ); + } + } - return true; - } + return canExecuteDebugCommand(context, debugPlatform); + }, + run: (context) => runDebugCommand(context, debugPlatform), + }); - public platform = this.$devicePlatformsConstants.iOS; -} +export const iosDebugCommand = defineApplePlatformDebugCommand( + "debug|ios", + "iOS", +); -injector.registerCommand("debug|ios", DebugIOSCommand); +export const visionDebugCommand = defineApplePlatformDebugCommand( + ["debug|vision", "debug|visionos"], + "visionOS", +); -export class DebugAndroidCommand implements ICommand { - @cache() - private get debugPlatformCommand(): DebugPlatformCommand { - return this.$injector.resolve(DebugPlatformCommand, { - platform: this.platform, - }); - } +export const androidDebugCommand = defineCommand({ + name: "debug|android", + description: "Debugs your project on a connected Android device or emulator.", + options: debugCommandOptions, + arguments: "any", + async canExecute(context): Promise { + const $errors = inject("errors"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); - 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); + const canExecuteBase = await canExecuteDebugCommand(context, "Android"); 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)) { + $errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } return canExecuteBase; - } - - public platform = this.$devicePlatformsConstants.Android; -} - -injector.registerCommand("debug|android", DebugAndroidCommand); + }, + run: (context) => runDebugCommand(context, "Android"), +}); diff --git a/lib/commands/deploy.ts b/lib/commands/deploy.ts index 2d2da7614f..acda8ab0ef 100644 --- a/lib/commands/deploy.ts +++ b/lib/commands/deploy.ts @@ -2,93 +2,80 @@ import { ANDROID_RELEASE_BUILD_ERROR_MESSAGE, ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, } from "../constants"; -import { ValidatePlatformCommandBase } from "./command-base"; +import { canExecuteCommandBase, 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 { IProjectData } from "../definitions/project"; 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"; - 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], + async canExecute(context): Promise { + const $errors = inject("errors"); + const $migrateController = inject("migrateController"); + const $mobileHelper = inject("mobileHelper"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); - await this.$deployCommandHelper.deploy(platform); - } + 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 $migrateController.validate({ + projectDir: $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) + $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) { + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - this.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } - const result = await super.canExecuteCommandBase(platform, { + return canExecuteCommandBase(context, platform, { validateOptions: true, }); - return result; - } -} + }, + async run(context): Promise { + const $deployCommandHelper = inject( + "deployCommandHelper", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); -injector.registerCommand("deploy", DeployOnDeviceCommand); + await $deployCommandHelper.deploy(context.args[0]); + }, +}); diff --git a/lib/commands/embedding/embed.ts b/lib/commands/embedding/embed.ts index fd3b58ac63..a39b361c19 100644 --- a/lib/commands/embedding/embed.ts +++ b/lib/commands/embedding/embed.ts @@ -1,62 +1,83 @@ -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 { IOptions } from "../../declarations"; +import { IProjectConfigService, IProjectData } from "../../definitions/project"; +import { Command } from "../../common/define-command"; +import { IFileSystem } from "../../common/declarations"; +import { inject } from "../../common/di"; +import { canExecuteCommand } from "../../common/services/command-definition-adapter"; +import { platformArgument } from "../command-base"; +import { + prepareCommandDefinition, + prepareCommandOptions, + runPrepareCommand, +} from "../prepare"; + +function resolveHostProjectPath( + projectDir: string, + hostProjectPath: string, +): string { + if (hostProjectPath.charAt(0) === ".") { + return resolve(projectDir, hostProjectPath); + } -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, + return resolve(hostProjectPath); +} - private $logger: ILogger, - private $fs: IFileSystem, - private $projectConfigService: IProjectConfigService, - ) { - super( - $options, - $prepareController, - $platformValidationService, - $projectData, - $platformCommandParameter, - $platformsDataService, - $prepareDataService, - $migrateController, - ); +export class EmbedCommand extends Command({ + name: "embed", + description: + "Prepares the project so it can be embedded into a native host project.", + options: prepareCommandOptions, + arguments: [ + platformArgument, + { name: "hostProjectPath" }, + { name: "hostProjectModuleName" }, + ], +}) { + 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(); } - private resolveHostProjectPath(hostProjectPath: string): string { - if (hostProjectPath.charAt(0) === ".") { - // resolve relative to the project dir - const projectDir = this.$projectData.projectDir; - return resolve(projectDir, hostProjectPath); + 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( + prepareCommandDefinition, + this.args.slice(0, 1), + )) + ) { + return false; } - return resolve(hostProjectPath); + return !!this.hostProjectPath; } - public async execute(args: string[]): Promise { - const hostProjectPath = args[1]; - const resolvedHostProjectPath = - this.resolveHostProjectPath(hostProjectPath); + public async run(): Promise { + const resolvedHostProjectPath = resolveHostProjectPath( + this.$projectData.projectDir, + this.hostProjectPath, + ); if (!this.$fs.exists(resolvedHostProjectPath)) { this.$logger.error( `The host project path ${color.yellow( - hostProjectPath, + this.hostProjectPath, )} (resolved to: ${color.styleText( ["yellow", "dim"], resolvedHostProjectPath, @@ -65,64 +86,19 @@ 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; - } - - // 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; - } + this.$options.hostProjectPath = resolvedHostProjectPath; + if (this.hostProjectModuleName) { + this.$options.hostProjectModuleName = this.hostProjectModuleName; } - console.log(args); - - if (args.length < 2) { - return false; - } - - return true; + await runPrepareCommand(this.context); } - private getEmbedConfigForKey(key: string, platform: string) { - // get the embed.. value, or fallback to embed. value + /** embed.., falling back to embed.. */ + private configValue(key: string): string { return this.$projectConfigService.getValue( - `embed.${platform}.${key}`, + `embed.${this.platform}.${key}`, this.$projectConfigService.getValue(`embed.${key}`), ); } } - -injector.registerCommand("embed", EmbedCommand); diff --git a/lib/commands/extensibility/install-extension.ts b/lib/commands/extensibility/install-extension.ts index 55c1718551..20483bf22e 100644 --- a/lib/commands/extensibility/install-extension.ts +++ b/lib/commands/extensibility/install-extension.ts @@ -1,36 +1,34 @@ -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"; -export class InstallExtensionCommand implements ICommand { - constructor( - private $extensibilityService: IExtensibilityService, - private $stringParameterBuilder: IStringParameterBuilder, - private $logger: ILogger - ) {} +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.", + }, + ], + async run(context): Promise { + const $extensibilityService = inject( + "extensibilityService", + ); + const $logger = inject("logger"); - public async execute(args: string[]): Promise { - const extensionData = await this.$extensibilityService.installExtension( - args[0] + const extensionData = await $extensibilityService.installExtension( + context.args[0], ); - this.$logger.info( - `Successfully installed extension ${extensionData.extensionName}.` + $logger.info( + `Successfully installed extension ${extensionData.extensionName}.`, ); - await this.$extensibilityService.loadExtension(extensionData.extensionName); - this.$logger.info( - `Successfully loaded extension ${extensionData.extensionName}.` + await $extensibilityService.loadExtension(extensionData.extensionName); + $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); + }, +}); diff --git a/lib/commands/extensibility/list-extensions.ts b/lib/commands/extensibility/list-extensions.ts index b25ab89dfc..35d7ab81c8 100644 --- a/lib/commands/extensibility/list-extensions.ts +++ b/lib/commands/extensibility/list-extensions.ts @@ -1,30 +1,29 @@ 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"; -export class ListExtensionsCommand implements ICommand { - constructor( - private $extensibilityService: IExtensibilityService, - private $logger: ILogger - ) {} +export const listExtensionsCommandDefinition = defineCommand({ + name: "extension|*list", + description: "Lists all installed extensions.", + run(): void { + const $extensibilityService = inject( + "extensibilityService", + ); + const $logger = inject("logger"); - public async execute(args: string[]): Promise { - const installedExtensions = this.$extensibilityService.getInstalledExtensions(); + const installedExtensions = $extensibilityService.getInstalledExtensions(); if (_.keys(installedExtensions).length) { - this.$logger.info("Installed extensions:"); + $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()); + $logger.info(table.toString()); } else { - this.$logger.info("No extensions installed."); + $logger.info("No extensions installed."); } - } - - allowedParameters: ICommandParameter[] = []; -} -injector.registerCommand("extension|*list", ListExtensionsCommand); + }, +}); diff --git a/lib/commands/extensibility/uninstall-extension.ts b/lib/commands/extensibility/uninstall-extension.ts index cea51bc26d..369de3ad07 100644 --- a/lib/commands/extensibility/uninstall-extension.ts +++ b/lib/commands/extensibility/uninstall-extension.ts @@ -1,28 +1,26 @@ -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"; -export class UninstallExtensionCommand implements ICommand { - constructor( - private $extensibilityService: IExtensibilityService, - private $stringParameterBuilder: IStringParameterBuilder, - private $logger: ILogger - ) {} +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.", + }, + ], + async run(context): Promise { + const $extensibilityService = inject( + "extensibilityService", + ); + const $logger = inject("logger"); - public async execute(args: string[]): Promise { - const extensionName = args[0]; - await this.$extensibilityService.uninstallExtension(extensionName); - this.$logger.info(`Successfully uninstalled extension ${extensionName}`); - } - - allowedParameters: ICommandParameter[] = [ - this.$stringParameterBuilder.createMandatoryParameter( - "You have to provide a valid name for extension that you want to uninstall." - ), - ]; -} -injector.registerCommand("extension|uninstall", UninstallExtensionCommand); + 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 bcdf0a996e..289f899ba5 100644 --- a/lib/commands/fonts.ts +++ b/lib/commands/fonts.ts @@ -1,46 +1,49 @@ 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 * 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[] = []; - - constructor( - private $projectData: IProjectData, - private $fs: IFileSystem, - private $logger: ILogger, - private $projectConfigService: IProjectConfigService - ) { - this.$projectData.initializeProjectData(); - } - - public async execute(args: string[]): Promise { +export const fontsCommandDefinition = defineCommand({ + name: "fonts", + description: "Lists the custom fonts the project bundles.", + arguments: "none", + // 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( - this.$projectConfigService.getValue("appPath") ?? "", - constants.FONTS_DIR + $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($projectData.projectDir, entry)); const fontsFolderPath = defaultFontsFolderPaths.find((entry) => - this.$fs.exists(entry) + $fs.exists(entry), ); if (!fontsFolderPath) { - this.$logger.warn("No fonts folder found."); + $logger.warn("No fonts folder found."); return; } - const files = this.$fs + const files = $fs .readDirectory(fontsFolderPath) .map((entry) => path.parse(entry)) .filter((entry) => { @@ -48,7 +51,7 @@ export class FontsCommand implements ICommand { }); if (!files.length) { - this.$logger.warn("No custom fonts found."); + $logger.warn("No custom fonts found."); return; } @@ -62,8 +65,6 @@ export class FontsCommand implements ICommand { ]); } - this.$logger.info(table.toString()); - } -} - -injector.registerCommand("fonts", FontsCommand); + $logger.info(table.toString()); + }, +}); diff --git a/lib/commands/generate-assets.ts b/lib/commands/generate-assets.ts index 5133cea3e0..c7ce072d81 100644 --- a/lib/commands/generate-assets.ts +++ b/lib/commands/generate-assets.ts @@ -1,106 +1,80 @@ -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, + CommandName, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { + IAssetsGenerationService, + IResourceGenerationData, +} from "../declarations"; +import { IProjectData } from "../definitions/project"; +import { inject } from "../common/di"; -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 command 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 generators: Record< + GeneratedAssets, + ( + service: IAssetsGenerationService, + data: IResourceGenerationData, + ) => Promise +> = { + icons: (service, data) => service.generateIcons(data), + splashes: (service, data) => service.generateSplashScreens(data), +}; - public async execute(args: string[]): Promise { - const [imagePath] = args; - await this.generate(imagePath, this.$options.background); - } +const generateAssetsCommandOptions = { + background: stringOption(), +} satisfies CommandOptionsSchema; - protected abstract generate( - imagePath: string, - background?: string - ): Promise; +function runGenerateAssetsCommand( + context: CommandContext, + assets: GeneratedAssets, +): Promise { + 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: $projectData.projectDir, + }); } -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 - ); - } - - protected async generate( - imagePath: string, - background?: string - ): Promise { - await this.$assetsGenerationService.generateIcons({ - imagePath, - background, - projectDir: this.$projectData.projectDir, - }); - } -} +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.", + }, + ], + // 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), + }); -injector.registerCommand("resources|generate|icons", GenerateIconsCommand); - -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 - ); - } - - protected async generate( - imagePath: string, - background?: string - ): Promise { - await this.$assetsGenerationService.generateSplashScreens({ - imagePath, - background, - projectDir: this.$projectData.projectDir, - }); - } -} +export const generateIconsCommand = defineGenerateAssetsCommand( + "resources|generate|icons", + "icons", +); -injector.registerCommand( +export const generateSplashesCommand = defineGenerateAssetsCommand( "resources|generate|splashes", - GenerateSplashScreensCommand + "splashes", ); diff --git a/lib/commands/generate-help.ts b/lib/commands/generate-help.ts index e80e64c6ee..476b185388 100644 --- a/lib/commands/generate-help.ts +++ b/lib/commands/generate-help.ts @@ -1,15 +1,13 @@ -import { ICommandParameter, ICommand } from "../common/definitions/commands"; import { IHelpService } from "../common/declarations"; -import { injector } from "../common/yok"; - -export class GenerateHelpCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor(private $helpService: IHelpService) {} - - public async execute(args: string[]): Promise { - return this.$helpService.generateHtmlPages(); - } -} - -injector.registerCommand("dev-generate-help", GenerateHelpCommand); +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; + +export const generateHelpCommandDefinition = defineCommand({ + name: "dev-generate-help", + description: "Generates the HTML help pages from the man pages.", + arguments: "none", + run(): Promise { + const $helpService = inject("helpService"); + return $helpService.generateHtmlPages(); + }, +}); diff --git a/lib/commands/generate.ts b/lib/commands/generate.ts index 96c1d2a176..70c173a8ef 100644 --- a/lib/commands/generate.ts +++ b/lib/commands/generate.ts @@ -1,67 +1,26 @@ // 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"; +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; -export class GenerateCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - // private executionOptions: ExecutionOptions; +export const generateCommandDefinition = defineCommand({ + name: "generate", + description: "Executes a schematic in the project.", + arguments: "any", + async run(): Promise { + const $logger = inject("logger"); + const $errors = inject("errors"); - constructor( - private $logger: ILogger, - // private $options: IOptions, - private $errors: IErrors, - ) {} - - public async execute(_rawArgs: string[]): Promise { try { - this.$logger.info( + $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); + $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, - // }; - // } -} + }, +}); /** * Converts an array of command line arguments to options for the executed schematic. @@ -95,5 +54,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..51d9e00cbd 100644 --- a/lib/commands/hooks/common.ts +++ b/lib/commands/hooks/common.ts @@ -1,8 +1,6 @@ -import * as _ from "lodash"; -import { IProjectData } from "../../definitions/project"; import { IPluginData } from "../../definitions/plugins"; -import { ICommandParameter } from "../../common/definitions/commands"; import { IErrors, IFileSystem } from "../../common/declarations"; +import { CommandContext } from "../../common/define-command"; import path = require("path"); import * as crypto from "crypto"; @@ -17,102 +15,103 @@ export interface OutputPlugin { hooks: OutputHook[]; } -export class HooksVerify { - public allowedParameters: ICommandParameter[] = []; +export function getPluginsWithHooks(plugins: IPluginData[]): IPluginData[] { + const pluginsWithHooks: IPluginData[] = []; + for (const plugin of plugins) { + if (plugin.nativescript?.hooks?.length > 0) { + pluginsWithHooks.push(plugin); + } + } - constructor( - protected $projectData: IProjectData, - protected $errors: IErrors, - protected $fs: IFileSystem, - protected $logger: ILogger, - ) { - this.$projectData.initializeProjectData(); + return pluginsWithHooks; +} + +export async function verifyHooksLock( + 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 = $fs.readText(hooksLockPath, "utf8"); + hooksLock = JSON.parse(lockFileContent); + } catch (err) { + $errors.fail( + `❌ Failed to read or parse ${LOCK_FILE_NAME} at ${hooksLockPath}`, + ); } - 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}`, - ); + 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) { + $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); - for (const plugin of plugins) { - const pluginLockHooks = lockMap.get(plugin.name); + if (!expectedHash) { + $logger.error( + `❌ Missing hook '${hook.type}' for plugin '${plugin.name}' in ${LOCK_FILE_NAME}`, + ); + isValid = false; + continue; + } + + let fileContent: string | Buffer; - if (!pluginLockHooks) { - this.$logger.error( - `❌ Plugin '${plugin.name}' not found in ${LOCK_FILE_NAME}`, + try { + fileContent = $fs.readFile(path.join(plugin.fullPath, hook.script)); + } catch (err) { + $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) { + $logger.error( + `❌ Hash mismatch for '${hook.script}' (${hook.type} in ${plugin.name}):`, + ); + $logger.error(` Expected: ${expectedHash}`); + $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) { + $logger.info("✅ All hooks verified successfully. No issues found."); + } else { + $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..7272aec725 100644 --- a/lib/commands/hooks/hooks-lock.ts +++ b/lib/commands/hooks/hooks-lock.ts @@ -1,135 +1,120 @@ import { IProjectData } from "../../definitions/project"; -import { IPluginsService, IPluginData } from "../../definitions/plugins"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; +import { IPluginData, IPluginsService } from "../../definitions/plugins"; import { IErrors, IFileSystem } from "../../common/declarations"; -import { injector } from "../../common/yok"; +import { CommandContext, defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; import path = require("path"); import * as crypto from "crypto"; import { - HooksVerify, + getPluginsWithHooks, 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( + 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) { + const hooks: OutputHook[] = []; + + for (const hook of plugin.nativescript?.hooks || []) { + try { + const fileContent = $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) { + $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 { + $fs.writeFile(filePath, JSON.stringify(output, null, 2), "utf8"); + $logger.info(`✅ ${LOCK_FILE_NAME} written to: ${filePath}`); + } catch (err) { + $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); - } +export const hooksLockCommandDefinition = defineCommand({ + name: "hooks|lock", + description: + "Records a hash of every plugin hook in the project's lock file.", + arguments: "any", + // 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"); - public async execute(): Promise { const plugins: IPluginData[] = - await this.$pluginsService.getAllInstalledPlugins(this.$projectData); + await $pluginsService.getAllInstalledPlugins($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( + context, + getPluginsWithHooks(plugins), + $projectData.projectDir, ); } else { - this.$logger.info("No plugins with hooks found."); + $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", + // 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"); -injector.registerCommand(["hooks|lock"], HooksLockPluginCommand); -injector.registerCommand(["hooks|verify"], HooksVerifyPluginCommand); + const plugins: IPluginData[] = + await $pluginsService.getAllInstalledPlugins($projectData); + if (plugins && plugins.length > 0) { + await verifyHooksLock( + context, + getPluginsWithHooks(plugins), + path.join($projectData.projectDir, LOCK_FILE_NAME), + ); + } else { + $logger.info("No plugins with hooks found."); + } + }, +}); diff --git a/lib/commands/hooks/hooks.ts b/lib/commands/hooks/hooks.ts index 4971c648cf..313f9c8153 100644 --- a/lib/commands/hooks/hooks.ts +++ b/lib/commands/hooks/hooks.ts @@ -1,104 +1,101 @@ import { IProjectData } from "../../definitions/project"; -import { IPluginsService, IPluginData } from "../../definitions/plugins"; -import { ICommand, ICommandParameter } from "../../common/definitions/commands"; -import { injector } from "../../common/yok"; +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 { HooksVerify, LOCK_FILE_NAME } from "./common"; +import { getPluginsWithHooks, LOCK_FILE_NAME, verifyHooksLock } from "./common"; -export class HooksPluginCommand 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(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); - } - } +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 }) => { + return [plugin.name, hook.type, hook.script]; + }), + ); + const hookDataTable: any = createTable(headers, hookDataData); + $logger.info("Hooks:"); + $logger.info(hookDataTable.toString()); +} - 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), - ); - } +async function installHooks( + context: CommandContext, + projectDir: string, + pluginsWithHooks: IPluginData[], +): Promise { + const $fs = context.injector.get("fs"); + const hooksDir = path.join(projectDir, HOOKS_DIR_NAME); + const hooksLockPath = path.join(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 ($fs.exists(hooksLockPath)) { + await verifyHooksLock(context, pluginsWithHooks, hooksLockPath); } - public async canExecute(args: string[]): Promise { - if (args.length > 0 && args[0] !== "list") { - this.$errors.failWithHelp( - `Invalid argument ${args[0]}. Supported argument is "list".`, - ); + if (pluginsWithHooks.length === 0) { + if (!$fs.exists(hooksDir)) { + $fs.createDirectory(hooksDir); } - return true; + } + for (const plugin of pluginsWithHooks) { + nsHooks(plugin.fullPath).postinstall(); } } -export class HooksListPluginCommand extends HooksPluginCommand { - public allowedParameters: ICommandParameter[] = []; +async function runHooksCommand( + context: CommandContext, + isList: boolean, +): Promise { + const $pluginsService = + context.injector.get("pluginsService"); + const $projectData = context.injector.get("projectData"); + $projectData.initializeProjectData(); - constructor( - $pluginsService: IPluginsService, - $projectData: IProjectData, - $errors: IErrors, - $fs: IFileSystem, - $logger: ILogger, - ) { - super($pluginsService, $projectData, $errors, $fs, $logger); + const plugins: IPluginData[] = + await $pluginsService.getAllInstalledPlugins($projectData); + if (plugins && plugins.length > 0) { + const pluginsWithHooks = getPluginsWithHooks(plugins); + + if (isList) { + listHooks(context.injector.get("logger"), pluginsWithHooks); + } else { + await installHooks(context, $projectData.projectDir, pluginsWithHooks); + } } +} - public async execute(): Promise { - await super.execute(["list"]); +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") { + inject("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", + canExecute: canExecuteHooksCommand, + run(context): Promise { + return runHooksCommand(context, context.args[0] === "list"); + }, +}); + +export const hooksListCommandDefinition = defineCommand({ + name: "hooks|*list", + description: "Lists the hooks every installed plugin contributes.", + arguments: "any", + // The name accepts "list" as its only argument, and lists either way. + canExecute: canExecuteHooksCommand, + run(context): Promise { + return runHooksCommand(context, true); + }, +}); diff --git a/lib/commands/info.ts b/lib/commands/info.ts index 946f50f934..f2dd0021a8 100644 --- a/lib/commands/info.ts +++ b/lib/commands/info.ts @@ -1,15 +1,13 @@ import { IInfoService } from "../declarations"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; -import { injector } from "../common/yok"; - -export class InfoCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor(private $infoService: IInfoService) {} - - public async execute(args: string[]): Promise { - return this.$infoService.printComponentsInfo(); - } -} - -injector.registerCommand("info", InfoCommand); +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; + +export const infoCommandDefinition = defineCommand({ + name: "info", + description: "Displays version information about the CLI and its components.", + arguments: "none", + run(): Promise { + const $infoService = inject("infoService"); + return $infoService.printComponentsInfo(); + }, +}); diff --git a/lib/commands/install.ts b/lib/commands/install.ts index 32de375055..69ab52f2b2 100644 --- a/lib/commands/install.ts +++ b/lib/commands/install.ts @@ -1,99 +1,123 @@ 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 { 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]; +const installCommandOptions = { + frameworkPath: stringOption(), + disableNpmInstall: booleanOption(), + ignoreScripts: booleanOption(), + path: stringOption(), +} satisfies CommandOptionsSchema; - 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(); - } +async function installProjectDependencies( + 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"); - public async execute(args: string[]): Promise { - return args[0] - ? this.installModule(args[0]) - : this.installProjectDependencies(); - } + let error: string = ""; - private async installProjectDependencies(): Promise { - let error: string = ""; + await $pluginsService.ensureAllDependenciesAreInstalled($projectData); - await this.$pluginsService.ensureAllDependenciesAreInstalled( - this.$projectData + for (const platform of $mobileHelper.platformNames) { + const platformData = $platformsDataService.getPlatformData( + platform, + $projectData, ); + const frameworkPackageData = $projectDataService.getRuntimePackage( + $projectData.projectDir, + platformData.platformNameLowerCase, + ); + if (frameworkPackageData && frameworkPackageData.version) { + try { + const platformProjectService = platformData.platformProjectService; + await platformProjectService.validate($projectData, $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 $platformCommandHelper.addPlatforms( + [`${platform}@${frameworkPackageData.version}`], + $projectData, + context.options.frameworkPath, + ); + } catch (err) { + error = `${error}${EOL}${err}`; } } + } - if (error) { - this.$logger.error(error); - } + if (error) { + $logger.error(error); } +} - private async installModule(moduleName: string): Promise { - const projectDir = this.$projectData.projectDir; +async function installModule( + context: CommandContext, + moduleName: string, +): Promise { + const $projectData = context.injector.get("projectData"); + const $fs = context.injector.get("fs"); + const $packageManager = + context.injector.get("packageManager"); - const devPrefix = "nativescript-dev-"; - if (!this.$fs.exists(moduleName) && moduleName.indexOf(devPrefix) !== 0) { - moduleName = devPrefix + moduleName; - } + const projectDir = $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 (!$fs.exists(moduleName) && moduleName.indexOf(devPrefix) !== 0) { + moduleName = devPrefix + moduleName; } + + await $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 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, + // 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 83c56e443c..9af91cf5d2 100644 --- a/lib/commands/list-platforms.ts +++ b/lib/commands/list-platforms.ts @@ -1,54 +1,50 @@ 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"; -export class ListPlatformsCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - private $platformCommandHelper: IPlatformCommandHelper, - private $projectData: IProjectData, - private $logger: ILogger - ) { - this.$projectData.initializeProjectData(); - } - - public async execute(args: string[]): Promise { - const installedPlatforms = this.$platformCommandHelper.getInstalledPlatforms( - this.$projectData +export const listPlatformsCommandDefinition = defineCommand({ + name: "platform|*list", + description: "Lists all platforms that the project currently targets.", + arguments: "none", + // 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 = + $platformCommandHelper.getInstalledPlatforms($projectData); if (installedPlatforms.length > 0) { - const preparedPlatforms = this.$platformCommandHelper.getPreparedPlatforms( - this.$projectData - ); + const preparedPlatforms = + $platformCommandHelper.getPreparedPlatforms($projectData); if (preparedPlatforms.length > 0) { - this.$logger.info( + $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"); + $logger.info("The project is not prepared for any platform"); } - this.$logger.info( + $logger.info( "Installed platforms: ", - helpers.formatListOfNames(installedPlatforms, "and") + helpers.formatListOfNames(installedPlatforms, "and"), ); } else { const formattedPlatformsList = helpers.formatListOfNames( - this.$platformCommandHelper.getAvailablePlatforms(this.$projectData), - "and" + $platformCommandHelper.getAvailablePlatforms($projectData), + "and", ); - this.$logger.info( - "Available platforms for this OS: ", - formattedPlatformsList - ); - this.$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"); } - } -} - -injector.registerCommand("platform|*list", ListPlatformsCommand); + }, +}); diff --git a/lib/commands/migrate.ts b/lib/commands/migrate.ts index 879f3b5e80..9fb9c88213 100644 --- a/lib/commands/migrate.ts +++ b/lib/commands/migrate.ts @@ -1,43 +1,44 @@ 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"; -export class MigrateCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - private $migrateController: IMigrateController, - private $staticConfig: Config.IStaticConfig, - private $projectData: IProjectData, - private $logger: ILogger - ) { - this.$projectData.initializeProjectData(); - } - - 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", + // 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: this.$projectData.projectDir, + projectDir: $projectData.projectDir, platforms: [ - this.$devicePlatformsConstants.Android, - this.$devicePlatformsConstants.iOS, + $devicePlatformsConstants.Android, + $devicePlatformsConstants.iOS, ], }; - const shouldMigrateResult = await this.$migrateController.shouldMigrate( - migrationData - ); + const shouldMigrateResult = + await $migrateController.shouldMigrate(migrationData); if (!shouldMigrateResult) { - const cliVersion = this.$staticConfig.version; - this.$logger.printMarkdown( - `__Project is compatible with NativeScript \`v${cliVersion}\`__` + const cliVersion = $staticConfig.version; + $logger.printMarkdown( + `__Project is compatible with NativeScript \`v${cliVersion}\`__`, ); return; } - await this.$migrateController.migrate(migrationData); - } -} - -injector.registerCommand("migrate", MigrateCommand); + await $migrateController.migrate(migrationData); + }, +}); diff --git a/lib/commands/native-add.ts b/lib/commands/native-add.ts index 3b5ca90f37..6b995934aa 100644 --- a/lib/commands/native-add.ts +++ b/lib/commands/native-add.ts @@ -1,90 +1,62 @@ -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 { + CommandContext, + CommandName, + defineCommand, +} from "../common/define-command"; +import { inject } from "../common/di"; import { capitalizeFirstLetter } from "../common/utils"; -import { EOL } from "os"; - -export class NativeAddCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - protected $projectData: IProjectData, - 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 native add [swift|objective-c|java|kotlin] [class name]" - ); - } - public async canExecute(args: string[]): Promise { - this.failWithUsage(); - return false; - } - - protected getIosSourcePathBase() { - const resources = this.$projectData.getAppResourcesDirectoryPath(); - return path.join(resources, "iOS", "src"); - } +import { IProjectData } from "../definitions/project"; - protected getAndroidSourcePathBase() { - const resources = this.$projectData.getAppResourcesDirectoryPath(); - return path.join(resources, "Android", "src", "main", "java"); - } +/** + * 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"; + +function failWithUsage($errors: IErrors): void { + $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($projectData: IProjectData): string { + const resources = $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($projectData: IProjectData): string { + const resources = $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 +65,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 +84,151 @@ 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(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(); - 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") { + $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}`); + $logger.info('Added "useKotlin=true" property to gradle.properties.'); } - return true; + } else { + fs.writeFileSync(filePath, `useKotlin=true${EOL}`); + $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(); +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($projectData), + ...packageName.split("."), + ); + const filePath = path.join(packagePath, `${classSimpleName}.${fileExt}`); + + if (fs.existsSync(filePath)) { + $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(ctx)) { + 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); + $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( + $logger: ILogger, + 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(); + if (fs.existsSync(moduleMapPath)) { + moduleMapContent = fs.readFileSync(moduleMapPath, "utf8"); } - 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") - ); - } - } - - private generateOrUpdateModuleMap( - headerFileName: string, - moduleMapPath: string - ): void { - const moduleName = "LocalModule"; - const headerPath = headerFileName; - let moduleMapContent = ""; + const headerDeclaration = `header "${headerPath}"`; - 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 + $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); - } + $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( + 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)) { + $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)) { + $errors.failWithHelp(`Error: File '${interfaceFilePath}' already exists.`); + return false; + } - const interfaceContent = `#import + const interfaceContent = `#import @interface ${className} : NSObject @@ -311,7 +237,7 @@ export class NativeAddObjectiveCCommand extends NativeAddSingleCommand { @end `; - const classContent = `#import "${className}.h" + const classContent = `#import "${className}.h" @implementation ${className} @@ -322,50 +248,56 @@ export class NativeAddObjectiveCCommand extends NativeAddSingleCommand { @end `; - fs.writeFileSync(classFilePath, classContent); - this.$logger.trace( - `Objective-C class file '${classFilePath}' generated successfully.` - ); + fs.writeFileSync(classFilePath, classContent); + $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); + $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); - } +function generateObjectiveC(ctx: CommandContext, className: string): void { + const $projectData = ctx.injector.get("projectData"); + const $logger = ctx.injector.get("logger"); + const iosSourceBase = getIosSourcePathBase($projectData); - 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(); + if (generateObjectiveCFiles(ctx, className, classFilePath, headerFilePath)) { + // Modify/Generate moduleMap + generateOrUpdateModuleMap( + $logger, + `${className}.h`, + path.join(iosSourceBase, "module.modulemap"), + ); } +} - private doSwift(className: string) { - const iosSourceBase = this.getIosSourcePathBase(); - const swiftFilePath = path.join(iosSourceBase, `${className}.swift`); - this.generateSwiftFile(className, swiftFilePath); +function generateSwiftFile( + 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 }); + $logger.trace(`Created directory: '${directory}'.`); } - private generateSwiftFile(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(filePath)) { - this.$errors.failWithHelp(`Error: File '${filePath}' already exists.`); - return; - } + if (fs.existsSync(filePath)) { + $errors.failWithHelp(`Error: File '${filePath}' already exists.`); + return; + } - const content = `import Foundation; + const content = `import Foundation; import os; @objc class ${className}: NSObject { @@ -374,16 +306,87 @@ import os; } }`; - fs.writeFileSync(filePath, content); - this.$logger.info(`Swift file '${filePath}' generated successfully.`); - } + fs.writeFileSync(filePath, content); + $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 +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(ctx, className, swiftFilePath); +} + +const generators: Record< + NativeAddLanguage, + (ctx: CommandContext, className: string) => void +> = { + java: (ctx, className) => generateJavaKotlin(ctx, className, "java"), + kotlin: (ctx, className) => generateJavaKotlin(ctx, 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() { + inject("projectData").initializeProjectData(); + }, + canExecute(): boolean { + failWithUsage(inject("errors")); + return false; + }, + run(): void { + failWithUsage(inject("errors")); + }, +}); + +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() { + inject("projectData").initializeProjectData(); + }, + canExecute(context): boolean { + const $errors = inject("errors"); + + if (context.args.length !== 1) { + failWithUsage($errors); + } + + return true; + }, + run(context): void { + generators[language](context, context.args[0]); + }, + }); + +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/open.ts b/lib/commands/open.ts new file mode 100644 index 0000000000..9d614ab2ba --- /dev/null +++ b/lib/commands/open.ts @@ -0,0 +1,223 @@ +import * as fs from "fs"; +import { platform as currentPlatform } from "os"; +import * as path from "path"; +import { IChildProcess, IXcodeSelectService } from "../common/declarations"; +import { + booleanOption, + CommandContext, + 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"; + +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. + */ +async function openAndroidStudioProject( + context: CommandContext, + platform: string, + isInteractive: boolean, +): Promise { + 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; + 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) { + $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") { + $childProcess.exec(`open -a "${studioPath}" ${androidDir}`); + } else if (os === "win32") { + const child = $childProcess.spawn(studioPath, [androidDir], { + detached: true, + stdio: "ignore", + }); + child.unref(); + } else if (os === "linux") { + $childProcess.exec(`${studioPath} ${androidDir}`); + } +} + +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") { + $logger.error("Opening a project in XCode requires macOS."); + return; + } + + $projectData.initializeProjectData(); + const platformDir = path.resolve($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 = $iOSProjectService.getPlatformData($projectData); + const xcprojectFile = $xcodebuildArgsService.getXcodeProjectArgs( + platformData, + $projectData, + )[1]; + + if (fs.existsSync(xcprojectFile)) { + $xcodeSelectService + .getDeveloperDirectoryPath() + .then(() => $childProcess.exec(`open ${xcprojectFile}`, {})) + .catch((e) => { + $logger.error(e.message); + }); + } else { + $logger.error(`Unable to open project file: ${xcprojectFile}`); + } +} + +async function openVisionOSProject( + context: CommandContext, + $options: IOptions, + isInteractive: boolean, +): Promise { + $options.platformOverride = "visionOS"; + await openXcodeProject(context, "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. + * It is restored afterwards because a key shortcut runs this inside a process + * whose own live sync is still watching. + */ +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({ + name: "open|ios", + description: "Opens the project in Xcode.", + options: openCommandOptions, + arguments: "none", + async run(context): Promise { + const $options = inject("options"); + await withoutWatch($options, () => openXcodeProject(context, "ios", false)); + }, +}); + +export const visionOpenCommand = defineCommand({ + name: ["open|visionos", "open|vision"], + description: "Opens the visionOS project in Xcode.", + options: openCommandOptions, + arguments: "none", + async run(context): Promise { + const $options = inject("options"); + await withoutWatch($options, () => + openVisionOSProject(context, $options, false), + ); + }, +}); + +export const androidOpenCommand = defineCommand({ + name: "open|android", + description: "Opens the project in Android Studio.", + options: openCommandOptions, + arguments: "none", + async run(context): Promise { + const $options = inject("options"); + await withoutWatch($options, () => + openAndroidStudioProject(context, "Android", false), + ); + }, +}); diff --git a/lib/commands/platform-clean.ts b/lib/commands/platform-clean.ts index 2cce2cd10c..873558cac9 100644 --- a/lib/commands/platform-clean.ts +++ b/lib/commands/platform-clean.ts @@ -6,56 +6,67 @@ 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 { + Command, + CommandOptionsSchema, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; -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 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"); - public async execute(args: string[]): Promise { - await this.$platformCommandHelper.cleanPlatforms( - args, - this.$projectData, - this.$options.frameworkPath - ); + constructor() { + super(); + this.$projectData.initializeProjectData(); } - public async canExecute(args: string[]): Promise { + 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." + "No platform specified. Please specify a platform to clean.", ); } _.each(args, (platform) => { this.$platformValidationService.validatePlatform( platform, - this.$projectData + this.$projectData, ); }); for (const platform of args) { this.$platformValidationService.validatePlatformInstalled( platform, - this.$projectData + this.$projectData, ); - const currentRuntimeVersion = this.$platformCommandHelper.getCurrentPlatformVersion( - platform, - this.$projectData - ); + const currentRuntimeVersion = + this.$platformCommandHelper.getCurrentPlatformVersion( + platform, + this.$projectData, + ); await this.$platformEnvironmentRequirements.checkEnvironmentRequirements({ platform, projectDir: this.$projectData.projectDir, @@ -66,6 +77,12 @@ export class CleanCommand implements ICommand { return true; } -} -injector.registerCommand("platform|clean", CleanCommand); + public async run(): Promise { + await this.$platformCommandHelper.cleanPlatforms( + this.args, + this.$projectData, + this.options.frameworkPath, + ); + } +} diff --git a/lib/commands/plugin/add-plugin.ts b/lib/commands/plugin/add-plugin.ts index b7102ba0f7..96b3d9dab4 100644 --- a/lib/commands/plugin/add-plugin.ts +++ b/lib/commands/plugin/add-plugin.ts @@ -1,45 +1,43 @@ 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 { defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; -export class AddPluginCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export const addPluginCommandDefinition = defineCommand({ + name: ["plugin|add", "plugin|install"], + description: "Installs the specified plugin and its dependencies.", + arguments: "any", + async canExecute(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + const $errors = inject("errors"); + $projectData.initializeProjectData(); - constructor( - private $pluginsService: IPluginsService, - private $projectData: IProjectData, - private $errors: IErrors - ) { - this.$projectData.initializeProjectData(); - } - - public async execute(args: string[]): Promise { - return this.$pluginsService.add(args[0], this.$projectData); - } - - public async canExecute(args: string[]): Promise { - if (!args[0]) { - this.$errors.failWithHelp("You must specify plugin name."); + if (!context.args[0]) { + $errors.failWithHelp("You must specify plugin name."); } - const installedPlugins = await this.$pluginsService.getAllInstalledPlugins( - this.$projectData - ); - const pluginName = args[0].toLowerCase(); + const installedPlugins = + await $pluginsService.getAllInstalledPlugins($projectData); + const pluginName = context.args[0].toLowerCase(); if ( _.some( installedPlugins, - (plugin: IPluginData) => plugin.name.toLowerCase() === pluginName + (plugin: IPluginData) => plugin.name.toLowerCase() === pluginName, ) ) { - this.$errors.fail(`Plugin "${pluginName}" is already installed.`); + $errors.fail(`Plugin "${pluginName}" is already installed.`); } return true; - } -} + }, + run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); -injector.registerCommand(["plugin|add", "plugin|install"], AddPluginCommand); + return $pluginsService.add(context.args[0], $projectData); + }, +}); diff --git a/lib/commands/plugin/build-plugin.ts b/lib/commands/plugin/build-plugin.ts index 09c2800f99..9c43357b14 100644 --- a/lib/commands/plugin/build-plugin.ts +++ b/lib/commands/plugin/build-plugin.ts @@ -1,42 +1,71 @@ 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 { + Command, + CommandOptionsSchema, + stringOption, +} from "../../common/define-command"; +import { inject } from "../../common/di"; import { ITempService } from "../../definitions/temp-service"; -export class BuildPluginCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - public pluginProjectPath: string; +const buildPluginCommandOptions = { + path: stringOption(), + gradlePath: stringOption(), + gradleArgs: stringOption(), +} satisfies CommandOptionsSchema; + +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 || "."); - constructor( - private $androidPluginBuildService: IAndroidPluginBuildService, - private $errors: IErrors, - private $logger: ILogger, - private $fs: IFileSystem, - private $options: IOptions, - private $tempService: ITempService - ) { - this.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`.", + ); + } + + return true; } - public async execute(args: string[]): Promise { + public async run(): Promise { const platformsAndroidPath = path.join( this.pluginProjectPath, constants.PLATFORMS_DIR_NAME, - "android" + "android", ); let pluginName = ""; const pluginPackageJsonPath = path.join( this.pluginProjectPath, - constants.PACKAGE_JSON_FILE_NAME + constants.PACKAGE_JSON_FILE_NAME, ); if (this.$fs.exists(pluginPackageJsonPath)) { @@ -47,55 +76,32 @@ export class BuildPluginCommand implements ICommand { } } - const tempAndroidProject = await this.$tempService.mkdirSync( - "android-project" - ); + const tempAndroidProject = + await this.$tempService.mkdirSync("android-project"); const options: IPluginBuildOptions = { - gradlePath: this.$options.gradlePath, - gradleArgs: this.$options.gradleArgs, + gradlePath: this.options.gradlePath, + gradleArgs: this.options.gradleArgs, aarOutputDir: platformsAndroidPath, platformsAndroidDirPath: platformsAndroidPath, pluginName: pluginName, tempPluginDirPath: tempAndroidProject, }; - const androidPluginBuildResult = await this.$androidPluginBuildService.buildAar( - options - ); + 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}.` + `${pluginName} successfully built aar at ${platformsAndroidPath}.${EOL}Temporary Android project can be found at ${tempAndroidProject}.`, ); } - const migratedIncludeGradle = this.$androidPluginBuildService.migrateIncludeGradle( - options - ); + const migratedIncludeGradle = + this.$androidPluginBuildService.migrateIncludeGradle(options); 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`." - ); - } - - return true; - } } - -injector.registerCommand("plugin|build", BuildPluginCommand); diff --git a/lib/commands/plugin/create-plugin.ts b/lib/commands/plugin/create-plugin.ts index 2e6f9ea2d4..62ee77059e 100644 --- a/lib/commands/plugin/create-plugin.ts +++ b/lib/commands/plugin/create-plugin.ts @@ -1,39 +1,64 @@ 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 { + Command, + CommandOptionsSchema, + stringOption, +} from "../../common/define-command"; +import { inject } from "../../common/di"; 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; +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"; + +const createPluginCommandOptions = { + path: stringOption(), + template: stringOption(), + username: stringOption(), + pluginName: stringOption(), + includeTypeScriptDemo: stringOption(), + includeAngularDemo: stringOption(), +} satisfies CommandOptionsSchema; + +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."); + } + + return true; + } + + 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); @@ -44,111 +69,36 @@ export class CreatePluginCommand implements ICommand { 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. + // 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 - ); - } - - public async canExecute(args: string[]): Promise { - if (!args[0]) { - this.$errors.failWithHelp("You must specify the plugin repository name."); - } - - return true; - } - - private async setupSeed( - projectDir: string, - pluginRepoName: string - ): Promise { - this.$logger.printMarkdown( - "Executing initial plugin configuration script..." - ); - - 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(); - } - - const gitHubUsername = await this.getGitHubUsername(config.username); - const pluginNameSource = await this.getPluginNameSource( - config.pluginName, - pluginRepoName - ); - const includeTypescriptDemo = await this.getShouldIncludeDemoResult( - config.includeTypeScriptDemo, - this.includeTypeScriptDemoMessage - ); - const includeAngularDemo = await this.getShouldIncludeDemoResult( - config.includeAngularDemo, - this.includeAngularDemoMessage - ); - - 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." - ); - } - - // 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 } + pluginRepoName, ); - if (outputScript && outputScript.stdout) { - this.$logger.printMarkdown(outputScript.stdout); - } } private ensurePackageDir(projectDir: string): void { this.$fs.createDirectory(projectDir); if (this.$fs.exists(projectDir) && !this.$fs.isEmptyDir(projectDir)) { - this.$errors.fail(this.pathAlreadyExistsMessageTemplate, projectDir); + this.$errors.fail(PATH_ALREADY_EXISTS_MESSAGE_TEMPLATE, projectDir); } } private async downloadPackage( selectedTemplate: string, - projectDir: 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/" + "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..." + "Downloading the latest version of NativeScript Plugin Seed...", ); } @@ -168,7 +118,7 @@ export class CreatePluginCommand implements ICommand { if (!gitHubUsername) { gitHubUsername = "NativeScriptDeveloper"; if (isInteractive()) { - gitHubUsername = await this.$prompter.getString(this.userMessage, { + gitHubUsername = await this.$prompter.getString(USER_MESSAGE, { allowEmpty: false, defaultAction: () => { return gitHubUsername; @@ -182,7 +132,7 @@ export class CreatePluginCommand implements ICommand { private async getPluginNameSource( pluginNameSource: string, - pluginRepoName: string + pluginRepoName: string, ): Promise { if (!pluginNameSource) { // remove nativescript- prefix for naming plugin files @@ -191,7 +141,7 @@ export class CreatePluginCommand implements ICommand { ? pluginRepoName.slice(prefix.length, pluginRepoName.length) : pluginRepoName; if (isInteractive()) { - pluginNameSource = await this.$prompter.getString(this.nameMessage, { + pluginNameSource = await this.$prompter.getString(NAME_MESSAGE, { allowEmpty: false, defaultAction: () => { return pluginNameSource; @@ -205,7 +155,7 @@ export class CreatePluginCommand implements ICommand { private async getShouldIncludeDemoResult( includeDemoOption: string, - message: string + message: string, ): Promise { let shouldIncludeDemo = !!includeDemoOption; if (!includeDemoOption && isInteractive()) { @@ -216,6 +166,71 @@ export class CreatePluginCommand implements ICommand { return shouldIncludeDemo ? "y" : "n"; } -} -injector.registerCommand(["plugin|create"], CreatePluginCommand); + private async setupSeed( + projectDir: string, + pluginRepoName: string, + ): Promise { + this.$logger.printMarkdown( + "Executing initial plugin configuration script...", + ); + + 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(); + } + + 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, + ); + + 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.", + ); + } + + // 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/plugin/list-plugins.ts b/lib/commands/plugin/list-plugins.ts index 365396657c..4341028f76 100644 --- a/lib/commands/plugin/list-plugins.ts +++ b/lib/commands/plugin/list-plugins.ts @@ -5,73 +5,71 @@ 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 { color } from "../../color"; -export class ListPluginsCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - private $pluginsService: IPluginsService, - private $projectData: IProjectData, - private $logger: ILogger - ) { - this.$projectData.initializeProjectData(); - } +function createTableCells(items: IBasePluginData[]): string[][] { + return items.map((item) => [item.name, item.version]); +} - public async execute(args: string[]): Promise { - const installedPlugins: IPackageJsonDepedenciesResult = this.$pluginsService.getDependenciesFromPackageJson( - this.$projectData.projectDir - ); +export const listPluginsCommandDefinition = defineCommand({ + name: "plugin|*list", + description: "Lists all installed plugins.", + arguments: "none", + // 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 = + $pluginsService.getDependenciesFromPackageJson($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()); + $logger.info("Dependencies:"); + $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()); + $logger.info("Dev Dependencies:"); + $logger.info(devDependenciesTable.toString()); } else { - this.$logger.info("There are no dev dependencies."); + $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}` + $logger.warn("NOTE:"); + $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}` + $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); + }, +}); diff --git a/lib/commands/plugin/remove-plugin.ts b/lib/commands/plugin/remove-plugin.ts index 29f5cdfae9..a2cfe193df 100644 --- a/lib/commands/plugin/remove-plugin.ts +++ b/lib/commands/plugin/remove-plugin.ts @@ -1,50 +1,48 @@ 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(); - } - - public async execute(args: string[]): Promise { - return this.$pluginsService.remove(args[0], this.$projectData); - } - - public async canExecute(args: string[]): Promise { - if (!args[0]) { - this.$errors.failWithHelp("You must specify plugin name."); +import { defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; + +export const removePluginCommandDefinition = defineCommand({ + name: "plugin|remove", + description: "Uninstalls the specified plugin and its dependencies.", + arguments: "any", + 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 this.$pluginsService.getAllInstalledPlugins( - this.$projectData - ); + const installedPlugins = + await $pluginsService.getAllInstalledPlugins($projectData); pluginNames = installedPlugins.map((pl) => pl.name); } catch (err) { - this.$logger.trace("Error while installing plugins. Error is:", err); - pluginNames = _.keys(this.$projectData.dependencies); + $logger.trace("Error while installing plugins. Error is:", err); + pluginNames = _.keys($projectData.dependencies); } - const pluginName = args[0].toLowerCase(); + const pluginName = context.args[0].toLowerCase(); if (!_.some(pluginNames, (name) => name.toLowerCase() === pluginName)) { - this.$errors.fail(`Plugin "${pluginName}" is not installed.`); + $errors.fail(`Plugin "${pluginName}" is not installed.`); } return true; - } -} - -injector.registerCommand("plugin|remove", RemovePluginCommand); + }, + 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 64d736068e..44de94e80d 100644 --- a/lib/commands/plugin/update-plugin.ts +++ b/lib/commands/plugin/update-plugin.ts @@ -1,58 +1,56 @@ 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 UpdatePluginCommand implements ICommand { - constructor( - private $pluginsService: IPluginsService, - private $projectData: IProjectData, - private $errors: IErrors - ) { - this.$projectData.initializeProjectData(); - } - - public async execute(args: string[]): Promise { - let pluginNames = args; - - if (!pluginNames || args.length === 0) { - const installedPlugins = await this.$pluginsService.getAllInstalledPlugins( - this.$projectData - ); - pluginNames = installedPlugins.map((p) => p.name); - } - - for (const pluginName of pluginNames) { - await this.$pluginsService.remove(pluginName, this.$projectData); - await this.$pluginsService.add(pluginName, this.$projectData); - } - } - - public async canExecute(args: string[]): Promise { +import { defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; + +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(); + + const args = context.args; if (!args || args.length === 0) { return true; } - const installedPlugins = await this.$pluginsService.getAllInstalledPlugins( - this.$projectData - ); + const installedPlugins = + await $pluginsService.getAllInstalledPlugins($projectData); const installedPluginNames: string[] = installedPlugins.map( - (pl) => pl.name + (pl) => pl.name, ); const pluginName = args[0].toLowerCase(); if ( !_.some(installedPluginNames, (name) => name.toLowerCase() === pluginName) ) { - this.$errors.fail(`Plugin "${pluginName}" is not installed.`); + $errors.fail(`Plugin "${pluginName}" is not installed.`); } return true; - } + }, + async run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + let pluginNames = context.args; - public allowedParameters: ICommandParameter[] = []; -} + if (!pluginNames || context.args.length === 0) { + const installedPlugins = + await $pluginsService.getAllInstalledPlugins($projectData); + pluginNames = installedPlugins.map((p) => p.name); + } -injector.registerCommand("plugin|update", UpdatePluginCommand); + for (const pluginName of pluginNames) { + await $pluginsService.remove(pluginName, $projectData); + await $pluginsService.add(pluginName, $projectData); + } + }, +}); diff --git a/lib/commands/post-install.ts b/lib/commands/post-install.ts index 6062467680..8206cdec7d 100644 --- a/lib/commands/post-install.ts +++ b/lib/commands/post-install.ts @@ -1,30 +1,30 @@ -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"; - -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, - ) {} +import { CommandsService } from "../common/contracts/commands-service"; +import { Command } from "../common/define-command"; +import { inject } from "../common/di"; +import { doesCurrentNpmCommandMatch } from "../common/helpers"; - public disableAnalytics = true; - public allowedParameters: ICommandParameter[] = []; +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"); - public async execute(args: string[]): Promise { + public async run(): Promise { const isRunningWithSudoUser = !!process.env.SUDO_USER; if (!this.$hostInfo.isWindows) { @@ -48,11 +48,15 @@ export class PostInstallCliCommand implements ICommand { // Explicitly ask for confirmation of usage-reporting: await this.$analyticsService.checkConsent(); - await this.$commandsService.tryExecuteCommand("autocomplete", []); + await this.$commandsService.runCommand("autocomplete"); } } - public async postCommandAction(args: string[]): Promise { + public postRun(): void { + this.reportSuccessfulInstallation(); + } + + private reportSuccessfulInstallation(): void { this.$logger.info(""); this.$logger.info( color.styleText( @@ -70,5 +74,3 @@ export class PostInstallCliCommand implements ICommand { ); } } - -injector.registerCommand("post-install-cli", PostInstallCliCommand); diff --git a/lib/commands/prepare.ts b/lib/commands/prepare.ts index 03323b9e6c..29780ef800 100644 --- a/lib/commands/prepare.ts +++ b/lib/commands/prepare.ts @@ -1,89 +1,87 @@ -import { ValidatePlatformCommandBase } from "./command-base"; +import { + canExecuteCommandBase, + 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 { IOptions } from "../declarations"; +import { IProjectData } from "../definitions/project"; + +export const prepareCommandOptions = { + watch: booleanOption({ default: false }), + hmr: booleanOption({ default: false }), + skipNative: booleanOption({ default: false }), + force: booleanOption(), +} satisfies CommandOptionsSchema; -export class PrepareCommand - extends ValidatePlatformCommandBase - implements ICommand -{ - public allowedParameters = [this.$platformCommandParameter]; +type PrepareCommandContext = CommandContext; - 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, - }, - }; +async function canExecutePrepareCommand( + context: PrepareCommandContext, +): Promise { + const $migrateController = + context.injector.get("migrateController"); + const $projectData = context.injector.get("projectData"); - 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(); + 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(context, platform); - const prepareData = this.$prepareDataService.getPrepareData( - this.$projectData.projectDir, - platform, - this.$options, - ); - await this.$prepareController.prepare(prepareData); + if (!context.options.force) { + await $migrateController.validate({ + projectDir: $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 (!result) { + return false; + } - if (!this.$options.force) { - await this.$migrateController.validate({ - projectDir: this.$projectData.projectDir, - platforms: [platform], - }); - } + return canExecuteCommandBase(context, platform); +} - if (!result) { - return false; - } +export async function runPrepareCommand( + context: PrepareCommandContext, +): Promise { + 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 canExecuteOutput = await super.canExecuteCommandBase(platform); - return canExecuteOutput; - } + const prepareData = $prepareDataService.getPrepareData( + $projectData.projectDir, + context.args[0], + $options, + ); + await $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() { + inject("projectData").initializeProjectData(); + }, + canExecute: canExecutePrepareCommand, + run: runPrepareCommand, +}); diff --git a/lib/commands/preview.ts b/lib/commands/preview.ts index 64bbb9e8eb..0ef938950b 100644 --- a/lib/commands/preview.ts +++ b/lib/commands/preview.ts @@ -1,92 +1,115 @@ -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, + Command, + CommandOptionsSchema, +} from "../common/define-command"; +import { inject } from "../common/di"; +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[] = []; - skipOptionsValidation = true; +const previewCommandOptions = { + disableNpmInstall: booleanOption(), +} satisfies CommandOptionsSchema; - constructor( - private $logger: ILogger, - private $errors: IErrors, - private $projectData: IProjectData, - private $packageManager: IPackageManager, - private $childProcess: IChildProcess, - private $options: IOptions, - ) {} +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"); - private getPreviewCLIPath(): string { - return resolvePackagePath(PREVIEW_CLI_PACKAGE, { - paths: [this.$projectData.projectDir], - }); - } - - 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, - ); + public async run(): Promise { + if (!this.options.disableNpmInstall) { + await this.installLatestPreviewCLI(); } const previewCLIPath = this.getPreviewCLIPath(); if (!previewCLIPath) { - const packageManagerName = - await this.$packageManager.getPackageManagerName(); - let installCommand = ""; + await this.failMissingPreviewCLI(); + } + + const previewCLIBinPath = path.resolve(previewCLIPath, "./dist/index.js"); + this.spawnPreviewCLI(previewCLIBinPath); + } - 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"), - ); + private async installLatestPreviewCLI(): Promise { + await this.$packageManager.install( + `${PREVIEW_CLI_PACKAGE}@latest`, + this.$projectData.projectDir, + { + "save-dev": true, + "save-exact": true, + } as any, + ); + } - this.$errors.fail("Running preview failed."); + private getPreviewCLIPath(): string { + return resolvePackagePath(PREVIEW_CLI_PACKAGE, { + paths: [this.$projectData.projectDir], + }); + } + + private async failMissingPreviewCLI(): Promise { + const packageManagerName = + await this.$packageManager.getPackageManagerName(); + let installCommand = ""; + + 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"), + ); - const previewCLIBinPath = path.resolve(previewCLIPath, "./dist/index.js"); + this.$errors.fail("Running preview failed."); + } + 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( @@ -97,10 +120,4 @@ export class PreviewCommand implements ICommand { }, ); } - - async canExecute(args: string[]): Promise { - return true; - } } - -injector.registerCommand("preview", PreviewCommand); diff --git a/lib/commands/remove-platform.ts b/lib/commands/remove-platform.ts index db5c5cb427..77b36f829d 100644 --- a/lib/commands/remove-platform.ts +++ b/lib/commands/remove-platform.ts @@ -4,42 +4,43 @@ import { IPlatformCommandHelper, IPlatformValidationService, } from "../declarations"; -import { injector } from "../common/yok"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { IErrors } from "../common/declarations"; +import { defineCommand } from "../common/define-command"; +import { inject } from "../common/di"; -export class RemovePlatformCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +export const removePlatformCommandDefinition = defineCommand({ + name: "platform|remove", + description: + "Removes the selected platform from the platforms that the project currently targets.", + arguments: "any", + async canExecute(context): Promise { + const $errors = inject("errors"); + const $platformValidationService = inject( + "platformValidationService", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); - constructor( - private $errors: IErrors, - private $platformCommandHelper: IPlatformCommandHelper, - private $platformValidationService: IPlatformValidationService, - private $projectData: IProjectData - ) { - this.$projectData.initializeProjectData(); - } - - public execute(args: string[]): Promise { - return this.$platformCommandHelper.removePlatforms(args, this.$projectData); - } - - public async canExecute(args: string[]): Promise { + const args = context.args; if (!args || args.length === 0) { - this.$errors.failWithHelp( - "No platform specified. Please specify a platform to remove." + $errors.failWithHelp( + "No platform specified. Please specify a platform to remove.", ); } _.each(args, (platform) => { - this.$platformValidationService.validatePlatform( - platform, - this.$projectData - ); + $platformValidationService.validatePlatform(platform, $projectData); }); return true; - } -} + }, + run(context): Promise { + const $platformCommandHelper = inject( + "platformCommandHelper", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); -injector.registerCommand("platform|remove", RemovePlatformCommand); + return $platformCommandHelper.removePlatforms(context.args, $projectData); + }, +}); diff --git a/lib/commands/resources/resources-update.ts b/lib/commands/resources/resources-update.ts index 5e87439003..4dfcf765cd 100644 --- a/lib/commands/resources/resources-update.ts +++ b/lib/commands/resources/resources-update.ts @@ -1,52 +1,60 @@ 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"; - -export class ResourcesUpdateCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor( - private $projectData: IProjectData, - private $errors: IErrors, - private $androidResourcesMigrationService: IAndroidResourcesMigrationService - ) { - this.$projectData.initializeProjectData(); - } - - public async execute(args: string[]): Promise { - await this.$androidResourcesMigrationService.migrate( - this.$projectData.getAppResourcesDirectoryPath() - ); - } - - public async canExecute(args: string[]): Promise { +import { defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; + +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", + ); + 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 + // 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 (!this.$androidResourcesMigrationService.canMigrate(platform)) { - this.$errors.fail( - `The ${platform} does not need to have its resources updated.` + if (!$androidResourcesMigrationService.canMigrate(platform)) { + $errors.fail( + `The ${platform} does not need to have its resources updated.`, ); } if ( - this.$androidResourcesMigrationService.hasMigrated( - this.$projectData.getAppResourcesDirectoryPath() + $androidResourcesMigrationService.hasMigrated( + $projectData.getAppResourcesDirectoryPath(), ) ) { - this.$errors.fail( - "The App_Resources have already been updated for the Android platform." + $errors.fail( + "The App_Resources have already been updated for the Android platform.", ); } } return true; - } -} - -injector.registerCommand("resources|update", ResourcesUpdateCommand); + }, + async run(): Promise { + const $androidResourcesMigrationService = + inject( + "androidResourcesMigrationService", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + await $androidResourcesMigrationService.migrate( + $projectData.getAppResourcesDirectoryPath(), + ); + }, +}); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index 8b7e789c1b..4d21a898ac 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -1,14 +1,15 @@ 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"; + booleanOption, + CommandContext, + CommandName, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; import { hasValidAndroidSigning } from "../common/helpers"; -import { injector } from "../common/yok"; import { ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, ANDROID_RELEASE_BUILD_ERROR_MESSAGE, @@ -16,210 +17,262 @@ import { import { IOptions, IPlatformValidationService } from "../declarations"; import { IMigrateController } from "../definitions/migrate"; import { IProjectData, IProjectDataService } from "../definitions/project"; +import { + DevicePlatformName, + IKeyShortcutService, + KeyShortcut, + keyShortcuts, + restartShortcut, + watcherShortcut, +} from "../services/key-shortcuts"; -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 - ); +const runCommandOptions = { + force: booleanOption(), + release: booleanOption(), + aab: booleanOption(), + keyStorePath: stringOption(), + keyStorePassword: stringOption(), + keyStoreAlias: stringOption(), + keyStoreAliasPassword: stringOption(), +} satisfies CommandOptionsSchema; - if (process.env.NS_IS_INTERACTIVE) { - this.$keyCommandHelper.attachKeyCommands( - this.platform as IKeyCommandPlatform, - "run" - ); - } - } +type RunCommandContext = CommandContext; - public async canExecute(args: string[]): Promise { - if (args.length) { - this.$errors.failWithHelp(ERROR_NO_VALID_SUBCOMMAND_FORMAT, "run"); - } +/** + * Which `$devicePlatformsConstants` entry a command runs for. The constants + * stay the source of truth for the platform spelling. + */ +type RunPlatform = "iOS" | "Android" | "visionOS"; - this.platform = args[0] || this.platform; - if (!this.platform && !this.$hostInfo.isDarwin) { - this.platform = this.$devicePlatformsConstants.Android; - } +const runPlatformName = ( + context: RunCommandContext, + platform: RunPlatform, +): string => + context.injector.get( + "devicePlatformsConstants", + )[platform]; - 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, - }); - } +async function canExecuteRunCommand( + context: RunCommandContext, + platform: string, +): Promise { + 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 (context.args.length) { + $errors.failWithHelp(ERROR_NO_VALID_SUBCOMMAND_FORMAT, "run"); + } - await this.$liveSyncCommandHelper.validatePlatform(this.platform); + $projectData.initializeProjectData(); + const platforms = platform + ? [platform] + : [$devicePlatformsConstants.Android, $devicePlatformsConstants.iOS]; - return true; + if (!context.options.force) { + await $migrateController.validate({ + projectDir: $projectData.projectDir, + platforms, + }); } + + await $liveSyncCommandHelper.validatePlatform(platform); + + return true; } -injector.registerCommand("run|*all", RunCommandBase); +async function runRunCommand( + context: RunCommandContext, + platform: string, +): Promise { + const $keyShortcutService = + context.injector.get("keyShortcutService"); + const $liveSyncCommandHelper = context.injector.get( + "liveSyncCommandHelper", + ); -export class RunIosCommand implements ICommand { - @cache() - protected get runCommand(): RunCommandBase { - const runCommand = this.$injector.resolve(RunCommandBase); - runCommand.platform = this.platform; - return runCommand; - } + await $liveSyncCommandHelper.executeCommandLiveSync( + platform, + {}, + ); - public allowedParameters: ICommandParameter[] = []; - public get platform(): string { - return this.$devicePlatformsConstants.iOS; + if (process.env.NS_IS_INTERACTIVE) { + $keyShortcutService.attach({ + context: { + platform: platform, + processType: "run", + }, + shortcuts: keyShortcuts(), + }); } +} - 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); +/** + * 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. + */ +function runCommandShortcuts( + context: RunCommandContext, + platform: string, +): 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 []; } - public async canExecute(args: string[]): Promise { - const projectData = this.$projectDataService.getProjectData(); + return [ + restartShortcut({ platform: platform }), + restartShortcut({ platform: platform, full: true }), + restartShortcut({ + platform: platform, + forceRebuildNativeApp: true, + }), + watcherShortcut(), + ]; +} - if ( - !this.$platformValidationService.isPlatformSupportedForOS( - this.platform, - projectData - ) - ) { - this.$errors.fail( - `Applications for platform ${this.platform} can not be built on this OS` - ); - } +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", + /** + * 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, +}); - const result = - (await this.runCommand.canExecute(args)) && - (await this.$platformValidationService.validateOptions( - this.$options.provision, - this.$options.teamId, - projectData, - this.platform.toLowerCase() - )); - return result; +async function canExecuteApplePlatformRunCommand( + context: RunCommandContext, + platform: string, +): Promise { + 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 ( + !$platformValidationService.isPlatformSupportedForOS(platform, projectData) + ) { + $errors.fail( + `Applications for platform ${platform} can not be built on this OS`, + ); } + + const result = + (await canExecuteRunCommand(context, platform)) && + (await $platformValidationService.validateOptions( + $options.provision, + $options.teamId, + projectData, + platform.toLowerCase(), + )); + return result; } -injector.registerCommand("run|ios", RunIosCommand); +const defineApplePlatformRunCommand = ( + name: TName, + platform: "iOS" | "visionOS", +) => + defineCommand({ + name, + description: "Runs your project on a connected Apple device or simulator.", + options: runCommandOptions, + arguments: "any", + 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 class RunAndroidCommand implements ICommand { - @cache() - private get runCommand(): RunCommandBase { - const runCommand = this.$injector.resolve(RunCommandBase); - runCommand.platform = this.platform; - return runCommand; - } +export const iosRunCommand = defineApplePlatformRunCommand("run|ios", "iOS"); - public allowedParameters: ICommandParameter[] = []; - public get platform(): string { - return this.$devicePlatformsConstants.Android; - } +export const visionRunCommand = defineApplePlatformRunCommand( + ["run|vision", "run|visionos"], + "visionOS", +); - 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); - } +export const androidRunCommand = defineCommand({ + name: "run|android", + description: "Runs your project on a connected Android device or emulator.", + options: runCommandOptions, + arguments: "any", + 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"); - public async canExecute(args: string[]): Promise { - await this.runCommand.canExecute(args); + // 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, platform); if ( - !this.$platformValidationService.isPlatformSupportedForOS( - this.$devicePlatformsConstants.Android, - this.$projectData + !$platformValidationService.isPlatformSupportedForOS( + platform, + $projectData, ) ) { - this.$errors.fail( - `Applications for platform ${this.$devicePlatformsConstants.Android} can not be built on this OS` + $errors.fail( + `Applications for platform ${platform} 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) { + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - this.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + $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 $platformValidationService.validateOptions( + $options.provision, + $options.teamId, + $projectData, + platform.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 - ); - } -} - -injector.registerCommand("run|vision", RunVisionOSCommand); -injector.registerCommand("run|visionos", RunVisionOSCommand); + }, + 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 5bb22dd6c2..17879c24db 100644 --- a/lib/commands/setup.ts +++ b/lib/commands/setup.ts @@ -1,14 +1,13 @@ -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"; -export class SetupCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - constructor(private $doctorService: IDoctorService) {} - - public execute(args: string[]): Promise { - return this.$doctorService.runSetupScript(); - } -} -injector.registerCommand("setup|*", SetupCommand); +export const setupCommandDefinition = defineCommand({ + name: "setup|*", + description: + "Run the setup script to try to automatically configure your environment.", + arguments: "none", + run(): Promise { + return inject("doctorService").runSetupScript(); + }, +}); diff --git a/lib/commands/start.ts b/lib/commands/start.ts index 4fc1ec3d6b..9070b4770f 100644 --- a/lib/commands/start.ts +++ b/lib/commands/start.ts @@ -1,19 +1,17 @@ -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 { 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", + async run(): Promise { + const $startService = inject("startService"); printHeader(); - this.$startService.start(); + // Left unawaited: the command returns while the service keeps running. + $startService.start(); return; - } - allowedParameters: ICommandParameter[]; - async canExecute?(args: string[]): Promise { - return true; - } -} - -injector.registerCommand("start", StartCommand); + }, +}); diff --git a/lib/commands/test-init.ts b/lib/commands/test-init.ts index 8cf06ebd4e..dc070f4e2d 100644 --- a/lib/commands/test-init.ts +++ b/lib/commands/test-init.ts @@ -8,7 +8,12 @@ import { } from "../definitions/project"; import { INodePackageManager, IOptions } from "../declarations"; import { IPluginsService } from "../definitions/plugins"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; +import { + Command, + CommandOptionsSchema, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; import { IDictionary, IErrors, @@ -16,15 +21,39 @@ import { IResourceLoader, IDependencyInformation, } from "../common/declarations"; -import { injector } from "../common/yok"; import { color } from "../color"; -class TestInitCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; - - private karmaConfigAdditionalFrameworks: IDictionary = { - mocha: ["chai"], - }; +const karmaConfigAdditionalFrameworks: IDictionary = { + mocha: ["chai"], +}; + +const testInitCommandOptions = { + framework: stringOption(), +} satisfies CommandOptionsSchema; + +export class TestInitCommand extends Command({ + name: "test|init", + description: "Configures your project for unit testing.", + options: testInitCommandOptions, + arguments: "none", +}) { + 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", + ); + + constructor() { + super(); + this.$projectData.initializeProjectData(); + } /** * Android blocks cleartext traffic by default (API 28+), which would @@ -90,58 +119,10 @@ class TestInitCommand implements ICommand { ); } - constructor( - private $packageManager: INodePackageManager, - private $projectData: IProjectData, - private $errors: IErrors, - private $options: IOptions, - private $prompter: IPrompter, - private $fs: IFileSystem, - private $resources: IResourceLoader, - private $pluginsService: IPluginsService, - private $logger: ILogger, - private $testInitializationService: ITestInitializationService, - ) { - this.$projectData.initializeProjectData(); - } - - public async execute(args: string[]): 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, - ); - + private async installModules( + modulesToInstall: IDependencyInformation[], + projectDir: string, + ): Promise { for (const mod of modulesToInstall) { let moduleToInstall = mod.name; moduleToInstall += `@${mod.version}`; @@ -218,6 +199,46 @@ class TestInitCommand implements ICommand { } } } + } + + 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"; @@ -272,7 +293,7 @@ class TestInitCommand implements ICommand { this.ensureAndroidNetworkSecurityConfig(bufferedLogs); } else { const frameworks = [frameworkToInstall] - .concat(this.karmaConfigAdditionalFrameworks[frameworkToInstall] || []) + .concat(karmaConfigAdditionalFrameworks[frameworkToInstall] || []) .map((fw) => `'${fw}'`) .join(", "); const testFiles = `'${fromWindowsRelativePathToUnix( @@ -396,5 +417,3 @@ class TestInitCommand implements ICommand { ); } } - -injector.registerCommand("test|init", TestInitCommand); diff --git a/lib/commands/test.ts b/lib/commands/test.ts index 646af87e3e..0829bb97ec 100644 --- a/lib/commands/test.ts +++ b/lib/commands/test.ts @@ -1,290 +1,279 @@ +import { + IAnalyticsService, + IDictionary, + IErrors, +} from "../common/declarations"; +import { + booleanOption, + CommandContext, + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; +import { ErrorCodes } from "../common/enums"; import { hasValidAndroidSigning } from "../common/helpers"; 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. */ +type TestPlatform = "android" | "iOS" | "visionOS"; - 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); - } +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.", +async function canExecuteTestCommand( + context: TestCommandContext, + 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", + ); - 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, - }); - - 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, - ); - } - - if (!this.$options.env) { - this.$options.env = {}; + 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. + $errors.fail("The `--hmr` option is not supported for this command."); } - this.$options.env.unitTesting = true; - const liveSyncInfo = this.$liveSyncCommandHelper.getLiveSyncData( - this.$projectData.projectDir, - ); - - const deviceDebugMap: IDictionary = {}; - devices.forEach( - (device) => - (deviceDebugMap[device.deviceInfo.identifier] = this.$options.debugBrk), - ); - - const deviceDescriptors = - await this.$liveSyncCommandHelper.createDeviceDescriptors( - devices, - this.platform, - { deviceDebugMap }, - ); - - await this.$testExecutionService.startKarmaServer( - this.platform, - liveSyncInfo, - deviceDescriptors, - ); - // if we got here, it means karma exited with exit code 0 (success) - process.exit(0); + await $migrateController.validate({ + projectDir: $projectData.projectDir, + platforms: [platform], + }); } - 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.", - ); - } + $projectData.initializeProjectData(); + $analyticsService.setShouldDispose( + context.options.justlaunch || !context.options.watch, + ); + $cleanupService.setShouldDispose( + context.options.justlaunch || !context.options.watch, + ); - await this.$migrateController.validate({ - projectDir: this.$projectData.projectDir, - platforms: [this.platform], - }); - } - - this.$projectData.initializeProjectData(); - this.$analyticsService.setShouldDispose( - this.$options.justlaunch || !this.$options.watch, - ); - this.$cleanupService.setShouldDispose( - this.$options.justlaunch || !this.$options.watch, - ); - - const output = - await this.$platformEnvironmentRequirements.checkEnvironmentRequirements({ - platform: this.platform, - projectDir: this.$projectData.projectDir, - options: this.$options, - }); - - 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; - } + const output = + await $platformEnvironmentRequirements.checkEnvironmentRequirements({ + platform, + projectDir: $projectData.projectDir, + options: $options, + }); - const canStartKarmaServer = - await this.$testExecutionService.canStartKarmaServer(this.$projectData); - if (!canStartKarmaServer) { - this.$errors.fail({ + if ($vitestExecutionService.isVitestProject($projectData)) { + const canStartTestRun = + $vitestExecutionService.canStartTestRun($projectData); + if (!canStartTestRun) { + $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; + } - return output.canExecute && canStartKarmaServer; + const canStartKarmaServer = + await $testExecutionService.canStartKarmaServer($projectData); + if (!canStartKarmaServer) { + $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; } -class TestAndroidCommand extends TestCommandBase implements ICommand { - protected platform = "android"; +async function runTestCommand( + context: TestCommandContext, + platform: TestPlatform, +): Promise { + 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); + } + + $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 $devicesService.initialize({ + platform, + deviceId: context.options.device, + emulator: context.options.emulator, + skipInferPlatform: !platform, + sdk: context.options.sdk, + }); - 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 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 $liveSyncCommandHelper.getDeviceInstances(platform); } - public async execute(args: string[]): Promise { - await super.execute(args); + // The bundler reads unitTesting off the shared options service, so the flag + // is set there rather than on the command's own snapshot. + if (!$options.env) { + $options.env = {}; } + $options.env.unitTesting = true; - async canExecute(args: string[]): Promise { - const canExecuteBase = await super.canExecute(args); + const liveSyncInfo = $liveSyncCommandHelper.getLiveSyncData( + $projectData.projectDir, + ); + + const deviceDebugMap: IDictionary = {}; + devices.forEach( + (device) => + (deviceDebugMap[device.deviceInfo.identifier] = context.options.debugBrk), + ); + + const deviceDescriptors = + await $liveSyncCommandHelper.createDeviceDescriptors(devices, platform, < + any + >{ deviceDebugMap }); + + await $testExecutionService.startKarmaServer( + platform, + liveSyncInfo, + deviceDescriptors, + ); + // if we got here, it means karma exited with exit code 0 (success) + process.exit(0); +} + +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", + canExecute: (context: TestCommandContext) => + canExecuteTestCommand(context, "iOS"), + run: (context: TestCommandContext) => runTestCommand(context, "iOS"), +}); + +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", + async canExecute(context: TestCommandContext): Promise { + const $errors = inject("errors"); + + const canExecuteBase = await canExecuteTestCommand(context, "android"); 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) { + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - this.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } } return canExecuteBase; - } -} + }, + run: (context: TestCommandContext) => runTestCommand(context, "android"), +}); -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, +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", + async canExecute(context: TestCommandContext): Promise { + const $errors = inject("errors"); + const $projectData = inject("projectData"); + const $vitestExecutionService = inject( + "vitestExecutionService", ); - } - async canExecute(args: string[]): Promise { - this.$projectData.initializeProjectData(); + $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 (!$vitestExecutionService.isVitestProject($projectData)) { + $errors.fail( "visionOS unit testing requires the Vitest runner. Run '$ ns test init --framework vitest' to configure your project.", ); } - return super.canExecute(args); - } -} - -injector.registerCommand("test|android", TestAndroidCommand); -injector.registerCommand("test|ios", TestIosCommand); -injector.registerCommand("test|vision", TestVisionOSCommand); -injector.registerCommand("test|visionos", TestVisionOSCommand); + return canExecuteTestCommand(context, "visionOS"); + }, + run: (context: TestCommandContext) => runTestCommand(context, "visionOS"), +}); diff --git a/lib/commands/typings.ts b/lib/commands/typings.ts index d2e296fa91..0850282161 100644 --- a/lib/commands/typings.ts +++ b/lib/commands/typings.ts @@ -4,27 +4,47 @@ 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 { + Command, + CommandOptionsSchema, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; 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]; +const typingsCommandOptions = { + aar: stringOption(), + copyTo: stringOption(), + filter: stringOption(), + jar: stringOption(), +} satisfies CommandOptionsSchema; + +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; + } + + public async run(): Promise { + const platform = this.args[0]; let result; if (this.$mobileHelper.isAndroidPlatform(platform)) { result = await this.handleAndroidTypings(); @@ -32,12 +52,12 @@ export class TypingsCommand implements ICommand { result = await this.handleiOSTypings(); } let typingsFolder = "./typings"; - if (this.$options.copyTo) { + if (this.options.copyTo) { this.$fs.copyFile( path.resolve(this.$projectData.projectDir, "typings"), - this.$options.copyTo, + this.options.copyTo, ); - typingsFolder = this.$options.copyTo; + typingsFolder = this.options.copyTo; } if (result !== false) { @@ -48,12 +68,6 @@ export class TypingsCommand implements ICommand { } } - public async canExecute(args: string[]): Promise { - const platform = args[0]; - this.$mobileHelper.validatePlatformName(platform); - return true; - } - private async resolveGradleDependencies(target: string) { const gradleHome = path.resolve( process.env.GRADLE_USER_HOME ?? path.join(homedir(), `/.gradle`), @@ -129,6 +143,9 @@ export class TypingsCommand implements ICommand { } 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[] = []; @@ -145,7 +162,7 @@ export class TypingsCommand implements ICommand { } } - if (!paths.length && !(this.$options.jar || this.$options.aar)) { + 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:", @@ -189,8 +206,8 @@ export class TypingsCommand implements ICommand { }; const inputs: string[] = [ - ...asArray(this.$options.jar), - ...asArray(this.$options.aar), + ...asArray(this.options.jar), + ...asArray(this.options.aar), ...paths, ]; @@ -210,7 +227,7 @@ export class TypingsCommand implements ICommand { } private async handleiOSTypings() { - if (this.$options.filter !== undefined) { + if (this.options.filter !== undefined) { this.$logger.warn("--filter flag is not supported yet."); } @@ -236,5 +253,3 @@ export class TypingsCommand implements ICommand { ); } } - -injector.registerCommand("typings", TypingsCommand); diff --git a/lib/commands/update-platform.ts b/lib/commands/update-platform.ts index da127b4ac5..eec8fd1f0e 100644 --- a/lib/commands/update-platform.ts +++ b/lib/commands/update-platform.ts @@ -9,32 +9,37 @@ import { IPlatformEnvironmentRequirements, ICheckEnvironmentRequirementsInput, } from "../definitions/platform"; -import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { IErrors } from "../common/declarations"; -import { injector } from "../common/yok"; +import { Command } from "../common/define-command"; +import { inject } from "../common/di"; -export class UpdatePlatformCommand implements ICommand { - public allowedParameters: ICommandParameter[] = []; +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"); - constructor( - private $errors: IErrors, - private $options: IOptions, - private $platformEnvironmentRequirements: IPlatformEnvironmentRequirements, - private $platformCommandHelper: IPlatformCommandHelper, - private $platformValidationService: IPlatformValidationService, - private $projectData: IProjectData - ) { + constructor() { + super(); this.$projectData.initializeProjectData(); } - public async execute(args: string[]): Promise { - await this.$platformCommandHelper.updatePlatforms(args, this.$projectData); - } - - public async canExecute(args: string[]): Promise { + public async canExecute(): Promise { + const args = this.args; if (!args || args.length === 0) { this.$errors.failWithHelp( - "No platform specified. Please specify platforms to update." + "No platform specified. Please specify platforms to update.", ); } @@ -42,32 +47,39 @@ export class UpdatePlatformCommand implements ICommand { const platform = arg.split("@")[0]; this.$platformValidationService.validatePlatform( platform, - this.$projectData + this.$projectData, ); }); for (const arg of args) { const [platform, versionToBeInstalled] = arg.split("@"); - const checkEnvironmentRequirementsInput: ICheckEnvironmentRequirementsInput = { - platform, - options: this.$options, - }; + 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.projectDir = + this.$projectData.projectDir; checkEnvironmentRequirementsInput.runtimeVersion = versionToBeInstalled; } await this.$platformEnvironmentRequirements.checkEnvironmentRequirements( - checkEnvironmentRequirementsInput + checkEnvironmentRequirementsInput, ); } return true; } -} -injector.registerCommand("platform|update", UpdatePlatformCommand); + public async run(): Promise { + await this.$platformCommandHelper.updatePlatforms( + this.args, + this.$projectData, + ); + } +} diff --git a/lib/commands/update.ts b/lib/commands/update.ts index 890b0d66bc..589ad5ecb1 100644 --- a/lib/commands/update.ts +++ b/lib/commands/update.ts @@ -1,32 +1,65 @@ 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, + Command, + CommandOptionsSchema, + stringOption, +} from "../common/define-command"; +import { inject } from "../common/di"; -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 - ) { +const updateCommandOptions = { + markingMode: booleanOption(), + frameworkPath: stringOption(), +} satisfies CommandOptionsSchema; + +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"); + + constructor() { + super(); this.$projectData.initializeProjectData(); } - public async execute(args: string[]): Promise { - if (this.$options.markingMode) { + public async canExecute(): Promise { + const shouldMigrate = await this.$migrateController.shouldMigrate({ + projectDir: this.$projectData.projectDir, + platforms: [ + this.$devicePlatformsConstants.Android, + this.$devicePlatformsConstants.iOS, + ], + loose: true, + }); + + if (shouldMigrate) { + this.$errors.fail(SHOULD_MIGRATE_PROJECT_MESSAGE); + } + + return this.args.length < 2 && this.$projectData.projectDir !== ""; + } + + public async run(): Promise { + if (this.options.markingMode) { // ns update --markingMode await this.$markingModeService.handleMarkingModeFullDeprecation({ projectDir: this.$projectData.projectDir, @@ -38,38 +71,17 @@ export class UpdateCommand implements ICommand { if ( !(await this.$updateController.shouldUpdate({ projectDir: this.$projectData.projectDir, - version: args[0], + version: this.args[0], })) ) { - this.$logger.printMarkdown( - `__${UpdateCommand.PROJECT_UP_TO_DATE_MESSAGE}__` - ); + this.$logger.printMarkdown(`__${PROJECT_UP_TO_DATE_MESSAGE}__`); return; } await this.$updateController.update({ projectDir: this.$projectData.projectDir, - version: args[0], - frameworkPath: this.$options.frameworkPath, + version: this.args[0], + frameworkPath: this.options.frameworkPath, }); } - - 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, - }); - - if (shouldMigrate) { - this.$errors.fail(UpdateCommand.SHOULD_MIGRATE_PROJECT_MESSAGE); - } - - return args.length < 2 && this.$projectData.projectDir !== ""; - } } - -injector.registerCommand("update", UpdateCommand); diff --git a/lib/commands/widget.ts b/lib/commands/widget.ts index 5d511d9a6e..1347e3ba4e 100644 --- a/lib/commands/widget.ts +++ b/lib/commands/widget.ts @@ -1,63 +1,23 @@ 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 { 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", @@ -935,5 +895,28 @@ declare class AppleWidgetUtils extends NSObject { } // 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", + // 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(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. + generator.startPrompt(ctx.args); + }, +}); diff --git a/lib/common/bootstrap.ts b/lib/common/bootstrap.ts index 287586c134..ba51125a76 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").ListDevicesCommand +>( + "device|*list", + () => require("./commands/device/list-devices").ListDevicesCommand, +); +registerBuiltInCommand< + typeof import("./commands/device/list-devices").ListDevicesCommand +>( + "devices|*list", + () => require("./commands/device/list-devices").ListDevicesCommand, +); +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,46 @@ 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").ProxySetCommand +>("proxy|set", () => require("./commands/proxy/proxy-set").ProxySetCommand); +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/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..ee1a4c50f7 100644 --- a/lib/common/commands/analytics.ts +++ b/lib/common/commands/analytics.ts @@ -1,103 +1,95 @@ -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, + CommandName, + CommandOptionsSchema, + defineCommand, +} from "../define-command"; +import { inject } from "../di"; -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 a command 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 analyticsCommandOptions = { + json: booleanOption(), +} satisfies CommandOptionsSchema; - public allowedParameters = [new AnalyticsCommandParameter(this.$errors)]; - public disableAnalytics = true; +type AnalyticsCommandContext = CommandContext; - 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; - } +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'.`; } } -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" - ); - } -} -injector.registerCommand("usage-reporting", UsageReportingCommand); +async function runAnalyticsCommand( + context: AnalyticsCommandContext, + setting: IAnalyticsSetting, +): Promise { + const $analyticsService = inject("analyticsService"); + const $logger = inject("logger"); + const $staticConfig = inject("staticConfig"); + const settingName = $staticConfig[setting.staticConfigKey]; + const { humanReadableSettingName } = setting; -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" - ); + const arg = context.args[0] || ""; + switch (arg.toLowerCase()) { + case "enable": + await $analyticsService.setStatus(settingName, true); + // TODO(Analytics): await this.$analyticsService.track(this.settingName, "enabled"); + $logger.info(`${humanReadableSettingName} is now enabled.`); + break; + case "disable": + // TODO(Analytics): await this.$analyticsService.track(this.settingName, "disabled"); + await $analyticsService.setStatus(settingName, false); + $logger.info(`${humanReadableSettingName} is now disabled.`); + break; + case "status": + case "": + $logger.info( + await $analyticsService.getStatusMessage( + settingName, + context.options.json, + humanReadableSettingName, + ), + ); + break; } } -injector.registerCommand("error-reporting", ErrorReportingCommand); + +const defineAnalyticsCommand = ( + name: TName, + setting: IAnalyticsSetting, +) => + defineCommand({ + name, + description: "Configures anonymous reporting for the CLI.", + options: analyticsCommandOptions, + arguments: [{ name: "state", validate: validateAnalyticsState }], + disableAnalytics: true, + run: (context) => runAnalyticsCommand(context, setting), + }); + +export const usageReportingCommand = defineAnalyticsCommand("usage-reporting", { + staticConfigKey: "TRACK_FEATURE_USAGE_SETTING_NAME", + humanReadableSettingName: "Usage reporting", +}); + +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 926c1a613c..1e47462689 100644 --- a/lib/common/commands/autocompletion.ts +++ b/lib/common/commands/autocompletion.ts @@ -1,102 +1,102 @@ 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"; -export class AutoCompleteCommand implements ICommand { - constructor( - private $autoCompletionService: IAutoCompletionService, - private $logger: ILogger, - private $prompter: IPrompter - ) {} +export const autoCompleteCommandDefinition = defineCommand({ + name: "autocomplete|*default", + description: "Prompts to enable command-line completion for the CLI.", + arguments: "none", + disableAnalytics: true, + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); + const $prompter = inject("prompter"); - public disableAnalytics = true; - public allowedParameters: ICommandParameter[] = []; - - public async execute(args: string[]): Promise { if (helpers.isInteractive()) { - if (this.$autoCompletionService.isAutoCompletionEnabled()) { - if (this.$autoCompletionService.isObsoleteAutoCompletionEnabled()) { + if ($autoCompletionService.isAutoCompletionEnabled()) { + if ($autoCompletionService.isObsoleteAutoCompletionEnabled()) { // obsolete autocompletion is enabled, update it to the new one: - await this.$autoCompletionService.enableAutoCompletion(); + await $autoCompletionService.enableAutoCompletion(); } else { - this.$logger.info("Autocompletion is already enabled"); + $logger.info("Autocompletion is already enabled"); } } else { - this.$logger.info( - "If you are using bash or zsh, you can enable command-line completion." + $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 $prompter.confirm( message, - () => true + () => true, ); if (autoCompetionStatus) { - await this.$autoCompletionService.enableAutoCompletion(); + await $autoCompletionService.enableAutoCompletion(); } else { // make sure we've removed all autocompletion code from all shell profiles - this.$autoCompletionService.disableAutoCompletion(); + $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[] = []; +export const disableAutoCompleteCommandDefinition = defineCommand({ + name: "autocomplete|disable", + description: "Disables command-line completion for the CLI.", + arguments: "none", + disableAnalytics: true, + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); - public async execute(args: string[]): Promise { - if (this.$autoCompletionService.isAutoCompletionEnabled()) { - this.$autoCompletionService.disableAutoCompletion(); + if ($autoCompletionService.isAutoCompletionEnabled()) { + $autoCompletionService.disableAutoCompletion(); } else { - this.$logger.info("Autocompletion is already disabled."); + $logger.info("Autocompletion is already disabled."); } - } -} -injector.registerCommand("autocomplete|disable", DisableAutoCompleteCommand); + }, +}); -export class EnableAutoCompleteCommand implements ICommand { - constructor( - private $autoCompletionService: IAutoCompletionService, - private $logger: ILogger - ) {} +export const enableAutoCompleteCommandDefinition = defineCommand({ + name: "autocomplete|enable", + description: "Enables command-line completion for the CLI.", + arguments: "none", + disableAnalytics: true, + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); - public disableAnalytics = true; - public allowedParameters: ICommandParameter[] = []; - - public async execute(args: string[]): Promise { - if (this.$autoCompletionService.isAutoCompletionEnabled()) { - this.$logger.info("Autocompletion is already enabled."); + if ($autoCompletionService.isAutoCompletionEnabled()) { + $logger.info("Autocompletion is already enabled."); } else { - await this.$autoCompletionService.enableAutoCompletion(); + await $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[] = []; +export const autoCompleteStatusCommandDefinition = defineCommand({ + name: "autocomplete|status", + description: "Prints whether command-line completion is enabled.", + arguments: "none", + disableAnalytics: true, + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); - public async execute(args: string[]): Promise { - if (this.$autoCompletionService.isAutoCompletionEnabled()) { - this.$logger.info("Autocompletion is enabled."); + if ($autoCompletionService.isAutoCompletionEnabled()) { + $logger.info("Autocompletion is enabled."); } else { - this.$logger.info("Autocompletion is disabled."); + $logger.info("Autocompletion is disabled."); } - } -} -injector.registerCommand("autocomplete|status", AutoCompleteStatusCommand); + }, +}); diff --git a/lib/common/commands/device/device-log-stream.ts b/lib/common/commands/device/device-log-stream.ts index 313e607314..ad165f6bc7 100644 --- a/lib/common/commands/device/device-log-stream.ts +++ b/lib/common/commands/device/device-log-stream.ts @@ -1,50 +1,55 @@ -import { IOptions } from "../../../declarations"; -import { ICommandParameter, ICommand } from "../../definitions/commands"; import { ICleanupService } from "../../../definitions/cleanup-service"; +import { CommandsService } from "../../contracts/commands-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); - } - - allowedParameters: ICommandParameter[] = []; - - public async execute(args: string[]): Promise { - this.$deviceLogProvider.setLogLevel(this.$loggingLevels.full); - - await this.$devicesService.initialize({ - deviceId: this.$options.device, +import { + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; + +const NOT_SPECIFIED_DEVICE_ERROR_MESSAGE = + "More than one device found. Specify device explicitly."; + +const openDeviceLogStreamCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; + +export const openDeviceLogStreamCommandDefinition = defineCommand({ + name: ["device|log", "devices|log"], + description: "Opens the device log stream for a connected device.", + options: openDeviceLogStreamCommandOptions, + arguments: "none", + // 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 (this.$devicesService.deviceCount > 1) { - await this.$commandsService.tryExecuteCommand("device", []); - this.$errors.failWithHelp( - OpenDeviceLogStreamCommand.NOT_SPECIFIED_DEVICE_ERROR_MESSAGE - ); + if ($devicesService.deviceCount > 1) { + await $commandsService.runCommand("device"); + $errors.failWithHelp(NOT_SPECIFIED_DEVICE_ERROR_MESSAGE); } const action = (device: Mobile.IiOSDevice) => device.openDeviceLogStream(); - await this.$devicesService.execute(action); - } -} - -injector.registerCommand( - ["device|log", "devices|log"], - OpenDeviceLogStreamCommand -); + await $devicesService.execute(action); + }, +}); diff --git a/lib/common/commands/device/get-file.ts b/lib/common/commands/device/get-file.ts index ebe632ae52..c6c2a2d760 100644 --- a/lib/common/commands/device/get-file.ts +++ b/lib/common/commands/device/get-file.ts @@ -1,39 +1,42 @@ import { IProjectData } from "../../../definitions/project"; -import { IOptions } from "../../../declarations"; -import { ICommandParameter, ICommand } from "../../definitions/commands"; import { IErrors } from "../../declarations"; -import { injector } from "../../yok"; +import { + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; -export class GetFileCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $stringParameter: ICommandParameter, - private $projectData: IProjectData, - private $errors: IErrors, - private $options: IOptions - ) {} +const getFileCommandOptions = { + device: stringOption(), + file: stringOption(), +} satisfies CommandOptionsSchema; - public allowedParameters: ICommandParameter[] = [ - this.$stringParameter, - this.$stringParameter, - ]; +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" }], + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $projectData = inject("projectData"); - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, + await $devicesService.initialize({ + deviceId: context.options.device, skipInferPlatform: true, }); - let appIdentifier = args[1]; + let appIdentifier = context.args[1]; if (!appIdentifier) { try { - this.$projectData.initializeProjectData(); + $projectData.initializeProjectData(); } catch (err) { // ignore the error } - if (!this.$projectData.projectIdentifiers) { - this.$errors.fail( - "Please enter application identifier or execute this command in project." + if (!$projectData.projectIdentifiers) { + $errors.fail( + "Please enter application identifier or execute this command in project.", ); } } @@ -41,20 +44,15 @@ export class GetFileCommand implements ICommand { const action = async (device: Mobile.IDevice) => { appIdentifier = appIdentifier || - this.$projectData.projectIdentifiers[ + $projectData.projectIdentifiers[ device.deviceInfo.platform.toLowerCase() ]; await device.fileSystem.getFile( - args[0], + context.args[0], appIdentifier, - this.$options.file + context.options.file, ); }; - await this.$devicesService.execute(action); - } -} - -injector.registerCommand( - ["device|get-file", "devices|get-file"], - GetFileCommand -); + await $devicesService.execute(action); + }, +}); diff --git a/lib/common/commands/device/list-applications.ts b/lib/common/commands/device/list-applications.ts index 567c0987ba..2b2a375353 100644 --- a/lib/common/commands/device/list-applications.ts +++ b/lib/common/commands/device/list-applications.ts @@ -1,45 +1,48 @@ +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"; +import { + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; -export class ListApplicationsCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $logger: ILogger, - private $options: IOptions - ) {} +const listApplicationsCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; - allowedParameters: ICommandParameter[] = []; +export const listApplicationsCommandDefinition = defineCommand({ + name: ["device|list-applications", "devices|list-applications"], + description: "Lists the installed applications on all connected devices.", + options: listApplicationsCommandOptions, + arguments: "none", + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $logger = inject("logger"); - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, + await $devicesService.initialize({ + deviceId: context.options.device, skipInferPlatform: true, }); const output: string[] = []; const action = async (device: Mobile.IDevice) => { - const applications = await device.applicationManager.getInstalledApplications(); + const applications = + await device.applicationManager.getInstalledApplications(); output.push( util.format( "%s=====Installed applications on device with UDID '%s' are:", EOL, - device.deviceInfo.identifier - ) + device.deviceInfo.identifier, + ), ); _.each(applications, (applicationId: string) => - output.push(applicationId) + output.push(applicationId), ); }; - await this.$devicesService.execute(action); + await $devicesService.execute(action); - this.$logger.info(output.join(EOL)); - } -} -injector.registerCommand( - ["device|list-applications", "devices|list-applications"], - ListApplicationsCommand -); + $logger.info(output.join(EOL)); + }, +}); diff --git a/lib/common/commands/device/list-devices.ts b/lib/common/commands/device/list-devices.ts index d4b5dfb9a7..24ed93670d 100644 --- a/lib/common/commands/device/list-devices.ts +++ b/lib/common/commands/device/list-devices.ts @@ -1,185 +1,196 @@ -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 { injector } from "../../yok"; -import { color } from "../../../color"; +import { + booleanOption, + Command, + CommandContext, + CommandName, + CommandOptionsSchema, + defineCommand, +} from "../../define-command"; +import { inject } from "../../di"; +import { createTable, formatListOfNames } from "../../helpers"; -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: [], - }; +const listDevicesCommandOptions = { + availableDevices: booleanOption(), + json: booleanOption(), +} satisfies CommandOptionsSchema; + +type ListDevicesCommandContext = CommandContext< + typeof listDevicesCommandOptions +>; + +function printEmulators( + $logger: ILogger, + 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 || "", + ]); + } - 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; + $logger.info(table.toString()); +} - if (!this.$options.json) { - this.$logger.info(color.bold("\n Available emulators")); - this.printEmulators(emulators); - } +async function listDevices( + context: ListDevicesCommandContext, + 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[]; + } = { + devices: [], + }; + + if (context.options.availableDevices) { + const platform = $mobileHelper.normalizePlatformName(platformFilter); + if (!platform && platformFilter) { + $errors.fail( + `${platformFilter} is not a valid device platform. The valid platforms are ${formatListOfNames( + $mobileHelper.platformNames, + )}`, + ); } - let index = 1; - await this.$devicesService.initialize({ - platform: args[0], - deviceId: null, - skipInferPlatform: true, - skipDeviceDetectionInterval: true, - skipEmulatorStart: true, - fullDiscovery: true, + const availableEmulatorsOutput = await $devicesService.getEmulatorImages({ + platform, }); - - if (!this.$options.json) { - this.$logger.info(color.bold("\n Connected devices & emulators")); - } - - const table: any = createTable( - [ - "#", - "Device Name", - "Platform", - "Device Identifier", - "Type", - "Status", - "Connection Type", - ], - [] + const emulators = $emulatorHelper.getEmulatorsFromAvailableEmulatorsOutput( + availableEmulatorsOutput, ); - 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(", "), - ]); - }; - } + devices.available = emulators; - await this.$devicesService.execute(action, undefined, { - allowNoDevices: true, - }); - - if (this.$options.json) { - return this.$logger.info(JSON.stringify(devices, null, 2)); + if (!context.options.json) { + $logger.info(color.bold("\n Available emulators")); + printEmulators($logger, emulators); } + } - if (table.length) { - this.$logger.info(table.toString()); - } + let index = 1; + await $devicesService.initialize({ + platform: platformFilter, + deviceId: null, + skipInferPlatform: true, + skipDeviceDetectionInterval: true, + skipEmulatorStart: true, + fullDiscovery: true, + }); + + if (!context.options.json) { + $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 $devicesService.execute(action, undefined, { + allowNoDevices: true, + }); -class ListAndroidDevicesCommand implements ICommand { - constructor( - private $injector: IInjector, - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants - ) {} + if (context.options.json) { + return $logger.info(JSON.stringify(devices, null, 2)); + } - public allowedParameters: ICommandParameter[] = []; + if (table.length) { + $logger.info(table.toString()); + } +} - public async execute(args: string[]): Promise { - const listDevicesCommand: ICommand = this.$injector.resolve( - ListDevicesCommand - ); - const platform = this.$devicePlatformsConstants.Android; - await listDevicesCommand.execute([platform]); +export class ListDevicesCommand extends Command({ + name: ["device|*list", "devices|*list"], + description: "Lists the connected devices and emulators.", + options: listDevicesCommandOptions, + arguments: [{ name: "platform" }], +}) { + public run(): Promise { + return listDevices(this.context, this.args[0]); } } -injector.registerCommand( +// 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", +) => + defineCommand({ + name, + description: "Lists the connected devices and emulators for one platform.", + options: listDevicesCommandOptions, + arguments: "none", + run(context): Promise { + const platform = inject( + "devicePlatformsConstants", + )[listedPlatform]; + + return listDevices(context, platform); + }, + }); + +export const androidListDevicesCommand = defineListPlatformDevicesCommand( ["device|android", "devices|android"], - ListAndroidDevicesCommand + "Android", ); -class ListiOSDevicesCommand implements ICommand { - constructor( - private $injector: IInjector, - private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants - ) {} - - public allowedParameters: ICommandParameter[] = []; - - public async execute(args: string[]): Promise { - const listDevicesCommand: ICommand = this.$injector.resolve( - ListDevicesCommand - ); - const platform = this.$devicePlatformsConstants.iOS; - await listDevicesCommand.execute([platform]); - } -} - -injector.registerCommand(["device|ios", "devices|ios"], ListiOSDevicesCommand); +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 1b603306ae..c20e591cd9 100644 --- a/lib/common/commands/device/list-files.ts +++ b/lib/common/commands/device/list-files.ts @@ -1,40 +1,42 @@ import { IProjectData } from "../../../definitions/project"; -import { IOptions } from "../../../declarations"; -import { ICommandParameter, ICommand } from "../../definitions/commands"; import { IErrors } from "../../declarations"; -import { injector } from "../../yok"; +import { + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; -export class ListFilesCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $stringParameter: ICommandParameter, - private $options: IOptions, - private $projectData: IProjectData, - private $errors: IErrors - ) {} +const listFilesCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; - public allowedParameters: ICommandParameter[] = [ - this.$stringParameter, - this.$stringParameter, - ]; +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" }], + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $projectData = inject("projectData"); - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, + await $devicesService.initialize({ + deviceId: context.options.device, skipInferPlatform: true, }); - const pathToList = args[0]; - let appIdentifier = args[1]; + const pathToList = context.args[0]; + let appIdentifier = context.args[1]; if (!appIdentifier) { try { - this.$projectData.initializeProjectData(); + $projectData.initializeProjectData(); } catch (err) { // ignore the error } - if (!this.$projectData.projectIdentifiers) { - this.$errors.fail( - "Please enter application identifier or execute this command in project." + if (!$projectData.projectIdentifiers) { + $errors.fail( + "Please enter application identifier or execute this command in project.", ); } } @@ -42,16 +44,11 @@ export class ListFilesCommand implements ICommand { const action = async (device: Mobile.IDevice) => { appIdentifier = appIdentifier || - this.$projectData.projectIdentifiers[ + $projectData.projectIdentifiers[ device.deviceInfo.platform.toLowerCase() ]; await device.fileSystem.listFiles(pathToList, appIdentifier); }; - await this.$devicesService.execute(action); - } -} - -injector.registerCommand( - ["device|list-files", "devices|list-files"], - ListFilesCommand -); + await $devicesService.execute(action); + }, +}); diff --git a/lib/common/commands/device/put-file.ts b/lib/common/commands/device/put-file.ts index 280afb9f57..bd658db719 100644 --- a/lib/common/commands/device/put-file.ts +++ b/lib/common/commands/device/put-file.ts @@ -1,40 +1,41 @@ import { IProjectData } from "../../../definitions/project"; -import { IOptions } from "../../../declarations"; -import { ICommand, ICommandParameter } from "../../definitions/commands"; import { IErrors } from "../../declarations"; -import { injector } from "../../yok"; +import { + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; -export class PutFileCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $stringParameter: ICommandParameter, - private $options: IOptions, - private $projectData: IProjectData, - private $errors: IErrors - ) {} +const putFileCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; - allowedParameters: ICommandParameter[] = [ - this.$stringParameter, - this.$stringParameter, - this.$stringParameter, - ]; +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" }], + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $projectData = inject("projectData"); - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, + await $devicesService.initialize({ + deviceId: context.options.device, skipInferPlatform: true, }); - let appIdentifier = args[2]; + let appIdentifier = context.args[2]; if (!appIdentifier) { try { - this.$projectData.initializeProjectData(); + $projectData.initializeProjectData(); } catch (err) { // ignore the error } - if (!this.$projectData.projectIdentifiers) { - this.$errors.fail( - "Please enter application identifier or execute this command in project." + if (!$projectData.projectIdentifiers) { + $errors.fail( + "Please enter application identifier or execute this command in project.", ); } } @@ -42,15 +43,15 @@ export class PutFileCommand implements ICommand { const action = async (device: Mobile.IDevice) => { appIdentifier = appIdentifier || - this.$projectData.projectIdentifiers[ + $projectData.projectIdentifiers[ device.deviceInfo.platform.toLowerCase() ]; - await device.fileSystem.putFile(args[0], args[1], appIdentifier); + await device.fileSystem.putFile( + context.args[0], + context.args[1], + appIdentifier, + ); }; - await this.$devicesService.execute(action); - } -} -injector.registerCommand( - ["device|put-file", "devices|put-file"], - PutFileCommand -); + await $devicesService.execute(action); + }, +}); diff --git a/lib/common/commands/device/run-application.ts b/lib/common/commands/device/run-application.ts index 10a56a2952..78b97ed2ad 100644 --- a/lib/common/commands/device/run-application.ts +++ b/lib/common/commands/device/run-application.ts @@ -1,47 +1,44 @@ -import { IOptions } from "../../../declarations"; -import { ICommand, ICommandParameter } from "../../definitions/commands"; import { IErrors } from "../../declarations"; -import { injector } from "../../yok"; +import { + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; -export class RunApplicationOnDeviceCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $errors: IErrors, - private $stringParameter: ICommandParameter, - private $staticConfig: Config.IStaticConfig, - private $options: IOptions - ) {} +const runApplicationOnDeviceCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; - public allowedParameters: ICommandParameter[] = [ - this.$stringParameter, - this.$stringParameter, - ]; +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" }], + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $staticConfig = inject("staticConfig"); - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, + await $devicesService.initialize({ + deviceId: context.options.device, skipInferPlatform: true, }); - if (this.$devicesService.deviceCount > 1) { - this.$errors.failWithHelp( + 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.", - this.$staticConfig.CLIENT_NAME.toLowerCase() + $staticConfig.CLIENT_NAME.toLowerCase(), ); } - await this.$devicesService.execute( + await $devicesService.execute( async (device: Mobile.IDevice) => await device.applicationManager.startApplication({ - appId: args[0], - projectName: args[1], + appId: context.args[0], + projectName: context.args[1], projectDir: null, - }) + }), ); - } -} - -injector.registerCommand( - ["device|run", "devices|run"], - RunApplicationOnDeviceCommand -); + }, +}); diff --git a/lib/common/commands/device/stop-application.ts b/lib/common/commands/device/stop-application.ts index 9e0106f72f..e0463ca2df 100644 --- a/lib/common/commands/device/stop-application.ts +++ b/lib/common/commands/device/stop-application.ts @@ -1,38 +1,34 @@ -import { IOptions } from "../../../declarations"; -import { injector } from "../../yok"; -import { ICommandParameter, ICommand } from "../../definitions/commands"; +import { + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; -export class StopApplicationOnDeviceCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $stringParameter: ICommandParameter, - private $options: IOptions - ) {} +const stopApplicationOnDeviceCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; - allowedParameters: ICommandParameter[] = [ - this.$stringParameter, - this.$stringParameter, - this.$stringParameter, - ]; +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" }], + async run(context): Promise { + const $devicesService = inject("devicesService"); - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, + await $devicesService.initialize({ + deviceId: context.options.device, skipInferPlatform: true, - platform: args[1], + platform: context.args[1], }); const action = (device: Mobile.IDevice) => device.applicationManager.stopApplication({ - appId: args[0], - projectName: args[2], + appId: context.args[0], + projectName: context.args[2], projectDir: null, }); - await this.$devicesService.execute(action); - } -} - -injector.registerCommand( - ["device|stop", "devices|stop"], - StopApplicationOnDeviceCommand -); + await $devicesService.execute(action); + }, +}); diff --git a/lib/common/commands/device/uninstall-application.ts b/lib/common/commands/device/uninstall-application.ts index af79be554f..9d90e88f53 100644 --- a/lib/common/commands/device/uninstall-application.ts +++ b/lib/common/commands/device/uninstall-application.ts @@ -1,28 +1,29 @@ -import { IOptions } from "../../../declarations"; -import { ICommandParameter, ICommand } from "../../definitions/commands"; -import { injector } from "../../yok"; +import { + CommandOptionsSchema, + defineCommand, + stringOption, +} from "../../define-command"; +import { inject } from "../../di"; -export class UninstallApplicationCommand implements ICommand { - constructor( - private $devicesService: Mobile.IDevicesService, - private $stringParameter: ICommandParameter, - private $options: IOptions - ) {} +const uninstallApplicationCommandOptions = { + device: stringOption(), +} satisfies CommandOptionsSchema; - allowedParameters: ICommandParameter[] = [this.$stringParameter]; +export const uninstallApplicationCommandDefinition = defineCommand({ + name: ["device|uninstall", "devices|uninstall"], + description: "Uninstalls an application from all connected devices.", + options: uninstallApplicationCommandOptions, + arguments: [{ name: "appId" }], + async run(context): Promise { + const $devicesService = inject("devicesService"); - public async execute(args: string[]): Promise { - await this.$devicesService.initialize({ - deviceId: this.$options.device, + await $devicesService.initialize({ + deviceId: context.options.device, skipInferPlatform: true, }); const action = (device: Mobile.IDevice) => - device.applicationManager.uninstallApplication(args[0]); - await this.$devicesService.execute(action); - } -} -injector.registerCommand( - ["device|uninstall", "devices|uninstall"], - UninstallApplicationCommand -); + 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 40b4fecbc7..5d0b04fe3d 100644 --- a/lib/common/commands/doctor.ts +++ b/lib/common/commands/doctor.ts @@ -1,62 +1,38 @@ -import { injector } from "../yok"; -import { ICommand, ICommandParameter } from "../definitions/commands"; import { IDoctorService, IProjectHelper } from "../declarations"; +import { CommandName, defineCommand } from "../define-command"; +import { inject } from "../di"; import { PlatformTypes } from "../../constants"; -export class DoctorCommand 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, - }); - } -} -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, - }); - } -} - -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({ - trackResult: false, - projectDir: this.$projectHelper.projectDir, - forceCheck: true, - platform: PlatformTypes.android, - }); - } -} - -injector.registerCommand("doctor|android", DoctorAndroidCommand); +const defineDoctorCommand = ( + name: TName, + platform?: PlatformTypes, +) => + defineCommand({ + name, + description: + "Checks the local environment for configuration issues, and prints what it finds.", + arguments: "none", + run(): Promise { + const $doctorService = inject("doctorService"); + const $projectHelper = inject("projectHelper"); + + return $doctorService.printWarnings({ + trackResult: false, + projectDir: $projectHelper.projectDir, + forceCheck: true, + ...(platform ? { platform } : {}), + }); + }, + }); + +export const doctorCommandDefinition = defineDoctorCommand("doctor|*all"); + +export const iosDoctorCommand = defineDoctorCommand( + "doctor|ios", + PlatformTypes.ios, +); + +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 cbab874bdb..e508491a86 100644 --- a/lib/common/commands/generate-messages.ts +++ b/lib/common/commands/generate-messages.ts @@ -1,50 +1,57 @@ 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"; -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[] = []; +export const generateMessagesCommandDefinition = defineCommand({ + name: "dev-generate-messages", + description: "Regenerates the CLI's message contracts.", + options: generateMessagesCommandOptions, + arguments: "none", + async run(context): Promise { + const $fs = inject("fs"); + const $messageContractGenerator = inject( + "messageContractGenerator", + ); - async execute(args: string[]): Promise { - const result = await this.$messageContractGenerator.generate(); + const result = await $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); + $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 0a4fe24503..f04f04dccb 100644 --- a/lib/common/commands/help.ts +++ b/lib/common/commands/help.ts @@ -1,30 +1,29 @@ 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, defineCommand } from "../define-command"; +import { inject } from "../di"; -export class HelpCommand implements ICommand { - constructor( - private $injector: IInjector, - private $helpService: IHelpService, - private $options: IOptions - ) {} +export const helpCommandDefinition = defineCommand({ + name: ["help", "/?"], + description: "Shows the help for a command.", + 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, + async run(context): Promise { + const $commandRegistry = inject(CommandRegistry); + const $helpService = inject("helpService"); - public enableHooks = false; - public async canExecute(args: string[]): Promise { - return true; - } - - public allowedParameters: ICommandParameter[] = []; - - public async execute(args: string[]): Promise { + const args = context.args; let commandName = (args[0] || "").toLowerCase(); let commandArguments = _.tail(args); - const hierarchicalCommand = this.$injector.buildHierarchicalCommand( + const hierarchicalCommand = $commandRegistry.buildHierarchicalCommand( args[0], - commandArguments + commandArguments, ); if (hierarchicalCommand) { commandName = hierarchicalCommand.commandName; @@ -36,12 +35,10 @@ export class HelpCommand implements ICommand { commandArguments, }; - if (this.$options.help) { - await this.$helpService.showCommandLineHelp(commandData); + if (context.options.help) { + await $helpService.showCommandLineHelp(commandData); } else { - await this.$helpService.openHelpForCommandInBrowser(commandData); + await $helpService.openHelpForCommandInBrowser(commandData); } - } -} - -injector.registerCommand(["help", "/?"], HelpCommand); + }, +}); diff --git a/lib/common/commands/package-manager-get.ts b/lib/common/commands/package-manager-get.ts index a258fcb110..47c95460b1 100644 --- a/lib/common/commands/package-manager-get.ts +++ b/lib/common/commands/package-manager-get.ts @@ -1,32 +1,19 @@ -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"; -export class PackageManagerGetCommand implements ICommand { - constructor( - private $errors: IErrors, - private $logger: ILogger, - private $userSettingsService: IUserSettingsService - ) {} - - public allowedParameters: ICommandParameter[] = []; - - 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.` - ); - } - - const result = await this.$userSettingsService.getSettingValue( - "packageManager" +export const packageManagerGetCommandDefinition = defineCommand({ + name: "package-manager|*get", + description: "Prints the value of the current package manager.", + async run(): Promise { + const $logger = inject("logger"); + const $userSettingsService = inject( + "userSettingsService", ); - this.$logger.printMarkdown( - `Your current package manager is \`${result || "npm"}\`.` - ); - } -} -injector.registerCommand("package-manager|*get", PackageManagerGetCommand); + 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 7f9c1924e0..64d09c5a8a 100644 --- a/lib/common/commands/package-manager-set.ts +++ b/lib/common/commands/package-manager-set.ts @@ -1,41 +1,39 @@ 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"; -export class PackageManagerCommand implements ICommand { - constructor( - private $userSettingsService: IUserSettingsService, - private $errors: IErrors, - private $logger: ILogger, - private $stringParameter: ICommandParameter - ) {} - - public allowedParameters: ICommandParameter[] = [this.$stringParameter]; +export const packageManagerSetCommandDefinition = defineCommand({ + name: "package-manager|set", + description: "Sets the package manager the CLI installs dependencies with.", + arguments: [{ name: "packageManager" }], + async run(context): Promise { + const $userSettingsService = inject( + "userSettingsService", + ); + const $errors = inject("errors"); + const $logger = inject("logger"); - public async execute(args: string[]): Promise { - const packageManagerName = args[0]; + const packageManagerName = context.args[0]; const supportedPackageManagers = Object.keys(PackageManagers); if (supportedPackageManagers.indexOf(packageManagerName) === -1) { - this.$errors.fail( + $errors.fail( `${packageManagerName} is not a valid package manager. Supported values are: ${supportedPackageManagers.join( - ", " - )}.` + ", ", + )}.`, ); } - await this.$userSettingsService.saveSetting( + await $userSettingsService.saveSetting( "packageManager", - packageManagerName + packageManagerName, ); - this.$logger.printMarkdown( - `Please ensure you have the directory containing \`${packageManagerName}\` executable available in your PATH.` + $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.` + $logger.printMarkdown( + `You've successfully set \`${packageManagerName}\` as your package manager.`, ); - } -} - -injector.registerCommand("package-manager|set", PackageManagerCommand); + }, +}); diff --git a/lib/common/commands/post-install.ts b/lib/common/commands/post-install.ts index f66d6ef343..fe027b6dd7 100644 --- a/lib/common/commands/post-install.ts +++ b/lib/common/commands/post-install.ts @@ -1,17 +1,17 @@ -import { injector } from "../yok"; -import { ICommand, ICommandParameter } from "../definitions/commands"; import { IErrors } from "../declarations"; +import { defineCommand } from "../define-command"; +import { inject } from "../di"; -export class PostInstallCommand implements ICommand { - constructor(protected $errors: IErrors) {} +export const postInstallCommandDefinition = defineCommand({ + name: "dev-post-install", + description: "Deprecated; use `ns dev-post-install-cli`.", + arguments: "none", + disableAnalytics: true, + async run(): Promise { + const $errors = inject("errors"); - 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" + $errors.fail( + "This command is deprecated. Use `ns dev-post-install-cli` instead", ); - } -} -injector.registerCommand("dev-post-install", PostInstallCommand); + }, +}); diff --git a/lib/common/commands/preuninstall.ts b/lib/common/commands/preuninstall.ts index 4d086a0bee..7a1b823fb5 100644 --- a/lib/common/commands/preuninstall.ts +++ b/lib/common/commands/preuninstall.ts @@ -5,31 +5,50 @@ 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 { IExtensibilityService } from "../definitions/extensibility"; -export class PreUninstallCommand implements ICommand { +// disabled for now (6/24/2020) +// const FEEDBACK_FORM_URL = "https://www.nativescript.org/uninstall-feedback"; + +async function handleFeedbackForm(): Promise { // disabled for now (6/24/2020) - // private static FEEDBACK_FORM_URL = "https://www.nativescript.org/uninstall-feedback"; + // if (isInteractive()) { + // $opener.open(FEEDBACK_FORM_URL); + // } + return Promise.resolve(); +} - public allowedParameters: ICommandParameter[] = []; +async function handleIntentionalUninstall( + $extensibilityService: IExtensibilityService, + $packageInstallationManager: IPackageInstallationManager, +): Promise { + $extensibilityService.removeAllExtensions(); + $packageInstallationManager.clearInspectorCache(); + await handleFeedbackForm(); +} - constructor( - private $analyticsService: IAnalyticsService, - private $extensibilityService: IExtensibilityService, - private $fs: IFileSystem, - // private $opener: IOpener, - private $packageInstallationManager: IPackageInstallationManager, - private $settingsService: ISettingsService - ) {} +export const preUninstallCommandDefinition = defineCommand({ + name: "dev-preuninstall", + description: "Runs the CLI's own uninstall bookkeeping.", + arguments: "none", + async run(): Promise { + const $analyticsService = inject("analyticsService"); + const $extensibilityService = inject( + "extensibilityService", + ); + const $fs = inject("fs"); + const $packageInstallationManager = inject( + "packageInstallationManager", + ); + const $settingsService = inject("settingsService"); - public async execute(args: string[]): Promise { const isIntentionalUninstall = doesCurrentNpmCommandMatch([ /^uninstall$/, /^remove$/, @@ -39,34 +58,21 @@ export class PreUninstallCommand implements ICommand { /^unlink$/, ]); - await this.$analyticsService.trackEventActionInGoogleAnalytics({ + await $analyticsService.trackEventActionInGoogleAnalytics({ action: TrackActionNames.UninstallCLI, additionalData: `isIntentionalUninstall${AnalyticsEventLabelDelimiter}${isIntentionalUninstall}${AnalyticsEventLabelDelimiter}isInteractive${AnalyticsEventLabelDelimiter}${!!isInteractive()}`, }); if (isIntentionalUninstall) { - await this.handleIntentionalUninstall(); + await handleIntentionalUninstall( + $extensibilityService, + $packageInstallationManager, + ); } - this.$fs.deleteFile( - path.join(this.$settingsService.getProfileDir(), "KillSwitches", "cli") + $fs.deleteFile( + path.join($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(); - } -} - -injector.registerCommand("dev-preuninstall", PreUninstallCommand); + await $analyticsService.finishTracking(); + }, +}); diff --git a/lib/common/commands/proxy/proxy-base.ts b/lib/common/commands/proxy/proxy-base.ts index e989dce6fa..f7908b51a1 100644 --- a/lib/common/commands/proxy/proxy-base.ts +++ b/lib/common/commands/proxy/proxy-base.ts @@ -1,28 +1,14 @@ -import { ICommandParameter, ICommand } from "../../definitions/commands"; -import { IAnalyticsService, IProxyService } from "../../declarations"; - -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 - ) {} - - public abstract execute(args: string[]): Promise; - - 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( + $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. + $logger.trace(commandName); + // await $analyticsService.trackFeature(commandName); + } catch (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 aa1981cbc9..80624762ab 100644 --- a/lib/common/commands/proxy/proxy-clear.ts +++ b/lib/common/commands/proxy/proxy-clear.ts @@ -1,22 +1,21 @@ -import { ProxyCommandBase } from "./proxy-base"; -import { IAnalyticsService, IProxyService } from "../../declarations"; -import { injector } from "../../yok"; -const proxyClearCommandName = "proxy|clear"; +import { IProxyService } from "../../declarations"; +import { defineCommand } from "../../define-command"; +import { inject } from "../../di"; +import { 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, + async run(): Promise { + const $logger = inject("logger"); + const $proxyService = inject("proxyService"); -injector.registerCommand(proxyClearCommandName, ProxyClearCommand); + 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 143b38384b..263a1e70c2 100644 --- a/lib/common/commands/proxy/proxy-get.ts +++ b/lib/common/commands/proxy/proxy-get.ts @@ -1,22 +1,20 @@ -import { ProxyCommandBase } from "./proxy-base"; -import { IAnalyticsService, IProxyService } from "../../declarations"; -import { injector } from "../../yok"; +import { IProxyService } from "../../declarations"; +import { defineCommand } from "../../define-command"; +import { inject } from "../../di"; +import { 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, + async run(): Promise { + const $logger = inject("logger"); + const $proxyService = inject("proxyService"); - public async execute(args: string[]): Promise { - this.$logger.info(await this.$proxyService.getInfo()); - await this.tryTrackUsage(); - } -} - -injector.registerCommand(proxyGetCommandName, ProxyGetCommand); + $logger.info(await $proxyService.getInfo()); + await tryTrackProxyCommandUsage($logger, proxyGetCommandName); + }, +}); diff --git a/lib/common/commands/proxy/proxy-set.ts b/lib/common/commands/proxy/proxy-set.ts index ee9b1f8a38..cea5c96280 100644 --- a/lib/common/commands/proxy/proxy-set.ts +++ b/lib/common/commands/proxy/proxy-set.ts @@ -1,112 +1,88 @@ -import * as commandParams from "../../command-params"; -import { isInteractive } from "../../helpers"; -import { ProxyCommandBase } from "./proxy-base"; +import { EOL, platform } from "os"; +import { parse, UrlWithStringQuery } from "url"; import { HttpProtocolToPort } from "../../constants"; -import { parse } from "url"; -import { platform, EOL } from "os"; -import { IOptions } from "../../../declarations"; import { IErrors, IHostInfo, - IAnalyticsService, - IProxyService, IProxyLibSettings, + IProxyService, IPrompterQuestion, } from "../../declarations"; -import { IInjector } from "../../definitions/yok"; -import { injector } from "../../yok"; +import { + booleanOption, + Command, + CommandOptionsSchema, +} from "../../define-command"; +import { inject } from "../../di"; +import { isInteractive } from "../../helpers"; +import { 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]; +function isPasswordRequired(username: string, password: string): boolean { + return !!(username && !password); +} - 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, - }); - } - } +function isValidPort(port: number): boolean { + return !isNaN(port) && port > 0 && port < 65536; +} - 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." - ); - } +function getInvalidPortMessage(port: number): string { + return `Specified port ${port} is not valid. Please enter a value between 1 and 65535.`; +} - 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 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 || !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; + const noPort = !port || !isValidPort(port); + + const credentials = this.resolveCredentials( + urlObj.auth || "", + username, + password, + ); + username = credentials.username; + password = credentials.password; if (!isInteractive()) { if (noPort) { this.$errors.fail( - `The port you have specified (${port || "none"}) is not valid.` + `The port you have specified (${port || "none"}) is not valid.`, ); - } else if (this.isPasswordRequired(username, password)) { + } else if (isPasswordRequired(username, password)) { this.$errors.failWithHelp( - "Console is not interactive - you need to supply all command parameters." + "Console is not interactive - you need to supply all command parameters.", ); } } if (noPort) { if (port) { - this.$logger.warn(this.getInvalidPortMessage(port)); + this.$logger.warn(getInvalidPortMessage(port)); } port = await this.getPortFromUserInput(); @@ -114,53 +90,84 @@ export class ProxySetCommand extends ProxyCommandBase { 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." + "In case your proxy requires authentication, please specify username and password. If authentication is not required, just leave it empty.", ); username = await this.$prompter.getString("Username", { defaultAction: () => "", }); } - if (this.isPasswordRequired(username, password)) { + if (isPasswordRequired(username, password)) { password = await this.$prompter.getPassword("Password"); } - const settings: IProxyLibSettings = { + await this.saveSettings({ proxyUrl: urlString, username, password, - rejectUnauthorized: !this.$options.insecure, - }; + rejectUnauthorized: !this.options.insecure, + }); + } - if (!this.$hostInfo.isWindows) { - this.$logger.warn( - `Note that storing credentials is not supported on ${platform()} yet.` - ); + 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, + }); + } } - 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; + 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.", + ); + } - this.$logger.warn( - `${messageNote}Run '${clientName} proxy set --help' for more information.` - ); + 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); + } - await this.$proxyService.setCache(settings); - this.$logger.info(`Successfully setup proxy.${EOL}`); - this.$logger.info(await this.$proxyService.getInfo()); - await this.tryTrackUsage(); + return { urlString, urlObj }; } - private isPasswordRequired(username: string, password: string): boolean { - return !!(username && !password); - } + 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.", + ); + } - private isValidPort(port: number): boolean { - return !isNaN(port) && port > 0 && port < 65536; + return { + username: username || authCredentials.username, + password: password || authCredentials.password, + }; } private async getPortFromUserInput(): Promise { @@ -170,8 +177,8 @@ export class ProxySetCommand extends ProxyCommandBase { type: "text", name: schemaName, validate: (value: any) => { - return !value || !this.isValidPort(value) - ? this.getInvalidPortMessage(value) + return !value || !isValidPort(value) + ? getInvalidPortMessage(value) : true; }, }; @@ -180,9 +187,27 @@ export class ProxySetCommand extends ProxyCommandBase { return parseInt(prompterResult[schemaName]); } - private getInvalidPortMessage(port: number): string { - return `Specified port ${port} is not valid. Please enter a value between 1 and 65535.`; + private async saveSettings(settings: IProxyLibSettings): Promise { + if (!this.$hostInfo.isWindows) { + this.$logger.warn( + `Note that storing credentials is not supported on ${platform()} yet.`, + ); + } + + 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.`, + ); + + await this.$proxyService.setCache(settings); + this.$logger.info(`Successfully setup proxy.${EOL}`); + this.$logger.info(await this.$proxyService.getInfo()); + await tryTrackProxyCommandUsage(this.$logger, proxySetCommandName); } } - -injector.registerCommand(proxySetCommandName, ProxySetCommand); 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/command-registry.ts b/lib/common/contracts/command-registry.ts index d6484f4e7d..205d1248d0 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 @@ -34,15 +47,37 @@ export type DeferredCommandRejection = | { reason: "parent-is-command"; parent: string }; /** - * Outcome of a deferred registration. Callers branch on `rejection.reason` - * rather than on message text, so the wording of the report stays theirs. + * 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 interface DeferredCommandResult { - registered: boolean; - /** Set exactly when `registered` is false. */ - rejection?: DeferredCommandRejection; +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. 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 type DeferredCommandResult = + | { registered: true } + | { registered: false; rejection: DeferredCommandRejection }; + /** * The command-registry face of the injector facade. Transitional contract: it * mirrors what consumers call today, so that extracting the registry from the diff --git a/lib/common/contracts/commands-service.ts b/lib/common/contracts/commands-service.ts new file mode 100644 index 0000000000..0cb64462db --- /dev/null +++ b/lib/common/contracts/commands-service.ts @@ -0,0 +1,46 @@ +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 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, + 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 29e06c2109..3267f1d173 100644 --- a/lib/common/contracts/index.ts +++ b/lib/common/contracts/index.ts @@ -4,12 +4,17 @@ // 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, DeferredCommandResult, } from "./command-registry"; -export { KeyCommandRegistry } from "./key-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/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/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/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/define-command.ts b/lib/common/define-command.ts index c5cf28e5fe..0f985e449a 100644 --- a/lib/common/define-command.ts +++ b/lib/common/define-command.ts @@ -6,6 +6,11 @@ * 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"; + /** * 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 +20,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,40 +71,128 @@ 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.params`. */ + 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. */ + params: 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[]; + name: CommandName; 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; + /** + * 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, + result: Awaited, + setupResult: Awaited, + ): Promise | void; } /** * 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 = - 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 +211,31 @@ 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", + "shortcuts", + "postRun", +]; + +const ARGUMENT_SPEC_FIELDS = [ + "name", + "required", + "variadic", + "description", + "errorMessage", + "validate", ]; const OPTION_SPEC_FIELDS = [ @@ -142,12 +251,16 @@ 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, 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; @@ -255,6 +368,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.params 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 +479,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", "shortcuts", "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 +539,49 @@ const validateDefinition = (definition: any): void => { } }; -export function defineCommand( - definition: CommandDefinition, -): DefinedCommand { +/** + * 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 { + definition: infer TClassDefinition; +} + ? // 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 = {}, + TResult = void, + TSetup = void, + const TName extends CommandName = CommandName, +>( + definition: CommandDefinition & { name: TName }, +): NamedCommand { validateDefinition(definition); const marked: any = { ...definition }; @@ -339,6 +589,215 @@ 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; } + +/** + * 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; +} + +/** + * What a dispatcher accepts: a registered command's name, or a definition or + * class to run as given. + */ +export type CommandReference = string | RegisterableCommand; + +/** + * 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/definitions/commands-service.d.ts b/lib/common/definitions/commands-service.d.ts index 8aafba931d..f6ed426658 100644 --- a/lib/common/definitions/commands-service.d.ts +++ b/lib/common/definitions/commands-service.d.ts @@ -1,13 +1,45 @@ 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, - 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. + */ + 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[], ): Promise; } diff --git a/lib/common/definitions/commands.d.ts b/lib/common/definitions/commands.d.ts index b2e43470e6..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; @@ -19,9 +21,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/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/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/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/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/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/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index 5ccefc5116..b967131d1a 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -1,17 +1,40 @@ +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 { IInjector } from "../definitions/yok"; import { ICommand } from "../definitions/commands"; -import { CommandRegistry } from "../contracts/command-registry"; +import { COMMAND_CONTEXT } from "../contracts/command-context"; +import { CommandsService } from "../contracts/commands-service"; import { + COMMAND_OWNER, + CommandRegistry, + DeferredCommandResult, + describeRejection, +} from "../contracts/command-registry"; +import { + commandShortcutsEnabled, + IKeyShortcutService, + KeyShortcut, +} from "../contracts/key-shortcuts"; +import { Provider } from "../di/providers"; +import { + ArgumentSpec, + CommandArgumentValues, CommandContext, CommandDefinition, + CommandClass, + CommandName, + CommandNamesOf, + CommandOptionSpec, CommandOptionType, CommandOptionsSchema, + CommandReference, DefinedCommand, - isCommandDefinition, + RegisterableCommand, + defineCommand, + toCommandDefinition, } from "../define-command"; const OPTION_TYPES: IDictionary = { @@ -19,26 +42,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 +93,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 +126,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 +183,35 @@ 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 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, + TResult = any, + TSetup = any, >( - definition: CommandDefinition, - targetInjector: IInjector = injector, + definition: CommandDefinition, + targetInjector: Injector = (getRootInjector()), ): 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,22 +225,211 @@ export function createCommandFromDefinition< ); } - const errors: IErrors = targetInjector.resolve("errors"); + const errors: IErrors = targetInjector.get("errors"); return errors.failWithHelp(message); }; - // Read per call rather than snapshotted here: the options service only holds - // this command's parsed values once validateOptions has run for it. + const 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 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) { options[optionName] = optionsService[optionName]; } - return { args, options, fail }; + return { + args, + params: 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}'.`, + ); + } + } + }; + + // 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 { + /** Built once when the invocation opens; every stage and COMMAND_CONTEXT share it. */ + context: CommandContext; + injector: Injector; + setup: Promise>; + hasRun: boolean; + runResult?: Awaited; + } + + 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 - + // rejects the promise instead of escaping into the caller. + new Promise>((resolve) => + resolve( + definition.setup + ? ( + runInInjectionContext(injector, () => + definition.setup.call(definition, context), + ) + ) + : undefined, + ), + ); + + // 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 => { + const invocation: Invocation = { + context, + injector: targetInjector.createChild([ + { provide: COMMAND_CONTEXT, useValue: context }, + ]), + setup: undefined, + hasRun: false, + }; + invocation.setup = startSetup(context, invocation.injector); + currentInvocation = invocation; + + return invocation; + }; + + /** + * 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 = ( + invocation: Invocation, + context: CommandContext, + setupResult: Awaited, + ): void => { + if (!commandShortcutsEnabled()) { + return; + } + + const commandsService = targetInjector.get(CommandsService, { + optional: true, + }); + if (commandsService && commandsService.isExecutingInProcess) { + return; + } + + const shortcuts: KeyShortcut[] = runInInjectionContext( + invocation.injector, + () => 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: [], @@ -179,10 +440,38 @@ export function createCommandFromDefinition< ...(definition.enableHooks === undefined ? {} : { enableHooks: definition.enableHooks }), + ...(definition.allowUnknownOptions === undefined + ? {} + : { allowUnknownOptions: definition.allowUnknownOptions }), + ...(definition.postRun === undefined + ? {} + : { + postCommandAction: async (args: string[]): Promise => { + const invocation = + currentInvocation || beginInvocation(buildContext(args)); + const context = invocation.context; + const setupResult = await invocation.setup; + await runInInjectionContext(invocation.injector, () => + definition.postRun.call( + definition, + context, + invocation.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 invocation = beginInvocation(context); + const setupResult = await invocation.setup; + + await enforceArguments(context); const refine = definition.canExecute; if (!refine) { @@ -191,14 +480,27 @@ 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)), + return await runInInjectionContext(invocation.injector, () => + refine.call(definition, context, setupResult), ); }, execute: async (args: string[]): Promise => { - await runInInjectionContext(targetInjector, () => - definition.run(buildContext(args)), + const invocation = + currentInvocation && !currentInvocation.hasRun + ? currentInvocation + : beginInvocation(buildContext(args)); + const context = invocation.context; + invocation.hasRun = true; + + const setupResult = await invocation.setup; + invocation.runResult = await runInInjectionContext( + invocation.injector, + () => definition.run.call(definition, context, setupResult), ); + + if (definition.shortcuts) { + attachShortcuts(invocation, context, setupResult); + } }, }; } @@ -208,10 +510,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 = (getRootInjector()), ): void { // The registry facet rather than the injector itself, so a child injector // that provides its own CommandRegistry receives the registration. @@ -223,22 +529,194 @@ export function registerDefinitionAs( ); } -export function registerCommandDefinition( - definition: DefinedCommand, - targetInjector: IInjector = injector, +/** 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]; + +/** + * 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 contextInjector = (): Injector => + getCurrentInjector() || (getRootInjector()); + +/** + * 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( + command: CommandReference, + args: string[] = [], +): Promise { + await contextInjector().get(CommandsService).runCommand(command, args); +} + +/** + * Convenience over `CommandsService.canExecuteCommand`, resolved from the + * current context the way `runCommand` is. + */ +export async function canExecuteCommand( + command: CommandReference, + args: string[] = [], +): Promise { + return contextInjector() + .get(CommandsService) + .canExecuteCommand(command, 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 + * 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: + | CommandClass + | DefinedCommand + | CommandDefinition, + providers: Provider[] = [], +): DeferredCommandResult { + 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; + 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 RegisterableCommand = 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 === false) { 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 RegisterableCommand = never, +>( + name: [TDefinition] extends [never] + ? MissingTypeArgument + : CommandNamesOf & string, + load: () => NoInfer, + providers: Provider[] = [], +): DeferredCommandResult { + const commandName = (name); + const target = contextInjector(); + const registry = target.get(CommandRegistry); + + return registry.registerDeferredCommand(commandName, { + owner: target.get(COMMAND_OWNER, { optional: true }) || CLI_OWNER, + 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. + const definition = toCommandDefinition(loaded); + if (!definition) { + throw new Error( + 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", + ); + } - 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/services/commands-service.ts b/lib/common/services/commands-service.ts index 37aeab6242..f66ac774b4 100644 --- a/lib/common/services/commands-service.ts +++ b/lib/common/services/commands-service.ts @@ -10,6 +10,9 @@ 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, toCommandDefinition } from "../define-command"; +import { createCommandFromDefinition } from "./command-definition-adapter"; import { ICommandParameter, ICommand, @@ -27,12 +30,20 @@ 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); } private commands: ICommandData[] = []; + private inProcessDepth: number = 0; + + public get isExecutingInProcess(): boolean { + return this.inProcessDepth > 0; + } constructor( private $errors: IErrors, @@ -43,7 +54,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( @@ -57,71 +70,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 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 beautifiedCommandName = this.beautifyCommandName( + commandName, + ).replace(/\|/g, " "); - await this.$hooksService.executeBeforeHooks(commandName); - } + const googleAnalyticsPageData: IGoogleAnalyticsPageviewData = { + googleAnalyticsDataType: GoogleAnalyticsDataType.Page, + path: beautifiedCommandName, + title: beautifiedCommandName, + }; - await command.execute(commandArguments); - if (command.postCommandAction) { - await command.postCommandAction(commandArguments); - } + await analyticsService.trackInGoogleAnalytics(googleAnalyticsPageData); + await this.$optionsTracker.trackOptions(this.$options); + } - if (shouldExecuteHooks) { - await this.$hooksService.executeAfterHooks(commandName); + 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, + ); } - this.commands.pop(); - return true; + await this.$hooksService.executeBeforeHooks(hookCommandName); } - this.commands.pop(); - return false; + await command.execute(commandArguments); + if (command.postCommandAction) { + await command.postCommandAction(commandArguments); + } + + if (shouldExecuteHooks) { + await this.$hooksService.executeAfterHooks(hookCommandName); + } } private printHelpSuggestion(commandName?: string): Promise { @@ -158,15 +191,15 @@ 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); + return this.canExecuteResolvedCommand(commandName, commandArguments); } public async tryExecuteCommand( @@ -203,12 +236,185 @@ export class CommandsService implements ICommandsService { } } - private async canExecuteCommand( + /** + * 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 runCommand( + reference: CommandReference, + commandArguments: string[] = [], + ): Promise { + // 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 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, + undefined, + command, + )) + ) { + 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; + } finally { + this.inProcessDepth--; + } + } + + /** + * 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 canExecuteCommand( + reference: CommandReference, + commandArguments: string[] = [], + ): Promise { + this.inProcessDepth++; + try { + const { commandName, command } = this.resolveReference(reference); + + this.commands.push({ commandName, commandArguments }); + const restoreOptions = this.primeOptions(command); + try { + return await this.canExecuteResolvedCommand( + commandName, + commandArguments, + undefined, + command, + ); + } finally { + restoreOptions(); + this.commands.pop(); + } + } finally { + this.inProcessDepth--; + } + } + + /** @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 + * 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; + } + + 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 canExecuteResolvedCommand( 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/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/test/unit-tests/preuninstall.ts b/lib/common/test/unit-tests/preuninstall.ts index 249d1c24f0..5d58599370 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"; @@ -8,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", () => { @@ -37,12 +39,14 @@ describe("preuninstall", () => { testInjector.register("analyticsService", { trackEventActionInGoogleAnalytics: async ( - data: IEventActionData + data: IEventActionData, ): Promise => undefined, finishTracking: async (): Promise => undefined, }); - testInjector.registerCommand("dev-preuninstall", PreUninstallCommand); + runInInjectionContext(testInjector, () => + registerCommand(preUninstallCommandDefinition), + ); return testInjector; }; @@ -56,9 +60,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 +97,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 +111,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 +127,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 +145,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 +170,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/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/common/yok.ts b/lib/common/yok.ts index 13642ad417..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); @@ -210,16 +202,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.`, ); } }, @@ -260,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. @@ -379,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) => @@ -501,6 +480,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 ( @@ -647,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`). @@ -734,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. */ @@ -758,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. @@ -773,8 +726,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 +738,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/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 c0c8e74d0e..54aac0ffa3 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"; @@ -48,16 +55,31 @@ 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, arrayOption, + objectOption, } from "../common/define-command"; export type { + ArgumentSpec, + ArgumentsPolicy, + CommandArgumentValues, + CommandClass, CommandDefinition, + CommandMeta, + CommandName, + CommandNamesOf, DefinedCommand, + NamedCommand, + RegisterableCommand, CommandContext, CommandOptionSpec, DefaultedCommandOptionSpec, @@ -66,6 +88,12 @@ 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"; +// 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/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/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/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/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/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/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/key-commands/bootstrap.ts b/lib/key-commands/bootstrap.ts deleted file mode 100644 index a10d2bd7b8..0000000000 --- a/lib/key-commands/bootstrap.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { SpecialKeys } from "../common/definitions/key-commands"; -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); -injector.requireCommand("open|ios", path); -injector.requireCommand("open|android", path); -injector.requireCommand("open|visionos", path); -injector.requireCommand("open|vision", path); diff --git a/lib/key-commands/index.ts b/lib/key-commands/index.ts deleted file mode 100644 index 904e3bf3b8..0000000000 --- a/lib/key-commands/index.ts +++ /dev/null @@ -1,522 +0,0 @@ -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 { 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 { - 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; - } - - 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(); - } -} - -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 { - 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 - ); - this.isInteractive = false; - } - async execute(): Promise { - this.$options.watch = false; - super.execute(); - } -} - -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 { - 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 - ); - this.isInteractive = false; - } - async execute(): Promise { - this.$options.watch = false; - super.execute(); - } -} - -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); - -injector.registerCommand("open|ios", OpenIOSCommand); -injector.registerCommand("open|visionos", OpenVisionOSCommand); -injector.registerCommand("open|vision", OpenVisionOSCommand); -injector.registerCommand("open|android", OpenAndroidCommand); 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/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/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/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index fe02c8d3ff..9c2884c534 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -21,10 +21,13 @@ 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 { DefinedCommand, isCommandDefinition } from "../common/define-command"; +import { inject, runInInjectionContext } from "../common/di/inject"; +import { + COMMAND_OWNER, + CommandRegistry, + describeRejection, +} from "../common/contracts"; +import { DefinedCommand, toCommandDefinition } from "../common/define-command"; import { registerDefinitionAs } from "../common/services/command-definition-adapter"; function isNonEmptyString(value: any): boolean { @@ -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. @@ -421,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, @@ -441,10 +446,13 @@ export class ExtensibilityService implements IExtensibilityService { commandName: string, absoluteModulePath: string, ): void { - const exported = require(absoluteModulePath); - const candidate = (exported && exported.default) ?? exported; + const exported = this.loadInExtensionScope(extensionName, () => + require(absoluteModulePath), + ); + const exportedValue = (exported && exported.default) ?? exported; - if (!isCommandDefinition(candidate)) { + const candidate = toCommandDefinition(exportedValue); + if (!candidate) { return; } 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 new file mode 100644 index 0000000000..6429dbc61b --- /dev/null +++ b/lib/services/key-shortcuts.ts @@ -0,0 +1,603 @@ +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 { + 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 { IProjectDataService } from "../definitions/project"; +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"; + +/** + * 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 { + IKeyShortcutService, + KeyContextBase, + KeyContextExtras, + KeyShortcut, + KeyShortcutRegistration, +}; +export { KeyShortcutRegistry }; + +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; +} + +/** + * 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 envSwitchIsOn(setting); + } + + 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")); + +/** 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, + forceRebuildNativeApp: boolean, +): Promise => { + const $liveSyncCommandHelper = ctx.injector.get( + "liveSyncCommandHelper", + ); + const target = platform || ctx.platform; + const devices = await $liveSyncCommandHelper.getDeviceInstances(target); + + await $liveSyncCommandHelper.executeLiveSyncOperation(devices, target, < + 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"); + } + }); +}; + +export interface RestartShortcutOptions { + /** 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; + /** + * 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?(): Promise; +} + +/** + * 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 ? "B" : full ? "R" : "r", + description: force + ? "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() + : full + ? restart(ctx, options.platform, force) + : restartApp(ctx, options.platform), + }; +} + +/** 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 + * 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()), + }, + openIdeShortcut("Android"), + { + key: "i", + description: "Run iOS app", + group: "iOS", + when: duringStart("iOS"), + action: launch((startService) => startService.runIOS()), + }, + openIdeShortcut("iOS"), + { + key: "v", + description: "Run visionOS app", + group: "visionOS", + when: duringStart("visionOS"), + action: launch((startService) => startService.runVisionOS()), + }, + openIdeShortcut("visionOS"), + restartShortcut(), + restartShortcut({ full: true }), + restartShortcut({ forceRebuildNativeApp: true }), + watcherShortcut(), + { + 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 batch `attach` registered, disposed when it is replaced or detached. */ + private attachedShortcuts: KeyShortcutRegistration; + private context: KeyContextBase; + private running: boolean = false; + private attached: boolean = false; + private hintSource: EventEmitter; + private hintTimer: NodeJS.Timeout; + + constructor( + private $injector: Injector, + private $logger: ILogger, + private $keyShortcutRegistry: KeyShortcutRegistry, + ) {} + + public attach(options: { + context?: KeyContextExtras; + shortcuts: KeyShortcut[]; + }): boolean { + this.detach(); + + this.context = { ...options.context, injector: this.$injector }; + this.attachedShortcuts = this.$keyShortcutRegistry.add( + ...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()) { + this.releaseShortcuts(); + return false; + } + + stdin.setRawMode(false); + stdin.setRawMode(true); + stdin.resume(); + stdin.on("data", this.onData); + process.once("exit", this.onExit); + this.attached = true; + this.repeatHintAfterSyncs(); + + return true; + } + + public detach(): void { + this.releaseShortcuts(); + + if (!this.attached) { + return; + } + this.attached = false; + this.stopRepeatingHint(); + + 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"), + ); + } + + /** 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`)); + } + + /** + * 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 + * the attach — moves both together. + */ + private resolve(): KeyShortcut[] { + 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 => { + 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/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 7ed068916d..2c6db992b4 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,67 @@ 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"), + forward("B"), + { + ...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/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-service.ts b/test/commands-service.ts index 0bd644fdee..48bec5ab0d 100644 --- a/test/commands-service.ts +++ b/test/commands-service.ts @@ -2,13 +2,18 @@ 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 }; -} { + validatedWith: { called: boolean; allowUnknown?: boolean }; +} + +function createTestInjector(command: ICommand): ITestSetup { 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 +26,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", {}); @@ -34,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 = { @@ -51,16 +175,169 @@ 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); + }); + }); + + 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/commands/post-install.ts b/test/commands/post-install.ts index fb9464d082..b0dd9ca476 100644 --- a/test/commands/post-install.ts +++ b/test/commands/post-install.ts @@ -1,9 +1,11 @@ import { Yok } from "../../lib/common/yok"; import { assert } from "chai"; 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"; import { IHelpService, IAnalyticsService } from "../../lib/common/declarations"; +import { runInInjectionContext } from "../../lib/common/di"; const createTestInjector = (): IInjector => { const testInjector = new Yok(); @@ -15,9 +17,9 @@ const createTestInjector = (): IInjector => { testInjector.register("staticConfig", {}); testInjector.register("commandsService", { - tryExecuteCommand: async ( + runCommand: async ( commandName: string, - commandArguments: string[] + commandArguments: string[], ): Promise => undefined, }); @@ -44,7 +46,9 @@ const createTestInjector = (): IInjector => { testInjector.register("settingsService", SettingsService); - testInjector.registerCommand("post-install-cli", PostInstallCliCommand); + runInInjectionContext(testInjector, () => + registerCommand(PostInstallCliCommand), + ); testInjector.register("hostInfo", {}); @@ -71,19 +75,17 @@ 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 => { + commandsService.runCommand = async (): Promise => { isTryExecuteCommandCalled = true; }; @@ -98,17 +100,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.runCommand`, ); }; diff --git a/test/compat/injector-facade-surface.ts b/test/compat/injector-facade-surface.ts index 96f4e3c522..130ea2150e 100644 --- a/test/compat/injector-facade-surface.ts +++ b/test/compat/injector-facade-surface.ts @@ -1,9 +1,8 @@ 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, - 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", @@ -62,17 +57,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", () => { @@ -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/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/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/define-command.ts b/test/define-command.ts index f249669849..1f28916dd7 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -1,10 +1,20 @@ 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 } from "../lib/common/di"; -import { CommandRegistry } from "../lib/common/contracts/command-registry"; +import { + inject, + InjectionToken, + runInInjectionContext, +} from "../lib/common/di"; +import { COMMAND_CONTEXT } from "../lib/common/contracts/command-context"; +import { + COMMAND_OWNER, + 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"; @@ -12,15 +22,21 @@ import { LoggerStub, HooksServiceStub } from "./stubs"; import { arrayOption, booleanOption, + Command, defineCommand, + isCommandClass, isCommandDefinition, numberOption, stringOption, } from "../lib/common/define-command"; import { + canExecuteCommand, createCommandFromDefinition, - registerCommandDefinition, + registerBuiltInCommand, + 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(); @@ -101,7 +117,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 +240,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, []); }); }); @@ -241,7 +263,7 @@ describe("defineCommand", () => { }); const testInjector = createTestInjector(); - registerCommandDefinition(definition, testInjector); + runInInjectionContext(testInjector, () => registerCommand(definition)); const command = testInjector.resolveCommand("dctestwidget|add"); assert.isFunction(command.execute); @@ -258,9 +280,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( @@ -271,26 +294,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/, ); }); @@ -300,42 +336,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", + }, + }, ); }); }); @@ -454,6 +539,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( @@ -487,6 +733,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 +805,7 @@ describe("defineCommand", () => { defineCommand({ name: "dctestshadow", options: { - verbose: booleanOption(), + verbose: stringOption(), output: stringOption({ alias: ["p", "o"] }), fresh: booleanOption({ alias: "f" }), }, @@ -530,6 +827,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 +1078,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 +1106,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 +1134,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", @@ -864,16 +1222,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 = @@ -891,14 +1250,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 = @@ -914,15 +1274,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 = @@ -938,15 +1299,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 = @@ -963,15 +1325,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 = @@ -982,4 +1345,1518 @@ describe("defineCommand", () => { assert.deepEqual(runs, [["beta"], []]); }); }); + + 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("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("runs a definition or class as given, registered or not", 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"); + } + } + // Registered under the same name as the definition, to show the + // definition wins over the lookup. + runInInjectionContext(testInjector, () => { + registerCommand( + defineCommand({ + name: "dctest-ref-primary", + arguments: "any", + run: () => { + runs.push("registered"); + }, + }), + ); + }); + 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); + await service.runCommand("dctest-ref-primary"); + assert.deepEqual(runs, ["definition", "class", "registered"]); + + await assert.isRejected( + service.runCommand({ name: "not-a-definition" }), + /Expected a command name/, + ); + }); + + 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( + defineCommand({ + name: "dctest-positional", + arguments: [ + { name: "platform", required: true }, + { name: "target" }, + ...(extra.variadic ? [{ name: "rest", variadic: true }] : []), + ], + run: (ctx) => { + extra.seen = ctx.params; + }, + }), + createTestInjector(), + ); + + it("maps arguments onto ctx.params 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.params", 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.params when no specs are declared", async () => { + let seen: any; + const command = createCommandFromDefinition( + defineCommand({ + name: "dctest-noargspecs", + arguments: "any", + run: (ctx) => { + seen = ctx.params; + }, + }), + 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.params, { 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("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( + 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; + runInInjectionContext(testInjector, () => + registerCommand( + defineCommand({ + name: "dctest-unknown-e2e", + allowUnknownOptions: true, + arguments: "any", + run: () => { + ran = true; + }, + }), + ), + ); + + 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("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(); + 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("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(); + 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("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(); + + 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"); + 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"]) { + runInInjectionContext(testInjector, () => + registerCommand({ ...definition, name: `dctest-run|${platform}` }, [ + { 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/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/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/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/platform-commands.ts b/test/platform-commands.ts index 8efe669bd9..bcdf340861 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 { AddPlatformCommand } from "../lib/commands/add-platform"; +import { removePlatformCommandDefinition } from "../lib/commands/remove-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"; import * as CommandsServiceLib from "../lib/common/services/commands-service"; import * as optionsLib from "../lib/options"; @@ -34,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; @@ -43,7 +45,7 @@ class PlatformData implements IPlatformData { platformNameLowerCase = "android"; platformProjectService: IPlatformProjectService = { validate: async ( - projectData: IProjectData + projectData: IProjectData, ): Promise => { return { checkEnvironmentRequirementsOutput: { @@ -84,7 +86,7 @@ class ErrorsNoFailStub implements IErrors { async beginCommand( action: () => Promise, - printHelpCommand: () => Promise + printHelpCommand: () => Promise, ): Promise { let result = false; try { @@ -96,6 +98,11 @@ class ErrorsNoFailStub implements IErrors { return result; } + async reportCommandError( + error: any, + printHelpCommand: () => Promise, + ): Promise {} + executeAction(action: Function): any { return action(); } @@ -111,7 +118,7 @@ class ErrorsNoFailStub implements IErrors { parsed: any, knownOpts: any, shorthands: any, - clientName?: string + clientName?: string, ): void { /* intentionally left blank */ } @@ -145,7 +152,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,21 +161,17 @@ function createTestInjector() { testInjector.register("prompter", {}); testInjector.register("sysInfo", {}); testInjector.register("commands-service", CommandsServiceLib.CommandsService); - testInjector.registerCommand( - "platform|add", - PlatformAddCommandLib.AddPlatformCommand + runInInjectionContext(testInjector, () => + registerCommand(AddPlatformCommand), ); - testInjector.registerCommand( - "platform|remove", - PlatformRemoveCommandLib.RemovePlatformCommand + runInInjectionContext(testInjector, () => + registerCommand(removePlatformCommandDefinition), ); - testInjector.registerCommand( - "platform|update", - PlatformUpdateCommandLib.UpdatePlatformCommand + runInInjectionContext(testInjector, () => + registerCommand(UpdatePlatformCommand), ); - testInjector.registerCommand( - "platform|clean", - PlatformCleanCommandLib.CleanCommand + runInInjectionContext(testInjector, () => + registerCommand(PlatformCleanCommand), ); testInjector.register("resources", {}); testInjector.register("commandsService", { @@ -188,13 +191,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 +207,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 +232,7 @@ function createTestInjector() { checkEnvironmentRequirements: async ( platform?: string, projectDir?: string, - runtimeVersion?: string + runtimeVersion?: string, ): Promise => { return { canExecute: true, @@ -241,7 +244,7 @@ function createTestInjector() { extractPackage: async ( packageName: string, destinationDirectory: string, - options?: IPacoteExtractOptions + options?: IPacoteExtractOptions, ): Promise => undefined, }); testInjector.register("optionsTracker", { @@ -280,7 +283,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 +299,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 +319,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 +335,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 +354,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 +375,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 +391,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 +409,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 +424,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 +443,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 +470,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 +486,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 +505,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 +528,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 +545,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 +572,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 +595,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 +633,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 +642,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 +658,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 +676,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 +692,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 +711,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..0a5f66bfea 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 { + CreatePluginCommand, + 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"; @@ -11,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; @@ -63,7 +73,9 @@ function createTestInjector() { }, }); - testInjector.register("createCommand", CreatePluginCommand); + runInInjectionContext(testInjector, () => + registerCommand(CreatePluginCommand), + ); return testInjector; } @@ -71,14 +83,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 +133,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 +152,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 +171,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 +189,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 +207,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 +283,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..edf62e97b0 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"; @@ -51,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; @@ -246,7 +248,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 +328,9 @@ describe("Plugins service", () => { const commands = ["add", "install"]; beforeEach(() => { testInjector = createTestInjector(); - testInjector.registerCommand("plugin|add", AddPluginCommand); - testInjector.registerCommand("plugin|install", AddPluginCommand); + runInInjectionContext(testInjector, () => + registerCommand(addPluginCommandDefinition), + ); }); _.each(commands, (command) => { diff --git a/test/project-commands.ts b/test/project-commands.ts index bb61c67771..a7b8d49379 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 { 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"; @@ -15,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; @@ -168,7 +170,9 @@ function createTestInjector() { ng: false, template: undefined, }); - testInjector.register("createCommand", CreateProjectCommand); + runInInjectionContext(testInjector, () => + registerCommand(CreateProjectCommand), + ); testInjector.register("stringParameter", StringCommandParameter); testInjector.register("prompter", PrompterStub); @@ -226,7 +230,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/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); + }); + }); }); 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 new file mode 100644 index 0000000000..4be80230ae --- /dev/null +++ b/test/services/key-shortcuts.ts @@ -0,0 +1,1020 @@ +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"; +import { KeyShortcutRegistryService } from "../../lib/services/key-shortcut-registry"; +import { + findShortcut, + KeyContextBase, + KeyShortcut, + KeyShortcutService, + keyShortcuts, + keyShortcutsEnabled, + NsKeyContext, + resolveShortcuts, + restartShortcut, +} 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) ?? registrations.get(getContractName(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", + "B", + "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", + "B", + "w", + "c", + "n", + "?", + ]); + }); + + it("routes the IDE shortcuts through the open commands", async () => { + const invoked: string[] = []; + const dispatcher = fakeInjector( + new Map([ + [ + "commandsService", + { + runCommand: 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("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( + () => 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; + let registry: KeyShortcutRegistryService; + + 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(); + registry = new KeyShortcutRegistryService(); + service = new KeyShortcutService( + fakeInjector(registrations), + ({ + error: (message: string) => errors.push(message), + }), + registry, + ); + 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"]); + }); + + 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"); + }); + + 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", () => { + it("dispatches through the commands service of the current context", async () => { + const dispatched: { name: string; args: string[] }[] = []; + const dispatcher = fakeInjector( + new Map([ + [ + "commandsService", + { + runCommand: 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", + { + runCommand: 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/stubs.ts b/test/stubs.ts index 7be77bc26a..8011d1c0be 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(); } @@ -1303,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 []; @@ -1322,6 +1328,34 @@ export class CommandsService implements ICommandsService { return Promise.resolve(true); } + public runCommand( + commandName: string, + commandArguments?: string[], + ): Promise { + return Promise.resolve(); + } + + 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); } 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; diff --git a/test/tns-appstore-upload.ts b/test/tns-appstore-upload.ts index 2fac17b6ab..4773ce8a8c 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 { PublishIOSCommand } from "../lib/commands/appstore-upload"; +import { registerCommand } from "../lib/common/services/command-definition-adapter"; +import { Injector } from "../lib/common/di"; import { PrompterStub, LoggerStub, @@ -13,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 = { @@ -48,9 +51,6 @@ class AppStore { projectRoot: "/Users/person/git/MyProject", }; this.initInjector({ - commands: { - appstore: PublishIOS, - }, services: { errors: {}, fs: {}, @@ -95,20 +95,18 @@ 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]); } } + runInInjectionContext((this.injector), () => + registerCommand({ ...PublishIOSCommand.definition, 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 e8d1f80d49..34cf91698a 100644 --- a/test/type-fixtures/define-command-types.ts +++ b/test/type-fixtures/define-command-types.ts @@ -9,10 +9,17 @@ import { arrayOption, booleanOption, + Command, defineCommand, numberOption, stringOption, } from "../../lib/common/define-command"; +import type { CommandArgumentValues } from "../../lib/common/define-command"; +import { + registerBuiltInCommand, + registerLazyCommand, +} from "../../lib/common/services/command-definition-adapter"; +import type { Injector } from "../../lib/common/di/injector"; type IsExact = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 @@ -82,3 +89,235 @@ defineCommand({ arguments: "one", run: () => undefined, }); + +// `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({ + name: "typefixture|positional", + arguments: [ + { name: "platform", required: true }, + { name: "extra", variadic: true }, + ], + run(ctx) { + expectExactType>(); + expectExactType< + IsExact<(typeof ctx.params)["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, +}); + +// 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 }; + +// 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, +); diff --git a/test/update.ts b/test/update.ts index 3842ae534c..820ef09eef 100644 --- a/test/update.ts +++ b/test/update.ts @@ -1,12 +1,15 @@ import * as stubs from "./stubs"; import * as yok from "../lib/common/yok"; 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"; import { Options } from "../lib/options"; 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 { @@ -42,6 +45,8 @@ function createTestInjector(projectDir: string = projectFolder): IInjector { }, }); + runInInjectionContext(testInjector, () => registerCommand(UpdateCommand)); + return testInjector; } @@ -49,7 +54,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 +66,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 +74,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);