diff --git a/PublicAPI.md b/PublicAPI.md index b59cba2fe3..dd6d8f6151 100644 --- a/PublicAPI.md +++ b/PublicAPI.md @@ -479,7 +479,7 @@ tns.settingsService.setSettings({ userAgentName: "myUserAgent", profileDir: "cus `npm` module provides a way to interact with npm specifically the use of install, uninstall, search and view commands. ### install -Installs specified package. Note that you can use the third argument in order to pass different options to the installation like `ignore-scripts`, `save` or `save-exact` which work exactly like they would if you would execute npm from the command line and pass them as `--` flags. +Installs specified package. The third argument takes package-manager-agnostic options (`dev`, `exact`, `save`, `optional`, `silent`, `ignoreScripts`); the selected package manager maps them onto its own command line flags. * Auxiliary interfaces: ```TypeScript /** @@ -533,11 +533,11 @@ Uninstalls a specified package. /** * Uninstalls a dependency * @param {string} packageName The name of the dependency. - * @param {IDictionary} config Additional options that can be passed to manipulate uninstallation. + * @param {IPackageUninstallOptions} options Package-manager-agnostic uninstallation options (`save`). * @param {string} path The destination of the uninstallation. * @return {Promise} The output of the uninstallation. */ -uninstall(packageName: string, config?: IDictionary, path?: string): Promise; +uninstall(packageName: string, options?: IPackageUninstallOptions, path?: string): Promise; ``` * Usage: @@ -556,39 +556,57 @@ Searches for a package using keywords. ```TypeScript /** * Searches for a package. - * @param {string[]} filter Keywords with which to perform the search. - * @param {IDictionary} config Additional options that can be passed to manipulate search. - * @return {Promise} The output of the uninstallation. + * @param {string[]} filter Keywords with which to perform the search. + * @return {Promise} The raw search output. */ -search(filter: string[], config: IDictionary): Promise; +search(filter: string[]): Promise; ``` * Usage: ```JavaScript -tns.npm.search(["nativescript", "cloud"], { silent: true }).then(output => { +tns.npm.search(["nativescript", "cloud"]).then(output => { console.log(`Found: ${output}`); }, err => { console.log("An error occurred during searching", err); }); ``` +### getInstalledPackagePath +Locates a package the way the selected package manager laid it out on disk, so callers never have to assume a `node_modules` layout. + +* Definition: +```TypeScript +/** + * @param {string} packageName The name of the package. + * @param {string} fromDir The directory whose dependencies are searched, usually the project directory. + * @return {string} The absolute path of the package directory, or null when it is not installed. + */ +getInstalledPackagePath(packageName: string, fromDir: string): string; +``` + +* Usage: +```JavaScript +const pathToPackage = tns.packageManager.getInstalledPackagePath("@nativescript/core", "/tmp/myProject"); +console.log(pathToPackage ? `Installed at ${pathToPackage}` : "Not installed"); +``` + ### view Provides information about a given package. * Definition ```TypeScript /** - * Provides information about a given package. - * @param {string} packageName The name of the package. - * @param {IDictionary} config Additional options that can be passed to manipulate view. - * @return {Promise} Object, containing information about the package. + * Provides registry information about a package. + * @param {string} packageName The name of the package, optionally with a version. + * @param {string} field Optional single registry field (e.g. "versions" or "dist-tags") to return instead of the whole document. + * @return {Promise} The parsed registry data. */ -view(packageName: string, config: Object): Promise; +view(packageName: string, field?: string): Promise; ``` * Usage: ```JavaScript -tns.npm.view(["nativescript"], {}).then(result => { +tns.npm.view("nativescript").then(result => { console.log(`${result.name}'s latest version is ${result["dist-tags"].latest}`); }, err => { console.log("An error occurred during viewing", err); diff --git a/defining-commands.md b/defining-commands.md index 49ca625488..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 --------------------------------------- @@ -169,11 +173,11 @@ options: { So a redeclaration of the same name with the same type is silent. What the CLI still warns about at registration is a redeclaration that changes what the -spelling *means*: +spelling _means_: - a declared option whose name matches a CLI-wide one but whose type differs — `verbose: stringOption()` against the CLI's boolean `--verbose`; -- an alias that belongs to a *different* CLI-wide option — `output: +- an alias that belongs to a _different_ CLI-wide option — `output: stringOption({ alias: "p" })` steals `--path`'s shorthand. Restating an option's own shorthand (`path: stringOption({ alias: "p" })`) is fine. @@ -415,39 +419,69 @@ resolves providers a child scope supplied — see [Registering a definition](#registering-a-definition). The same guidance, and the reasoning behind it, is in `dependency-injection.md`. -`setup` — hoisting work out of `run` ------------------------------------- +Where a handler gets its services +--------------------------------- -`setup(ctx)` runs once per invocation, before `canExecute`, and its return -value is handed to `canExecute`, `run` and `postRun` as their second argument: +A handler resolves what it needs itself, at the top of its own body: ```ts export default defineCommand({ name: "widget|add", arguments: "any", - setup() { + async run(ctx) { + const widgets = inject(WidgetService); const projectData = inject(ProjectData); + projectData.initializeProjectData(); - return { projectData, widgets: inject(WidgetService) }; - }, - canExecute(ctx, { projectData }) { - return !!projectData.projectDir; - }, - async run(ctx, { widgets }) { await widgets.add(ctx.args); }, }); ``` -It exists for two reasons. It is the place to inject services before the first -`await` when several handlers need them, and it is where the work a command -class used to do in its constructor goes — most often -`$projectData.initializeProjectData()`. +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` is sugar. A command may ignore it entirely and call `inject()` at the -top of `run`; nothing else changes. "Once per invocation" means once across -`canExecute`, `run` and `postRun` together — whichever of them the CLI reaches -first triggers it, and the rest reuse the value. +`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` ----------------------------------- @@ -484,6 +518,138 @@ 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 ------------------------ @@ -499,12 +665,17 @@ registerCommand({ }); ``` +Every registration helper — `registerCommand`, `registerLazyCommand` and +`registerBuiltInCommand` — takes a [class form](#class-form) command wherever +it takes a definition, and reads the name it declares through its +`static definition`. + It takes either a `DefinedCommand` — the result of `defineCommand`, marker and all — or the definition itself, which it defines on your behalf, so registering a command is one call. Either way the definition is validated before it reaches the registry. It claims every name the definition declares, through the `CommandRegistry` the target injector provides, and returns a -`DeferredCommandResult` — see *The owner is ambient* below. The command instance +`DeferredCommandResult` — see _The owner is ambient_ below. The command instance is built by a factory on first resolution and cached. Pass providers as the second argument to scope the command to a child injector @@ -523,7 +694,7 @@ That is how one definition serves several commands that differ only in data — the platform each one targets — instead of one command subclassing another. **Which injector it registers against is not a parameter.** It is the injector -of the current injection context — see *The owner is ambient* below — and the +of the current injection context — see _The owner is ambient_ below — and the CLI's own injector outside one. To register against some other injector, run the call in its context: @@ -653,12 +824,20 @@ per-platform command subclasses a shared base to override one field. Running a command in process ---------------------------- -`runCommand` dispatches a registered command from inside the process that is -already running: +The `CommandsService` contract dispatches a registered command from inside the +process that is already running. A class command injects it like any other +service; an inline handler or a key shortcut may use the `runCommand` +convenience, which only resolves the contract from the current context: ```ts +import { CommandsService } from "../common/contracts/commands-service"; import { runCommand } from "../common/services/command-definition-adapter"; +// in a class command +private $commandsService = inject(CommandsService); +await this.$commandsService.runCommand("autocomplete"); + +// in an inline handler or a shortcut action await runCommand("open|ios"); await runCommand("install", ["lodash"]); ``` @@ -686,10 +865,51 @@ declarations into it rewrites the values the host process is still running on — `open|ios` declares `watch: false`, which would otherwise leave an `ns start` out of watch mode for the rest of its life. -Which injector it dispatches through follows the rule `registerCommand` does: -the injector of the current injection context, and the CLI's own outside one. -`runCommand` is a thin call onto `CommandsService.executeCommandInProcess`, -where the pipeline itself lives. +Which injector `runCommand` dispatches through follows the rule +`registerCommand` does: the injector of the current injection context, and the +CLI's own outside one. The pipeline itself lives on the contract, so a plugin +that holds an injector can call `CommandsService.runCommand` directly. + +### Asking another command + +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 @@ -722,16 +942,16 @@ A definition is compiled into an ordinary `ICommand`, so nothing downstream — the registry, the router, hooks, help, analytics — knows the difference. The mapping is: -| Definition | `ICommand` | -| --------------------------------- | --------------------------------------------------- | -| `options` | `dashedOptions` | -| `run` | `execute`, wrapped in an injection context | -| `arguments`, `canExecute` | `canExecute`: policy enforced, then the refinement | -| `setup` | — run inside `canExecute`/`execute`, memoised | -| `postRun` | `postCommandAction`, with `run`'s return value | -| `allowUnknownOptions` | `skipOptionsValidation` | -| — | `allowedParameters`, always `[]` | -| `disableAnalytics`, `enableHooks` | passed through unchanged | +| Definition | `ICommand` | +| --------------------------------- | -------------------------------------------------- | +| `options` | `dashedOptions` | +| `run` | `execute`, wrapped in an injection context | +| `arguments`, `canExecute` | `canExecute`: policy enforced, then the refinement | +| `setup` | — run inside `canExecute`/`execute`, memoised | +| `postRun` | `postCommandAction`, with `run`'s return value | +| `allowUnknownOptions` | `skipOptionsValidation` | +| — | `allowedParameters`, always `[]` | +| `disableAnalytics`, `enableHooks` | passed through unchanged | The compiled command always exposes `canExecute`, because `CommandsService` stops consulting `allowedParameters` as soon as a command has one — the adapter diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 860a46f609..a1dfe80dc5 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -176,11 +176,8 @@ injector.require( ); injector.require("platformCommandParameter", "./platform-command-param"); registerBuiltInCommand< - typeof import("./commands/create-project").createProjectCommandDefinition ->( - "create", - () => require("./commands/create-project").createProjectCommandDefinition, -); + typeof import("./commands/create-project").CreateProjectCommand +>("create", () => require("./commands/create-project").CreateProjectCommand); registerBuiltInCommand< typeof import("./commands/clean").cleanCommandDefinition >("clean", () => require("./commands/clean").cleanCommandDefinition); @@ -206,11 +203,8 @@ registerBuiltInCommand< () => require("./commands/list-platforms").listPlatformsCommandDefinition, ); registerBuiltInCommand< - typeof import("./commands/add-platform").addPlatformCommandDefinition ->( - "platform|add", - () => require("./commands/add-platform").addPlatformCommandDefinition, -); + typeof import("./commands/add-platform").AddPlatformCommand +>("platform|add", () => require("./commands/add-platform").AddPlatformCommand); registerBuiltInCommand< typeof import("./commands/remove-platform").removePlatformCommandDefinition >( @@ -218,10 +212,10 @@ registerBuiltInCommand< () => require("./commands/remove-platform").removePlatformCommandDefinition, ); registerBuiltInCommand< - typeof import("./commands/update-platform").updatePlatformCommandDefinition + typeof import("./commands/update-platform").UpdatePlatformCommand >( "platform|update", - () => require("./commands/update-platform").updatePlatformCommandDefinition, + () => require("./commands/update-platform").UpdatePlatformCommand, ); registerBuiltInCommand( "run|*all", @@ -259,13 +253,15 @@ registerBuiltInCommand( "open|vision", () => require("./commands/open").visionOpenCommand, ); -registerBuiltInCommand< - typeof import("./commands/typings").typingsCommandDefinition ->("typings", () => require("./commands/typings").typingsCommandDefinition); +registerBuiltInCommand( + "typings", + () => require("./commands/typings").TypingsCommand, +); -registerBuiltInCommand< - typeof import("./commands/preview").previewCommandDefinition ->("preview", () => require("./commands/preview").previewCommandDefinition); +registerBuiltInCommand( + "preview", + () => require("./commands/preview").PreviewCommand, +); registerBuiltInCommand( "debug|ios", @@ -312,8 +308,8 @@ registerBuiltInCommand< >("deploy", () => require("./commands/deploy").deployCommandDefinition); registerBuiltInCommand< - typeof import("./commands/embedding/embed").embedCommandDefinition ->("embed", () => require("./commands/embedding/embed").embedCommandDefinition); + typeof import("./commands/embedding/embed").EmbedCommand +>("embed", () => require("./commands/embedding/embed").EmbedCommand); injector.require("testExecutionService", "./services/test-execution-service"); injector.require( @@ -342,9 +338,10 @@ registerBuiltInCommand< "test|visionos", () => require("./commands/test").testVisionOSCommandDefinition, ); -registerBuiltInCommand< - typeof import("./commands/test-init").testInitCommandDefinition ->("test|init", () => require("./commands/test-init").testInitCommandDefinition); +registerBuiltInCommand( + "test|init", + () => require("./commands/test-init").TestInitCommand, +); registerBuiltInCommand< typeof import("./commands/generate-help").generateHelpCommandDefinition >( @@ -353,23 +350,20 @@ registerBuiltInCommand< ); registerBuiltInCommand< - typeof import("./commands/appstore-list").listiOSAppsCommandDefinition + typeof import("./commands/appstore-list").ListiOSAppsCommand >( "appstore|*list", - () => require("./commands/appstore-list").listiOSAppsCommandDefinition, + () => require("./commands/appstore-list").ListiOSAppsCommand, ); registerBuiltInCommand< - typeof import("./commands/appstore-upload").publishIOSCommandDefinition + typeof import("./commands/appstore-upload").PublishIOSCommand >( "appstore|upload", - () => require("./commands/appstore-upload").publishIOSCommandDefinition, + () => require("./commands/appstore-upload").PublishIOSCommand, ); registerBuiltInCommand< - typeof import("./commands/appstore-upload").publishIOSCommandDefinition ->( - "publish|ios", - () => require("./commands/appstore-upload").publishIOSCommandDefinition, -); + typeof import("./commands/appstore-upload").PublishIOSCommand +>("publish|ios", () => require("./commands/appstore-upload").PublishIOSCommand); registerBuiltInCommand< typeof import("./commands/apple-login").appleLoginCommandDefinition >( @@ -385,12 +379,12 @@ registerBuiltInCommand< typeof import("./commands/setup").setupCommandDefinition >("setup|*", () => require("./commands/setup").setupCommandDefinition); -injector.requirePublic("packageManager", "./package-manager"); -injector.requirePublic("npm", "./node-package-manager"); -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.requirePublic("packageManager", "./package-managers/index"); +injector.requirePublic("npm", "./package-managers/npm"); +injector.requirePublic("yarn", "./package-managers/yarn"); +injector.requirePublic("yarn2", "./package-managers/yarn2"); +injector.requirePublic("pnpm", "./package-managers/pnpm"); +injector.requirePublic("bun", "./package-managers/bun"); registerBuiltInCommand< typeof import("./common/commands/package-manager-get").packageManagerGetCommandDefinition >( @@ -410,7 +404,7 @@ registerBuiltInCommand< injector.require( "packageInstallationManager", - "./package-installation-manager", + "./package-managers/package-installation-manager", ); injector.require("deviceLogProvider", "./common/mobile/device-log-provider"); @@ -459,17 +453,16 @@ registerBuiltInCommand< require("./commands/plugin/update-plugin").updatePluginCommandDefinition, ); registerBuiltInCommand< - typeof import("./commands/plugin/build-plugin").buildPluginCommandDefinition + typeof import("./commands/plugin/build-plugin").BuildPluginCommand >( "plugin|build", - () => require("./commands/plugin/build-plugin").buildPluginCommandDefinition, + () => require("./commands/plugin/build-plugin").BuildPluginCommand, ); registerBuiltInCommand< - typeof import("./commands/plugin/create-plugin").createPluginCommandDefinition + typeof import("./commands/plugin/create-plugin").CreatePluginCommand >( "plugin|create", - () => - require("./commands/plugin/create-plugin").createPluginCommandDefinition, + () => require("./commands/plugin/create-plugin").CreatePluginCommand, ); registerBuiltInCommand< @@ -527,10 +520,10 @@ injector.require("androidToolsInfo", "./android-tools-info"); injector.require("devicePathProvider", "./device-path-provider"); registerBuiltInCommand< - typeof import("./commands/platform-clean").platformCleanCommandDefinition + typeof import("./commands/platform-clean").PlatformCleanCommand >( "platform|clean", - () => require("./commands/platform-clean").platformCleanCommandDefinition, + () => require("./commands/platform-clean").PlatformCleanCommand, ); injector.require( @@ -575,17 +568,18 @@ injector.require( injector.require("messages", "./common/messages/messages"); registerBuiltInCommand< - typeof import("./commands/post-install").postInstallCliCommandDefinition + typeof import("./commands/post-install").PostInstallCliCommand >( "post-install-cli", - () => require("./commands/post-install").postInstallCliCommandDefinition, + () => require("./commands/post-install").PostInstallCliCommand, ); registerBuiltInCommand< typeof import("./commands/migrate").migrateCommandDefinition >("migrate", () => require("./commands/migrate").migrateCommandDefinition); -registerBuiltInCommand< - typeof import("./commands/update").updateCommandDefinition ->("update", () => require("./commands/update").updateCommandDefinition); +registerBuiltInCommand( + "update", + () => require("./commands/update").UpdateCommand, +); injector.require("iOSLogFilter", "./services/ios-log-filter"); injector.require("logSourceMapService", "./services/log-source-map-service"); diff --git a/lib/commands/add-platform.ts b/lib/commands/add-platform.ts index 79faa9f01e..049978876e 100644 --- a/lib/commands/add-platform.ts +++ b/lib/commands/add-platform.ts @@ -1,14 +1,13 @@ +import { canExecuteCommandBase } from "./command-base"; import { - canExecuteCommandBase, - injectPlatformCommandServices, - IPlatformCommandServices, -} from "./command-base"; -import { IPlatformCommandHelper } from "../declarations"; + IPlatformCommandHelper, + IPlatformValidationService, +} from "../declarations"; +import { IProjectData } from "../definitions/project"; import { IErrors } from "../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -17,83 +16,63 @@ const addPlatformCommandOptions = { frameworkPath: stringOption(), } satisfies CommandOptionsSchema; -export type AddPlatformCommandContext = CommandContext< - typeof addPlatformCommandOptions ->; - -export interface IAddPlatformCommandServices extends IPlatformCommandServices { - $errors: IErrors; - $platformCommandHelper: IPlatformCommandHelper; -} +export class AddPlatformCommand extends Command({ + name: "platform|add", + description: + "Configures the current project to target the selected platform.", + options: addPlatformCommandOptions, + arguments: "any", +}) { + private $errors = inject("errors"); + private $platformCommandHelper = inject( + "platformCommandHelper", + ); + private $platformValidationService = inject( + "platformValidationService", + ); + private $projectData = inject("projectData"); -export function setupAddPlatformCommand(): IAddPlatformCommandServices { - const services = { - ...injectPlatformCommandServices(), - $errors: inject("errors"), - $platformCommandHelper: inject( - "platformCommandHelper", - ), - }; - services.$projectData.initializeProjectData(); + constructor() { + super(); + this.$projectData.initializeProjectData(); + } - return services; -} + public async canExecute(): Promise { + const args = this.args; + if (!args || args.length === 0) { + this.$errors.failWithHelp( + "No platform specified. Please specify a platform to add.", + ); + } -export async function canExecuteAddPlatformCommand( - context: AddPlatformCommandContext, - services: IAddPlatformCommandServices, -): Promise { - const args = context.args; - if (!args || args.length === 0) { - services.$errors.failWithHelp( - "No platform specified. Please specify a platform to add.", - ); - } + let canExecute = true; + for (const arg of args) { + this.$platformValidationService.validatePlatform(arg, this.$projectData); - let canExecute = true; - for (const arg of args) { - services.$platformValidationService.validatePlatform( - arg, - services.$projectData, - ); + if ( + !this.$platformValidationService.isPlatformSupportedForOS( + arg, + this.$projectData, + ) + ) { + this.$errors.fail( + `Applications for platform ${arg} cannot be built on this OS`, + ); + } - if ( - !services.$platformValidationService.isPlatformSupportedForOS( - arg, - services.$projectData, - ) - ) { - services.$errors.fail( - `Applications for platform ${arg} cannot be built on this OS`, - ); + // The assignment overwrites the previous platform's verdict, so only the + // last one decides. + canExecute = await canExecuteCommandBase(this.context, arg); } - // The assignment overwrites the previous platform's verdict, so only the - // last one decides. Kept as it was. - canExecute = await canExecuteCommandBase(services, arg); + return canExecute; } - return canExecute; -} - -export async function runAddPlatformCommand( - context: AddPlatformCommandContext, - services: IAddPlatformCommandServices, -): Promise { - await services.$platformCommandHelper.addPlatforms( - context.args, - services.$projectData, - context.options.frameworkPath, - ); + public async run(): Promise { + await this.$platformCommandHelper.addPlatforms( + this.args, + this.$projectData, + this.options.frameworkPath, + ); + } } - -export const addPlatformCommandDefinition = defineCommand({ - name: "platform|add", - description: - "Configures the current project to target the selected platform.", - options: addPlatformCommandOptions, - arguments: "any", - setup: setupAddPlatformCommand, - canExecute: canExecuteAddPlatformCommand, - run: runAddPlatformCommand, -}); diff --git a/lib/commands/apple-login.ts b/lib/commands/apple-login.ts index fc0a523fda..4002080743 100644 --- a/lib/commands/apple-login.ts +++ b/lib/commands/apple-login.ts @@ -1,62 +1,43 @@ import { IErrors } from "../common/declarations"; -import { CommandContext, defineCommand } from "../common/define-command"; +import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; import { IApplePortalSessionService } from "../services/apple-portal/definitions"; -export type AppleLoginCommandContext = CommandContext; - -export interface IAppleLoginCommandServices { - $applePortalSessionService: IApplePortalSessionService; - $errors: IErrors; - $logger: ILogger; - $prompter: IPrompter; -} - -export function setupAppleLoginCommand(): IAppleLoginCommandServices { - return { - $applePortalSessionService: inject( - "applePortalSessionService", - ), - $errors: inject("errors"), - $logger: inject("logger"), - $prompter: inject("prompter"), - }; -} - -export async function runAppleLoginCommand( - context: AppleLoginCommandContext, - services: IAppleLoginCommandServices, -): Promise { - let username = context.args[0]; - if (!username) { - username = await services.$prompter.getString("Apple ID", { - allowEmpty: false, - }); - } - - let password = context.args[1]; - if (!password) { - password = await services.$prompter.getPassword("Apple ID password"); - } - - const user = await services.$applePortalSessionService.createUserSession({ - username, - password, - }); - if (!user.areCredentialsValid) { - services.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.`, - ); - } - - const output = Buffer.from(user.userSessionCookie).toString("base64"); - services.$logger.info(output); -} - export const appleLoginCommandDefinition = defineCommand({ name: "apple-login", description: "Logs in to an Apple account and prints the session cookie.", arguments: [{ name: "appleId" }, { name: "password" }], - setup: setupAppleLoginCommand, - run: runAppleLoginCommand, + async run(context) { + const $applePortalSessionService = inject( + "applePortalSessionService", + ); + const $errors = inject("errors"); + const $logger = inject("logger"); + const $prompter = inject("prompter"); + + let username = context.args[0]; + if (!username) { + username = await $prompter.getString("Apple ID", { + allowEmpty: false, + }); + } + + let password = context.args[1]; + if (!password) { + password = await $prompter.getPassword("Apple ID password"); + } + + const user = await $applePortalSessionService.createUserSession({ + username, + password, + }); + if (!user.areCredentialsValid) { + $errors.fail( + `Invalid username and password combination. Used '${username}' as the username.`, + ); + } + + const output = Buffer.from(user.userSessionCookie).toString("base64"); + $logger.info(output); + }, }); diff --git a/lib/commands/appstore-list.ts b/lib/commands/appstore-list.ts index 2417008c28..1279a3b27f 100644 --- a/lib/commands/appstore-list.ts +++ b/lib/commands/appstore-list.ts @@ -1,8 +1,7 @@ import { IErrors } from "../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -18,114 +17,91 @@ const listiOSAppsCommandOptions = { appleSessionBase64: stringOption(), } satisfies CommandOptionsSchema; -export type ListiOSAppsCommandContext = CommandContext< - typeof listiOSAppsCommandOptions ->; - -export interface IListiOSAppsCommandServices { - $applePortalApplicationService: IApplePortalApplicationService; - $applePortalSessionService: IApplePortalSessionService; - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $errors: IErrors; - $logger: ILogger; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; - $prompter: IPrompter; -} - -export function setupListiOSAppsCommand(): IListiOSAppsCommandServices { - const services = { - $applePortalApplicationService: inject( - "applePortalApplicationService", - ), - $applePortalSessionService: inject( - "applePortalSessionService", - ), - $devicePlatformsConstants: inject( - "devicePlatformsConstants", - ), - $errors: inject("errors"), - $logger: inject("logger"), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - $prompter: inject("prompter"), - }; - services.$projectData.initializeProjectData(); - - return services; -} +export class ListiOSAppsCommand extends Command({ + name: "appstore|*list", + description: "Lists the applications in App Store Connect.", + options: listiOSAppsCommandOptions, + arguments: [{ name: "appleId" }, { name: "password" }], +}) { + private $applePortalApplicationService = + inject("applePortalApplicationService"); + private $applePortalSessionService = inject( + "applePortalSessionService", + ); + private $devicePlatformsConstants = inject( + "devicePlatformsConstants", + ); + private $errors = inject("errors"); + private $logger = inject("logger"); + private $platformValidationService = inject( + "platformValidationService", + ); + private $projectData = inject("projectData"); + private $prompter = inject("prompter"); -export async function runListiOSAppsCommand( - context: ListiOSAppsCommandContext, - services: IListiOSAppsCommandServices, -): Promise { - if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.$devicePlatformsConstants.iOS, - services.$projectData, - ) - ) { - services.$errors.fail( - `Applications for platform ${services.$devicePlatformsConstants.iOS} can not be built on this OS`, - ); + constructor() { + super(); + this.$projectData.initializeProjectData(); } - let username = context.args[0]; - let password = context.args[1]; + public async run(): Promise { + if ( + !this.$platformValidationService.isPlatformSupportedForOS( + this.$devicePlatformsConstants.iOS, + this.$projectData, + ) + ) { + this.$errors.fail( + `Applications for platform ${this.$devicePlatformsConstants.iOS} can not be built on this OS`, + ); + } - if (!username) { - username = await services.$prompter.getString("Apple ID", { - allowEmpty: false, - }); - } + let username = this.args[0]; + let password = this.args[1]; - if (!password) { - password = await services.$prompter.getPassword("Apple ID password"); - } + if (!username) { + username = await this.$prompter.getString("Apple ID", { + allowEmpty: false, + }); + } - const user = await services.$applePortalSessionService.createUserSession( - { username, password }, - { - sessionBase64: context.options.appleSessionBase64, - }, - ); - if (!user.areCredentialsValid) { - services.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.`, + if (!password) { + password = await this.$prompter.getPassword("Apple ID password"); + } + + const user = await this.$applePortalSessionService.createUserSession( + { username, password }, + { + sessionBase64: this.options.appleSessionBase64, + }, ); - } + if (!user.areCredentialsValid) { + this.$errors.fail( + `Invalid username and password combination. Used '${username}' as the username.`, + ); + } - const applications = - await services.$applePortalApplicationService.getApplications(user); + const applications = + await this.$applePortalApplicationService.getApplications(user); - if (!applications || !applications.length) { - services.$logger.info("Seems you don't have any applications yet."); - } else { - const table: any = createTable( - ["Application Name", "Bundle Identifier", "In Flight Version"], - applications.map((application) => { - const version = - (application && - application.versionSets && - application.versionSets.length && - application.versionSets[0].inFlightVersion && - application.versionSets[0].inFlightVersion.version) || - ""; - return [application.name, application.bundleId, version]; - }), - ); + if (!applications || !applications.length) { + this.$logger.info("Seems you don't have any applications yet."); + } else { + const table: any = createTable( + ["Application Name", "Bundle Identifier", "In Flight Version"], + applications.map((application) => { + const version = + (application && + application.versionSets && + application.versionSets.length && + application.versionSets[0].inFlightVersion && + application.versionSets[0].inFlightVersion.version) || + ""; + return [application.name, application.bundleId, version]; + }), + ); - services.$logger.info(table.toString()); + this.$logger.info(table.toString()); + } } } - -export const listiOSAppsCommandDefinition = defineCommand({ - name: "appstore|*list", - description: "Lists the applications in App Store Connect.", - options: listiOSAppsCommandOptions, - arguments: [{ name: "appleId" }, { name: "password" }], - setup: setupListiOSAppsCommand, - run: runListiOSAppsCommand, -}); diff --git a/lib/commands/appstore-upload.ts b/lib/commands/appstore-upload.ts index 81eadc6281..3d13e24cf8 100644 --- a/lib/commands/appstore-upload.ts +++ b/lib/commands/appstore-upload.ts @@ -2,9 +2,8 @@ import * as path from "path"; import { IErrors, IHostInfo } from "../common/declarations"; import { booleanOption, - CommandContext, + Command, CommandOptionsSchema, - defineCommand, objectOption, stringOption, } from "../common/define-command"; @@ -28,174 +27,153 @@ const publishIOSCommandOptions = { teamId: objectOption(), } satisfies CommandOptionsSchema; -export type PublishIOSCommandContext = CommandContext< - typeof publishIOSCommandOptions ->; - -export interface IPublishIOSCommandServices { - $applePortalSessionService: IApplePortalSessionService; - $buildController: BuildController; - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $errors: IErrors; - $hostInfo: IHostInfo; - $itmsTransporterService: IITMSTransporterService; - $logger: ILogger; - $options: IOptions; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; - $prompter: IPrompter; -} +export class PublishIOSCommand extends Command({ + name: ["publish|ios", "appstore|upload"], + description: "Uploads a project to App Store Connect.", + options: publishIOSCommandOptions, + // Arguments have never been rejected here, only ignored past the third. + arguments: "any", +}) { + private $applePortalSessionService = inject( + "applePortalSessionService", + ); + private $buildController = inject("buildController"); + private $devicePlatformsConstants = inject( + "devicePlatformsConstants", + ); + private $errors = inject("errors"); + private $hostInfo = inject("hostInfo"); + private $itmsTransporterService = inject( + "itmsTransporterService", + ); + private $logger = inject("logger"); + private $options = inject("options"); + private $platformValidationService = inject( + "platformValidationService", + ); + private $projectData = inject("projectData"); + private $prompter = inject("prompter"); -export function setupPublishIOSCommand(): IPublishIOSCommandServices { - const services = { - $applePortalSessionService: inject( - "applePortalSessionService", - ), - $buildController: inject("buildController"), - $devicePlatformsConstants: inject( - "devicePlatformsConstants", - ), - $errors: inject("errors"), - $hostInfo: inject("hostInfo"), - $itmsTransporterService: inject( - "itmsTransporterService", - ), - $logger: inject("logger"), - $options: inject("options"), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - $prompter: inject("prompter"), - }; - services.$projectData.initializeProjectData(); - - return services; -} + constructor() { + super(); + this.$projectData.initializeProjectData(); + } + + 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`, + ); + } -export function canExecutePublishIOSCommand( - context: PublishIOSCommandContext, - services: IPublishIOSCommandServices, -): boolean { - if (!services.$hostInfo.isDarwin) { - services.$errors.fail("iOS publishing is only available on macOS."); + return true; } - if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.$devicePlatformsConstants.iOS, - services.$projectData, - ) - ) { - services.$errors.fail( - `Applications for platform ${services.$devicePlatformsConstants.iOS} can not be built on this OS`, + public async run(): Promise { + await this.$itmsTransporterService.validate( + this.options.appleApplicationSpecificPassword, ); - } - return true; -} + const username = + this.args[0] || + (await this.$prompter.getString("Apple ID", { allowEmpty: false })); -export async function runPublishIOSCommand( - context: PublishIOSCommandContext, - services: IPublishIOSCommandServices, -): Promise { - await services.$itmsTransporterService.validate( - context.options.appleApplicationSpecificPassword, - ); + const password = + this.args[1] || (await this.$prompter.getPassword("Apple ID password")); - const username = - context.args[0] || - (await services.$prompter.getString("Apple ID", { allowEmpty: false })); + const user = await this.createUserSession(username, password); - const password = - context.args[1] || - (await services.$prompter.getPassword("Apple ID password")); + const mobileProvisionIdentifier = this.options.provision ?? this.args[2]; - const user = await services.$applePortalSessionService.createUserSession( - { username, password }, - { - applicationSpecificPassword: - context.options.appleApplicationSpecificPassword, - sessionBase64: context.options.appleSessionBase64, - requireInteractiveConsole: true, - requireApplicationSpecificPassword: true, - }, - ); - if (!user.areCredentialsValid) { - services.$errors.fail( - `Invalid username and password combination. Used '${username}' as the username.`, - ); - } + let ipaFilePath = this.options.ipa ? path.resolve(this.options.ipa) : null; - const mobileProvisionIdentifier = - context.options.provision ?? context.args[2]; + if (!mobileProvisionIdentifier && !ipaFilePath) { + this.$logger.warn( + "No mobile provision identifier set. A default mobile provision will be used. You can set one in app/App_Resources/iOS/build.xcconfig", + ); + } - let ipaFilePath = context.options.ipa - ? path.resolve(context.options.ipa) - : null; + // The build data is spread off the parsed command line, so the flags the + // upload implies have to be set on the options service rather than on the + // context, which is a copy. + this.$options.release = true; - if (!mobileProvisionIdentifier && !ipaFilePath) { - services.$logger.warn( - "No mobile provision identifier set. A default mobile provision will be used. You can set one in app/App_Resources/iOS/build.xcconfig", - ); + if (!ipaFilePath) { + ipaFilePath = await this.buildIpa(mobileProvisionIdentifier); + } + + await this.$itmsTransporterService.upload({ + credentials: { username, password }, + user, + applicationSpecificPassword: + this.options.appleApplicationSpecificPassword, + ipaFilePath, + shouldExtractIpa: !!this.options.ipa, + verboseLogging: this.$logger.getLevel() === "TRACE", + teamId: this.options.teamId, + }); } - // The build data is spread off the parsed command line, so the flags the - // upload implies have to be set on the options service rather than on the - // context, which is a copy. - services.$options.release = true; + private async createUserSession(username: string, password: string) { + const user = await this.$applePortalSessionService.createUserSession( + { username, password }, + { + applicationSpecificPassword: + this.options.appleApplicationSpecificPassword, + sessionBase64: this.options.appleSessionBase64, + requireInteractiveConsole: true, + requireApplicationSpecificPassword: true, + }, + ); + if (!user.areCredentialsValid) { + this.$errors.fail( + `Invalid username and password combination. Used '${username}' as the username.`, + ); + } + + return user; + } - if (!ipaFilePath) { - const platform = services.$devicePlatformsConstants.iOS.toLowerCase(); + private async buildIpa(mobileProvisionIdentifier: string): Promise { + const platform = this.$devicePlatformsConstants.iOS.toLowerCase(); // No .ipa path provided, build .ipa on out own. if (mobileProvisionIdentifier) { // This is not very correct as if we build multiple targets we will try to sign all of them using the signing identity here. - services.$logger.info( + this.$logger.info( "Building .ipa with the selected mobile provision and/or certificate. " + mobileProvisionIdentifier, ); - services.$options.provision = mobileProvisionIdentifier; + this.$options.provision = mobileProvisionIdentifier; const buildData = new IOSBuildData( - services.$projectData.projectDir, + this.$projectData.projectDir, platform, - { ...services.$options.argv, buildForAppStore: true, watch: false }, + { ...this.$options.argv, buildForAppStore: true, watch: false }, ); - ipaFilePath = await services.$buildController.prepareAndBuild(buildData); + return await this.$buildController.prepareAndBuild(buildData); } else { - services.$logger.info( + this.$logger.info( "No .ipa, mobile provision or certificate set. Perfect! Now we'll build .xcarchive and let Xcode pick the distribution certificate and provisioning profile for you when exporting .ipa for AppStore submission.", ); const buildData = new IOSBuildData( - services.$projectData.projectDir, + this.$projectData.projectDir, platform, - { ...services.$options.argv, buildForAppStore: true, watch: false }, + { ...this.$options.argv, buildForAppStore: true, watch: false }, ); - ipaFilePath = await services.$buildController.prepareAndBuild(buildData); - services.$logger.info(`Export at: ${ipaFilePath}`); + const ipaFilePath = + await this.$buildController.prepareAndBuild(buildData); + this.$logger.info(`Export at: ${ipaFilePath}`); + return ipaFilePath; } } - - await services.$itmsTransporterService.upload({ - credentials: { username, password }, - user, - applicationSpecificPassword: - context.options.appleApplicationSpecificPassword, - ipaFilePath, - shouldExtractIpa: !!context.options.ipa, - verboseLogging: services.$logger.getLevel() === "TRACE", - teamId: context.options.teamId, - }); } - -export const publishIOSCommandDefinition = defineCommand({ - name: ["publish|ios", "appstore|upload"], - description: "Uploads a project to App Store Connect.", - options: publishIOSCommandOptions, - // Arguments have never been rejected here, only ignored past the third. - arguments: "any", - setup: setupPublishIOSCommand, - canExecute: canExecutePublishIOSCommand, - run: runPublishIOSCommand, -}); diff --git a/lib/commands/build.ts b/lib/commands/build.ts index 8552f26e93..59c25a5e5b 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -2,16 +2,16 @@ import { ANDROID_RELEASE_BUILD_ERROR_MESSAGE, AndroidAppBundleMessages, } from "../constants"; -import { - canExecuteCommandBase, - injectPlatformCommandServices, - IPlatformCommandServices, - validatePlatformOptions, -} from "./command-base"; +import { canExecuteCommandBase, validatePlatformOptions } from "./command-base"; import { hasValidAndroidSigning } from "../common/helpers"; -import { IAndroidBundleValidatorHelper } from "../declarations"; +import { + IAndroidBundleValidatorHelper, + IOptions, + IPlatformValidationService, +} from "../declarations"; import { IBuildController, IBuildDataService } from "../definitions/build"; import { IMigrateController } from "../definitions/migrate"; +import { IProjectData } from "../definitions/project"; import { IErrors } from "../common/declarations"; import { booleanOption, @@ -40,17 +40,6 @@ const buildCommandOptions = { keyStoreAliasPassword: stringOption(), } satisfies CommandOptionsSchema; -interface IBuildCommandServices extends IPlatformCommandServices { - platform: string; - isAndroid: boolean; - $errors: IErrors; - $logger: ILogger; - $buildController: IBuildController; - $buildDataService: IBuildDataService; - $migrateController: IMigrateController; - $androidBundleValidatorHelper: IAndroidBundleValidatorHelper; -} - const defineBuildCommand = ( name: TName, buildPlatform: BuildPlatform, @@ -60,87 +49,82 @@ const defineBuildCommand = ( description: "Builds the project for the selected target platform.", options: buildCommandOptions, arguments: "none", - setup(): IBuildCommandServices { - const devicePlatformsConstants = inject( - "devicePlatformsConstants", + async canExecute(context): Promise { + const $devicePlatformsConstants = + inject("devicePlatformsConstants"); + const $errors = inject("errors"); + const $migrateController = + inject("migrateController"); + const $platformValidationService = inject( + "platformValidationService", ); - const platform = devicePlatformsConstants[buildPlatform]; - const isAndroid = devicePlatformsConstants.isAndroid(platform); - const services = { - ...injectPlatformCommandServices(), - platform, - isAndroid, - $errors: inject("errors"), - $logger: inject("logger"), - $buildController: inject("buildController"), - $buildDataService: inject("buildDataService"), - $migrateController: inject("migrateController"), - // Only the android build checks the runtime version. - $androidBundleValidatorHelper: isAndroid - ? inject( - "androidBundleValidatorHelper", - ) - : null, - }; - services.$projectData.initializeProjectData(); - - return services; - }, - async canExecute(context, services): Promise { - const { platform } = services; + const $projectData = inject("projectData"); + const platform = $devicePlatformsConstants[buildPlatform]; + const isAndroid = $devicePlatformsConstants.isAndroid(platform); + // Only the android build checks the runtime version. + const $androidBundleValidatorHelper = isAndroid + ? inject("androidBundleValidatorHelper") + : null; + $projectData.initializeProjectData(); if (!context.options.force) { - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, + await $migrateController.validate({ + projectDir: $projectData.projectDir, platforms: [platform], }); } - if (services.isAndroid) { - services.$androidBundleValidatorHelper.validateRuntimeVersion( - services.$projectData, - ); + if (isAndroid) { + $androidBundleValidatorHelper.validateRuntimeVersion($projectData); } else if ( - !services.$platformValidationService.isPlatformSupportedForOS( + !$platformValidationService.isPlatformSupportedForOS( platform, - services.$projectData, + $projectData, ) ) { - services.$errors.fail( + $errors.fail( `Applications for platform ${platform} can not be built on this OS`, ); } - if (!(await canExecuteCommandBase(services, platform))) { + if (!(await canExecuteCommandBase(context, platform))) { return false; } if ( - services.isAndroid && + isAndroid && context.options.release && !hasValidAndroidSigning(context.options) ) { - services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } - return validatePlatformOptions(services, platform); + return validatePlatformOptions(context, platform); }, - async run(context, services): Promise { - const buildData = services.$buildDataService.getBuildData( - services.$projectData.projectDir, - services.platform.toLowerCase(), - services.$options, + async run(context): Promise { + const $buildController = inject("buildController"); + const $buildDataService = inject("buildDataService"); + const $devicePlatformsConstants = + inject("devicePlatformsConstants"); + const $logger = inject("logger"); + const $options = inject("options"); + const $projectData = inject("projectData"); + const platform = $devicePlatformsConstants[buildPlatform]; + const isAndroid = $devicePlatformsConstants.isAndroid(platform); + $projectData.initializeProjectData(); + + const buildData = $buildDataService.getBuildData( + $projectData.projectDir, + platform.toLowerCase(), + $options, ); - const outputPath = - await services.$buildController.prepareAndBuild(buildData); + const outputPath = await $buildController.prepareAndBuild(buildData); - if (services.isAndroid && context.options.aab) { - services.$logger.info( - AndroidAppBundleMessages.ANDROID_APP_BUNDLE_DOCS_MESSAGE, - ); + if (isAndroid && context.options.aab) { + $logger.info(AndroidAppBundleMessages.ANDROID_APP_BUNDLE_DOCS_MESSAGE); if (context.options.release) { - services.$logger.info( + $logger.info( AndroidAppBundleMessages.ANDROID_APP_BUNDLE_PUBLISH_DOCS_MESSAGE, ); } diff --git a/lib/commands/clean.ts b/lib/commands/clean.ts index b193930020..c4ca161f33 100644 --- a/lib/commands/clean.ts +++ b/lib/commands/clean.ts @@ -86,42 +86,8 @@ const cleanCommandOptions = { json: booleanOption(), } satisfies CommandOptionsSchema; -export type CleanCommandContext = CommandContext; - -export interface ICleanCommandServices { - $childProcess: IChildProcess; - $logger: ILogger; - $projectCleanupService: IProjectCleanupService; - $projectConfigService: IProjectConfigService; - $projectData: IProjectData; - $projectService: IProjectService; - $prompter: IPrompter; - $staticConfig: IStaticConfig; - $terminalSpinnerService: ITerminalSpinnerService; -} - -export function setupCleanCommand(): ICleanCommandServices { - return { - $childProcess: inject("childProcess"), - $logger: inject("logger"), - $projectCleanupService: inject( - "projectCleanupService", - ), - $projectConfigService: inject( - "projectConfigService", - ), - $projectData: inject("projectData"), - $projectService: inject("projectService"), - $prompter: inject("prompter"), - $staticConfig: inject("staticConfig"), - $terminalSpinnerService: inject( - "terminalSpinnerService", - ), - }; -} - async function getNSProjectPathsInDirectory( - services: ICleanCommandServices, + $logger: ILogger, dir = process.cwd(), ): Promise { let nsDirs: string[] = []; @@ -134,11 +100,7 @@ async function getNSProjectPathsInDirectory( const dirents = await readdir(dir, { withFileTypes: true }).catch( (err): any[] => { - services.$logger.trace( - 'Failed to read directory "%s". Error is:', - dir, - err, - ); + $logger.trace('Failed to read directory "%s". Error is:', dir, err); return []; }, ); @@ -172,17 +134,21 @@ async function getNSProjectPathsInDirectory( } async function cleanMultipleProjects( - context: CleanCommandContext, - services: ICleanCommandServices, + context: CommandContext, spinner: ITerminalSpinner, ) { + const $childProcess = context.injector.get("childProcess"); + const $logger = context.injector.get("logger"); + const $prompter = context.injector.get("prompter"); + const $staticConfig = context.injector.get("staticConfig"); + if (!isInteractive() || context.options.json) { // interactive terminal is required, and we can't output json in an interactive command. - services.$logger.warn("No project found in the current directory."); + $logger.warn("No project found in the current directory."); return; } - const shouldScan = await services.$prompter.confirm( + const shouldScan = await $prompter.confirm( "No project found in the current directory. Would you like to scan for all projects in sub-directories instead?", ); @@ -191,7 +157,7 @@ async function cleanMultipleProjects( } spinner.start("Scanning for projects... Please wait."); - const paths = await getNSProjectPathsInDirectory(services); + const paths = await getNSProjectPathsInDirectory($logger); spinner.succeed(`Found ${paths.length} projects.`); let computed = 0; @@ -210,9 +176,9 @@ async function cleanMultipleProjects( await promiseMap( paths, (p) => { - return services.$childProcess + return $childProcess .exec( - `node ${services.$staticConfig.cliBinPath} clean --dry-run --json --disable-analytics`, + `node ${$staticConfig.cliBinPath} clean --dry-run --json --disable-analytics`, { cwd: p, }, @@ -222,11 +188,7 @@ async function cleanMultipleProjects( return Object.values(paths).reduce((a, b) => a + b, 0); }) .catch((err) => { - services.$logger.trace( - "Failed to get project size for %s, Error is:", - p, - err, - ); + $logger.trace("Failed to get project size for %s, Error is:", p, err); return -1; }) .then((size) => { @@ -245,13 +207,13 @@ async function cleanMultipleProjects( spinner.clear(); spinner.stop(); - services.$logger.clearScreen(); + $logger.clearScreen(); const totalSize = Array.from(projects.values()) .filter((s) => s > 0) .reduce((a, b) => a + b, 0); - const pathsToClean = await services.$prompter.promptForChoice( + const pathsToClean = await $prompter.promptForChoice( `Found ${ projects.size } cleanable project(s) with a total size of: ${color.green( @@ -276,7 +238,7 @@ async function cleanMultipleProjects( optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions } as Partial, ); - services.$logger.clearScreen(); + $logger.clearScreen(); spinner.warn( `This will run "${color.yellow( @@ -288,7 +250,7 @@ async function cleanMultipleProjects( ); spinner.warn(`This action cannot be undone!`); - let confirmed = await services.$prompter.confirm( + let confirmed = await $prompter.confirm( "Are you sure you want to clean the selected projects?", ); if (!confirmed) { @@ -305,9 +267,9 @@ async function cleanMultipleProjects( `Cleaning ${color.cyan(currentPath)}... ${i + 1}/${pathsToClean.length}`, ); - const ok = await services.$childProcess + const ok = await $childProcess .exec( - `node ${services.$staticConfig.cliBinPath} clean ${ + `node ${$staticConfig.cliBinPath} clean ${ context.options.dryRun ? "--dry-run" : "" } --json --disable-analytics`, { @@ -319,11 +281,7 @@ async function cleanMultipleProjects( return cleanupRes.ok; }) .catch((err) => { - services.$logger.trace( - 'Failed to clean project "%s"', - currentPath, - err, - ); + $logger.trace('Failed to clean project "%s"', currentPath, err); return false; }); @@ -353,82 +311,88 @@ async function cleanMultipleProjects( } } -export async function runCleanCommand( - context: CleanCommandContext, - services: ICleanCommandServices, -): Promise { - const isDryRun = context.options.dryRun ?? false; - const isJSON = context.options.json ?? false; +export const cleanCommandDefinition = defineCommand({ + name: "clean", + description: "Cleans the project's build artefacts and dependencies.", + options: cleanCommandOptions, + arguments: "none", + async run(context): Promise { + const $projectCleanupService = inject( + "projectCleanupService", + ); + const $projectConfigService = inject( + "projectConfigService", + ); + const $projectData = inject("projectData"); + const $projectService = inject("projectService"); + const $terminalSpinnerService = inject( + "terminalSpinnerService", + ); - const spinner = services.$terminalSpinnerService.createSpinner({ - isSilent: isJSON, - }); + const isDryRun = context.options.dryRun ?? false; + const isJSON = context.options.json ?? false; - if (!services.$projectService.isValidNativeScriptProject()) { - return cleanMultipleProjects(context, services, spinner); - } + const spinner = $terminalSpinnerService.createSpinner({ + isSilent: isJSON, + }); - spinner.start("Cleaning project...\n"); + if (!$projectService.isValidNativeScriptProject()) { + return cleanMultipleProjects(context, spinner); + } - let pathsToClean = [ - constants.HOOKS_DIR_NAME, - services.$projectData.getBuildRelativeDirectoryPath(), - constants.NODE_MODULES_FOLDER_NAME, - ]; + spinner.start("Cleaning project...\n"); - try { - const overridePathsToClean = - services.$projectConfigService.getValue("cli.pathsToClean"); - const additionalPaths = services.$projectConfigService.getValue( - "cli.additionalPathsToClean", - ); + let pathsToClean = [ + constants.HOOKS_DIR_NAME, + $projectData.getBuildRelativeDirectoryPath(), + constants.NODE_MODULES_FOLDER_NAME, + ]; - // allow overriding default paths to clean - if (Array.isArray(overridePathsToClean)) { - pathsToClean = overridePathsToClean; - } + try { + const overridePathsToClean = + $projectConfigService.getValue("cli.pathsToClean"); + const additionalPaths = $projectConfigService.getValue( + "cli.additionalPathsToClean", + ); - if (Array.isArray(additionalPaths)) { - pathsToClean.push(...additionalPaths); + // allow overriding default paths to clean + if (Array.isArray(overridePathsToClean)) { + pathsToClean = overridePathsToClean; + } + + if (Array.isArray(additionalPaths)) { + pathsToClean.push(...additionalPaths); + } + } catch (err) { + // ignore } - } catch (err) { - // ignore - } - const res = await services.$projectCleanupService.clean(pathsToClean, { - dryRun: isDryRun, - silent: isJSON, - stats: isJSON, - }); + const res = await $projectCleanupService.clean(pathsToClean, { + dryRun: isDryRun, + silent: isJSON, + stats: isJSON, + }); - if (res.stats && isJSON) { - console.log( - JSON.stringify( - { - ok: res.ok, - dryRun: isDryRun, - stats: Object.fromEntries(res.stats.entries()), - }, - null, - 2, - ), - ); - - return; - } + if (res.stats && isJSON) { + console.log( + JSON.stringify( + { + ok: res.ok, + dryRun: isDryRun, + stats: Object.fromEntries(res.stats.entries()), + }, + null, + 2, + ), + ); - if (res.ok) { - spinner.succeed("Project successfully cleaned."); - } else { - spinner.fail(color.red("Project unsuccessfully cleaned.")); - } -} + return; + } -export const cleanCommandDefinition = defineCommand({ - name: "clean", - description: "Cleans the project's build artefacts and dependencies.", - options: cleanCommandOptions, - arguments: "none", - setup: setupCleanCommand, - run: runCleanCommand, + if (res.ok) { + spinner.succeed("Project successfully cleaned."); + } else { + spinner.fail(color.red("Project unsuccessfully cleaned.")); + } + }, }); diff --git a/lib/commands/command-base.ts b/lib/commands/command-base.ts index 748c54dcdd..e09dbfdf97 100644 --- a/lib/commands/command-base.ts +++ b/lib/commands/command-base.ts @@ -2,44 +2,20 @@ import { IProjectData, IValidatePlatformOutput } from "../definitions/project"; import { IOptions, IPlatformValidationService } from "../declarations"; import { IPlatformsDataService } from "../definitions/platform"; import { - ICommandParameter, ICanExecuteCommandOptions, INotConfiguredEnvOptions, } from "../common/definitions/commands"; -import { ArgumentSpec } from "../common/define-command"; -import { inject, Injector } from "../common/di"; +import { ArgumentSpec, CommandContext } from "../common/define-command"; +import { Injector } from "../common/di"; -/** - * What the platform-validation helpers below need. A command definition's - * `setup` returns this shape (see `injectPlatformCommandServices`), so its - * result can be handed straight to them. - */ -export interface IPlatformCommandServices { - $options: IOptions; - $platformsDataService: IPlatformsDataService; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; -} - -/** Callable from `setup` and from `canExecute` before their first `await`. */ -export function injectPlatformCommandServices(): IPlatformCommandServices { - return { - $options: inject("options"), - $platformsDataService: inject( - "platformsDataService", - ), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - }; -} +/** The part of a command context these helpers read. */ +type PlatformCommandContext = Pick, "injector">; /** * The declarative form of `$platformCommandParameter`. Initializing the * project data is what makes the platform check possible, so it stays part of - * validating the argument instead of moving to `setup`, which the adapter runs - * only after argument enforcement. + * validating the argument instead of moving to the command's own handlers, + * which the adapter runs only after argument enforcement. */ export function validatePlatformArgument( targetInjector: Injector, @@ -62,33 +38,38 @@ export const platformArgument: ArgumentSpec = { }; export function validatePlatformOptions( - services: IPlatformCommandServices, + context: PlatformCommandContext, platform: string, ): Promise { - return services.$platformValidationService.validateOptions( - services.$options.provision, - services.$options.teamId, - services.$projectData, - platform, - ); + const $options = context.injector.get("options"); + const $projectData = context.injector.get("projectData"); + + return context.injector + .get("platformValidationService") + .validateOptions( + $options.provision, + $options.teamId, + $projectData, + platform, + ); } async function validatePlatformBase( - services: IPlatformCommandServices, + context: PlatformCommandContext, platform: string, notConfiguredEnvOptions: INotConfiguredEnvOptions, ): Promise { - const platformData = services.$platformsDataService.getPlatformData( - platform, - services.$projectData, - ); - const platformProjectService = platformData.platformProjectService; - const result = await platformProjectService.validate( - services.$projectData, - services.$options, + const $options = context.injector.get("options"); + const $projectData = context.injector.get("projectData"); + const platformData = context.injector + .get("platformsDataService") + .getPlatformData(platform, $projectData); + + return platformData.platformProjectService.validate( + $projectData, + $options, notConfiguredEnvOptions, ); - return result; } function hasUsableEnvironment( @@ -102,12 +83,12 @@ function hasUsableEnvironment( } export async function canExecuteCommandBase( - services: IPlatformCommandServices, + context: PlatformCommandContext, platform: string, options: ICanExecuteCommandOptions = {}, ): Promise { const validatePlatformOutput = await validatePlatformBase( - services, + context, platform, options.notConfiguredEnvOptions, ); @@ -115,40 +96,8 @@ export async function canExecuteCommandBase( let result = canExecute; if (canExecute && options.validateOptions) { - result = await validatePlatformOptions(services, platform); + result = await validatePlatformOptions(context, platform); } return result; } - -/** - * @deprecated Nothing extends this any more; the exported functions beside it carry - * the same behaviour for definitions. - */ -export abstract class ValidatePlatformCommandBase { - constructor( - protected $options: IOptions, - protected $platformsDataService: IPlatformsDataService, - protected $platformValidationService: IPlatformValidationService, - protected $projectData: IProjectData, - ) {} - - abstract allowedParameters: ICommandParameter[]; - abstract execute(args: string[]): Promise; - - public canExecuteCommandBase( - platform: string, - options?: ICanExecuteCommandOptions, - ): Promise { - return canExecuteCommandBase( - { - $options: this.$options, - $platformsDataService: this.$platformsDataService, - $platformValidationService: this.$platformValidationService, - $projectData: this.$projectData, - }, - platform, - options, - ); - } -} diff --git a/lib/commands/config.ts b/lib/commands/config.ts index b3d21b8eef..d2ff838d02 100644 --- a/lib/commands/config.ts +++ b/lib/commands/config.ts @@ -5,22 +5,6 @@ import { CommandContext, defineCommand } from "../common/define-command"; import { inject } from "../common/di"; import { color } from "../color"; -export interface IConfigCommandServices { - $projectConfigService: IProjectConfigService; - $logger: ILogger; - $errors: IErrors; -} - -export function injectConfigCommandServices(): IConfigCommandServices { - return { - $projectConfigService: inject( - "projectConfigService", - ), - $logger: inject("logger"), - $errors: inject("errors"), - }; -} - function getValueString(value: SupportedConfigValues, depth = 0): string { const indent = () => " ".repeat(depth); if (typeof value === "object") { @@ -52,12 +36,11 @@ function getConvertedValue(v: any): any { } } -export function requireConfigKey( - context: CommandContext, - services: IConfigCommandServices, -): void { +function requireConfigKey(context: CommandContext): void { if (!context.args[0]) { - services.$errors.failWithHelp("You must specify a key. Eg: ios.id"); + context.injector + .get("errors") + .failWithHelp("You must specify a key. Eg: ios.id"); } } @@ -65,13 +48,17 @@ export const configListCommandDefinition = defineCommand({ name: "config|*list", description: "Prints the project configuration.", arguments: "none", - setup: injectConfigCommandServices, - async run(context, services): Promise { + async run(): Promise { + const $projectConfigService = inject( + "projectConfigService", + ); + const $logger = inject("logger"); + try { - const config = services.$projectConfigService.readConfig(); - services.$logger.info(getValueString(config as SupportedConfigValues)); + const config = $projectConfigService.readConfig(); + $logger.info(getValueString(config as SupportedConfigValues)); } catch (error) { - services.$logger.info("Failed to read config. Error is: ", error); + $logger.info("Failed to read config. Error is: ", error); } }, }); @@ -80,17 +67,21 @@ export const configGetCommandDefinition = defineCommand({ name: "config|get", description: "Prints the value the project configuration holds for a key.", arguments: "any", - setup: injectConfigCommandServices, - async canExecute(context, services): Promise { - requireConfigKey(context, services); + async canExecute(context): Promise { + requireConfigKey(context); return true; }, - async run(context, services): Promise { + async run(context): Promise { + const $projectConfigService = inject( + "projectConfigService", + ); + const $logger = inject("logger"); + try { const [key] = context.args; - const current = services.$projectConfigService.getValue(key); - services.$logger.info(current); + const current = $projectConfigService.getValue(key); + $logger.info(current); } catch (err) { // ignore } @@ -101,21 +92,28 @@ export const configSetCommandDefinition = defineCommand({ name: "config|set", description: "Sets a value in the project configuration.", arguments: "any", - setup: injectConfigCommandServices, - async canExecute(context, services): Promise { - requireConfigKey(context, services); + async canExecute(context): Promise { + const $errors = inject("errors"); + + requireConfigKey(context); if (!context.args[1]) { - services.$errors.failWithHelp("You must specify a value."); + $errors.failWithHelp("You must specify a value."); } return true; }, - async run(context, services): Promise { + async run(context): Promise { + const $projectConfigService = inject( + "projectConfigService", + ); + const $logger = inject("logger"); + const $errors = inject("errors"); + const [key, value] = context.args; - const current = services.$projectConfigService.getValue(key); + const current = $projectConfigService.getValue(key); if (current && typeof current === "object") { - services.$errors.fail( + $errors.fail( `Unable to change object values. Please update individual values instead.\nEg: ns config set android.codeCache true`, ); } @@ -126,17 +124,17 @@ export const configSetCommandDefinition = defineCommand({ const currentDisplay = current ? color.yellow(current) : ""; const updatedDisplay = color.cyan(convertedValue); - services.$logger.info( + $logger.info( `${existingKey ? "Updating" : "Setting"} ${keyDisplay}${ existingKey ? ` from ${currentDisplay} ` : " " }to ${updatedDisplay}`, ); try { - await services.$projectConfigService.setValue(key, convertedValue); - services.$logger.info("Done"); + await $projectConfigService.setValue(key, convertedValue); + $logger.info("Done"); } catch (error) { - services.$logger.info("Could not update conifg. Error is: ", error); + $logger.info("Could not update conifg. Error is: ", error); } }, }); diff --git a/lib/commands/create-project.ts b/lib/commands/create-project.ts index f3a72dca02..321e60bb06 100644 --- a/lib/commands/create-project.ts +++ b/lib/commands/create-project.ts @@ -2,9 +2,9 @@ import * as path from "path"; import { color } from "../color"; import { booleanOption, - CommandContext, + Command, CommandOptionsSchema, - defineCommand, + CommandOptionValues, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -27,7 +27,7 @@ const TABS_TEMPLATE_KEY = "Tabs"; const TABS_TEMPLATE_DESCRIPTION = "An app with pre-built pages that uses tabs for navigation"; -export const createProjectCommandOptions = { +const createProjectCommandOptions = { js: booleanOption(), ng: booleanOption(), react: booleanOption(), @@ -49,24 +49,6 @@ export const createProjectCommandOptions = { ignoreScripts: booleanOption(), } satisfies CommandOptionsSchema; -export type CreateProjectCommandContext = CommandContext< - typeof createProjectCommandOptions ->; - -export interface ICreateProjectCommandServices { - $projectService: IProjectService; - $logger: ILogger; - $prompter: IPrompter; -} - -export function setupCreateProjectCommand(): ICreateProjectCommandServices { - return { - $projectService: inject("projectService"), - $logger: inject("logger"), - $prompter: inject("prompter"), - }; -} - interface ITemplateChoice { key?: string; value: string; @@ -235,7 +217,7 @@ const flavorTemplates: { [flavorName: string]: () => ITemplateChoice[] } = { /** The template a flavor flag selects, without asking anything. */ function selectTemplateFromOptions( - options: CreateProjectCommandContext["options"], + options: CommandOptionValues, ): string { if (options["vision-ng"] || (options.vision && options.ng)) { return constants.RESERVED_TEMPLATE_NAMES["vision-ng"]; @@ -300,10 +282,10 @@ function selectTemplateFromOptions( } function interactiveFlavorSelection( - services: ICreateProjectCommandServices, + $prompter: IPrompter, adverb: string, ): Promise { - return services.$prompter.promptForDetailedChoice( + return $prompter.promptForDetailedChoice( `${adverb}, which style of NativeScript project would you like to use:`, [ { @@ -340,7 +322,8 @@ function interactiveFlavorSelection( } async function interactiveTemplateSelection( - services: ICreateProjectCommandServices, + $logger: ILogger, + $prompter: IPrompter, flavorSelection: string, adverb: string, ): Promise { @@ -350,15 +333,14 @@ async function interactiveTemplateSelection( : []; if (selectedFlavorTemplates.length > 1) { - services.$logger.info(); + $logger.info(); const templateChoices = selectedFlavorTemplates.map((template) => { return { key: template.key, description: template.description }; }); - const selectedTemplateKey = - await services.$prompter.promptForDetailedChoice( - `${adverb}, which template would you like to start from:`, - templateChoices, - ); + const selectedTemplateKey = await $prompter.promptForDetailedChoice( + `${adverb}, which template would you like to start from:`, + templateChoices, + ); return selectedFlavorTemplates.find((t) => t.key === selectedTemplateKey) .value; @@ -368,164 +350,169 @@ async function interactiveTemplateSelection( } async function interactiveFlavorAndTemplateSelection( - services: ICreateProjectCommandServices, + $logger: ILogger, + $prompter: IPrompter, flavorAdverb: string, templateAdverb: string, ): Promise { const selectedFlavor = await interactiveFlavorSelection( - services, + $prompter, flavorAdverb, ); - return interactiveTemplateSelection(services, selectedFlavor, templateAdverb); + return interactiveTemplateSelection( + $logger, + $prompter, + selectedFlavor, + templateAdverb, + ); } -export async function runCreateProjectCommand( - context: CreateProjectCommandContext, - services: ICreateProjectCommandServices, -): Promise { - const options = context.options; - const interactiveAdverbs = ["First", "Next", "Finally"]; - const getNextInteractiveAdverb = () => { - return interactiveAdverbs.shift() || "Next"; - }; - - let isInteractionIntroShown = false; - const printInteractiveCreationIntroIfNeeded = () => { - if (isInteractionIntroShown) { - return; - } - - isInteractionIntroShown = true; - services.$logger.info(); - services.$logger.printMarkdown(`# Let’s create a NativeScript app!`); - services.$logger.printMarkdown(` +export class CreateProjectCommand extends Command< + "create", + typeof createProjectCommandOptions, + ICreateProjectData +>({ + name: "create", + description: "Creates a new NativeScript project.", + options: createProjectCommandOptions, + arguments: [{ name: "projectName" }], + enableHooks: false, +}) { + private $projectService = inject("projectService"); + private $logger = inject("logger"); + private $prompter = inject("prompter"); + + public async run(): Promise { + const options = this.options; + const interactiveAdverbs = ["First", "Next", "Finally"]; + const getNextInteractiveAdverb = () => { + return interactiveAdverbs.shift() || "Next"; + }; + + let isInteractionIntroShown = false; + const printInteractiveCreationIntroIfNeeded = () => { + if (isInteractionIntroShown) { + return; + } + + isInteractionIntroShown = true; + this.$logger.info(); + this.$logger.printMarkdown(`# Let’s create a NativeScript app!`); + this.$logger.printMarkdown(` Answer the following questions to help us build the right app for you. (Note: you can skip this prompt next time using the --template option, or using --ng, --react, --solid, --svelte, --vue, --ts, or --js flags.) `); - }; - - if ( - (options.tsc || - options.ng || - options.vue || - options.react || - options.solid || - options.svelte || - options.js) && - options.template - ) { - context.fail( - "You cannot use a flavor option like --ng, --vue, --react, --solid, --svelte, --tsc and --js together with --template.", - ); - } + }; + + if ( + (options.tsc || + options.ng || + options.vue || + options.react || + options.solid || + options.svelte || + options.js) && + options.template + ) { + this.context.fail( + "You cannot use a flavor option like --ng, --vue, --react, --solid, --svelte, --tsc and --js together with --template.", + ); + } - let projectName = context.args[0]; - let selectedTemplate = selectTemplateFromOptions(options); + let projectName = this.args[0]; + let selectedTemplate = selectTemplateFromOptions(options); - if (!projectName && isInteractive()) { - printInteractiveCreationIntroIfNeeded(); - projectName = await services.$prompter.getString( - `${getNextInteractiveAdverb()}, what will be the name of your app?`, - { allowEmpty: false }, - ); - services.$logger.info(); - } + if (!projectName && isInteractive()) { + printInteractiveCreationIntroIfNeeded(); + projectName = await this.$prompter.getString( + `${getNextInteractiveAdverb()}, what will be the name of your app?`, + { allowEmpty: false }, + ); + this.$logger.info(); + } - projectName = await services.$projectService.validateProjectName({ - projectName: projectName, - force: options.force, - pathToProject: options.path, - }); - - if (!selectedTemplate && isInteractive()) { - printInteractiveCreationIntroIfNeeded(); - selectedTemplate = await interactiveFlavorAndTemplateSelection( - services, - getNextInteractiveAdverb(), - getNextInteractiveAdverb(), - ); - } + projectName = await this.$projectService.validateProjectName({ + projectName: projectName, + force: options.force, + pathToProject: options.path, + }); - return services.$projectService.createProject({ - projectName: projectName, - template: selectedTemplate, - appId: options.appid, - pathToProject: options.path, - // its already validated above - force: true, - ignoreScripts: options.ignoreScripts, - }); -} + if (!selectedTemplate && isInteractive()) { + printInteractiveCreationIntroIfNeeded(); + selectedTemplate = await interactiveFlavorAndTemplateSelection( + this.$logger, + this.$prompter, + getNextInteractiveAdverb(), + getNextInteractiveAdverb(), + ); + } -export function reportCreatedProject( - context: CreateProjectCommandContext, - createdProjectData: ICreateProjectData, - services: ICreateProjectCommandServices, -): void { - const { projectDir, projectName } = createdProjectData; - const relativePath = path.relative(process.cwd(), projectDir); - - const greyDollarSign = color.grey("$"); - services.$logger.clearScreen(); - let runDebugNotes: Array = []; - if ( - context.options.vision || - context.options["vision-ng"] || - context.options["vision-react"] || - context.options["vision-solid"] || - context.options["vision-svelte"] || - context.options["vision-vue"] - ) { - runDebugNotes = [ - `Run the project on Vision Pro with:`, - "", - ` ${greyDollarSign} ${color.green("ns run visionos --no-hmr")}`, - ]; - } else { - runDebugNotes = [ - `Run the project on multiple devices:`, - "", - ` ${greyDollarSign} ${color.green("ns run ios")}`, - ` ${greyDollarSign} ${color.green("ns run android")}`, - "", - "Debug the project with Chrome DevTools:", - "", - ` ${greyDollarSign} ${color.green("ns debug ios")}`, - ` ${greyDollarSign} ${color.green("ns debug android")}`, - ]; + return this.$projectService.createProject({ + projectName: projectName, + template: selectedTemplate, + appId: options.appid, + pathToProject: options.path, + // its already validated above + force: true, + ignoreScripts: options.ignoreScripts, + }); } - services.$logger.info( - [ + + public postRun(createdProjectData: ICreateProjectData): void { + const { projectDir, projectName } = createdProjectData; + const relativePath = path.relative(process.cwd(), projectDir); + + const greyDollarSign = color.grey("$"); + this.$logger.clearScreen(); + let runDebugNotes: Array = []; + if ( + this.options.vision || + this.options["vision-ng"] || + this.options["vision-react"] || + this.options["vision-solid"] || + this.options["vision-svelte"] || + this.options["vision-vue"] + ) { + runDebugNotes = [ + `Run the project on Vision Pro with:`, + "", + ` ${greyDollarSign} ${color.green("ns run visionos --no-hmr")}`, + ]; + } else { + runDebugNotes = [ + `Run the project on multiple devices:`, + "", + ` ${greyDollarSign} ${color.green("ns run ios")}`, + ` ${greyDollarSign} ${color.green("ns run android")}`, + "", + "Debug the project with Chrome DevTools:", + "", + ` ${greyDollarSign} ${color.green("ns debug ios")}`, + ` ${greyDollarSign} ${color.green("ns debug android")}`, + ]; + } + this.$logger.info( [ - color.green(`Project`), - color.cyan(projectName), - color.green(`was successfully created.`), - ].join(" "), - "", - `Now you can navigate to your project with ${color.cyan( - `cd ${relativePath}`, - )} and then:`, - "", - ...runDebugNotes, - ``, - `For more options consult the docs or run ${color.green("ns --help")}`, - "", - ].join("\n"), - ); - // todo: add back ns preview - // this.$logger.printMarkdown( - // `After that you can preview it on device by executing \`$ ns preview\`` - // ); + [ + color.green(`Project`), + color.cyan(projectName), + color.green(`was successfully created.`), + ].join(" "), + "", + `Now you can navigate to your project with ${color.cyan( + `cd ${relativePath}`, + )} and then:`, + "", + ...runDebugNotes, + ``, + `For more options consult the docs or run ${color.green("ns --help")}`, + "", + ].join("\n"), + ); + // todo: add back ns preview + // this.$logger.printMarkdown( + // `After that you can preview it on device by executing \`$ ns preview\`` + // ); + } } - -export const createProjectCommandDefinition = defineCommand({ - name: "create", - description: "Creates a new NativeScript project.", - options: createProjectCommandOptions, - arguments: [{ name: "projectName" }], - enableHooks: false, - setup: setupCreateProjectCommand, - run: runCreateProjectCommand, - postRun: reportCreatedProject, -}); diff --git a/lib/commands/debug.ts b/lib/commands/debug.ts index 9da5a8aeb1..aa57de1fd7 100644 --- a/lib/commands/debug.ts +++ b/lib/commands/debug.ts @@ -11,6 +11,7 @@ import { import { inject } from "../common/di"; import { hasValidAndroidSigning } from "../common/helpers"; import { ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE } from "../constants"; +import { IOptions, IPlatformValidationService } from "../declarations"; import { ICleanupService } from "../definitions/cleanup-service"; import { IDebugController, @@ -18,6 +19,7 @@ import { IDebugOptions, } from "../definitions/debug"; import { IMigrateController } from "../definitions/migrate"; +import { IProjectData } from "../definitions/project"; import { SystemWarningsSeverity } from "../definitions/system-warnings"; import { IKeyShortcutService, @@ -25,11 +27,7 @@ import { restartShortcut, watcherShortcut, } from "../services/key-shortcuts"; -import { - canExecuteCommandBase, - injectPlatformCommandServices, - IPlatformCommandServices, -} from "./command-base"; +import { canExecuteCommandBase } from "./command-base"; import * as _ from "lodash"; /** Which `$devicePlatformsConstants` entry a command debugs. */ @@ -51,110 +49,103 @@ const debugCommandOptions = { keyStoreAliasPassword: stringOption(), } satisfies CommandOptionsSchema; -export type DebugCommandContext = CommandContext; - -export interface IDebugCommandServices extends IPlatformCommandServices { - platform: string; - $cleanupService: ICleanupService; - $debugController: IDebugController; - $debugDataService: IDebugDataService; - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $liveSyncCommandHelper: ILiveSyncCommandHelper; - $migrateController: IMigrateController; -} - -export function setupDebugCommand( - debugPlatform: DebugPlatform, -): IDebugCommandServices { - const $devicePlatformsConstants = inject( - "devicePlatformsConstants", - ); - - return { - ...injectPlatformCommandServices(), - platform: $devicePlatformsConstants[debugPlatform], - $cleanupService: inject("cleanupService"), - $debugController: inject("debugController"), - $debugDataService: inject("debugDataService"), - $devicePlatformsConstants, - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $liveSyncCommandHelper: inject( - "liveSyncCommandHelper", - ), - $migrateController: inject("migrateController"), - }; -} +type DebugCommandContext = CommandContext; -export async function canExecuteDebugCommand( +async function canExecuteDebugCommand( context: DebugCommandContext, - services: IDebugCommandServices, + debugPlatform: DebugPlatform, ): Promise { + const $cleanupService = + context.injector.get("cleanupService"); + const $devicePlatformsConstants = + context.injector.get( + "devicePlatformsConstants", + ); + const $errors = context.injector.get("errors"); + const $migrateController = + context.injector.get("migrateController"); + const $platformValidationService = + context.injector.get( + "platformValidationService", + ); + const $projectData = context.injector.get("projectData"); + const platform = $devicePlatformsConstants[debugPlatform]; + // Keeping the cleanup process alive is what makes a debugger able to stay // attached, so it must not happen before the platform-specific checks that // run ahead of this function have had their chance to fail the command. - services.$cleanupService.setShouldDispose(false); + $cleanupService.setShouldDispose(false); if (!context.options.force) { - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, - platforms: [services.platform], + await $migrateController.validate({ + projectDir: $projectData.projectDir, + platforms: [platform], }); } if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.platform, - services.$projectData, - ) + !$platformValidationService.isPlatformSupportedForOS(platform, $projectData) ) { - services.$errors.fail( - `Applications for platform ${services.platform} can not be built on this OS`, + $errors.fail( + `Applications for platform ${platform} can not be built on this OS`, ); } if (context.options.release) { - services.$errors.failWithHelp( - "--release flag is not applicable to this command.", - ); + $errors.failWithHelp("--release flag is not applicable to this command."); } - return canExecuteCommandBase(services, services.platform, { + return canExecuteCommandBase(context, platform, { validateOptions: true, }); } -export async function runDebugCommand( +async function runDebugCommand( context: DebugCommandContext, - services: IDebugCommandServices, + debugPlatform: DebugPlatform, ): Promise { - await services.$devicesService.initialize({ - platform: services.platform, + const $debugController = + context.injector.get("debugController"); + const $debugDataService = + context.injector.get("debugDataService"); + const $devicePlatformsConstants = + context.injector.get( + "devicePlatformsConstants", + ); + const $devicesService = + context.injector.get("devicesService"); + const $liveSyncCommandHelper = context.injector.get( + "liveSyncCommandHelper", + ); + const $options = context.injector.get("options"); + const $projectData = context.injector.get("projectData"); + const platform = $devicePlatformsConstants[debugPlatform]; + $projectData.initializeProjectData(); + + await $devicesService.initialize({ + platform, deviceId: context.options.device, emulator: context.options.emulator, skipDeviceDetectionInterval: true, }); - const selectedDeviceForDebug = - await services.$devicesService.pickSingleDevice({ - onlyEmulators: context.options.emulator, - onlyDevices: context.options.forDevice, - deviceId: context.options.device, - }); + const selectedDeviceForDebug = await $devicesService.pickSingleDevice({ + onlyEmulators: context.options.emulator, + onlyDevices: context.options.forDevice, + deviceId: context.options.device, + }); if (context.options.start) { // The debug services read the whole parsed command line, including flags // no command declares, so the raw argv is what they get. - const debugOptions = _.cloneDeep(services.$options.argv); - const debugData = services.$debugDataService.getDebugData( + const debugOptions = _.cloneDeep($options.argv); + const debugData = $debugDataService.getDebugData( selectedDeviceForDebug.deviceInfo.identifier, - services.$projectData, + $projectData, debugOptions, ); - await services.$debugController.printDebugInformation( - await services.$debugController.startDebug(debugData), + await $debugController.printDebugInformation( + await $debugController.startDebug(debugData), ); return; } @@ -170,9 +161,9 @@ export async function runDebugCommand( ...additional, }); - await services.$liveSyncCommandHelper.executeLiveSyncOperation( + await $liveSyncCommandHelper.executeLiveSyncOperation( [selectedDeviceForDebug], - services.platform, + platform, liveSyncOptions({}), ); @@ -187,9 +178,9 @@ export async function runDebugCommand( const restartDebugSession = ( forceRebuildNativeApp: boolean = false, ): Promise => - services.$liveSyncCommandHelper.executeLiveSyncOperation( + $liveSyncCommandHelper.executeLiveSyncOperation( [selectedDeviceForDebug], - services.platform, + platform, liveSyncOptions(>{ restartLiveSync: true, ...(forceRebuildNativeApp ? { forceRebuildNativeApp: true } : {}), @@ -213,31 +204,6 @@ export async function runDebugCommand( } } -interface IDebugApplePlatformCommandServices extends IDebugCommandServices { - $sysInfo: ISysInfo; -} - -const setupDebugApplePlatformCommand = - (debugPlatform: "iOS" | "visionOS") => - (): IDebugApplePlatformCommandServices => { - const services = { - ...setupDebugCommand(debugPlatform), - $sysInfo: inject("sysInfo"), - }; - services.$projectData.initializeProjectData(); - - // Do not dispose ios-device-lib, so the process will remain alive and the debug application (NativeScript Inspector or Chrome DevTools) will be able to connect to the socket. - // In case we dispose ios-device-lib, the socket will be closed and the code will fail when the debug application tries to read/send data to device socket. - // That's why the `$ ns debug ios --justlaunch` command will not release the terminal. - // In case we do not set it to false, the dispose will be called once the command finishes its execution, which will prevent the debugging. - inject("iosDeviceOperations").setShouldDispose(false); - inject( - "iOSSimulatorLogProvider", - ).setShouldDispose(false); - - return services; - }; - function isValidTimeoutOption(timeout: string): boolean { if (!timeout) { return true; @@ -266,43 +232,61 @@ const defineApplePlatformDebugCommand = ( options: debugCommandOptions, // Arguments have never been rejected here, only ignored. arguments: "any", - setup: setupDebugApplePlatformCommand(debugPlatform), - async canExecute( - context: DebugCommandContext, - services: IDebugApplePlatformCommandServices, - ): Promise { + async canExecute(context): Promise { + const $devicePlatformsConstants = + inject("devicePlatformsConstants"); + const $errors = inject("errors"); + const $platformValidationService = inject( + "platformValidationService", + ); + const $projectData = inject("projectData"); + const $sysInfo = inject("sysInfo"); + const platform = $devicePlatformsConstants[debugPlatform]; + $projectData.initializeProjectData(); + + // Do not dispose ios-device-lib, so the process will remain alive and the debug application (NativeScript Inspector or Chrome DevTools) will be able to connect to the socket. + // In case we dispose ios-device-lib, the socket will be closed and the code will fail when the debug application tries to read/send data to device socket. + // That's why the `$ ns debug ios --justlaunch` command will not release the terminal. + // In case we do not set it to false, the dispose will be called once the command finishes its execution, which will prevent the debugging. + inject("iosDeviceOperations").setShouldDispose( + false, + ); + inject( + "iOSSimulatorLogProvider", + ).setShouldDispose(false); + if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.platform, - services.$projectData, + !$platformValidationService.isPlatformSupportedForOS( + platform, + $projectData, ) ) { - services.$errors.fail( - `Applications for platform ${services.platform} can not be built on this OS`, + $errors.fail( + `Applications for platform ${platform} can not be built on this OS`, ); } if (!isValidTimeoutOption(context.options.timeout)) { - services.$errors.fail( + $errors.fail( `Timeout option specifies the seconds NativeScript CLI will wait to find the inspector socket port from device's logs. Must be a number.`, ); } if (context.options.inspector) { - const macOSWarning = await services.$sysInfo.getMacOSWarningMessage(); + const macOSWarning = await $sysInfo.getMacOSWarningMessage(); if ( macOSWarning && macOSWarning.severity === SystemWarningsSeverity.high ) { - services.$errors.fail( + $errors.fail( `You cannot use NativeScript Inspector on this OS. To use it, please update your OS.`, ); } } - return canExecuteDebugCommand(context, services); + return canExecuteDebugCommand(context, debugPlatform); }, - run: runDebugCommand, + run: (context) => runDebugCommand(context, debugPlatform), }); export const iosDebugCommand = defineApplePlatformDebugCommand( @@ -320,24 +304,19 @@ export const androidDebugCommand = defineCommand({ description: "Debugs your project on a connected Android device or emulator.", options: debugCommandOptions, arguments: "any", - setup(): IDebugCommandServices { - const services = setupDebugCommand("Android"); - services.$projectData.initializeProjectData(); + async canExecute(context): Promise { + const $errors = inject("errors"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); - return services; - }, - async canExecute( - context: DebugCommandContext, - services: IDebugCommandServices, - ): Promise { - const canExecuteBase = await canExecuteDebugCommand(context, services); + const canExecuteBase = await canExecuteDebugCommand(context, "Android"); if (canExecuteBase) { if (context.options.aab && !hasValidAndroidSigning(context.options)) { - services.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } return canExecuteBase; }, - run: runDebugCommand, + run: (context) => runDebugCommand(context, "Android"), }); diff --git a/lib/commands/deploy.ts b/lib/commands/deploy.ts index d96166600f..acda8ab0ef 100644 --- a/lib/commands/deploy.ts +++ b/lib/commands/deploy.ts @@ -2,14 +2,11 @@ import { ANDROID_RELEASE_BUILD_ERROR_MESSAGE, ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, } from "../constants"; -import { - canExecuteCommandBase, - injectPlatformCommandServices, - platformArgument, -} from "./command-base"; +import { canExecuteCommandBase, platformArgument } from "./command-base"; import { DeployCommandHelper } from "../helpers/deploy-command-helper"; import { hasValidAndroidSigning } from "../common/helpers"; import { IMigrateController } from "../definitions/migrate"; +import { IProjectData } from "../definitions/project"; import { IErrors } from "../common/declarations"; import { booleanOption, @@ -36,24 +33,18 @@ export const deployCommandDefinition = defineCommand({ description: "Builds and deploys the project to a connected device.", options: deployCommandOptions, arguments: [platformArgument], - setup() { - const services = { - ...injectPlatformCommandServices(), - $errors: inject("errors"), - $mobileHelper: inject("mobileHelper"), - $deployCommandHelper: inject("deployCommandHelper"), - $migrateController: inject("migrateController"), - }; - services.$projectData.initializeProjectData(); + async canExecute(context): Promise { + const $errors = inject("errors"); + const $migrateController = inject("migrateController"); + const $mobileHelper = inject("mobileHelper"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); - return services; - }, - async canExecute(context, services): Promise { const platform = context.args[0]; if (!context.options.force) { - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, + await $migrateController.validate({ + projectDir: $projectData.projectDir, platforms: [platform], }); } @@ -63,22 +54,28 @@ export const deployCommandDefinition = defineCommand({ } if ( - services.$mobileHelper.isAndroidPlatform(platform) && + $mobileHelper.isAndroidPlatform(platform) && (context.options.release || context.options.aab) && !hasValidAndroidSigning(context.options) ) { if (context.options.release) { - services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - services.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } - return canExecuteCommandBase(services, platform, { + return canExecuteCommandBase(context, platform, { validateOptions: true, }); }, - async run(context, services): Promise { - await services.$deployCommandHelper.deploy(context.args[0]); + async run(context): Promise { + const $deployCommandHelper = inject( + "deployCommandHelper", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + await $deployCommandHelper.deploy(context.args[0]); }, }); diff --git a/lib/commands/embedding/embed.ts b/lib/commands/embedding/embed.ts index d4489ddfaa..a39b361c19 100644 --- a/lib/commands/embedding/embed.ts +++ b/lib/commands/embedding/embed.ts @@ -1,25 +1,18 @@ import { resolve } from "path"; import { color } from "../../color"; -import { defineCommand } from "../../common/define-command"; -import { inject } from "../../common/di"; +import { IOptions } from "../../declarations"; +import { IProjectConfigService, IProjectData } from "../../definitions/project"; +import { Command } from "../../common/define-command"; import { IFileSystem } from "../../common/declarations"; -import { IProjectConfigService } from "../../definitions/project"; +import { inject } from "../../common/di"; +import { canExecuteCommand } from "../../common/services/command-definition-adapter"; import { platformArgument } from "../command-base"; import { - canExecutePrepareCommand, - IPrepareCommandServices, + prepareCommandDefinition, prepareCommandOptions, runPrepareCommand, - setupPrepareCommand, } from "../prepare"; -interface IEmbedCommandServices extends IPrepareCommandServices { - $fs: IFileSystem; - $logger: ILogger; - hostProjectPath: string; - hostProjectModuleName: string; -} - function resolveHostProjectPath( projectDir: string, hostProjectPath: string, @@ -31,7 +24,7 @@ function resolveHostProjectPath( return resolve(hostProjectPath); } -export const embedCommandDefinition = defineCommand({ +export class EmbedCommand extends Command({ name: "embed", description: "Prepares the project so it can be embedded into a native host project.", @@ -41,45 +34,50 @@ export const embedCommandDefinition = defineCommand({ { name: "hostProjectPath" }, { name: "hostProjectModuleName" }, ], - setup(context): IEmbedCommandServices { - const services = setupPrepareCommand(); - const $projectConfigService = inject( - "projectConfigService", - ); - const platform = (context.args[0] || "").toLowerCase(); - // embed.., falling back to embed. - const configValue = (key: string) => - $projectConfigService.getValue( - `embed.${platform}.${key}`, - $projectConfigService.getValue(`embed.${key}`), - ); +}) { + private $fs = inject("fs"); + private $logger = inject("logger"); + private $options = inject("options"); + private $projectConfigService = inject( + "projectConfigService", + ); + private $projectData = inject("projectData"); + + private platform = (this.args[0] || "").toLowerCase(); + private hostProjectPath = this.args[1] || this.configValue("hostProjectPath"); + private hostProjectModuleName = + this.args[2] || this.configValue("hostProjectModuleName"); + + constructor() { + super(); + this.$projectData.initializeProjectData(); + } - return { - ...services, - $fs: inject("fs"), - $logger: inject("logger"), - hostProjectPath: context.args[1] || configValue("hostProjectPath"), - hostProjectModuleName: - context.args[2] || configValue("hostProjectModuleName"), - }; - }, - async canExecute(context, services): Promise { - if (!(await canExecutePrepareCommand(context, services))) { + public async canExecute(): Promise { + // `prepare` takes the platform alone; the host project arguments are this + // command's own and it would reject them. + if ( + !(await canExecuteCommand( + prepareCommandDefinition, + this.args.slice(0, 1), + )) + ) { return false; } - return !!services.hostProjectPath; - }, - async run(context, services): Promise { + return !!this.hostProjectPath; + } + + public async run(): Promise { const resolvedHostProjectPath = resolveHostProjectPath( - services.$projectData.projectDir, - services.hostProjectPath, + this.$projectData.projectDir, + this.hostProjectPath, ); - if (!services.$fs.exists(resolvedHostProjectPath)) { - services.$logger.error( + if (!this.$fs.exists(resolvedHostProjectPath)) { + this.$logger.error( `The host project path ${color.yellow( - services.hostProjectPath, + this.hostProjectPath, )} (resolved to: ${color.styleText( ["yellow", "dim"], resolvedHostProjectPath, @@ -88,11 +86,19 @@ export const embedCommandDefinition = defineCommand({ return; } - services.$options.hostProjectPath = resolvedHostProjectPath; - if (services.hostProjectModuleName) { - services.$options.hostProjectModuleName = services.hostProjectModuleName; + this.$options.hostProjectPath = resolvedHostProjectPath; + if (this.hostProjectModuleName) { + this.$options.hostProjectModuleName = this.hostProjectModuleName; } - await runPrepareCommand(context, services); - }, -}); + await runPrepareCommand(this.context); + } + + /** embed.., falling back to embed.. */ + private configValue(key: string): string { + return this.$projectConfigService.getValue( + `embed.${this.platform}.${key}`, + this.$projectConfigService.getValue(`embed.${key}`), + ); + } +} diff --git a/lib/commands/extensibility/install-extension.ts b/lib/commands/extensibility/install-extension.ts index d69eca8202..20483bf22e 100644 --- a/lib/commands/extensibility/install-extension.ts +++ b/lib/commands/extensibility/install-extension.ts @@ -2,20 +2,6 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; -export interface IInstallExtensionCommandServices { - $extensibilityService: IExtensibilityService; - $logger: ILogger; -} - -export function setupInstallExtensionCommand(): IInstallExtensionCommandServices { - return { - $extensibilityService: inject( - "extensibilityService", - ), - $logger: inject("logger"), - }; -} - export const installExtensionCommandDefinition = defineCommand({ name: "extension|install", description: "Installs the specified extension.", @@ -27,22 +13,21 @@ export const installExtensionCommandDefinition = defineCommand({ "You have to provide a valid name for extension that you want to install.", }, ], - setup: setupInstallExtensionCommand, - async run( - context, - services: IInstallExtensionCommandServices, - ): Promise { - const extensionData = await services.$extensibilityService.installExtension( + async run(context): Promise { + const $extensibilityService = inject( + "extensibilityService", + ); + const $logger = inject("logger"); + + const extensionData = await $extensibilityService.installExtension( context.args[0], ); - services.$logger.info( + $logger.info( `Successfully installed extension ${extensionData.extensionName}.`, ); - await services.$extensibilityService.loadExtension( - extensionData.extensionName, - ); - services.$logger.info( + await $extensibilityService.loadExtension(extensionData.extensionName); + $logger.info( `Successfully loaded extension ${extensionData.extensionName}.`, ); }, diff --git a/lib/commands/extensibility/list-extensions.ts b/lib/commands/extensibility/list-extensions.ts index 1ae4fb8383..35d7ab81c8 100644 --- a/lib/commands/extensibility/list-extensions.ts +++ b/lib/commands/extensibility/list-extensions.ts @@ -4,37 +4,26 @@ import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; import * as helpers from "../../common/helpers"; -export interface IListExtensionsCommandServices { - $extensibilityService: IExtensibilityService; - $logger: ILogger; -} - -export function setupListExtensionsCommand(): IListExtensionsCommandServices { - return { - $extensibilityService: inject( - "extensibilityService", - ), - $logger: inject("logger"), - }; -} - export const listExtensionsCommandDefinition = defineCommand({ name: "extension|*list", description: "Lists all installed extensions.", - setup: setupListExtensionsCommand, - run(context, services: IListExtensionsCommandServices): void { - const installedExtensions = - services.$extensibilityService.getInstalledExtensions(); + run(): void { + const $extensibilityService = inject( + "extensibilityService", + ); + const $logger = inject("logger"); + + const installedExtensions = $extensibilityService.getInstalledExtensions(); if (_.keys(installedExtensions).length) { - services.$logger.info("Installed extensions:"); + $logger.info("Installed extensions:"); const data = _.map(installedExtensions, (version, name) => { return [name, version]; }); const table = helpers.createTable(["Name", "Version"], data); - services.$logger.info(table.toString()); + $logger.info(table.toString()); } else { - services.$logger.info("No extensions installed."); + $logger.info("No extensions installed."); } }, }); diff --git a/lib/commands/extensibility/uninstall-extension.ts b/lib/commands/extensibility/uninstall-extension.ts index 44b26b915e..369de3ad07 100644 --- a/lib/commands/extensibility/uninstall-extension.ts +++ b/lib/commands/extensibility/uninstall-extension.ts @@ -2,20 +2,6 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { IExtensibilityService } from "../../common/definitions/extensibility"; -export interface IUninstallExtensionCommandServices { - $extensibilityService: IExtensibilityService; - $logger: ILogger; -} - -export function setupUninstallExtensionCommand(): IUninstallExtensionCommandServices { - return { - $extensibilityService: inject( - "extensibilityService", - ), - $logger: inject("logger"), - }; -} - export const uninstallExtensionCommandDefinition = defineCommand({ name: "extension|uninstall", description: "Uninstalls the specified extension.", @@ -27,15 +13,14 @@ export const uninstallExtensionCommandDefinition = defineCommand({ "You have to provide a valid name for extension that you want to uninstall.", }, ], - setup: setupUninstallExtensionCommand, - async run( - context, - services: IUninstallExtensionCommandServices, - ): Promise { - const extensionName = context.args[0]; - await services.$extensibilityService.uninstallExtension(extensionName); - services.$logger.info( - `Successfully uninstalled extension ${extensionName}`, + async run(context): Promise { + const $extensibilityService = inject( + "extensibilityService", ); + const $logger = inject("logger"); + + const extensionName = context.args[0]; + await $extensibilityService.uninstallExtension(extensionName); + $logger.info(`Successfully uninstalled extension ${extensionName}`); }, }); diff --git a/lib/commands/fonts.ts b/lib/commands/fonts.ts index 7d4a13e4ba..289f899ba5 100644 --- a/lib/commands/fonts.ts +++ b/lib/commands/fonts.ts @@ -7,54 +7,43 @@ import * as fontFinder from "font-finder"; import { createTable } from "../common/helpers"; import * as path from "path"; -export interface IFontsCommandServices { - $projectData: IProjectData; - $fs: IFileSystem; - $logger: ILogger; - $projectConfigService: IProjectConfigService; -} - -export function setupFontsCommand(): IFontsCommandServices { - const services = { - $projectData: inject("projectData"), - $fs: inject("fs"), - $logger: inject("logger"), - $projectConfigService: inject( - "projectConfigService", - ), - }; - services.$projectData.initializeProjectData(); - - return services; -} - export const fontsCommandDefinition = defineCommand({ name: "fonts", description: "Lists the custom fonts the project bundles.", arguments: "none", - setup: setupFontsCommand, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(): Promise { + const $projectData = inject("projectData"); + const $fs = inject("fs"); + const $logger = inject("logger"); + const $projectConfigService = inject( + "projectConfigService", + ); const supportedExtensions = [".ttf", ".otf"]; const defaultFontsFolderPaths = [ path.join( - services.$projectConfigService.getValue("appPath") ?? "", + $projectConfigService.getValue("appPath") ?? "", constants.FONTS_DIR, ), path.join(constants.APP_FOLDER_NAME, constants.FONTS_DIR), path.join(constants.SRC_DIR, constants.FONTS_DIR), - ].map((entry) => path.resolve(services.$projectData.projectDir, entry)); + ].map((entry) => path.resolve($projectData.projectDir, entry)); const fontsFolderPath = defaultFontsFolderPaths.find((entry) => - services.$fs.exists(entry), + $fs.exists(entry), ); if (!fontsFolderPath) { - services.$logger.warn("No fonts folder found."); + $logger.warn("No fonts folder found."); return; } - const files = services.$fs + const files = $fs .readDirectory(fontsFolderPath) .map((entry) => path.parse(entry)) .filter((entry) => { @@ -62,7 +51,7 @@ export const fontsCommandDefinition = defineCommand({ }); if (!files.length) { - services.$logger.warn("No custom fonts found."); + $logger.warn("No custom fonts found."); return; } @@ -76,6 +65,6 @@ export const fontsCommandDefinition = defineCommand({ ]); } - services.$logger.info(table.toString()); + $logger.info(table.toString()); }, }); diff --git a/lib/commands/generate-assets.ts b/lib/commands/generate-assets.ts index a299ab1e4f..c7ce072d81 100644 --- a/lib/commands/generate-assets.ts +++ b/lib/commands/generate-assets.ts @@ -5,12 +5,12 @@ import { defineCommand, stringOption, } from "../common/define-command"; -import { inject } from "../common/di"; import { IAssetsGenerationService, IResourceGenerationData, } from "../declarations"; import { IProjectData } from "../definitions/project"; +import { inject } from "../common/di"; /** Which set of assets a command generates from the source image. */ type GeneratedAssets = "icons" | "splashes"; @@ -26,43 +26,21 @@ const generators: Record< splashes: (service, data) => service.generateSplashScreens(data), }; -export const generateAssetsCommandOptions = { +const generateAssetsCommandOptions = { background: stringOption(), } satisfies CommandOptionsSchema; -export type GenerateAssetsCommandContext = CommandContext< - typeof generateAssetsCommandOptions ->; - -export interface IGenerateAssetsCommandServices { - assets: GeneratedAssets; - $assetsGenerationService: IAssetsGenerationService; - $projectData: IProjectData; -} - -export function setupGenerateAssetsCommand( +function runGenerateAssetsCommand( + context: CommandContext, assets: GeneratedAssets, -): IGenerateAssetsCommandServices { - const services = { - assets, - $assetsGenerationService: inject( - "assetsGenerationService", - ), - $projectData: inject("projectData"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export function runGenerateAssetsCommand( - context: GenerateAssetsCommandContext, - services: IGenerateAssetsCommandServices, ): Promise { - return generators[services.assets](services.$assetsGenerationService, { + const $assetsGenerationService = + context.injector.get("assetsGenerationService"); + const $projectData = context.injector.get("projectData"); + return generators[assets]($assetsGenerationService, { imagePath: context.args[0], background: context.options.background, - projectDir: services.$projectData.projectDir, + projectDir: $projectData.projectDir, }); } @@ -83,8 +61,12 @@ const defineGenerateAssetsCommand = ( "You have to provide path to image to generate other images based on it.", }, ], - setup: () => setupGenerateAssetsCommand(assets), - run: runGenerateAssetsCommand, + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a missing image path reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + run: (context) => runGenerateAssetsCommand(context, assets), }); export const generateIconsCommand = defineGenerateAssetsCommand( diff --git a/lib/commands/generate-help.ts b/lib/commands/generate-help.ts index ca0c64ae79..476b185388 100644 --- a/lib/commands/generate-help.ts +++ b/lib/commands/generate-help.ts @@ -6,10 +6,8 @@ export const generateHelpCommandDefinition = defineCommand({ name: "dev-generate-help", description: "Generates the HTML help pages from the man pages.", arguments: "none", - setup: () => ({ - $helpService: inject("helpService"), - }), - run(context, services): Promise { - return services.$helpService.generateHtmlPages(); + run(): Promise { + const $helpService = inject("helpService"); + return $helpService.generateHtmlPages(); }, }); diff --git a/lib/commands/generate.ts b/lib/commands/generate.ts index 29caa6ece4..70c173a8ef 100644 --- a/lib/commands/generate.ts +++ b/lib/commands/generate.ts @@ -7,18 +7,17 @@ export const generateCommandDefinition = defineCommand({ name: "generate", description: "Executes a schematic in the project.", arguments: "any", - setup: () => ({ - $logger: inject("logger"), - $errors: inject("errors"), - }), - async run(context, services): Promise { + async run(): Promise { + const $logger = inject("logger"); + const $errors = inject("errors"); + try { - services.$logger.info( + $logger.info( "If you have ideas for this command, please discuss at https://nativescript.org/discord", ); // await run(this.executionOptions); } catch (error) { - services.$errors.fail(error.message); + $errors.fail(error.message); } }, }); diff --git a/lib/commands/hooks/common.ts b/lib/commands/hooks/common.ts index a51bf8dac1..51d9e00cbd 100644 --- a/lib/commands/hooks/common.ts +++ b/lib/commands/hooks/common.ts @@ -1,7 +1,6 @@ -import { IProjectData } from "../../definitions/project"; -import { IPluginData, IPluginsService } from "../../definitions/plugins"; +import { IPluginData } from "../../definitions/plugins"; import { IErrors, IFileSystem } from "../../common/declarations"; -import { inject } from "../../common/di"; +import { CommandContext } from "../../common/define-command"; import path = require("path"); import * as crypto from "crypto"; @@ -16,28 +15,6 @@ export interface OutputPlugin { hooks: OutputHook[]; } -export interface IHooksCommandServices { - $pluginsService: IPluginsService; - $projectData: IProjectData; - $errors: IErrors; - $fs: IFileSystem; - $logger: ILogger; -} - -/** Callable from `setup` and from `canExecute` before their first `await`. */ -export function injectHooksCommandServices(): IHooksCommandServices { - const services = { - $pluginsService: inject("pluginsService"), - $projectData: inject("projectData"), - $errors: inject("errors"), - $fs: inject("fs"), - $logger: inject("logger"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - export function getPluginsWithHooks(plugins: IPluginData[]): IPluginData[] { const pluginsWithHooks: IPluginData[] = []; for (const plugin of plugins) { @@ -50,18 +27,22 @@ export function getPluginsWithHooks(plugins: IPluginData[]): IPluginData[] { } export async function verifyHooksLock( - services: IHooksCommandServices, + context: CommandContext, plugins: IPluginData[], hooksLockPath: string, ): Promise { + const $errors = context.injector.get("errors"); + const $fs = context.injector.get("fs"); + const $logger = context.injector.get("logger"); + let lockFileContent: string; let hooksLock: OutputPlugin[]; try { - lockFileContent = services.$fs.readText(hooksLockPath, "utf8"); + lockFileContent = $fs.readText(hooksLockPath, "utf8"); hooksLock = JSON.parse(lockFileContent); } catch (err) { - services.$errors.fail( + $errors.fail( `❌ Failed to read or parse ${LOCK_FILE_NAME} at ${hooksLockPath}`, ); } @@ -82,7 +63,7 @@ export async function verifyHooksLock( const pluginLockHooks = lockMap.get(plugin.name); if (!pluginLockHooks) { - services.$logger.error( + $logger.error( `❌ Plugin '${plugin.name}' not found in ${LOCK_FILE_NAME}`, ); isValid = false; @@ -93,7 +74,7 @@ export async function verifyHooksLock( const expectedHash = pluginLockHooks.get(hook.type); if (!expectedHash) { - services.$logger.error( + $logger.error( `❌ Missing hook '${hook.type}' for plugin '${plugin.name}' in ${LOCK_FILE_NAME}`, ); isValid = false; @@ -103,11 +84,9 @@ export async function verifyHooksLock( let fileContent: string | Buffer; try { - fileContent = services.$fs.readFile( - path.join(plugin.fullPath, hook.script), - ); + fileContent = $fs.readFile(path.join(plugin.fullPath, hook.script)); } catch (err) { - services.$logger.error( + $logger.error( `❌ Cannot read script file '${hook.script}' for hook '${hook.type}' in plugin '${plugin.name}'`, ); isValid = false; @@ -120,21 +99,19 @@ export async function verifyHooksLock( .digest("hex"); if (actualHash !== expectedHash) { - services.$logger.error( + $logger.error( `❌ Hash mismatch for '${hook.script}' (${hook.type} in ${plugin.name}):`, ); - services.$logger.error(` Expected: ${expectedHash}`); - services.$logger.error(` Actual: ${actualHash}`); + $logger.error(` Expected: ${expectedHash}`); + $logger.error(` Actual: ${actualHash}`); isValid = false; } } } if (isValid) { - services.$logger.info( - "✅ All hooks verified successfully. No issues found.", - ); + $logger.info("✅ All hooks verified successfully. No issues found."); } else { - services.$errors.fail("❌ One or more hooks failed verification."); + $errors.fail("❌ One or more hooks failed verification."); } } diff --git a/lib/commands/hooks/hooks-lock.ts b/lib/commands/hooks/hooks-lock.ts index 18142e8a92..7272aec725 100644 --- a/lib/commands/hooks/hooks-lock.ts +++ b/lib/commands/hooks/hooks-lock.ts @@ -1,11 +1,12 @@ -import { IPluginData } from "../../definitions/plugins"; -import { defineCommand } from "../../common/define-command"; +import { IProjectData } from "../../definitions/project"; +import { IPluginData, IPluginsService } from "../../definitions/plugins"; +import { IErrors, IFileSystem } from "../../common/declarations"; +import { CommandContext, defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; import path = require("path"); import * as crypto from "crypto"; import { getPluginsWithHooks, - IHooksCommandServices, - injectHooksCommandServices, LOCK_FILE_NAME, OutputHook, OutputPlugin, @@ -13,10 +14,13 @@ import { } from "./common"; async function writeHooksLockFile( - services: IHooksCommandServices, + context: CommandContext, plugins: IPluginData[], outputDir: string, ): Promise { + const $errors = context.injector.get("errors"); + const $fs = context.injector.get("fs"); + const $logger = context.injector.get("logger"); const output: OutputPlugin[] = []; for (const plugin of plugins) { @@ -24,7 +28,7 @@ async function writeHooksLockFile( for (const hook of plugin.nativescript?.hooks || []) { try { - const fileContent = services.$fs.readFile( + const fileContent = $fs.readFile( path.join(plugin.fullPath, hook.script), ); const hash = crypto @@ -37,7 +41,7 @@ async function writeHooksLockFile( hash, }); } catch (err) { - services.$logger.warn( + $logger.warn( `Warning: Failed to read script '${hook.script}' for plugin '${plugin.name}'. Skipping this hook.`, ); continue; @@ -50,10 +54,10 @@ async function writeHooksLockFile( const filePath = path.resolve(outputDir, LOCK_FILE_NAME); try { - services.$fs.writeFile(filePath, JSON.stringify(output, null, 2), "utf8"); - services.$logger.info(`✅ ${LOCK_FILE_NAME} written to: ${filePath}`); + $fs.writeFile(filePath, JSON.stringify(output, null, 2), "utf8"); + $logger.info(`✅ ${LOCK_FILE_NAME} written to: ${filePath}`); } catch (err) { - services.$errors.fail(`❌ Failed to write ${LOCK_FILE_NAME}: ${err}`); + $errors.fail(`❌ Failed to write ${LOCK_FILE_NAME}: ${err}`); } } @@ -62,20 +66,26 @@ export const hooksLockCommandDefinition = defineCommand({ description: "Records a hash of every plugin hook in the project's lock file.", arguments: "any", - setup: injectHooksCommandServices, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + const $logger = inject("logger"); + const plugins: IPluginData[] = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); + await $pluginsService.getAllInstalledPlugins($projectData); if (plugins && plugins.length > 0) { await writeHooksLockFile( - services, + context, getPluginsWithHooks(plugins), - services.$projectData.projectDir, + $projectData.projectDir, ); } else { - services.$logger.info("No plugins with hooks found."); + $logger.info("No plugins with hooks found."); } }, }); @@ -85,20 +95,26 @@ export const hooksVerifyCommandDefinition = defineCommand({ description: "Checks every plugin hook against the hashes in the project's lock file.", arguments: "any", - setup: injectHooksCommandServices, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + const $logger = inject("logger"); + const plugins: IPluginData[] = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); + await $pluginsService.getAllInstalledPlugins($projectData); if (plugins && plugins.length > 0) { await verifyHooksLock( - services, + context, getPluginsWithHooks(plugins), - path.join(services.$projectData.projectDir, LOCK_FILE_NAME), + path.join($projectData.projectDir, LOCK_FILE_NAME), ); } else { - services.$logger.info("No plugins with hooks found."); + $logger.info("No plugins with hooks found."); } }, }); diff --git a/lib/commands/hooks/hooks.ts b/lib/commands/hooks/hooks.ts index afe451f6d9..313f9c8153 100644 --- a/lib/commands/hooks/hooks.ts +++ b/lib/commands/hooks/hooks.ts @@ -1,21 +1,15 @@ -import { IPluginData } from "../../definitions/plugins"; +import { IProjectData } from "../../definitions/project"; +import { IPluginData, IPluginsService } from "../../definitions/plugins"; +import { IErrors, IFileSystem } from "../../common/declarations"; import { CommandContext, defineCommand } from "../../common/define-command"; +import { inject } from "../../common/di"; import path = require("path"); import { HOOKS_DIR_NAME } from "../../constants"; import { createTable } from "../../common/helpers"; import nsHooks = require("@nativescript/hook"); -import { - getPluginsWithHooks, - IHooksCommandServices, - injectHooksCommandServices, - LOCK_FILE_NAME, - verifyHooksLock, -} from "./common"; +import { getPluginsWithHooks, LOCK_FILE_NAME, verifyHooksLock } from "./common"; -function listHooks( - services: IHooksCommandServices, - pluginsWithHooks: IPluginData[], -): void { +function listHooks($logger: ILogger, pluginsWithHooks: IPluginData[]): void { const headers: string[] = ["Plugin", "HookName", "HookPath"]; const hookDataData: string[][] = pluginsWithHooks.flatMap((plugin) => plugin.nativescript.hooks.map((hook: { type: string; script: string }) => { @@ -23,31 +17,26 @@ function listHooks( }), ); const hookDataTable: any = createTable(headers, hookDataData); - services.$logger.info("Hooks:"); - services.$logger.info(hookDataTable.toString()); + $logger.info("Hooks:"); + $logger.info(hookDataTable.toString()); } async function installHooks( - services: IHooksCommandServices, + context: CommandContext, + projectDir: string, pluginsWithHooks: IPluginData[], ): Promise { - const hooksDir = path.join(services.$projectData.projectDir, HOOKS_DIR_NAME); + const $fs = context.injector.get("fs"); + const hooksDir = path.join(projectDir, HOOKS_DIR_NAME); + const hooksLockPath = path.join(projectDir, LOCK_FILE_NAME); - if ( - services.$fs.exists( - path.join(services.$projectData.projectDir, LOCK_FILE_NAME), - ) - ) { - await verifyHooksLock( - services, - pluginsWithHooks, - path.join(services.$projectData.projectDir, LOCK_FILE_NAME), - ); + if ($fs.exists(hooksLockPath)) { + await verifyHooksLock(context, pluginsWithHooks, hooksLockPath); } if (pluginsWithHooks.length === 0) { - if (!services.$fs.exists(hooksDir)) { - services.$fs.createDirectory(hooksDir); + if (!$fs.exists(hooksDir)) { + $fs.createDirectory(hooksDir); } } for (const plugin of pluginsWithHooks) { @@ -55,31 +44,35 @@ async function installHooks( } } -export async function runHooksCommand( - services: IHooksCommandServices, +async function runHooksCommand( + context: CommandContext, isList: boolean, ): Promise { + const $pluginsService = + context.injector.get("pluginsService"); + const $projectData = context.injector.get("projectData"); + $projectData.initializeProjectData(); + const plugins: IPluginData[] = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); + await $pluginsService.getAllInstalledPlugins($projectData); if (plugins && plugins.length > 0) { const pluginsWithHooks = getPluginsWithHooks(plugins); if (isList) { - listHooks(services, pluginsWithHooks); + listHooks(context.injector.get("logger"), pluginsWithHooks); } else { - await installHooks(services, pluginsWithHooks); + await installHooks(context, $projectData.projectDir, pluginsWithHooks); } } } -export function canExecuteHooksCommand( - context: CommandContext, - services: IHooksCommandServices, -): boolean { +function canExecuteHooksCommand(context: CommandContext): boolean { + // A hooks command only makes sense inside a project, and reporting a missing + // one takes precedence over the argument check. + inject("projectData").initializeProjectData(); + if (context.args.length > 0 && context.args[0] !== "list") { - services.$errors.failWithHelp( + inject("errors").failWithHelp( `Invalid argument ${context.args[0]}. Supported argument is "list".`, ); } @@ -90,10 +83,9 @@ export const hooksInstallCommandDefinition = defineCommand({ name: "hooks|install", description: "Runs the postinstall hook of every installed plugin.", arguments: "any", - setup: injectHooksCommandServices, canExecute: canExecuteHooksCommand, - run(context, services): Promise { - return runHooksCommand(services, context.args[0] === "list"); + run(context): Promise { + return runHooksCommand(context, context.args[0] === "list"); }, }); @@ -101,10 +93,9 @@ export const hooksListCommandDefinition = defineCommand({ name: "hooks|*list", description: "Lists the hooks every installed plugin contributes.", arguments: "any", - setup: injectHooksCommandServices, // The name accepts "list" as its only argument, and lists either way. canExecute: canExecuteHooksCommand, - run(context, services): Promise { - return runHooksCommand(services, true); + run(context): Promise { + return runHooksCommand(context, true); }, }); diff --git a/lib/commands/info.ts b/lib/commands/info.ts index 008ad002e4..f2dd0021a8 100644 --- a/lib/commands/info.ts +++ b/lib/commands/info.ts @@ -6,10 +6,8 @@ export const infoCommandDefinition = defineCommand({ name: "info", description: "Displays version information about the CLI and its components.", arguments: "none", - setup: () => ({ - $infoService: inject("infoService"), - }), - run(context, services): Promise { - return services.$infoService.printComponentsInfo(); + run(): Promise { + const $infoService = inject("infoService"); + return $infoService.printComponentsInfo(); }, }); diff --git a/lib/commands/install.ts b/lib/commands/install.ts index 788a3e00b1..ba60986a2b 100644 --- a/lib/commands/install.ts +++ b/lib/commands/install.ts @@ -18,82 +18,53 @@ import { IPlatformsDataService } from "../definitions/platform"; import { IPluginsService } from "../definitions/plugins"; import { IProjectData, IProjectDataService } from "../definitions/project"; -export const installCommandOptions = { +const installCommandOptions = { frameworkPath: stringOption(), disableNpmInstall: booleanOption(), ignoreScripts: booleanOption(), path: stringOption(), } satisfies CommandOptionsSchema; -export type InstallCommandContext = CommandContext< - typeof installCommandOptions ->; - -export interface IInstallCommandServices { - $options: IOptions; - $mobileHelper: Mobile.IMobileHelper; - $platformsDataService: IPlatformsDataService; - $platformCommandHelper: IPlatformCommandHelper; - $projectData: IProjectData; - $projectDataService: IProjectDataService; - $pluginsService: IPluginsService; - $logger: ILogger; - $fs: IFileSystem; - $packageManager: INodePackageManager; -} - -export function setupInstallCommand(): IInstallCommandServices { - const services = { - $options: inject("options"), - $mobileHelper: inject("mobileHelper"), - $platformsDataService: inject( - "platformsDataService", - ), - $platformCommandHelper: inject( - "platformCommandHelper", - ), - $projectData: inject("projectData"), - $projectDataService: inject("projectDataService"), - $pluginsService: inject("pluginsService"), - $logger: inject("logger"), - $fs: inject("fs"), - $packageManager: inject("packageManager"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - async function installProjectDependencies( - context: InstallCommandContext, - services: IInstallCommandServices, + context: CommandContext, ): Promise { + const $options = context.injector.get("options"); + const $mobileHelper = + context.injector.get("mobileHelper"); + const $platformsDataService = context.injector.get( + "platformsDataService", + ); + const $platformCommandHelper = context.injector.get( + "platformCommandHelper", + ); + const $projectData = context.injector.get("projectData"); + const $projectDataService = + context.injector.get("projectDataService"); + const $pluginsService = + context.injector.get("pluginsService"); + const $logger = context.injector.get("logger"); + let error: string = ""; - await services.$pluginsService.ensureAllDependenciesAreInstalled( - services.$projectData, - ); + await $pluginsService.ensureAllDependenciesAreInstalled($projectData); - for (const platform of services.$mobileHelper.platformNames) { - const platformData = services.$platformsDataService.getPlatformData( + for (const platform of $mobileHelper.platformNames) { + const platformData = $platformsDataService.getPlatformData( platform, - services.$projectData, + $projectData, ); - const frameworkPackageData = services.$projectDataService.getRuntimePackage( - services.$projectData.projectDir, + const frameworkPackageData = $projectDataService.getRuntimePackage( + $projectData.projectDir, platformData.platformNameLowerCase, ); if (frameworkPackageData && frameworkPackageData.version) { try { const platformProjectService = platformData.platformProjectService; - await platformProjectService.validate( - services.$projectData, - services.$options, - ); + await platformProjectService.validate($projectData, $options); - await services.$platformCommandHelper.addPlatforms( + await $platformCommandHelper.addPlatforms( [`${platform}@${frameworkPackageData.version}`], - services.$projectData, + $projectData, context.options.frameworkPath, ); } catch (err) { @@ -103,24 +74,28 @@ async function installProjectDependencies( } if (error) { - services.$logger.error(error); + $logger.error(error); } } async function installModule( - context: InstallCommandContext, - services: IInstallCommandServices, + context: CommandContext, moduleName: string, ): Promise { - const projectDir = services.$projectData.projectDir; + const $projectData = context.injector.get("projectData"); + const $fs = context.injector.get("fs"); + const $packageManager = + context.injector.get("packageManager"); + + const projectDir = $projectData.projectDir; const devPrefix = "nativescript-dev-"; - if (!services.$fs.exists(moduleName) && moduleName.indexOf(devPrefix) !== 0) { + if (!$fs.exists(moduleName) && moduleName.indexOf(devPrefix) !== 0) { moduleName = devPrefix + moduleName; } - await services.$packageManager.install(moduleName, projectDir, { - "save-dev": true, + await $packageManager.install(moduleName, projectDir, { + dev: true, disableNpmInstall: context.options.disableNpmInstall, frameworkPath: context.options.frameworkPath, ignoreScripts: context.options.ignoreScripts, @@ -128,15 +103,6 @@ async function installModule( }); } -export function runInstallCommand( - context: InstallCommandContext, - services: IInstallCommandServices, -): Promise { - return context.args[0] - ? installModule(context, services, context.args[0]) - : installProjectDependencies(context, services); -} - export const installCommandDefinition = defineCommand({ name: "install", description: @@ -144,6 +110,14 @@ export const installCommandDefinition = defineCommand({ options: installCommandOptions, arguments: [{ name: "moduleName" }], enableHooks: false, - setup: setupInstallCommand, - run: runInstallCommand, + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + run(context): Promise { + return context.args[0] + ? installModule(context, context.args[0]) + : installProjectDependencies(context); + }, }); diff --git a/lib/commands/list-platforms.ts b/lib/commands/list-platforms.ts index 2250ddb5bd..9af91cf5d2 100644 --- a/lib/commands/list-platforms.ts +++ b/lib/commands/list-platforms.ts @@ -4,68 +4,47 @@ import { IPlatformCommandHelper } from "../declarations"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -export interface IListPlatformsCommandServices { - $platformCommandHelper: IPlatformCommandHelper; - $projectData: IProjectData; - $logger: ILogger; -} - -export function setupListPlatformsCommand(): IListPlatformsCommandServices { - const services = { - $platformCommandHelper: inject( - "platformCommandHelper", - ), - $projectData: inject("projectData"), - $logger: inject("logger"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - export const listPlatformsCommandDefinition = defineCommand({ name: "platform|*list", description: "Lists all platforms that the project currently targets.", arguments: "none", - setup: setupListPlatformsCommand, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(): Promise { + const $platformCommandHelper = inject( + "platformCommandHelper", + ); + const $projectData = inject("projectData"); + const $logger = inject("logger"); const installedPlatforms = - services.$platformCommandHelper.getInstalledPlatforms( - services.$projectData, - ); + $platformCommandHelper.getInstalledPlatforms($projectData); if (installedPlatforms.length > 0) { const preparedPlatforms = - services.$platformCommandHelper.getPreparedPlatforms( - services.$projectData, - ); + $platformCommandHelper.getPreparedPlatforms($projectData); if (preparedPlatforms.length > 0) { - services.$logger.info( + $logger.info( "The project is prepared for: ", helpers.formatListOfNames(preparedPlatforms, "and"), ); } else { - services.$logger.info("The project is not prepared for any platform"); + $logger.info("The project is not prepared for any platform"); } - services.$logger.info( + $logger.info( "Installed platforms: ", helpers.formatListOfNames(installedPlatforms, "and"), ); } else { const formattedPlatformsList = helpers.formatListOfNames( - services.$platformCommandHelper.getAvailablePlatforms( - services.$projectData, - ), + $platformCommandHelper.getAvailablePlatforms($projectData), "and", ); - services.$logger.info( - "Available platforms for this OS: ", - formattedPlatformsList, - ); - services.$logger.info( - "No installed platforms found. Use $ ns platform add", - ); + $logger.info("Available platforms for this OS: ", formattedPlatformsList); + $logger.info("No installed platforms found. Use $ ns platform add"); } }, }); diff --git a/lib/commands/migrate.ts b/lib/commands/migrate.ts index 909511e397..9fb9c88213 100644 --- a/lib/commands/migrate.ts +++ b/lib/commands/migrate.ts @@ -3,54 +3,42 @@ import { IMigrateController, IMigrationData } from "../definitions/migrate"; import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -export interface IMigrateCommandServices { - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $migrateController: IMigrateController; - $staticConfig: Config.IStaticConfig; - $projectData: IProjectData; - $logger: ILogger; -} - -export function setupMigrateCommand(): IMigrateCommandServices { - const services = { - $devicePlatformsConstants: inject( - "devicePlatformsConstants", - ), - $migrateController: inject("migrateController"), - $staticConfig: inject("staticConfig"), - $projectData: inject("projectData"), - $logger: inject("logger"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - export const migrateCommandDefinition = defineCommand({ name: "migrate", description: "Migrates the project's dependencies to the ones the current CLI supports.", arguments: "none", - setup: setupMigrateCommand, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(): Promise { + const $devicePlatformsConstants = inject( + "devicePlatformsConstants", + ); + const $migrateController = inject("migrateController"); + const $staticConfig = inject("staticConfig"); + const $projectData = inject("projectData"); + const $logger = inject("logger"); const migrationData: IMigrationData = { - projectDir: services.$projectData.projectDir, + projectDir: $projectData.projectDir, platforms: [ - services.$devicePlatformsConstants.Android, - services.$devicePlatformsConstants.iOS, + $devicePlatformsConstants.Android, + $devicePlatformsConstants.iOS, ], }; const shouldMigrateResult = - await services.$migrateController.shouldMigrate(migrationData); + await $migrateController.shouldMigrate(migrationData); if (!shouldMigrateResult) { - const cliVersion = services.$staticConfig.version; - services.$logger.printMarkdown( + const cliVersion = $staticConfig.version; + $logger.printMarkdown( `__Project is compatible with NativeScript \`v${cliVersion}\`__`, ); return; } - await services.$migrateController.migrate(migrationData); + await $migrateController.migrate(migrationData); }, }); diff --git a/lib/commands/native-add.ts b/lib/commands/native-add.ts index 879c169ecb..6b995934aa 100644 --- a/lib/commands/native-add.ts +++ b/lib/commands/native-add.ts @@ -2,7 +2,11 @@ import * as fs from "fs"; import { EOL } from "os"; import * as path from "path"; import { IErrors } from "../common/declarations"; -import { CommandName, defineCommand } from "../common/define-command"; +import { + CommandContext, + CommandName, + defineCommand, +} from "../common/define-command"; import { inject } from "../common/di"; import { capitalizeFirstLetter } from "../common/utils"; import { IProjectData } from "../definitions/project"; @@ -14,40 +18,19 @@ import { IProjectData } from "../definitions/project"; */ type NativeAddLanguage = "java" | "kotlin" | "swift" | "objective-c"; -export interface INativeAddCommandServices { - $projectData: IProjectData; - $logger: ILogger; - $errors: IErrors; -} - -interface INativeAddLanguageCommandServices extends INativeAddCommandServices { - language: NativeAddLanguage; -} - -export function setupNativeAddCommand(): INativeAddCommandServices { - const services = { - $projectData: inject("projectData"), - $logger: inject("logger"), - $errors: inject("errors"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -function failWithUsage(services: INativeAddCommandServices): void { - services.$errors.failWithHelp( +function failWithUsage($errors: IErrors): void { + $errors.failWithHelp( "Usage: ns native add [swift|objective-c|java|kotlin] [class name]", ); } -function getIosSourcePathBase(services: INativeAddCommandServices): string { - const resources = services.$projectData.getAppResourcesDirectoryPath(); +function getIosSourcePathBase($projectData: IProjectData): string { + const resources = $projectData.getAppResourcesDirectoryPath(); return path.join(resources, "iOS", "src"); } -function getAndroidSourcePathBase(services: INativeAddCommandServices): string { - const resources = services.$projectData.getAppResourcesDirectoryPath(); +function getAndroidSourcePathBase($projectData: IProjectData): string { + const resources = $projectData.getAppResourcesDirectoryPath(); return path.join(resources, "Android", "src", "main", "java"); } @@ -104,10 +87,11 @@ class ${classSimpleName} { ); } -function checkAndUpdateGradleProperties( - services: INativeAddCommandServices, -): boolean { - const resources = services.$projectData.getAppResourcesDirectoryPath(); +function checkAndUpdateGradleProperties(ctx: CommandContext): boolean { + const $projectData = ctx.injector.get("projectData"); + const $logger = ctx.injector.get("logger"); + const $errors = ctx.injector.get("errors"); + const resources = $projectData.getAppResourcesDirectoryPath(); const filePath = path.join(resources, "Android", "gradle.properties"); @@ -120,7 +104,7 @@ function checkAndUpdateGradleProperties( const useKotlin = match[1]; if (useKotlin === "false") { - services.$errors.failWithHelp( + $errors.failWithHelp( "The useKotlin property is set to false. Stopping processing. Kotlin must be enabled in gradle.properties to use.", ); return false; @@ -131,41 +115,38 @@ function checkAndUpdateGradleProperties( } } else { fs.appendFileSync(filePath, `${EOL}useKotlin=true${EOL}`); - services.$logger.info( - 'Added "useKotlin=true" property to gradle.properties.', - ); + $logger.info('Added "useKotlin=true" property to gradle.properties.'); } } else { fs.writeFileSync(filePath, `useKotlin=true${EOL}`); - services.$logger.info( - 'Created gradle.properties with "useKotlin=true" property.', - ); + $logger.info('Created gradle.properties with "useKotlin=true" property.'); } return true; } -export function generateJavaKotlin( - services: INativeAddCommandServices, +function generateJavaKotlin( + ctx: CommandContext, className: string, extension: string, ): void { + const $projectData = ctx.injector.get("projectData"); + const $logger = ctx.injector.get("logger"); + const $errors = ctx.injector.get("errors"); const fileExt = extension == "java" ? extension : "kt"; const packageName = getPackageName(className); const classSimpleName = getClassSimpleName(className); const packagePath = path.join( - getAndroidSourcePathBase(services), + getAndroidSourcePathBase($projectData), ...packageName.split("."), ); const filePath = path.join(packagePath, `${classSimpleName}.${fileExt}`); if (fs.existsSync(filePath)) { - services.$errors.failWithHelp( - `${extension} file '${filePath}' already exists.`, - ); + $errors.failWithHelp(`${extension} file '${filePath}' already exists.`); return; } - if (extension == "kotlin" && !checkAndUpdateGradleProperties(services)) { + if (extension == "kotlin" && !checkAndUpdateGradleProperties(ctx)) { return; } @@ -176,7 +157,7 @@ export function generateJavaKotlin( fs.mkdirSync(packagePath, { recursive: true }); fs.writeFileSync(filePath, fileContent); - services.$logger.info( + $logger.info( `${capitalizeFirstLetter( extension, )} file '${filePath}' generated successfully.`, @@ -184,7 +165,7 @@ export function generateJavaKotlin( } function generateOrUpdateModuleMap( - services: INativeAddCommandServices, + $logger: ILogger, headerFileName: string, moduleMapPath: string, ): void { @@ -203,7 +184,7 @@ function generateOrUpdateModuleMap( // Module declaration already exists in the module map if (moduleMapContent.includes(headerDeclaration)) { // Header is already present in the module map - services.$logger.warn( + $logger.warn( `Header '${headerFileName}' is already added to the module map.`, ); return; @@ -223,28 +204,27 @@ function generateOrUpdateModuleMap( fs.writeFileSync(moduleMapPath, moduleMapContent); } - services.$logger.info( + $logger.info( `Module map '${moduleMapPath}' has been updated with the header '${headerFileName}'.`, ); } function generateObjectiveCFiles( - services: INativeAddCommandServices, + ctx: CommandContext, className: string, classFilePath: string, interfaceFilePath: string, ): boolean { + const $logger = ctx.injector.get("logger"); + const $errors = ctx.injector.get("errors"); + if (fs.existsSync(classFilePath)) { - services.$errors.failWithHelp( - `Error: File '${classFilePath}' already exists.`, - ); + $errors.failWithHelp(`Error: File '${classFilePath}' already exists.`); return false; } if (fs.existsSync(interfaceFilePath)) { - services.$errors.failWithHelp( - `Error: File '${interfaceFilePath}' already exists.`, - ); + $errors.failWithHelp(`Error: File '${interfaceFilePath}' already exists.`); return false; } @@ -269,32 +249,29 @@ function generateObjectiveCFiles( `; fs.writeFileSync(classFilePath, classContent); - services.$logger.trace( + $logger.trace( `Objective-C class file '${classFilePath}' generated successfully.`, ); fs.writeFileSync(interfaceFilePath, interfaceContent); - services.$logger.trace( + $logger.trace( `Objective-C interface file '${interfaceFilePath}' generated successfully.`, ); return true; } -export function generateObjectiveC( - services: INativeAddCommandServices, - className: string, -): void { - const iosSourceBase = getIosSourcePathBase(services); +function generateObjectiveC(ctx: CommandContext, className: string): void { + const $projectData = ctx.injector.get("projectData"); + const $logger = ctx.injector.get("logger"); + const iosSourceBase = getIosSourcePathBase($projectData); const classFilePath = path.join(iosSourceBase, `${className}.m`); const headerFilePath = path.join(iosSourceBase, `${className}.h`); - if ( - generateObjectiveCFiles(services, className, classFilePath, headerFilePath) - ) { + if (generateObjectiveCFiles(ctx, className, classFilePath, headerFilePath)) { // Modify/Generate moduleMap generateOrUpdateModuleMap( - services, + $logger, `${className}.h`, path.join(iosSourceBase, "module.modulemap"), ); @@ -302,19 +279,21 @@ export function generateObjectiveC( } function generateSwiftFile( - services: INativeAddCommandServices, + ctx: CommandContext, className: string, filePath: string, ): void { + const $logger = ctx.injector.get("logger"); + const $errors = ctx.injector.get("errors"); const directory = path.dirname(filePath); if (!fs.existsSync(directory)) { fs.mkdirSync(directory, { recursive: true }); - services.$logger.trace(`Created directory: '${directory}'.`); + $logger.trace(`Created directory: '${directory}'.`); } if (fs.existsSync(filePath)) { - services.$errors.failWithHelp(`Error: File '${filePath}' already exists.`); + $errors.failWithHelp(`Error: File '${filePath}' already exists.`); return; } @@ -328,26 +307,22 @@ import os; }`; fs.writeFileSync(filePath, content); - services.$logger.info(`Swift file '${filePath}' generated successfully.`); + $logger.info(`Swift file '${filePath}' generated successfully.`); } -export function generateSwift( - services: INativeAddCommandServices, - className: string, -): void { - const iosSourceBase = getIosSourcePathBase(services); +function generateSwift(ctx: CommandContext, className: string): void { + const $projectData = ctx.injector.get("projectData"); + const iosSourceBase = getIosSourcePathBase($projectData); const swiftFilePath = path.join(iosSourceBase, `${className}.swift`); - generateSwiftFile(services, className, swiftFilePath); + generateSwiftFile(ctx, className, swiftFilePath); } const generators: Record< NativeAddLanguage, - (services: INativeAddCommandServices, className: string) => void + (ctx: CommandContext, className: string) => void > = { - java: (services, className) => - generateJavaKotlin(services, className, "java"), - kotlin: (services, className) => - generateJavaKotlin(services, className, "kotlin"), + java: (ctx, className) => generateJavaKotlin(ctx, className, "java"), + kotlin: (ctx, className) => generateJavaKotlin(ctx, className, "kotlin"), swift: generateSwift, "objective-c": generateObjectiveC, }; @@ -357,13 +332,15 @@ export const nativeAddCommandDefinition = defineCommand({ description: "Commands to add native files to the application placing them in the correct directory.", arguments: "any", - setup: setupNativeAddCommand, - canExecute(context, services: INativeAddCommandServices): boolean { - failWithUsage(services); + setup() { + inject("projectData").initializeProjectData(); + }, + canExecute(): boolean { + failWithUsage(inject("errors")); return false; }, - run(context, services: INativeAddCommandServices): void { - failWithUsage(services); + run(): void { + failWithUsage(inject("errors")); }, }); @@ -377,21 +354,20 @@ const defineNativeAddLanguageCommand = ( // The one usage message answers both too few and too many arguments; a // declared argument spec would report them with two different ones. arguments: "any", - setup(): INativeAddLanguageCommandServices { - return { - ...setupNativeAddCommand(), - language, - }; + setup() { + inject("projectData").initializeProjectData(); }, - canExecute(context, services: INativeAddLanguageCommandServices): boolean { + canExecute(context): boolean { + const $errors = inject("errors"); + if (context.args.length !== 1) { - failWithUsage(services); + failWithUsage($errors); } return true; }, - run(context, services: INativeAddLanguageCommandServices): void { - generators[services.language](services, context.args[0]); + run(context): void { + generators[language](context, context.args[0]); }, }); diff --git a/lib/commands/open.ts b/lib/commands/open.ts index fe57eabf14..9d614ab2ba 100644 --- a/lib/commands/open.ts +++ b/lib/commands/open.ts @@ -4,6 +4,7 @@ import * as path from "path"; import { IChildProcess, IXcodeSelectService } from "../common/declarations"; import { booleanOption, + CommandContext, CommandOptionsSchema, defineCommand, } from "../common/define-command"; @@ -14,47 +15,7 @@ import { IOptions } from "../declarations"; import { IProjectData } from "../definitions/project"; import type { IOSProjectService } from "../services/ios-project-service"; -export interface IOpenXcodeProjectServices { - $iOSProjectService: IOSProjectService; - $logger: ILogger; - $childProcess: IChildProcess; - $projectData: IProjectData; - $xcodeSelectService: IXcodeSelectService; - $xcodebuildArgsService: IXcodebuildArgsService; -} - -export interface IOpenAndroidStudioServices { - $logger: ILogger; - $liveSyncCommandHelper: ILiveSyncCommandHelper; - $childProcess: IChildProcess; - $projectData: IProjectData; -} - -export function injectOpenXcodeProjectServices(): IOpenXcodeProjectServices { - return { - $iOSProjectService: inject("iOSProjectService"), - $logger: inject("logger"), - $childProcess: inject("childProcess"), - $projectData: inject("projectData"), - $xcodeSelectService: inject("xcodeSelectService"), - $xcodebuildArgsService: inject( - "xcodebuildArgsService", - ), - }; -} - -export function injectOpenAndroidStudioServices(): IOpenAndroidStudioServices { - return { - $logger: inject("logger"), - $liveSyncCommandHelper: inject( - "liveSyncCommandHelper", - ), - $childProcess: inject("childProcess"), - $projectData: inject("projectData"), - }; -} - -export function getAndroidStudioPath(): string | null { +function getAndroidStudioPath(): string | null { const os = currentPlatform(); if (os === "darwin") { @@ -87,14 +48,21 @@ export function getAndroidStudioPath(): string | null { * while `ns run` owns stdin and has to hand it back after `prepare` consumed * it, a one-shot CLI command exits instead. */ -export async function openAndroidStudioProject( - services: IOpenAndroidStudioServices, +async function openAndroidStudioProject( + context: CommandContext, platform: string, isInteractive: boolean, ): Promise { - services.$liveSyncCommandHelper.validatePlatform(platform); - services.$projectData.initializeProjectData(); - const androidDir = `${services.$projectData.platformsDir}/android`; + const $childProcess = context.injector.get("childProcess"); + const $liveSyncCommandHelper = context.injector.get( + "liveSyncCommandHelper", + ); + const $logger = context.injector.get("logger"); + const $projectData = context.injector.get("projectData"); + + $liveSyncCommandHelper.validatePlatform(platform); + $projectData.initializeProjectData(); + const androidDir = `${$projectData.platformsDir}/android`; if (!fs.existsSync(androidDir)) { const prepareCommand = injector.resolveCommand("prepare") as ICommand; @@ -112,7 +80,7 @@ export async function openAndroidStudioProject( studioPath = getAndroidStudioPath(); if (!studioPath) { - services.$logger.error( + $logger.error( "Android Studio is not installed, or is not in a standard location. Use NATIVESCRIPT_ANDROID_STUDIO_PATH.", ); return; @@ -121,34 +89,42 @@ export async function openAndroidStudioProject( const os = currentPlatform(); if (os === "darwin") { - services.$childProcess.exec(`open -a "${studioPath}" ${androidDir}`); + $childProcess.exec(`open -a "${studioPath}" ${androidDir}`); } else if (os === "win32") { - const child = services.$childProcess.spawn(studioPath, [androidDir], { + const child = $childProcess.spawn(studioPath, [androidDir], { detached: true, stdio: "ignore", }); child.unref(); } else if (os === "linux") { - services.$childProcess.exec(`${studioPath} ${androidDir}`); + $childProcess.exec(`${studioPath} ${androidDir}`); } } -export async function openXcodeProject( - services: IOpenXcodeProjectServices, +async function openXcodeProject( + context: CommandContext, platformDirName: string, isInteractive: boolean, ): Promise { + const $childProcess = context.injector.get("childProcess"); + const $iOSProjectService = + context.injector.get("iOSProjectService"); + const $logger = context.injector.get("logger"); + const $projectData = context.injector.get("projectData"); + const $xcodeSelectService = + context.injector.get("xcodeSelectService"); + const $xcodebuildArgsService = context.injector.get( + "xcodebuildArgsService", + ); + const os = currentPlatform(); if (os !== "darwin") { - services.$logger.error("Opening a project in XCode requires macOS."); + $logger.error("Opening a project in XCode requires macOS."); return; } - services.$projectData.initializeProjectData(); - const platformDir = path.resolve( - services.$projectData.platformsDir, - platformDirName, - ); + $projectData.initializeProjectData(); + const platformDir = path.resolve($projectData.platformsDir, platformDirName); if (!fs.existsSync(platformDir)) { const prepareCommand = injector.resolveCommand("prepare") as ICommand; @@ -158,33 +134,31 @@ export async function openXcodeProject( process.stdin.resume(); } } - const platformData = services.$iOSProjectService.getPlatformData( - services.$projectData, - ); - const xcprojectFile = services.$xcodebuildArgsService.getXcodeProjectArgs( + const platformData = $iOSProjectService.getPlatformData($projectData); + const xcprojectFile = $xcodebuildArgsService.getXcodeProjectArgs( platformData, - services.$projectData, + $projectData, )[1]; if (fs.existsSync(xcprojectFile)) { - services.$xcodeSelectService + $xcodeSelectService .getDeveloperDirectoryPath() - .then(() => services.$childProcess.exec(`open ${xcprojectFile}`, {})) + .then(() => $childProcess.exec(`open ${xcprojectFile}`, {})) .catch((e) => { - services.$logger.error(e.message); + $logger.error(e.message); }); } else { - services.$logger.error(`Unable to open project file: ${xcprojectFile}`); + $logger.error(`Unable to open project file: ${xcprojectFile}`); } } -export async function openVisionOSProject( - services: IOpenXcodeProjectServices, +async function openVisionOSProject( + context: CommandContext, $options: IOptions, isInteractive: boolean, ): Promise { $options.platformOverride = "visionOS"; - await openXcodeProject(services, "visionos", isInteractive); + await openXcodeProject(context, "visionos", isInteractive); $options.platformOverride = null; } @@ -216,16 +190,9 @@ export const iosOpenCommand = defineCommand({ description: "Opens the project in Xcode.", options: openCommandOptions, arguments: "none", - setup() { - return { - ...injectOpenXcodeProjectServices(), - $options: inject("options"), - }; - }, - async run(context, services): Promise { - await withoutWatch(services.$options, () => - openXcodeProject(services, "ios", false), - ); + async run(context): Promise { + const $options = inject("options"); + await withoutWatch($options, () => openXcodeProject(context, "ios", false)); }, }); @@ -234,15 +201,10 @@ export const visionOpenCommand = defineCommand({ description: "Opens the visionOS project in Xcode.", options: openCommandOptions, arguments: "none", - setup() { - return { - ...injectOpenXcodeProjectServices(), - $options: inject("options"), - }; - }, - async run(context, services): Promise { - await withoutWatch(services.$options, () => - openVisionOSProject(services, services.$options, false), + async run(context): Promise { + const $options = inject("options"); + await withoutWatch($options, () => + openVisionOSProject(context, $options, false), ); }, }); @@ -252,15 +214,10 @@ export const androidOpenCommand = defineCommand({ description: "Opens the project in Android Studio.", options: openCommandOptions, arguments: "none", - setup() { - return { - ...injectOpenAndroidStudioServices(), - $options: inject("options"), - }; - }, - async run(context, services): Promise { - await withoutWatch(services.$options, () => - openAndroidStudioProject(services, "Android", false), + async run(context): Promise { + const $options = inject("options"); + await withoutWatch($options, () => + openAndroidStudioProject(context, "Android", false), ); }, }); diff --git a/lib/commands/platform-clean.ts b/lib/commands/platform-clean.ts index 887dc4bdd1..873558cac9 100644 --- a/lib/commands/platform-clean.ts +++ b/lib/commands/platform-clean.ts @@ -8,9 +8,8 @@ import { import { IPlatformEnvironmentRequirements } from "../definitions/platform"; import { IErrors } from "../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -19,98 +18,71 @@ const platformCleanCommandOptions = { frameworkPath: stringOption(), } satisfies CommandOptionsSchema; -export type PlatformCleanCommandContext = CommandContext< - typeof platformCleanCommandOptions ->; - -export interface IPlatformCleanCommandServices { - $errors: IErrors; - $options: IOptions; - $platformCommandHelper: IPlatformCommandHelper; - $platformValidationService: IPlatformValidationService; - $platformEnvironmentRequirements: IPlatformEnvironmentRequirements; - $projectData: IProjectData; -} - -export function setupPlatformCleanCommand(): IPlatformCleanCommandServices { - const services = { - $errors: inject("errors"), - $options: inject("options"), - $platformCommandHelper: inject( - "platformCommandHelper", - ), - $platformValidationService: inject( - "platformValidationService", - ), - $platformEnvironmentRequirements: inject( - "platformEnvironmentRequirements", - ), - $projectData: inject("projectData"), - }; - services.$projectData.initializeProjectData(); - - return services; -} +export class PlatformCleanCommand extends Command({ + name: "platform|clean", + description: "Removes and adds again the selected platform.", + options: platformCleanCommandOptions, + arguments: "any", +}) { + private $errors = inject("errors"); + private $options = inject("options"); + private $platformCommandHelper = inject( + "platformCommandHelper", + ); + private $platformValidationService = inject( + "platformValidationService", + ); + private $platformEnvironmentRequirements = + inject("platformEnvironmentRequirements"); + private $projectData = inject("projectData"); -export async function canExecutePlatformCleanCommand( - context: PlatformCleanCommandContext, - services: IPlatformCleanCommandServices, -): Promise { - const args = context.args; - if (!args || args.length === 0) { - services.$errors.failWithHelp( - "No platform specified. Please specify a platform to clean.", - ); + constructor() { + super(); + this.$projectData.initializeProjectData(); } - _.each(args, (platform) => { - services.$platformValidationService.validatePlatform( - platform, - services.$projectData, - ); - }); + public async canExecute(): Promise { + const args = this.args; + if (!args || args.length === 0) { + this.$errors.failWithHelp( + "No platform specified. Please specify a platform to clean.", + ); + } - for (const platform of args) { - services.$platformValidationService.validatePlatformInstalled( - platform, - services.$projectData, - ); + _.each(args, (platform) => { + this.$platformValidationService.validatePlatform( + platform, + this.$projectData, + ); + }); - const currentRuntimeVersion = - services.$platformCommandHelper.getCurrentPlatformVersion( + for (const platform of args) { + this.$platformValidationService.validatePlatformInstalled( platform, - services.$projectData, + this.$projectData, ); - await services.$platformEnvironmentRequirements.checkEnvironmentRequirements( - { + + const currentRuntimeVersion = + this.$platformCommandHelper.getCurrentPlatformVersion( + platform, + this.$projectData, + ); + await this.$platformEnvironmentRequirements.checkEnvironmentRequirements({ platform, - projectDir: services.$projectData.projectDir, + projectDir: this.$projectData.projectDir, runtimeVersion: currentRuntimeVersion, - options: services.$options, - }, - ); - } + options: this.$options, + }); + } - return true; -} + return true; + } -export async function runPlatformCleanCommand( - context: PlatformCleanCommandContext, - services: IPlatformCleanCommandServices, -): Promise { - await services.$platformCommandHelper.cleanPlatforms( - context.args, - services.$projectData, - context.options.frameworkPath, - ); + public async run(): Promise { + await this.$platformCommandHelper.cleanPlatforms( + this.args, + this.$projectData, + this.options.frameworkPath, + ); + } } - -export const platformCleanCommandDefinition = defineCommand({ - name: "platform|clean", - description: "Removes and adds again the selected platform.", - options: platformCleanCommandOptions, - arguments: "any", - setup: setupPlatformCleanCommand, - canExecute: canExecutePlatformCleanCommand, - run: runPlatformCleanCommand, -}); diff --git a/lib/commands/plugin/add-plugin.ts b/lib/commands/plugin/add-plugin.ts index fda3acf033..96b3d9dab4 100644 --- a/lib/commands/plugin/add-plugin.ts +++ b/lib/commands/plugin/add-plugin.ts @@ -2,58 +2,42 @@ import * as _ from "lodash"; import { IProjectData } from "../../definitions/project"; import { IPluginsService, IPluginData } from "../../definitions/plugins"; import { IErrors } from "../../common/declarations"; -import { CommandContext, defineCommand } from "../../common/define-command"; +import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export interface IAddPluginCommandServices { - $pluginsService: IPluginsService; - $projectData: IProjectData; - $errors: IErrors; -} - -export function setupAddPluginCommand(): IAddPluginCommandServices { - const services = { - $pluginsService: inject("pluginsService"), - $projectData: inject("projectData"), - $errors: inject("errors"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export async function canExecuteAddPluginCommand( - context: CommandContext, - services: IAddPluginCommandServices, -): Promise { - if (!context.args[0]) { - services.$errors.failWithHelp("You must specify plugin name."); - } - - const installedPlugins = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); - const pluginName = context.args[0].toLowerCase(); - if ( - _.some( - installedPlugins, - (plugin: IPluginData) => plugin.name.toLowerCase() === pluginName, - ) - ) { - services.$errors.fail(`Plugin "${pluginName}" is already installed.`); - } - - return true; -} - export const addPluginCommandDefinition = defineCommand({ name: ["plugin|add", "plugin|install"], description: "Installs the specified plugin and its dependencies.", arguments: "any", - setup: setupAddPluginCommand, - canExecute: canExecuteAddPluginCommand, - run(context, services): Promise { - return services.$pluginsService.add(context.args[0], services.$projectData); + async canExecute(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + const $errors = inject("errors"); + $projectData.initializeProjectData(); + + if (!context.args[0]) { + $errors.failWithHelp("You must specify plugin name."); + } + + const installedPlugins = + await $pluginsService.getAllInstalledPlugins($projectData); + const pluginName = context.args[0].toLowerCase(); + if ( + _.some( + installedPlugins, + (plugin: IPluginData) => plugin.name.toLowerCase() === pluginName, + ) + ) { + $errors.fail(`Plugin "${pluginName}" is already installed.`); + } + + return true; + }, + run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + return $pluginsService.add(context.args[0], $projectData); }, }); diff --git a/lib/commands/plugin/build-plugin.ts b/lib/commands/plugin/build-plugin.ts index 0e5f94c9d1..9c43357b14 100644 --- a/lib/commands/plugin/build-plugin.ts +++ b/lib/commands/plugin/build-plugin.ts @@ -7,9 +7,8 @@ import { } from "../../definitions/android-plugin-migrator"; import { IErrors, IFileSystem } from "../../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../../common/define-command"; import { inject } from "../../common/di"; @@ -21,115 +20,88 @@ const buildPluginCommandOptions = { gradleArgs: stringOption(), } satisfies CommandOptionsSchema; -export type BuildPluginCommandContext = CommandContext< - typeof buildPluginCommandOptions ->; - -export interface IBuildPluginCommandServices { - pluginProjectPath: string; - $androidPluginBuildService: IAndroidPluginBuildService; - $errors: IErrors; - $logger: ILogger; - $fs: IFileSystem; - $tempService: ITempService; -} - -export function setupBuildPluginCommand( - context: BuildPluginCommandContext, -): IBuildPluginCommandServices { - return { - pluginProjectPath: path.resolve(context.options.path || "."), - $androidPluginBuildService: inject( - "androidPluginBuildService", - ), - $errors: inject("errors"), - $logger: inject("logger"), - $fs: inject("fs"), - $tempService: inject("tempService"), - }; -} +export class BuildPluginCommand extends Command({ + name: "plugin|build", + description: + "Builds the Android parts of a NativeScript plugin into an `.aar`.", + options: buildPluginCommandOptions, + arguments: "any", +}) { + private $androidPluginBuildService = inject( + "androidPluginBuildService", + ); + private $errors = inject("errors"); + private $logger = inject("logger"); + private $fs = inject("fs"); + private $tempService = inject("tempService"); + + private pluginProjectPath = path.resolve(this.options.path || "."); + + public async canExecute(): Promise { + if ( + !this.$fs.exists( + path.join( + this.pluginProjectPath, + constants.PLATFORMS_DIR_NAME, + "android", + ), + ) + ) { + this.$errors.fail( + "No plugin found at the current directory, or the plugin does not need to have its platforms/android components built into an `.aar`.", + ); + } -export async function canExecuteBuildPluginCommand( - context: BuildPluginCommandContext, - services: IBuildPluginCommandServices, -): Promise { - if ( - !services.$fs.exists( - path.join( - services.pluginProjectPath, - constants.PLATFORMS_DIR_NAME, - "android", - ), - ) - ) { - services.$errors.fail( - "No plugin found at the current directory, or the plugin does not need to have its platforms/android components built into an `.aar`.", - ); + return true; } - return true; -} - -export async function runBuildPluginCommand( - context: BuildPluginCommandContext, - services: IBuildPluginCommandServices, -): Promise { - const platformsAndroidPath = path.join( - services.pluginProjectPath, - constants.PLATFORMS_DIR_NAME, - "android", - ); - let pluginName = ""; + public async run(): Promise { + const platformsAndroidPath = path.join( + this.pluginProjectPath, + constants.PLATFORMS_DIR_NAME, + "android", + ); + let pluginName = ""; - const pluginPackageJsonPath = path.join( - services.pluginProjectPath, - constants.PACKAGE_JSON_FILE_NAME, - ); + const pluginPackageJsonPath = path.join( + this.pluginProjectPath, + constants.PACKAGE_JSON_FILE_NAME, + ); - if (services.$fs.exists(pluginPackageJsonPath)) { - const packageJsonContents = services.$fs.readJson(pluginPackageJsonPath); + if (this.$fs.exists(pluginPackageJsonPath)) { + const packageJsonContents = this.$fs.readJson(pluginPackageJsonPath); - if (packageJsonContents && packageJsonContents["name"]) { - pluginName = packageJsonContents["name"]; + if (packageJsonContents && packageJsonContents["name"]) { + pluginName = packageJsonContents["name"]; + } } - } - - const tempAndroidProject = - await services.$tempService.mkdirSync("android-project"); - const options: IPluginBuildOptions = { - gradlePath: context.options.gradlePath, - gradleArgs: context.options.gradleArgs, - aarOutputDir: platformsAndroidPath, - platformsAndroidDirPath: platformsAndroidPath, - pluginName: pluginName, - tempPluginDirPath: tempAndroidProject, - }; - - const androidPluginBuildResult = - await services.$androidPluginBuildService.buildAar(options); - - if (androidPluginBuildResult) { - services.$logger.info( - `${pluginName} successfully built aar at ${platformsAndroidPath}.${EOL}Temporary Android project can be found at ${tempAndroidProject}.`, - ); - } + const tempAndroidProject = + await this.$tempService.mkdirSync("android-project"); + + const options: IPluginBuildOptions = { + gradlePath: this.options.gradlePath, + gradleArgs: this.options.gradleArgs, + aarOutputDir: platformsAndroidPath, + platformsAndroidDirPath: platformsAndroidPath, + pluginName: pluginName, + tempPluginDirPath: tempAndroidProject, + }; + + const androidPluginBuildResult = + await this.$androidPluginBuildService.buildAar(options); + + if (androidPluginBuildResult) { + this.$logger.info( + `${pluginName} successfully built aar at ${platformsAndroidPath}.${EOL}Temporary Android project can be found at ${tempAndroidProject}.`, + ); + } - const migratedIncludeGradle = - services.$androidPluginBuildService.migrateIncludeGradle(options); + const migratedIncludeGradle = + this.$androidPluginBuildService.migrateIncludeGradle(options); - if (migratedIncludeGradle) { - services.$logger.info(`${pluginName} include gradle updated.`); + if (migratedIncludeGradle) { + this.$logger.info(`${pluginName} include gradle updated.`); + } } } - -export const buildPluginCommandDefinition = defineCommand({ - name: "plugin|build", - description: - "Builds the Android parts of a NativeScript plugin into an `.aar`.", - options: buildPluginCommandOptions, - arguments: "any", - setup: setupBuildPluginCommand, - canExecute: canExecuteBuildPluginCommand, - run: runBuildPluginCommand, -}); diff --git a/lib/commands/plugin/create-plugin.ts b/lib/commands/plugin/create-plugin.ts index 71bb45340a..a350d995a8 100644 --- a/lib/commands/plugin/create-plugin.ts +++ b/lib/commands/plugin/create-plugin.ts @@ -3,9 +3,8 @@ import { isInteractive } from "../../common/helpers"; import { INodePackageManager } from "../../declarations"; import { IErrors, IFileSystem, IChildProcess } from "../../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../../common/define-command"; import { inject } from "../../common/di"; @@ -31,246 +30,206 @@ const createPluginCommandOptions = { includeAngularDemo: stringOption(), } satisfies CommandOptionsSchema; -export type CreatePluginCommandContext = CommandContext< - typeof createPluginCommandOptions ->; - -export interface ICreatePluginCommandServices { - $errors: IErrors; - $terminalSpinnerService: ITerminalSpinnerService; - $logger: ILogger; - $pacoteService: IPacoteService; - $fs: IFileSystem; - $childProcess: IChildProcess; - $prompter: IPrompter; - $packageManager: INodePackageManager; -} - -export function setupCreatePluginCommand(): ICreatePluginCommandServices { - return { - $errors: inject("errors"), - $terminalSpinnerService: inject( - "terminalSpinnerService", - ), - $logger: inject("logger"), - $pacoteService: inject("pacoteService"), - $fs: inject("fs"), - $childProcess: inject("childProcess"), - $prompter: inject("prompter"), - $packageManager: inject("packageManager"), - }; -} - -function ensurePackageDir( - services: ICreatePluginCommandServices, - projectDir: string, -): void { - services.$fs.createDirectory(projectDir); +export class CreatePluginCommand extends Command({ + name: "plugin|create", + description: "Creates a new project for a NativeScript plugin.", + options: createPluginCommandOptions, + arguments: "any", +}) { + private $errors = inject("errors"); + private $terminalSpinnerService = inject( + "terminalSpinnerService", + ); + private $logger = inject("logger"); + private $pacoteService = inject("pacoteService"); + private $fs = inject("fs"); + private $childProcess = inject("childProcess"); + private $prompter = inject("prompter"); + private $packageManager = inject("packageManager"); + + public canExecute(): boolean { + if (!this.args[0]) { + this.$errors.failWithHelp("You must specify the plugin repository name."); + } - if (services.$fs.exists(projectDir) && !services.$fs.isEmptyDir(projectDir)) { - services.$errors.fail(PATH_ALREADY_EXISTS_MESSAGE_TEMPLATE, projectDir); + return true; } -} -async function downloadPackage( - services: ICreatePluginCommandServices, - selectedTemplate: string, - projectDir: string, -): Promise { - if (selectedTemplate) { - services.$logger.printMarkdown( - "Make sure your custom template is compatible with the Plugin Seed at https://github.com/NativeScript/nativescript-plugin-seed/", - ); - } else { - services.$logger.printMarkdown( - "Downloading the latest version of NativeScript Plugin Seed...", + public async run(): Promise { + const pluginRepoName = this.args[0]; + const pathToProject = this.options.path; + const selectedTemplate = this.options.template; + const selectedPath = path.resolve(pathToProject || "."); + const projectDir = path.join(selectedPath, pluginRepoName); + + // Must be out of try catch block, because will throw error if folder alredy exists and we don't want to delete it. + this.ensurePackageDir(projectDir); + + try { + await this.downloadPackage(selectedTemplate, projectDir); + await this.setupSeed(projectDir, pluginRepoName); + } catch (err) { + // The call to ensurePackageDir() above will throw error if folder alredy exists, so it is safe to delete here. + this.$fs.deleteDirectory(projectDir); + throw err; + } + + this.$logger.printMarkdown( + "Solution for `%s` was successfully created.", + pluginRepoName, ); } - const spinner = services.$terminalSpinnerService.createSpinner(); - const packageToInstall = - selectedTemplate || - "https://github.com/NativeScript/nativescript-plugin-seed/archive/master.tar.gz"; - try { - spinner.start(); - await services.$pacoteService.extractPackage(packageToInstall, projectDir); - } finally { - spinner.stop(); - } -} + private ensurePackageDir(projectDir: string): void { + this.$fs.createDirectory(projectDir); -async function getGitHubUsername( - services: ICreatePluginCommandServices, - gitHubUsername: string, -): Promise { - if (!gitHubUsername) { - gitHubUsername = "NativeScriptDeveloper"; - if (isInteractive()) { - gitHubUsername = await services.$prompter.getString(USER_MESSAGE, { - allowEmpty: false, - defaultAction: () => { - return gitHubUsername; - }, - }); + if (this.$fs.exists(projectDir) && !this.$fs.isEmptyDir(projectDir)) { + this.$errors.fail(PATH_ALREADY_EXISTS_MESSAGE_TEMPLATE, projectDir); } } - return gitHubUsername; -} - -async function getPluginNameSource( - services: ICreatePluginCommandServices, - pluginNameSource: string, - pluginRepoName: string, -): Promise { - if (!pluginNameSource) { - // remove nativescript- prefix for naming plugin files - const prefix = "nativescript-"; - pluginNameSource = pluginRepoName.toLowerCase().startsWith(prefix) - ? pluginRepoName.slice(prefix.length, pluginRepoName.length) - : pluginRepoName; - if (isInteractive()) { - pluginNameSource = await services.$prompter.getString(NAME_MESSAGE, { - allowEmpty: false, - defaultAction: () => { - return pluginNameSource; - }, - }); + private async downloadPackage( + selectedTemplate: string, + projectDir: string, + ): Promise { + if (selectedTemplate) { + this.$logger.printMarkdown( + "Make sure your custom template is compatible with the Plugin Seed at https://github.com/NativeScript/nativescript-plugin-seed/", + ); + } else { + this.$logger.printMarkdown( + "Downloading the latest version of NativeScript Plugin Seed...", + ); } - } - - return pluginNameSource; -} -async function getShouldIncludeDemoResult( - services: ICreatePluginCommandServices, - includeDemoOption: string, - message: string, -): Promise { - let shouldIncludeDemo = !!includeDemoOption; - if (!includeDemoOption && isInteractive()) { - shouldIncludeDemo = await services.$prompter.confirm(message, () => { - return true; - }); + const spinner = this.$terminalSpinnerService.createSpinner(); + const packageToInstall = + selectedTemplate || + "https://github.com/NativeScript/nativescript-plugin-seed/archive/master.tar.gz"; + try { + spinner.start(); + await this.$pacoteService.extractPackage(packageToInstall, projectDir); + } finally { + spinner.stop(); + } } - return shouldIncludeDemo ? "y" : "n"; -} - -async function setupSeed( - context: CreatePluginCommandContext, - services: ICreatePluginCommandServices, - projectDir: string, - pluginRepoName: string, -): Promise { - services.$logger.printMarkdown( - "Executing initial plugin configuration script...", - ); + private async getGitHubUsername(gitHubUsername: string): Promise { + if (!gitHubUsername) { + gitHubUsername = "NativeScriptDeveloper"; + if (isInteractive()) { + gitHubUsername = await this.$prompter.getString(USER_MESSAGE, { + allowEmpty: false, + defaultAction: () => { + return gitHubUsername; + }, + }); + } + } - const config = context.options; - const spinner = services.$terminalSpinnerService.createSpinner(); - const cwd = path.join(projectDir, "src"); - try { - spinner.start(); - const npmOptions: any = { silent: true }; - await services.$packageManager.install(cwd, cwd, npmOptions); - } finally { - spinner.stop(); + return gitHubUsername; } - const gitHubUsername = await getGitHubUsername(services, config.username); - const pluginNameSource = await getPluginNameSource( - services, - config.pluginName, - pluginRepoName, - ); - const includeTypescriptDemo = await getShouldIncludeDemoResult( - services, - config.includeTypeScriptDemo, - INCLUDE_TYPESCRIPT_DEMO_MESSAGE, - ); - const includeAngularDemo = await getShouldIncludeDemoResult( - services, - config.includeAngularDemo, - INCLUDE_ANGULAR_DEMO_MESSAGE, - ); + private async getPluginNameSource( + pluginNameSource: string, + pluginRepoName: string, + ): Promise { + if (!pluginNameSource) { + // remove nativescript- prefix for naming plugin files + const prefix = "nativescript-"; + pluginNameSource = pluginRepoName.toLowerCase().startsWith(prefix) + ? pluginRepoName.slice(prefix.length, pluginRepoName.length) + : pluginRepoName; + if (isInteractive()) { + pluginNameSource = await this.$prompter.getString(NAME_MESSAGE, { + allowEmpty: false, + defaultAction: () => { + return pluginNameSource; + }, + }); + } + } - if ( - !isInteractive() && - (!config.username || - !config.pluginName || - !config.includeAngularDemo || - !config.includeTypeScriptDemo) - ) { - services.$logger.printMarkdown( - "Using default values for plugin creation options since your shell is not interactive.", - ); + return pluginNameSource; } - // run postclone script manually and kill it if it takes more than 10 sec - const pathToPostCloneScript = path.join("scripts", "postclone"); - const params = [ - pathToPostCloneScript, - `gitHubUsername=${gitHubUsername}`, - `pluginName=${pluginNameSource}`, - "initGit=y", - `includeTypeScriptDemo=${includeTypescriptDemo}`, - `includeAngularDemo=${includeAngularDemo}`, - ]; + private async getShouldIncludeDemoResult( + includeDemoOption: string, + message: string, + ): Promise { + let shouldIncludeDemo = !!includeDemoOption; + if (!includeDemoOption && isInteractive()) { + shouldIncludeDemo = await this.$prompter.confirm(message, () => { + return true; + }); + } - const outputScript = await services.$childProcess.spawnFromEvent( - process.execPath, - params, - "close", - { stdio: "inherit", cwd, timeout: 10000 }, - ); - if (outputScript && outputScript.stdout) { - services.$logger.printMarkdown(outputScript.stdout); + return shouldIncludeDemo ? "y" : "n"; } -} - -export async function runCreatePluginCommand( - context: CreatePluginCommandContext, - services: ICreatePluginCommandServices, -): Promise { - const pluginRepoName = context.args[0]; - const pathToProject = context.options.path; - const selectedTemplate = context.options.template; - const selectedPath = path.resolve(pathToProject || "."); - const projectDir = path.join(selectedPath, pluginRepoName); - // Must be out of try catch block, because will throw error if folder alredy exists and we don't want to delete it. - ensurePackageDir(services, projectDir); + private async setupSeed( + projectDir: string, + pluginRepoName: string, + ): Promise { + this.$logger.printMarkdown( + "Executing initial plugin configuration script...", + ); - try { - await downloadPackage(services, selectedTemplate, projectDir); - await setupSeed(context, services, projectDir, pluginRepoName); - } catch (err) { - // The call to ensurePackageDir() above will throw error if folder alredy exists, so it is safe to delete here. - services.$fs.deleteDirectory(projectDir); - throw err; - } + const config = this.options; + const spinner = this.$terminalSpinnerService.createSpinner(); + const cwd = path.join(projectDir, "src"); + try { + spinner.start(); + await this.$packageManager.install(cwd, cwd, { silent: true }); + } finally { + spinner.stop(); + } - services.$logger.printMarkdown( - "Solution for `%s` was successfully created.", - pluginRepoName, - ); -} + const gitHubUsername = await this.getGitHubUsername(config.username); + const pluginNameSource = await this.getPluginNameSource( + config.pluginName, + pluginRepoName, + ); + const includeTypescriptDemo = await this.getShouldIncludeDemoResult( + config.includeTypeScriptDemo, + INCLUDE_TYPESCRIPT_DEMO_MESSAGE, + ); + const includeAngularDemo = await this.getShouldIncludeDemoResult( + config.includeAngularDemo, + INCLUDE_ANGULAR_DEMO_MESSAGE, + ); -export const createPluginCommandDefinition = defineCommand({ - name: "plugin|create", - description: "Creates a new project for a NativeScript plugin.", - options: createPluginCommandOptions, - arguments: "any", - setup: setupCreatePluginCommand, - canExecute(context, services): boolean { - if (!context.args[0]) { - services.$errors.failWithHelp( - "You must specify the plugin repository name.", + if ( + !isInteractive() && + (!config.username || + !config.pluginName || + !config.includeAngularDemo || + !config.includeTypeScriptDemo) + ) { + this.$logger.printMarkdown( + "Using default values for plugin creation options since your shell is not interactive.", ); } - return true; - }, - run: runCreatePluginCommand, -}); + // run postclone script manually and kill it if it takes more than 10 sec + const pathToPostCloneScript = path.join("scripts", "postclone"); + const params = [ + pathToPostCloneScript, + `gitHubUsername=${gitHubUsername}`, + `pluginName=${pluginNameSource}`, + "initGit=y", + `includeTypeScriptDemo=${includeTypescriptDemo}`, + `includeAngularDemo=${includeAngularDemo}`, + ]; + + const outputScript = await this.$childProcess.spawnFromEvent( + process.execPath, + params, + "close", + { stdio: "inherit", cwd, timeout: 10000 }, + ); + if (outputScript && outputScript.stdout) { + this.$logger.printMarkdown(outputScript.stdout); + } + } +} diff --git a/lib/commands/plugin/list-plugins.ts b/lib/commands/plugin/list-plugins.ts index 771a3f595e..4341028f76 100644 --- a/lib/commands/plugin/list-plugins.ts +++ b/lib/commands/plugin/list-plugins.ts @@ -9,23 +9,6 @@ import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; import { color } from "../../color"; -export interface IListPluginsCommandServices { - $pluginsService: IPluginsService; - $projectData: IProjectData; - $logger: ILogger; -} - -export function setupListPluginsCommand(): IListPluginsCommandServices { - const services = { - $pluginsService: inject("pluginsService"), - $projectData: inject("projectData"), - $logger: inject("logger"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - function createTableCells(items: IBasePluginData[]): string[][] { return items.map((item) => [item.name, item.version]); } @@ -34,12 +17,17 @@ export const listPluginsCommandDefinition = defineCommand({ name: "plugin|*list", description: "Lists all installed plugins.", arguments: "none", - setup: setupListPluginsCommand, - async run(context, services): Promise { + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); + }, + async run(): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + const $logger = inject("logger"); const installedPlugins: IPackageJsonDepedenciesResult = - services.$pluginsService.getDependenciesFromPackageJson( - services.$projectData.projectDir, - ); + $pluginsService.getDependenciesFromPackageJson($projectData.projectDir); const headers: string[] = ["Plugin", "Version"]; const dependenciesData: string[][] = createTableCells( @@ -47,8 +35,8 @@ export const listPluginsCommandDefinition = defineCommand({ ); const dependenciesTable: any = createTable(headers, dependenciesData); - services.$logger.info("Dependencies:"); - services.$logger.info(dependenciesTable.toString()); + $logger.info("Dependencies:"); + $logger.info(dependenciesTable.toString()); if ( installedPlugins.devDependencies && @@ -63,10 +51,10 @@ export const listPluginsCommandDefinition = defineCommand({ devDependenciesData, ); - services.$logger.info("Dev Dependencies:"); - services.$logger.info(devDependenciesTable.toString()); + $logger.info("Dev Dependencies:"); + $logger.info(devDependenciesTable.toString()); } else { - services.$logger.info("There are no dev dependencies."); + $logger.info("There are no dev dependencies."); } const viewDependenciesCommand: string = color.cyan( @@ -76,11 +64,11 @@ export const listPluginsCommandDefinition = defineCommand({ "npm view grep devDependencies", ); - services.$logger.warn("NOTE:"); - services.$logger.warn( + $logger.warn("NOTE:"); + $logger.warn( `If you want to check the dependencies of installed plugin use ${viewDependenciesCommand}`, ); - services.$logger.warn( + $logger.warn( `If you want to check the dev dependencies of installed plugin use ${viewDevDependenciesCommand}`, ); }, diff --git a/lib/commands/plugin/remove-plugin.ts b/lib/commands/plugin/remove-plugin.ts index 4feb50305b..a2cfe193df 100644 --- a/lib/commands/plugin/remove-plugin.ts +++ b/lib/commands/plugin/remove-plugin.ts @@ -2,67 +2,47 @@ import * as _ from "lodash"; import { IProjectData } from "../../definitions/project"; import { IPluginsService } from "../../definitions/plugins"; import { IErrors } from "../../common/declarations"; -import { CommandContext, defineCommand } from "../../common/define-command"; +import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export interface IRemovePluginCommandServices { - $pluginsService: IPluginsService; - $errors: IErrors; - $logger: ILogger; - $projectData: IProjectData; -} - -export function setupRemovePluginCommand(): IRemovePluginCommandServices { - const services = { - $pluginsService: inject("pluginsService"), - $errors: inject("errors"), - $logger: inject("logger"), - $projectData: inject("projectData"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export async function canExecuteRemovePluginCommand( - context: CommandContext, - services: IRemovePluginCommandServices, -): Promise { - if (!context.args[0]) { - services.$errors.failWithHelp("You must specify plugin name."); - } - - let pluginNames: string[] = []; - try { - // try installing the plugins, so we can get information from node_modules about their native code, libs, etc. - const installedPlugins = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); - pluginNames = installedPlugins.map((pl) => pl.name); - } catch (err) { - services.$logger.trace("Error while installing plugins. Error is:", err); - pluginNames = _.keys(services.$projectData.dependencies); - } - - const pluginName = context.args[0].toLowerCase(); - if (!_.some(pluginNames, (name) => name.toLowerCase() === pluginName)) { - services.$errors.fail(`Plugin "${pluginName}" is not installed.`); - } - - return true; -} - export const removePluginCommandDefinition = defineCommand({ name: "plugin|remove", description: "Uninstalls the specified plugin and its dependencies.", arguments: "any", - setup: setupRemovePluginCommand, - canExecute: canExecuteRemovePluginCommand, - run(context, services): Promise { - return services.$pluginsService.remove( - context.args[0], - services.$projectData, - ); + async canExecute(context): Promise { + const $pluginsService = inject("pluginsService"); + const $errors = inject("errors"); + const $logger = inject("logger"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + if (!context.args[0]) { + $errors.failWithHelp("You must specify plugin name."); + } + + let pluginNames: string[] = []; + try { + // try installing the plugins, so we can get information from node_modules about their native code, libs, etc. + const installedPlugins = + await $pluginsService.getAllInstalledPlugins($projectData); + pluginNames = installedPlugins.map((pl) => pl.name); + } catch (err) { + $logger.trace("Error while installing plugins. Error is:", err); + pluginNames = _.keys($projectData.dependencies); + } + + const pluginName = context.args[0].toLowerCase(); + if (!_.some(pluginNames, (name) => name.toLowerCase() === pluginName)) { + $errors.fail(`Plugin "${pluginName}" is not installed.`); + } + + return true; + }, + run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + return $pluginsService.remove(context.args[0], $projectData); }, }); diff --git a/lib/commands/plugin/update-plugin.ts b/lib/commands/plugin/update-plugin.ts index 1180850f2f..44de94e80d 100644 --- a/lib/commands/plugin/update-plugin.ts +++ b/lib/commands/plugin/update-plugin.ts @@ -2,76 +2,55 @@ import * as _ from "lodash"; import { IProjectData } from "../../definitions/project"; import { IPluginsService } from "../../definitions/plugins"; import { IErrors } from "../../common/declarations"; -import { CommandContext, defineCommand } from "../../common/define-command"; +import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export interface IUpdatePluginCommandServices { - $pluginsService: IPluginsService; - $projectData: IProjectData; - $errors: IErrors; -} - -export function setupUpdatePluginCommand(): IUpdatePluginCommandServices { - const services = { - $pluginsService: inject("pluginsService"), - $projectData: inject("projectData"), - $errors: inject("errors"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export async function canExecuteUpdatePluginCommand( - context: CommandContext, - services: IUpdatePluginCommandServices, -): Promise { - const args = context.args; - if (!args || args.length === 0) { - return true; - } - - const installedPlugins = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); - const installedPluginNames: string[] = installedPlugins.map((pl) => pl.name); - - const pluginName = args[0].toLowerCase(); - if ( - !_.some(installedPluginNames, (name) => name.toLowerCase() === pluginName) - ) { - services.$errors.fail(`Plugin "${pluginName}" is not installed.`); - } - - return true; -} +export const updatePluginCommandDefinition = defineCommand({ + name: "plugin|update", + description: "Uninstalls and installs the specified plugin(s).", + arguments: "any", + async canExecute(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + const $errors = inject("errors"); + $projectData.initializeProjectData(); -export async function runUpdatePluginCommand( - context: CommandContext, - services: IUpdatePluginCommandServices, -): Promise { - let pluginNames = context.args; + const args = context.args; + if (!args || args.length === 0) { + return true; + } - if (!pluginNames || context.args.length === 0) { const installedPlugins = - await services.$pluginsService.getAllInstalledPlugins( - services.$projectData, - ); - pluginNames = installedPlugins.map((p) => p.name); - } + await $pluginsService.getAllInstalledPlugins($projectData); + const installedPluginNames: string[] = installedPlugins.map( + (pl) => pl.name, + ); - for (const pluginName of pluginNames) { - await services.$pluginsService.remove(pluginName, services.$projectData); - await services.$pluginsService.add(pluginName, services.$projectData); - } -} + const pluginName = args[0].toLowerCase(); + if ( + !_.some(installedPluginNames, (name) => name.toLowerCase() === pluginName) + ) { + $errors.fail(`Plugin "${pluginName}" is not installed.`); + } -export const updatePluginCommandDefinition = defineCommand({ - name: "plugin|update", - description: "Uninstalls and installs the specified plugin(s).", - arguments: "any", - setup: setupUpdatePluginCommand, - canExecute: canExecuteUpdatePluginCommand, - run: runUpdatePluginCommand, + return true; + }, + async run(context): Promise { + const $pluginsService = inject("pluginsService"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + let pluginNames = context.args; + + if (!pluginNames || context.args.length === 0) { + const installedPlugins = + await $pluginsService.getAllInstalledPlugins($projectData); + pluginNames = installedPlugins.map((p) => p.name); + } + + for (const pluginName of pluginNames) { + await $pluginsService.remove(pluginName, $projectData); + await $pluginsService.add(pluginName, $projectData); + } + }, }); diff --git a/lib/commands/post-install.ts b/lib/commands/post-install.ts index bcfdab2fd4..8206cdec7d 100644 --- a/lib/commands/post-install.ts +++ b/lib/commands/post-install.ts @@ -6,89 +6,71 @@ import { IHostInfo, ISettingsService, } from "../common/declarations"; -import { CommandContext, defineCommand } from "../common/define-command"; +import { CommandsService } from "../common/contracts/commands-service"; +import { Command } from "../common/define-command"; import { inject } from "../common/di"; import { doesCurrentNpmCommandMatch } from "../common/helpers"; -export interface IPostInstallCliCommandServices { - $fs: IFileSystem; - $commandsService: ICommandsService; - $helpService: IHelpService; - $settingsService: ISettingsService; - $analyticsService: IAnalyticsService; - $logger: ILogger; - $hostInfo: IHostInfo; -} - -export function setupPostInstallCliCommand(): IPostInstallCliCommandServices { - return { - $fs: inject("fs"), - $commandsService: inject("commandsService"), - $helpService: inject("helpService"), - $settingsService: inject("settingsService"), - $analyticsService: inject("analyticsService"), - $logger: inject("logger"), - $hostInfo: inject("hostInfo"), - }; -} +export class PostInstallCliCommand extends Command({ + name: "post-install-cli", + description: "Completes the CLI installation.", + disableAnalytics: true, +}) { + private $fs = inject("fs"); + private $commandsService = inject(CommandsService); + private $helpService = inject("helpService"); + private $settingsService = inject("settingsService"); + private $analyticsService = inject("analyticsService"); + private $logger = inject("logger"); + private $hostInfo = inject("hostInfo"); -export async function runPostInstallCliCommand( - context: CommandContext, - services: IPostInstallCliCommandServices, -): Promise { - const isRunningWithSudoUser = !!process.env.SUDO_USER; + public async run(): Promise { + const isRunningWithSudoUser = !!process.env.SUDO_USER; - if (!services.$hostInfo.isWindows) { - // when running under 'sudo' we create a working dir with wrong owner (root) and - // it is no longer accessible for the user initiating the installation - // patch the owner here - if (isRunningWithSudoUser) { - // TODO: Check if this is the correct place, probably we should set this at the end of the command. - await services.$fs.setCurrentUserAsOwner( - services.$settingsService.getProfileDir(), - process.env.SUDO_USER, - ); + if (!this.$hostInfo.isWindows) { + // when running under 'sudo' we create a working dir with wrong owner (root) and + // it is no longer accessible for the user initiating the installation + // patch the owner here + if (isRunningWithSudoUser) { + // TODO: Check if this is the correct place, probably we should set this at the end of the command. + await this.$fs.setCurrentUserAsOwner( + this.$settingsService.getProfileDir(), + process.env.SUDO_USER, + ); + } } - } - const canExecutePostInstallTask = - !isRunningWithSudoUser || doesCurrentNpmCommandMatch([/^--unsafe-perm$/]); + const canExecutePostInstallTask = + !isRunningWithSudoUser || doesCurrentNpmCommandMatch([/^--unsafe-perm$/]); + + if (canExecutePostInstallTask) { + await this.$helpService.generateHtmlPages(); - if (canExecutePostInstallTask) { - await services.$helpService.generateHtmlPages(); + // Explicitly ask for confirmation of usage-reporting: + await this.$analyticsService.checkConsent(); + await this.$commandsService.runCommand("autocomplete"); + } + } - // Explicitly ask for confirmation of usage-reporting: - await services.$analyticsService.checkConsent(); - await services.$commandsService.tryExecuteCommand("autocomplete", []); + public postRun(): void { + this.reportSuccessfulInstallation(); } -} -export function reportSuccessfulInstallation( - services: IPostInstallCliCommandServices, -): void { - services.$logger.info(""); - services.$logger.info( - color.styleText( - ["green", "bold"], - "You have successfully installed the NativeScript CLI!", - ), - ); - services.$logger.info(""); - services.$logger.info("Your next step is to create a new project:"); - services.$logger.info(color.styleText(["green", "bold"], "ns create")); + private reportSuccessfulInstallation(): void { + this.$logger.info(""); + this.$logger.info( + color.styleText( + ["green", "bold"], + "You have successfully installed the NativeScript CLI!", + ), + ); + this.$logger.info(""); + this.$logger.info("Your next step is to create a new project:"); + this.$logger.info(color.styleText(["green", "bold"], "ns create")); - services.$logger.info(""); - services.$logger.printMarkdown( - "If you have any questions, check Stack Overflow: `https://stackoverflow.com/questions/tagged/nativescript` and our public Discord channel: `https://nativescript.org/discord`", - ); + this.$logger.info(""); + this.$logger.printMarkdown( + "If you have any questions, check Stack Overflow: `https://stackoverflow.com/questions/tagged/nativescript` and our public Discord channel: `https://nativescript.org/discord`", + ); + } } - -export const postInstallCliCommandDefinition = defineCommand({ - name: "post-install-cli", - description: "Completes the CLI installation.", - disableAnalytics: true, - setup: setupPostInstallCliCommand, - run: runPostInstallCliCommand, - postRun: (context, result, services) => - reportSuccessfulInstallation(services), -}); diff --git a/lib/commands/prepare.ts b/lib/commands/prepare.ts index 18bb43e76c..29780ef800 100644 --- a/lib/commands/prepare.ts +++ b/lib/commands/prepare.ts @@ -1,7 +1,5 @@ import { canExecuteCommandBase, - injectPlatformCommandServices, - IPlatformCommandServices, platformArgument, validatePlatformArgument, validatePlatformOptions, @@ -16,6 +14,8 @@ import { defineCommand, } from "../common/define-command"; import { inject } from "../common/di"; +import { IOptions } from "../declarations"; +import { IProjectData } from "../definitions/project"; export const prepareCommandOptions = { watch: booleanOption({ default: false }), @@ -24,32 +24,15 @@ export const prepareCommandOptions = { force: booleanOption(), } satisfies CommandOptionsSchema; -export type PrepareCommandContext = CommandContext< - typeof prepareCommandOptions ->; +type PrepareCommandContext = CommandContext; -export interface IPrepareCommandServices extends IPlatformCommandServices { - $prepareController: PrepareController; - $prepareDataService: PrepareDataService; - $migrateController: IMigrateController; -} - -export function setupPrepareCommand(): IPrepareCommandServices { - const services = { - ...injectPlatformCommandServices(), - $prepareController: inject("prepareController"), - $prepareDataService: inject("prepareDataService"), - $migrateController: inject("migrateController"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export async function canExecutePrepareCommand( +async function canExecutePrepareCommand( context: PrepareCommandContext, - services: IPrepareCommandServices, ): Promise { + const $migrateController = + context.injector.get("migrateController"); + const $projectData = context.injector.get("projectData"); + const platform = context.args[0]; if (!platform) { // The declared argument validates only a platform that was passed; an @@ -57,11 +40,11 @@ export async function canExecutePrepareCommand( validatePlatformArgument(context.injector, platform); } - const result = await validatePlatformOptions(services, platform); + const result = await validatePlatformOptions(context, platform); if (!context.options.force) { - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, + await $migrateController.validate({ + projectDir: $projectData.projectDir, platforms: [platform], }); } @@ -70,19 +53,25 @@ export async function canExecutePrepareCommand( return false; } - return canExecuteCommandBase(services, platform); + return canExecuteCommandBase(context, platform); } export async function runPrepareCommand( context: PrepareCommandContext, - services: IPrepareCommandServices, ): Promise { - const prepareData = services.$prepareDataService.getPrepareData( - services.$projectData.projectDir, + const $options = context.injector.get("options"); + const $prepareController = + context.injector.get("prepareController"); + const $prepareDataService = + context.injector.get("prepareDataService"); + const $projectData = context.injector.get("projectData"); + + const prepareData = $prepareDataService.getPrepareData( + $projectData.projectDir, context.args[0], - services.$options, + $options, ); - await services.$prepareController.prepare(prepareData); + await $prepareController.prepare(prepareData); } export const prepareCommandDefinition = defineCommand({ @@ -90,7 +79,9 @@ export const prepareCommandDefinition = defineCommand({ description: "Copies common and platform-specific content to the platform.", options: prepareCommandOptions, arguments: [platformArgument], - setup: setupPrepareCommand, + setup() { + inject("projectData").initializeProjectData(); + }, canExecute: canExecutePrepareCommand, run: runPrepareCommand, }); diff --git a/lib/commands/preview.ts b/lib/commands/preview.ts index 8e19715443..f4f45ca6bf 100644 --- a/lib/commands/preview.ts +++ b/lib/commands/preview.ts @@ -1,12 +1,10 @@ -import { resolvePackagePath } from "@rigor789/resolve-package-path"; import * as path from "path"; import { color } from "../color"; import { IChildProcess, IErrors } from "../common/declarations"; import { booleanOption, - CommandContext, + Command, CommandOptionsSchema, - defineCommand, } from "../common/define-command"; import { inject } from "../common/di"; import { PackageManagers } from "../constants"; @@ -19,55 +17,57 @@ const previewCommandOptions = { disableNpmInstall: booleanOption(), } satisfies CommandOptionsSchema; -export type PreviewCommandContext = CommandContext< - typeof previewCommandOptions ->; +export class PreviewCommand extends Command({ + name: "preview", + description: "Runs your project with the NativeScript Preview CLI.", + options: previewCommandOptions, + // Arguments have never been rejected here, only ignored: they reach the + // preview CLI through the raw argv instead. + arguments: "any", + allowUnknownOptions: true, +}) { + private $childProcess = inject("childProcess"); + private $errors = inject("errors"); + private $logger = inject("logger"); + private $packageManager = inject("packageManager"); + private $projectData = inject("projectData"); -export interface IPreviewCommandServices { - $childProcess: IChildProcess; - $errors: IErrors; - $logger: ILogger; - $packageManager: IPackageManager; - $projectData: IProjectData; -} + public async run(): Promise { + if (!this.options.disableNpmInstall) { + await this.installLatestPreviewCLI(); + } -export function setupPreviewCommand(): IPreviewCommandServices { - return { - $childProcess: inject("childProcess"), - $errors: inject("errors"), - $logger: inject("logger"), - $packageManager: inject("packageManager"), - $projectData: inject("projectData"), - }; -} + const previewCLIPath = this.getPreviewCLIPath(); -function getPreviewCLIPath(services: IPreviewCommandServices): string { - return resolvePackagePath(PREVIEW_CLI_PACKAGE, { - paths: [services.$projectData.projectDir], - }); -} + if (!previewCLIPath) { + await this.failMissingPreviewCLI(); + } + + const previewCLIBinPath = path.resolve(previewCLIPath, "./dist/index.js"); + this.spawnPreviewCLI(previewCLIBinPath); + } -export async function runPreviewCommand( - context: PreviewCommandContext, - services: IPreviewCommandServices, -): Promise { - if (!context.options.disableNpmInstall) { - // ensure latest is installed - await services.$packageManager.install( + private async installLatestPreviewCLI(): Promise { + await this.$packageManager.install( `${PREVIEW_CLI_PACKAGE}@latest`, - services.$projectData.projectDir, + this.$projectData.projectDir, { - "save-dev": true, - "save-exact": true, - } as any, + dev: true, + exact: true, + }, ); } - const previewCLIPath = getPreviewCLIPath(services); + private getPreviewCLIPath(): string { + return this.$packageManager.getInstalledPackagePath( + PREVIEW_CLI_PACKAGE, + this.$projectData.projectDir, + ); + } - if (!previewCLIPath) { + private async failMissingPreviewCLI(): Promise { const packageManagerName = - await services.$packageManager.getPackageManagerName(); + await this.$packageManager.getPackageManagerName(); let installCommand = ""; switch (packageManagerName) { @@ -85,7 +85,7 @@ export async function runPreviewCommand( installCommand = "npm install --save-dev @nativescript/preview-cli"; break; } - services.$logger.info( + this.$logger.info( [ `Uhh ohh, no Preview CLI found.`, "", @@ -103,33 +103,21 @@ export async function runPreviewCommand( ].join("\n"), ); - services.$errors.fail("Running preview failed."); + this.$errors.fail("Running preview failed."); } - const previewCLIBinPath = path.resolve(previewCLIPath, "./dist/index.js"); - - // The preview CLI takes the command line verbatim, including flags this CLI - // does not know, so the raw process arguments are what it gets rather than - // anything the command layer parsed. - const commandIndex = process.argv.indexOf("preview"); - const commandArgs = process.argv.slice(commandIndex + 1); - services.$childProcess.spawn( - process.execPath, - [previewCLIBinPath, ...commandArgs], - { - stdio: "inherit", - }, - ); + private spawnPreviewCLI(previewCLIBinPath: string): void { + // The preview CLI takes the command line verbatim, including flags this CLI + // does not know, so the raw process arguments are what it gets rather than + // anything the command layer parsed. + const commandIndex = process.argv.indexOf("preview"); + const commandArgs = process.argv.slice(commandIndex + 1); + this.$childProcess.spawn( + process.execPath, + [previewCLIBinPath, ...commandArgs], + { + stdio: "inherit", + }, + ); + } } - -export const previewCommandDefinition = defineCommand({ - name: "preview", - description: "Runs your project with the NativeScript Preview CLI.", - options: previewCommandOptions, - // Arguments have never been rejected here, only ignored: they reach the - // preview CLI through the raw argv instead. - arguments: "any", - allowUnknownOptions: true, - setup: setupPreviewCommand, - run: runPreviewCommand, -}); diff --git a/lib/commands/remove-platform.ts b/lib/commands/remove-platform.ts index 67e6805f64..77b36f829d 100644 --- a/lib/commands/remove-platform.ts +++ b/lib/commands/remove-platform.ts @@ -5,69 +5,42 @@ import { IPlatformValidationService, } from "../declarations"; import { IErrors } from "../common/declarations"; -import { CommandContext, defineCommand } from "../common/define-command"; +import { defineCommand } from "../common/define-command"; import { inject } from "../common/di"; -export interface IRemovePlatformCommandServices { - $errors: IErrors; - $platformCommandHelper: IPlatformCommandHelper; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; -} - -export function setupRemovePlatformCommand(): IRemovePlatformCommandServices { - const services = { - $errors: inject("errors"), - $platformCommandHelper: inject( - "platformCommandHelper", - ), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export async function canExecuteRemovePlatformCommand( - context: CommandContext, - services: IRemovePlatformCommandServices, -): Promise { - const args = context.args; - if (!args || args.length === 0) { - services.$errors.failWithHelp( - "No platform specified. Please specify a platform to remove.", - ); - } - - _.each(args, (platform) => { - services.$platformValidationService.validatePlatform( - platform, - services.$projectData, - ); - }); - - return true; -} - -export function runRemovePlatformCommand( - context: CommandContext, - services: IRemovePlatformCommandServices, -): Promise { - return services.$platformCommandHelper.removePlatforms( - context.args, - services.$projectData, - ); -} - export const removePlatformCommandDefinition = defineCommand({ name: "platform|remove", description: "Removes the selected platform from the platforms that the project currently targets.", arguments: "any", - setup: setupRemovePlatformCommand, - canExecute: canExecuteRemovePlatformCommand, - run: runRemovePlatformCommand, + async canExecute(context): Promise { + const $errors = inject("errors"); + const $platformValidationService = inject( + "platformValidationService", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + const args = context.args; + if (!args || args.length === 0) { + $errors.failWithHelp( + "No platform specified. Please specify a platform to remove.", + ); + } + + _.each(args, (platform) => { + $platformValidationService.validatePlatform(platform, $projectData); + }); + + return true; + }, + run(context): Promise { + const $platformCommandHelper = inject( + "platformCommandHelper", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + return $platformCommandHelper.removePlatforms(context.args, $projectData); + }, }); diff --git a/lib/commands/resources/resources-update.ts b/lib/commands/resources/resources-update.ts index 0be239f3da..4dfcf765cd 100644 --- a/lib/commands/resources/resources-update.ts +++ b/lib/commands/resources/resources-update.ts @@ -1,71 +1,60 @@ import { IProjectData } from "../../definitions/project"; import { IAndroidResourcesMigrationService } from "../../declarations"; import { IErrors } from "../../common/declarations"; -import { CommandContext, defineCommand } from "../../common/define-command"; +import { defineCommand } from "../../common/define-command"; import { inject } from "../../common/di"; -export interface IResourcesUpdateCommandServices { - $projectData: IProjectData; - $errors: IErrors; - $androidResourcesMigrationService: IAndroidResourcesMigrationService; -} - -export function setupResourcesUpdateCommand(): IResourcesUpdateCommandServices { - const services = { - $projectData: inject("projectData"), - $errors: inject("errors"), - $androidResourcesMigrationService: +export const resourcesUpdateCommandDefinition = defineCommand({ + name: "resources|update", + description: + "Updates the App_Resources directory to the structure the current Android runtime expects.", + arguments: "any", + async canExecute(context): Promise { + const $androidResourcesMigrationService = inject( "androidResourcesMigrationService", - ), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -export async function canExecuteResourcesUpdateCommand( - context: CommandContext, - services: IResourcesUpdateCommandServices, -): Promise { - let args = context.args; - if (!args || args.length === 0) { - // Command defaults to migrating the Android App_Resources, unless explicitly specified. - // The default reaches this check only; the migration itself ignores the arguments. - args = ["android"]; - } - - for (const platform of args) { - if (!services.$androidResourcesMigrationService.canMigrate(platform)) { - services.$errors.fail( - `The ${platform} does not need to have its resources updated.`, ); + const $errors = inject("errors"); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); + + let args = context.args; + if (!args || args.length === 0) { + // Command defaults to migrating the Android App_Resources, unless explicitly specified. + // The default reaches this check only; the migration itself ignores the arguments. + args = ["android"]; } - if ( - services.$androidResourcesMigrationService.hasMigrated( - services.$projectData.getAppResourcesDirectoryPath(), - ) - ) { - services.$errors.fail( - "The App_Resources have already been updated for the Android platform.", - ); + for (const platform of args) { + if (!$androidResourcesMigrationService.canMigrate(platform)) { + $errors.fail( + `The ${platform} does not need to have its resources updated.`, + ); + } + + if ( + $androidResourcesMigrationService.hasMigrated( + $projectData.getAppResourcesDirectoryPath(), + ) + ) { + $errors.fail( + "The App_Resources have already been updated for the Android platform.", + ); + } } - } - return true; -} + return true; + }, + async run(): Promise { + const $androidResourcesMigrationService = + inject( + "androidResourcesMigrationService", + ); + const $projectData = inject("projectData"); + $projectData.initializeProjectData(); -export const resourcesUpdateCommandDefinition = defineCommand({ - name: "resources|update", - description: - "Updates the App_Resources directory to the structure the current Android runtime expects.", - arguments: "any", - setup: setupResourcesUpdateCommand, - canExecute: canExecuteResourcesUpdateCommand, - async run(context, services): Promise { - await services.$androidResourcesMigrationService.migrate( - services.$projectData.getAppResourcesDirectoryPath(), + await $androidResourcesMigrationService.migrate( + $projectData.getAppResourcesDirectoryPath(), ); }, }); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index 1e72107be6..4d21a898ac 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -36,103 +36,78 @@ const runCommandOptions = { keyStoreAliasPassword: stringOption(), } satisfies CommandOptionsSchema; -export type RunCommandContext = CommandContext; - -export interface IRunCommandServices { - /** - * Undefined for `run|*all`, which targets every platform. `canExecute` - * narrows it to Android off macOS, and `run` reads whatever it settled on. - */ - platform: string; - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $errors: IErrors; - $hostInfo: IHostInfo; - $keyShortcutService: IKeyShortcutService; - $liveSyncCommandHelper: ILiveSyncCommandHelper; - $migrateController: IMigrateController; - $options: IOptions; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; - $projectDataService: IProjectDataService; -} - -export function setupRunCommand(): IRunCommandServices { - return { - platform: undefined, - $devicePlatformsConstants: inject( - "devicePlatformsConstants", - ), - $errors: inject("errors"), - $hostInfo: inject("hostInfo"), - $keyShortcutService: inject("keyShortcutService"), - $liveSyncCommandHelper: inject( - "liveSyncCommandHelper", - ), - $migrateController: inject("migrateController"), - $options: inject("options"), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - $projectDataService: inject("projectDataService"), - }; -} +type RunCommandContext = CommandContext; +/** + * Which `$devicePlatformsConstants` entry a command runs for. The constants + * stay the source of truth for the platform spelling. + */ type RunPlatform = "iOS" | "Android" | "visionOS"; -const setupPlatformRunCommand = - (platform: RunPlatform) => (): IRunCommandServices => { - const services = setupRunCommand(); - services.platform = services.$devicePlatformsConstants[platform]; - - return services; - }; +const runPlatformName = ( + context: RunCommandContext, + platform: RunPlatform, +): string => + context.injector.get( + "devicePlatformsConstants", + )[platform]; -export async function canExecuteRunCommand( +async function canExecuteRunCommand( context: RunCommandContext, - services: IRunCommandServices, + platform: string, ): Promise { - if (context.args.length) { - services.$errors.failWithHelp(ERROR_NO_VALID_SUBCOMMAND_FORMAT, "run"); - } + const $devicePlatformsConstants = + context.injector.get( + "devicePlatformsConstants", + ); + const $errors = context.injector.get("errors"); + const $liveSyncCommandHelper = context.injector.get( + "liveSyncCommandHelper", + ); + const $migrateController = + context.injector.get("migrateController"); + const $projectData = context.injector.get("projectData"); - if (!services.platform && !services.$hostInfo.isDarwin) { - services.platform = services.$devicePlatformsConstants.Android; + if (context.args.length) { + $errors.failWithHelp(ERROR_NO_VALID_SUBCOMMAND_FORMAT, "run"); } - services.$projectData.initializeProjectData(); - const platforms = services.platform - ? [services.platform] - : [ - services.$devicePlatformsConstants.Android, - services.$devicePlatformsConstants.iOS, - ]; + $projectData.initializeProjectData(); + const platforms = platform + ? [platform] + : [$devicePlatformsConstants.Android, $devicePlatformsConstants.iOS]; if (!context.options.force) { - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, + await $migrateController.validate({ + projectDir: $projectData.projectDir, platforms, }); } - await services.$liveSyncCommandHelper.validatePlatform(services.platform); + await $liveSyncCommandHelper.validatePlatform(platform); return true; } -export async function runRunCommand( +async function runRunCommand( context: RunCommandContext, - services: IRunCommandServices, + platform: string, ): Promise { - await services.$liveSyncCommandHelper.executeCommandLiveSync( - services.platform, + const $keyShortcutService = + context.injector.get("keyShortcutService"); + const $liveSyncCommandHelper = context.injector.get( + "liveSyncCommandHelper", + ); + + await $liveSyncCommandHelper.executeCommandLiveSync( + platform, {}, ); if (process.env.NS_IS_INTERACTIVE) { - services.$keyShortcutService.attach({ + $keyShortcutService.attach({ context: { - platform: services.platform, + platform: platform, processType: "run", }, shortcuts: keyShortcuts(), @@ -145,9 +120,9 @@ export async function runRunCommand( * outright; the launch and clean keys belong to the parent that respawns * things, which is why the `ns start` table is not reused here. */ -export function runCommandShortcuts( +function runCommandShortcuts( context: RunCommandContext, - services: IRunCommandServices, + platform: string, ): KeyShortcut[] { if (process.env.NS_IS_INTERACTIVE) { // A `ns start` child is driven over IPC through the table `run` attaches @@ -155,12 +130,13 @@ export function runCommandShortcuts( return []; } - const platform = services.platform; - return [ - restartShortcut({ platform }), - restartShortcut({ platform, full: true }), - restartShortcut({ platform, forceRebuildNativeApp: true }), + restartShortcut({ platform: platform }), + restartShortcut({ platform: platform, full: true }), + restartShortcut({ + platform: platform, + forceRebuildNativeApp: true, + }), watcherShortcut(), ]; } @@ -171,7 +147,16 @@ export const runCommandDefinition = defineCommand({ options: runCommandOptions, // The base rejects arguments itself, with the sub-command message. arguments: "any", - setup: setupRunCommand, + /** + * Undefined for `run|*all`, which targets every platform, except off macOS + * where only Android can be built. It is settled here, once per invocation, + * because `canExecute` and `run` have to agree on the platform. + */ + setup(context: RunCommandContext): string { + const $hostInfo = inject("hostInfo"); + + return $hostInfo.isDarwin ? undefined : runPlatformName(context, "Android"); + }, canExecute: canExecuteRunCommand, run: runRunCommand, shortcuts: runCommandShortcuts, @@ -179,28 +164,34 @@ export const runCommandDefinition = defineCommand({ async function canExecuteApplePlatformRunCommand( context: RunCommandContext, - services: IRunCommandServices, + platform: string, ): Promise { - const projectData = services.$projectDataService.getProjectData(); + const $errors = context.injector.get("errors"); + const $options = context.injector.get("options"); + const $platformValidationService = + context.injector.get( + "platformValidationService", + ); + const $projectDataService = + context.injector.get("projectDataService"); + + const projectData = $projectDataService.getProjectData(); if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.platform, - projectData, - ) + !$platformValidationService.isPlatformSupportedForOS(platform, projectData) ) { - services.$errors.fail( - `Applications for platform ${services.platform} can not be built on this OS`, + $errors.fail( + `Applications for platform ${platform} can not be built on this OS`, ); } const result = - (await canExecuteRunCommand(context, services)) && - (await services.$platformValidationService.validateOptions( - services.$options.provision, - services.$options.teamId, + (await canExecuteRunCommand(context, platform)) && + (await $platformValidationService.validateOptions( + $options.provision, + $options.teamId, projectData, - services.platform.toLowerCase(), + platform.toLowerCase(), )); return result; } @@ -214,10 +205,15 @@ const defineApplePlatformRunCommand = ( description: "Runs your project on a connected Apple device or simulator.", options: runCommandOptions, arguments: "any", - setup: setupPlatformRunCommand(platform), - canExecute: canExecuteApplePlatformRunCommand, - run: runRunCommand, - shortcuts: runCommandShortcuts, + canExecute: (context: RunCommandContext) => + canExecuteApplePlatformRunCommand( + context, + runPlatformName(context, platform), + ), + run: (context: RunCommandContext) => + runRunCommand(context, runPlatformName(context, platform)), + shortcuts: (context: RunCommandContext) => + runCommandShortcuts(context, runPlatformName(context, platform)), }); export const iosRunCommand = defineApplePlatformRunCommand("run|ios", "iOS"); @@ -232,24 +228,28 @@ export const androidRunCommand = defineCommand({ description: "Runs your project on a connected Android device or emulator.", options: runCommandOptions, arguments: "any", - setup: setupPlatformRunCommand("Android"), - async canExecute( - context: RunCommandContext, - services: IRunCommandServices, - ): Promise { + async canExecute(context: RunCommandContext): Promise { + const $errors = inject("errors"); + const $options = inject("options"); + const $platformValidationService = inject( + "platformValidationService", + ); + const $projectData = inject("projectData"); + const platform = runPlatformName(context, "Android"); + // The base verdict is dropped rather than combined with the checks below; // the base only ever returns true or throws, so the Android command has // always relied on it for its side effects alone. - await canExecuteRunCommand(context, services); + await canExecuteRunCommand(context, platform); if ( - !services.$platformValidationService.isPlatformSupportedForOS( - services.$devicePlatformsConstants.Android, - services.$projectData, + !$platformValidationService.isPlatformSupportedForOS( + platform, + $projectData, ) ) { - services.$errors.fail( - `Applications for platform ${services.$devicePlatformsConstants.Android} can not be built on this OS`, + $errors.fail( + `Applications for platform ${platform} can not be built on this OS`, ); } @@ -258,19 +258,21 @@ export const androidRunCommand = defineCommand({ !hasValidAndroidSigning(context.options) ) { if (context.options.release) { - services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - services.$errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } - return services.$platformValidationService.validateOptions( - services.$options.provision, - services.$options.teamId, - services.$projectData, - services.$devicePlatformsConstants.Android.toLowerCase(), + return $platformValidationService.validateOptions( + $options.provision, + $options.teamId, + $projectData, + platform.toLowerCase(), ); }, - run: runRunCommand, - shortcuts: runCommandShortcuts, + run: (context: RunCommandContext) => + runRunCommand(context, runPlatformName(context, "Android")), + shortcuts: (context: RunCommandContext) => + runCommandShortcuts(context, runPlatformName(context, "Android")), }); diff --git a/lib/commands/setup.ts b/lib/commands/setup.ts index 69e21a161e..17879c24db 100644 --- a/lib/commands/setup.ts +++ b/lib/commands/setup.ts @@ -7,10 +7,7 @@ export const setupCommandDefinition = defineCommand({ description: "Run the setup script to try to automatically configure your environment.", arguments: "none", - setup: () => ({ - $doctorService: inject("doctorService"), - }), - run(context, services): Promise { - return services.$doctorService.runSetupScript(); + run(): Promise { + return inject("doctorService").runSetupScript(); }, }); diff --git a/lib/commands/start.ts b/lib/commands/start.ts index d1ac27f997..9070b4770f 100644 --- a/lib/commands/start.ts +++ b/lib/commands/start.ts @@ -7,13 +7,11 @@ export const startCommandDefinition = defineCommand({ name: "start", description: "Starts the NativeScript interactive command line.", arguments: "any", - setup: () => ({ - $startService: inject("startService"), - }), - async run(context, services): Promise { + async run(): Promise { + const $startService = inject("startService"); printHeader(); // Left unawaited: the command returns while the service keeps running. - services.$startService.start(); + $startService.start(); return; }, }); diff --git a/lib/commands/test-init.ts b/lib/commands/test-init.ts index 0db996dc5f..e5d4e2dbf2 100644 --- a/lib/commands/test-init.ts +++ b/lib/commands/test-init.ts @@ -9,8 +9,8 @@ import { import { INodePackageManager, IOptions } from "../declarations"; import { IPluginsService } from "../definitions/plugins"; import { + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -31,174 +31,124 @@ const testInitCommandOptions = { framework: stringOption(), } satisfies CommandOptionsSchema; -interface ITestInitCommandServices { - $errors: IErrors; - $fs: IFileSystem; - $logger: ILogger; - $options: IOptions; - $packageManager: INodePackageManager; - $pluginsService: IPluginsService; - $projectData: IProjectData; - $prompter: IPrompter; - $resources: IResourceLoader; - $testInitializationService: ITestInitializationService; -} - -function setupTestInitCommand(): ITestInitCommandServices { - const services = { - $errors: inject("errors"), - $fs: inject("fs"), - $logger: inject("logger"), - $options: inject("options"), - $packageManager: inject("packageManager"), - $pluginsService: inject("pluginsService"), - $projectData: inject("projectData"), - $prompter: inject("prompter"), - $resources: inject("resources"), - $testInitializationService: inject( - "testInitializationService", - ), - }; - services.$projectData.initializeProjectData(); - - return services; -} - -/** - * Android blocks cleartext traffic by default (API 28+), which would - * reject the runner's ws:// connection to the host. Scope the exception - * to the emulator loopback alias and adb-reverse loopback only. - */ -function ensureAndroidNetworkSecurityConfig( - services: ITestInitCommandServices, - bufferedLogs: string[], -): void { - const manifestPath = path.join( - services.$projectData.appResourcesDirectoryPath, - "Android", - "src", - "main", - "AndroidManifest.xml", - ); - if (!services.$fs.exists(manifestPath)) { - bufferedLogs.push( - color.yellow( - "Could not locate App_Resources/Android/src/main/AndroidManifest.xml. For Android test runs, allow cleartext traffic to 10.0.2.2 and 127.0.0.1 via a network security config.", - ), - ); - return; - } - - const manifestContent = services.$fs.readText(manifestPath); - if (manifestContent.indexOf("networkSecurityConfig") !== -1) { - bufferedLogs.push( - color.yellow( - "AndroidManifest.xml already sets android:networkSecurityConfig — make sure it permits cleartext traffic to 10.0.2.2 and 127.0.0.1 for test runs.", - ), - ); - return; - } - - const xmlDirectory = path.join( - services.$projectData.appResourcesDirectoryPath, - "Android", - "src", - "main", - "res", - "xml", - ); - services.$fs.ensureDirectoryExists(xmlDirectory); - const securityConfigPath = path.join(xmlDirectory, "network_security.xml"); - if (!services.$fs.exists(securityConfigPath)) { - services.$fs.copyFile( - services.$resources.resolvePath("test/network_security.xml"), - securityConfigPath, - ); - bufferedLogs.push( - `Added ${color.yellow("App_Resources/Android/src/main/res/xml/network_security.xml")}`, - ); - } - - services.$fs.writeFile( - manifestPath, - manifestContent.replace( - / { - const projectDir = services.$projectData.projectDir; +}) { + private $errors = inject("errors"); + private $fs = inject("fs"); + private $logger = inject("logger"); + private $options = inject("options"); + private $packageManager = inject("packageManager"); + private $pluginsService = inject("pluginsService"); + private $projectData = inject("projectData"); + private $prompter = inject("prompter"); + private $resources = inject("resources"); + private $testInitializationService = inject( + "testInitializationService", + ); - const frameworkToInstall = - context.options.framework || - (await services.$prompter.promptForChoice( - "Select testing framework:", - TESTING_FRAMEWORKS, - )); - if (TESTING_FRAMEWORKS.indexOf(frameworkToInstall) === -1) { - services.$errors.failWithHelp( - `Unknown or unsupported unit testing framework: ${frameworkToInstall}.`, + constructor() { + super(); + this.$projectData.initializeProjectData(); + } + + /** + * Android blocks cleartext traffic by default (API 28+), which would + * reject the runner's ws:// connection to the host. Scope the exception + * to the emulator loopback alias and adb-reverse loopback only. + */ + private ensureAndroidNetworkSecurityConfig(bufferedLogs: string[]): void { + const manifestPath = path.join( + this.$projectData.appResourcesDirectoryPath, + "Android", + "src", + "main", + "AndroidManifest.xml", + ); + if (!this.$fs.exists(manifestPath)) { + bufferedLogs.push( + color.yellow( + "Could not locate App_Resources/Android/src/main/AndroidManifest.xml. For Android test runs, allow cleartext traffic to 10.0.2.2 and 127.0.0.1 via a network security config.", + ), ); + return; } - const projectFilesExtension = - services.$projectData.projectType === ProjectTypes.TsFlavorName || - services.$projectData.projectType === ProjectTypes.NgFlavorName - ? ".ts" - : ".js"; + const manifestContent = this.$fs.readText(manifestPath); + if (manifestContent.indexOf("networkSecurityConfig") !== -1) { + bufferedLogs.push( + color.yellow( + "AndroidManifest.xml already sets android:networkSecurityConfig — make sure it permits cleartext traffic to 10.0.2.2 and 127.0.0.1 for test runs.", + ), + ); + return; + } - let modulesToInstall: IDependencyInformation[] = []; - try { - modulesToInstall = - services.$testInitializationService.getDependencies(frameworkToInstall); - } catch (err) { - services.$errors.fail( - `Unable to install the unit testing dependencies. Error: '${err.message}'`, + const xmlDirectory = path.join( + this.$projectData.appResourcesDirectoryPath, + "Android", + "src", + "main", + "res", + "xml", + ); + this.$fs.ensureDirectoryExists(xmlDirectory); + const securityConfigPath = path.join(xmlDirectory, "network_security.xml"); + if (!this.$fs.exists(securityConfigPath)) { + this.$fs.copyFile( + this.$resources.resolvePath("test/network_security.xml"), + securityConfigPath, + ); + bufferedLogs.push( + `Added ${color.yellow("App_Resources/Android/src/main/res/xml/network_security.xml")}`, ); } - modulesToInstall = modulesToInstall.filter( - (moduleToInstall) => - !moduleToInstall.projectType || - moduleToInstall.projectType === projectFilesExtension, + this.$fs.writeFile( + manifestPath, + manifestContent.replace( + / { for (const mod of modulesToInstall) { let moduleToInstall = mod.name; moduleToInstall += `@${mod.version}`; - await services.$packageManager.install(moduleToInstall, projectDir, { + await this.$packageManager.install(moduleToInstall, projectDir, { // Packages with native code must land in "dependencies" — the CLI // integrates plugin platform files (pods, aars) only from there. - ...(mod.saveInDependencies ? { save: true } : { "save-dev": true }), - "save-exact": true, - optional: false, - disableNpmInstall: services.$options.disableNpmInstall, - frameworkPath: services.$options.frameworkPath, - ignoreScripts: services.$options.ignoreScripts, - path: services.$options.path, + dev: !mod.saveInDependencies, + exact: true, + disableNpmInstall: this.$options.disableNpmInstall, + frameworkPath: this.$options.frameworkPath, + ignoreScripts: this.$options.ignoreScripts, + path: this.$options.path, }); - const modulePath = path.join(projectDir, "node_modules", mod.name); - const modulePackageJsonPath = path.join(modulePath, "package.json"); - const modulePackageJsonContent = services.$fs.readJson( - modulePackageJsonPath, + const modulePath = this.$packageManager.getInstalledPackagePath( + mod.name, + projectDir, + ); + const modulePackageJsonContent = this.$fs.readJson( + path.join(modulePath, "package.json"), ); const modulePeerDependencies = modulePackageJsonContent.peerDependencies || {}; const modulePeerDependenciesMeta = modulePackageJsonContent.peerDependenciesMeta || {}; - const projectPackageJson = services.$fs.readJson( + const projectPackageJson = this.$fs.readJson( path.join(projectDir, "package.json"), ); const installedProjectDependencies = { @@ -235,50 +185,90 @@ export const testInitCommandDefinition = defineCommand({ // catch errors when a peerDependency is already installed // e.g karma is installed; karma-jasmine depends on karma and will try to install it again try { - await services.$packageManager.install( + await this.$packageManager.install( `${peerDependency}@${dependencyVersion}`, projectDir, { - "save-dev": true, - "save-exact": true, + dev: true, + exact: true, disableNpmInstall: false, - frameworkPath: services.$options.frameworkPath, - ignoreScripts: services.$options.ignoreScripts, - path: services.$options.path, + frameworkPath: this.$options.frameworkPath, + ignoreScripts: this.$options.ignoreScripts, + path: this.$options.path, }, ); } catch (e) { - services.$logger.error(e.message); + this.$logger.error(e.message); } } } + } + + public async run(): Promise { + const projectDir = this.$projectData.projectDir; + + const frameworkToInstall = + this.options.framework || + (await this.$prompter.promptForChoice( + "Select testing framework:", + TESTING_FRAMEWORKS, + )); + if (TESTING_FRAMEWORKS.indexOf(frameworkToInstall) === -1) { + this.$errors.failWithHelp( + `Unknown or unsupported unit testing framework: ${frameworkToInstall}.`, + ); + } + + const projectFilesExtension = + this.$projectData.projectType === ProjectTypes.TsFlavorName || + this.$projectData.projectType === ProjectTypes.NgFlavorName + ? ".ts" + : ".js"; + + let modulesToInstall: IDependencyInformation[] = []; + try { + modulesToInstall = + this.$testInitializationService.getDependencies(frameworkToInstall); + } catch (err) { + this.$errors.fail( + `Unable to install the unit testing dependencies. Error: '${err.message}'`, + ); + } + + modulesToInstall = modulesToInstall.filter( + (moduleToInstall) => + !moduleToInstall.projectType || + moduleToInstall.projectType === projectFilesExtension, + ); + + await this.installModules(modulesToInstall, projectDir); const isVitest = frameworkToInstall === "vitest"; if (!isVitest) { // The Karma client only exists in the v4 line — v5+ is Vitest-only, so // an unpinned install would break these setups once v5 is `latest`. - await services.$pluginsService.add( + await this.$pluginsService.add( "@nativescript/unit-test-runner@^4.0.0", - services.$projectData, + this.$projectData, ); } - services.$logger.clearScreen(); + this.$logger.clearScreen(); const bufferedLogs = []; - const testsDir = path.join(services.$projectData.appDirectoryPath, "tests"); + const testsDir = path.join(this.$projectData.appDirectoryPath, "tests"); const projectTestsDir = path.relative( - services.$projectData.projectDir, + this.$projectData.projectDir, testsDir, ); const relativeTestsDir = path.relative( - services.$projectData.appDirectoryPath, + this.$projectData.appDirectoryPath, testsDir, ); let shouldCreateSampleTests = true; - if (services.$fs.exists(testsDir)) { + if (this.$fs.exists(testsDir)) { const specFilenamePattern = `.spec${projectFilesExtension}`; bufferedLogs.push( color.yellow( @@ -292,18 +282,18 @@ export const testInitCommandDefinition = defineCommand({ shouldCreateSampleTests = false; } - services.$fs.ensureDirectoryExists(testsDir); + this.$fs.ensureDirectoryExists(testsDir); if (isVitest) { - const vitestConfigResourcePath = services.$resources.resolvePath( + const vitestConfigResourcePath = this.$resources.resolvePath( "test/vitest.config.mts", ); - services.$fs.copyFile( + this.$fs.copyFile( vitestConfigResourcePath, path.join(projectDir, "vitest.config.mts"), ); bufferedLogs.push(`Added/replaced ${color.yellow("vitest.config.mts")}`); - ensureAndroidNetworkSecurityConfig(services, bufferedLogs); + this.ensureAndroidNetworkSecurityConfig(bufferedLogs); } else { const frameworks = [frameworkToInstall] .concat(karmaConfigAdditionalFrameworks[frameworkToInstall] || []) @@ -312,18 +302,17 @@ export const testInitCommandDefinition = defineCommand({ const testFiles = `'${fromWindowsRelativePathToUnix( relativeTestsDir, )}/**/*${projectFilesExtension}'`; - const karmaConfTemplate = - services.$resources.readText("test/karma.conf.js"); + const karmaConfTemplate = this.$resources.readText("test/karma.conf.js"); const karmaConf = _.template(karmaConfTemplate)({ frameworks, testFiles, - basePath: services.$projectData.getAppDirectoryRelativePath(), + basePath: this.$projectData.getAppDirectoryRelativePath(), }); - services.$fs.writeFile(path.join(projectDir, "karma.conf.js"), karmaConf); + this.$fs.writeFile(path.join(projectDir, "karma.conf.js"), karmaConf); } - const exampleFilePath = services.$resources.resolvePath( + const exampleFilePath = this.$resources.resolvePath( `test/example.${frameworkToInstall}${projectFilesExtension}`, ); const targetExampleTestPath = path.join( @@ -331,8 +320,8 @@ export const testInitCommandDefinition = defineCommand({ `example.spec${projectFilesExtension}`, ); - if (shouldCreateSampleTests && services.$fs.exists(exampleFilePath)) { - services.$fs.copyFile(exampleFilePath, targetExampleTestPath); + if (shouldCreateSampleTests && this.$fs.exists(exampleFilePath)) { + this.$fs.copyFile(exampleFilePath, targetExampleTestPath); const targetExampleTestRelativePath = path.relative( projectDir, targetExampleTestPath, @@ -343,18 +332,18 @@ export const testInitCommandDefinition = defineCommand({ } // test main entry - const testMainResourcesPath = services.$resources.resolvePath( + const testMainResourcesPath = this.$resources.resolvePath( isVitest ? `test/test-main.vitest${projectFilesExtension}` : `test/test-main${projectFilesExtension}`, ); const testMainPath = path.join( - services.$projectData.appDirectoryPath, + this.$projectData.appDirectoryPath, `test${projectFilesExtension}`, ); - if (!services.$fs.exists(testMainPath)) { - services.$fs.copyFile(testMainResourcesPath, testMainPath); + if (!this.$fs.exists(testMainPath)) { + this.$fs.copyFile(testMainResourcesPath, testMainPath); const testMainRelativePath = path.relative(projectDir, testMainPath); bufferedLogs.push( `Main test entrypoint created: ${color.yellow(testMainRelativePath)}`, @@ -362,14 +351,14 @@ export const testInitCommandDefinition = defineCommand({ } if (!isVitest || projectFilesExtension === ".ts") { - const testTsConfigTemplate = services.$resources.readText( + const testTsConfigTemplate = this.$resources.readText( "test/tsconfig.spec.json", ); const testTsConfig = _.template(testTsConfigTemplate)({ - basePath: services.$projectData.getAppDirectoryRelativePath(), + basePath: this.$projectData.getAppDirectoryRelativePath(), }); - services.$fs.writeFile( + this.$fs.writeFile( path.join(projectDir, "tsconfig.spec.json"), testTsConfig, ); @@ -416,7 +405,7 @@ export const testInitCommandDefinition = defineCommand({ "", ]; - services.$logger.info( + this.$logger.info( [ [ color.green(`Tests using`), @@ -429,5 +418,5 @@ export const testInitCommandDefinition = defineCommand({ ...closingNotes, ].join("\n"), ); - }, -}); + } +} diff --git a/lib/commands/test.ts b/lib/commands/test.ts index 49c528f48f..0829bb97ec 100644 --- a/lib/commands/test.ts +++ b/lib/commands/test.ts @@ -49,95 +49,66 @@ const testCommandOptions = { keyStoreAliasPassword: stringOption(), } satisfies CommandOptionsSchema; -export type TestCommandContext = CommandContext; +type TestCommandContext = CommandContext; -export interface ITestCommandServices { - platform: string; - $analyticsService: IAnalyticsService; - $cleanupService: ICleanupService; - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $liveSyncCommandHelper: ILiveSyncCommandHelper; - $logger: ILogger; - $migrateController: IMigrateController; - $options: IOptions; - $platformEnvironmentRequirements: IPlatformEnvironmentRequirements; - $projectData: IProjectData; - $testExecutionService: ITestExecutionService; - $vitestExecutionService: IVitestExecutionService; -} - -export function setupTestCommand( - testPlatform: TestPlatform, -): ITestCommandServices { - return { - platform: testPlatform, - $analyticsService: inject("analyticsService"), - $cleanupService: inject("cleanupService"), - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $liveSyncCommandHelper: inject( - "liveSyncCommandHelper", - ), - $logger: inject("logger"), - $migrateController: inject("migrateController"), - $options: inject("options"), - $platformEnvironmentRequirements: inject( - "platformEnvironmentRequirements", - ), - $projectData: inject("projectData"), - $testExecutionService: inject( - "testExecutionService", - ), - $vitestExecutionService: inject( - "vitestExecutionService", - ), - }; -} - -export async function canExecuteTestCommand( +async function canExecuteTestCommand( context: TestCommandContext, - services: ITestCommandServices, + platform: TestPlatform, ): Promise { + const $analyticsService = + context.injector.get("analyticsService"); + const $cleanupService = + context.injector.get("cleanupService"); + const $errors = context.injector.get("errors"); + const $migrateController = + context.injector.get("migrateController"); + const $options = context.injector.get("options"); + const $platformEnvironmentRequirements = + context.injector.get( + "platformEnvironmentRequirements", + ); + const $projectData = context.injector.get("projectData"); + const $testExecutionService = context.injector.get( + "testExecutionService", + ); + const $vitestExecutionService = context.injector.get( + "vitestExecutionService", + ); + if (!context.options.force) { if (context.options.hmr) { // With HMR we are not restarting after LiveSync which is causing a 30 seconds app start on Android // because the Runtime does not watch for the `/data/local/tmp-livesync-in-progress` file deletion. // The App is closing itself after each test execution and the bug will be reproducible on each LiveSync. - services.$errors.fail( - "The `--hmr` option is not supported for this command.", - ); + $errors.fail("The `--hmr` option is not supported for this command."); } - await services.$migrateController.validate({ - projectDir: services.$projectData.projectDir, - platforms: [services.platform], + await $migrateController.validate({ + projectDir: $projectData.projectDir, + platforms: [platform], }); } - services.$projectData.initializeProjectData(); - services.$analyticsService.setShouldDispose( + $projectData.initializeProjectData(); + $analyticsService.setShouldDispose( context.options.justlaunch || !context.options.watch, ); - services.$cleanupService.setShouldDispose( + $cleanupService.setShouldDispose( context.options.justlaunch || !context.options.watch, ); const output = - await services.$platformEnvironmentRequirements.checkEnvironmentRequirements( - { - platform: services.platform, - projectDir: services.$projectData.projectDir, - options: services.$options, - }, - ); + await $platformEnvironmentRequirements.checkEnvironmentRequirements({ + platform, + projectDir: $projectData.projectDir, + options: $options, + }); - if (services.$vitestExecutionService.isVitestProject(services.$projectData)) { - const canStartTestRun = services.$vitestExecutionService.canStartTestRun( - services.$projectData, - ); + if ($vitestExecutionService.isVitestProject($projectData)) { + const canStartTestRun = + $vitestExecutionService.canStartTestRun($projectData); if (!canStartTestRun) { - services.$errors.fail({ + $errors.fail({ formatStr: "Error: In order to run unit tests, your project must already be configured by running $ ns test init.", errorCode: ErrorCodes.TESTS_INIT_REQUIRED, @@ -147,11 +118,9 @@ export async function canExecuteTestCommand( } const canStartKarmaServer = - await services.$testExecutionService.canStartKarmaServer( - services.$projectData, - ); + await $testExecutionService.canStartKarmaServer($projectData); if (!canStartKarmaServer) { - services.$errors.fail({ + $errors.fail({ formatStr: "Error: In order to run unit tests, your project must already be configured by running $ ns test init.", errorCode: ErrorCodes.TESTS_INIT_REQUIRED, @@ -161,57 +130,66 @@ export async function canExecuteTestCommand( return output.canExecute && canStartKarmaServer; } -export async function runTestCommand( +async function runTestCommand( context: TestCommandContext, - services: ITestCommandServices, + platform: TestPlatform, ): Promise { - if (services.$vitestExecutionService.isVitestProject(services.$projectData)) { - await services.$vitestExecutionService.startTestRun( - services.platform, - services.$projectData, - ); + const $devicesService = + context.injector.get("devicesService"); + const $liveSyncCommandHelper = context.injector.get( + "liveSyncCommandHelper", + ); + const $logger = context.injector.get("logger"); + const $options = context.injector.get("options"); + const $projectData = context.injector.get("projectData"); + const $testExecutionService = context.injector.get( + "testExecutionService", + ); + const $vitestExecutionService = context.injector.get( + "vitestExecutionService", + ); + + if ($vitestExecutionService.isVitestProject($projectData)) { + await $vitestExecutionService.startTestRun(platform, $projectData); process.exit(0); } - services.$logger.warn( + $logger.warn( "Karma-based unit testing is deprecated and will be removed in a future release. " + "Re-initialize your tests with '$ ns test init --framework vitest' to migrate.", ); let devices = []; if (context.options.debugBrk) { - await services.$devicesService.initialize({ - platform: services.platform, + await $devicesService.initialize({ + platform, deviceId: context.options.device, emulator: context.options.emulator, - skipInferPlatform: !services.platform, + skipInferPlatform: !platform, sdk: context.options.sdk, }); - const selectedDeviceForDebug = - await services.$devicesService.pickSingleDevice({ - onlyEmulators: context.options.emulator, - onlyDevices: context.options.forDevice, - deviceId: context.options.device, - }); + const selectedDeviceForDebug = await $devicesService.pickSingleDevice({ + onlyEmulators: context.options.emulator, + onlyDevices: context.options.forDevice, + deviceId: context.options.device, + }); devices = [selectedDeviceForDebug]; // const debugData = this.getDebugData(platform, projectData, deployOptions, { device: selectedDeviceForDebug.deviceInfo.identifier }); // await this.$debugService.debug(debugData, this.$options); } else { - devices = await services.$liveSyncCommandHelper.getDeviceInstances( - services.platform, - ); + devices = await $liveSyncCommandHelper.getDeviceInstances(platform); } // The bundler reads unitTesting off the shared options service, so the flag // is set there rather than on the command's own snapshot. - if (!services.$options.env) { - services.$options.env = {}; + if (!$options.env) { + $options.env = {}; } - services.$options.env.unitTesting = true; + $options.env.unitTesting = true; - const liveSyncInfo = services.$liveSyncCommandHelper.getLiveSyncData( - services.$projectData.projectDir, + const liveSyncInfo = $liveSyncCommandHelper.getLiveSyncData( + $projectData.projectDir, ); const deviceDebugMap: IDictionary = {}; @@ -221,14 +199,12 @@ export async function runTestCommand( ); const deviceDescriptors = - await services.$liveSyncCommandHelper.createDeviceDescriptors( - devices, - services.platform, - { deviceDebugMap }, - ); + await $liveSyncCommandHelper.createDeviceDescriptors(devices, platform, < + any + >{ deviceDebugMap }); - await services.$testExecutionService.startKarmaServer( - services.platform, + await $testExecutionService.startKarmaServer( + platform, liveSyncInfo, deviceDescriptors, ); @@ -242,9 +218,9 @@ export const testCommandDefinition = defineCommand({ options: testCommandOptions, // Arguments have never been rejected here, only ignored. arguments: "any", - setup: () => setupTestCommand("iOS"), - canExecute: canExecuteTestCommand, - run: runTestCommand, + canExecute: (context: TestCommandContext) => + canExecuteTestCommand(context, "iOS"), + run: (context: TestCommandContext) => runTestCommand(context, "iOS"), }); export const testAndroidCommandDefinition = defineCommand({ @@ -253,30 +229,26 @@ export const testAndroidCommandDefinition = defineCommand({ "Runs the tests in your project on connected Android devices or Android emulators.", options: testCommandOptions, arguments: "any", - setup: () => setupTestCommand("android"), - async canExecute( - context: TestCommandContext, - services: ITestCommandServices, - ): Promise { - const canExecuteBase = await canExecuteTestCommand(context, services); + async canExecute(context: TestCommandContext): Promise { + const $errors = inject("errors"); + + const canExecuteBase = await canExecuteTestCommand(context, "android"); if (canExecuteBase) { if ( (context.options.release || context.options.aab) && !hasValidAndroidSigning(context.options) ) { if (context.options.release) { - services.$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); + $errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE); } else { - services.$errors.failWithHelp( - ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, - ); + $errors.failWithHelp(ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE); } } } return canExecuteBase; }, - run: runTestCommand, + run: (context: TestCommandContext) => runTestCommand(context, "android"), }); export const testVisionOSCommandDefinition = defineCommand({ @@ -285,23 +257,23 @@ export const testVisionOSCommandDefinition = defineCommand({ "Runs the tests in your project in the visionOS Simulator or on connected Apple Vision Pro devices.", options: testCommandOptions, arguments: "any", - setup: () => setupTestCommand("visionOS"), - async canExecute( - context: TestCommandContext, - services: ITestCommandServices, - ): Promise { - services.$projectData.initializeProjectData(); + async canExecute(context: TestCommandContext): Promise { + const $errors = inject("errors"); + const $projectData = inject("projectData"); + const $vitestExecutionService = inject( + "vitestExecutionService", + ); + + $projectData.initializeProjectData(); // The Karma runner (v4 line) never supported visionOS — only the Vitest // path can drive it. - if ( - !services.$vitestExecutionService.isVitestProject(services.$projectData) - ) { - services.$errors.fail( + if (!$vitestExecutionService.isVitestProject($projectData)) { + $errors.fail( "visionOS unit testing requires the Vitest runner. Run '$ ns test init --framework vitest' to configure your project.", ); } - return canExecuteTestCommand(context, services); + return canExecuteTestCommand(context, "visionOS"); }, - run: runTestCommand, + run: (context: TestCommandContext) => runTestCommand(context, "visionOS"), }); diff --git a/lib/commands/typings.ts b/lib/commands/typings.ts index 79152d97de..0850282161 100644 --- a/lib/commands/typings.ts +++ b/lib/commands/typings.ts @@ -5,9 +5,8 @@ import { PromptObject } from "prompts"; import { color } from "../color"; import { IChildProcess, IFileSystem, IHostInfo } from "../common/declarations"; import { - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -21,276 +20,236 @@ const typingsCommandOptions = { jar: stringOption(), } satisfies CommandOptionsSchema; -export type TypingsCommandContext = CommandContext< - typeof typingsCommandOptions ->; - -export interface ITypingsCommandServices { - $childProcess: IChildProcess; - $fs: IFileSystem; - $hostInfo: IHostInfo; - $logger: ILogger; - $mobileHelper: Mobile.IMobileHelper; - $options: IOptions; - $projectData: IProjectData; - $prompter: IPrompter; - $staticConfig: IStaticConfig; -} - -export function setupTypingsCommand(): ITypingsCommandServices { - return { - $childProcess: inject("childProcess"), - $fs: inject("fs"), - $hostInfo: inject("hostInfo"), - $logger: inject("logger"), - $mobileHelper: inject("mobileHelper"), - $options: inject("options"), - $projectData: inject("projectData"), - $prompter: inject("prompter"), - $staticConfig: inject("staticConfig"), - }; -} - -async function resolveGradleDependencies( - services: ITypingsCommandServices, - target: string, -) { - const gradleHome = path.resolve( - process.env.GRADLE_USER_HOME ?? path.join(homedir(), `/.gradle`), - ); - const gradleFiles = path.resolve(gradleHome, "caches/modules-2/files-2.1/"); - - if (!services.$fs.exists(gradleFiles)) { - services.$logger.warn("No gradle files found"); - return; +export class TypingsCommand extends Command({ + name: "typings", + description: "Generates typings for the native platform APIs.", + options: typingsCommandOptions, + // Only the first argument is read; the rest are gradle targets this command + // takes off the raw argv, so the policy must not reject them. + arguments: "any", +}) { + private $childProcess = inject("childProcess"); + private $fs = inject("fs"); + private $hostInfo = inject("hostInfo"); + private $logger = inject("logger"); + private $mobileHelper = inject("mobileHelper"); + private $options = inject("options"); + private $projectData = inject("projectData"); + private $prompter = inject("prompter"); + private $staticConfig = inject("staticConfig"); + + public canExecute(): boolean { + this.$mobileHelper.validatePlatformName(this.args[0]); + return true; } - const pattern = `${target.replaceAll(":", "/")}/**/*.{jar,aar}`; + public async run(): Promise { + const platform = this.args[0]; + let result; + if (this.$mobileHelper.isAndroidPlatform(platform)) { + result = await this.handleAndroidTypings(); + } else if (this.$mobileHelper.isiOSPlatform(platform)) { + result = await this.handleiOSTypings(); + } + let typingsFolder = "./typings"; + if (this.options.copyTo) { + this.$fs.copyFile( + path.resolve(this.$projectData.projectDir, "typings"), + this.options.copyTo, + ); + typingsFolder = this.options.copyTo; + } - const items = []; - for await (const item of glob(pattern, { - cwd: gradleFiles, - })) { - const [group, artifact, version, sha1, file] = item.split(path.sep); - items.push({ - id: sha1 + version, - group, - artifact, - version, - sha1, - file, - path: path.resolve(gradleFiles, item), - }); + if (result !== false) { + this.$logger.info( + "Typings have been generated in the following directory:", + typingsFolder, + ); + } } - if (items.length === 0) { - services.$logger.warn("No files found"); - return []; - } + private async resolveGradleDependencies(target: string) { + const gradleHome = path.resolve( + process.env.GRADLE_USER_HOME ?? path.join(homedir(), `/.gradle`), + ); + const gradleFiles = path.resolve(gradleHome, "caches/modules-2/files-2.1/"); - services.$logger.clearScreen(); + if (!this.$fs.exists(gradleFiles)) { + this.$logger.warn("No gradle files found"); + return; + } - const choices = await services.$prompter.promptForChoice( - `Select dependencies to generate typings for (${color.greenBright( - target, - )})`, - items - .sort((a, b) => { - if (a.artifact < b.artifact) return -1; - if (a.artifact > b.artifact) return 1; + const pattern = `${target.replaceAll(":", "/")}/**/*.{jar,aar}`; + + const items = []; + for await (const item of glob(pattern, { + cwd: gradleFiles, + })) { + const [group, artifact, version, sha1, file] = item.split(path.sep); + items.push({ + id: sha1 + version, + group, + artifact, + version, + sha1, + file, + path: path.resolve(gradleFiles, item), + }); + } - return a.version.localeCompare(b.version, undefined, { - numeric: true, - sensitivity: "base", - }); - }) - .map((item) => { - return { - title: `${color.white(item.group)}:${color.greenBright( - item.artifact, - )}:${color.yellow(item.version)} - ${color.styleText( - ["cyanBright", "bold"], - item.file, - )}`, - value: item.id, - }; - }), - true, - { - optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions - } as Partial, - ); + if (items.length === 0) { + this.$logger.warn("No files found"); + return []; + } - services.$logger.clearScreen(); + this.$logger.clearScreen(); + + const choices = await this.$prompter.promptForChoice( + `Select dependencies to generate typings for (${color.greenBright( + target, + )})`, + items + .sort((a, b) => { + if (a.artifact < b.artifact) return -1; + if (a.artifact > b.artifact) return 1; + + return a.version.localeCompare(b.version, undefined, { + numeric: true, + sensitivity: "base", + }); + }) + .map((item) => { + return { + title: `${color.white(item.group)}:${color.greenBright( + item.artifact, + )}:${color.yellow(item.version)} - ${color.styleText( + ["cyanBright", "bold"], + item.file, + )}`, + value: item.id, + }; + }), + true, + { + optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions + } as Partial, + ); - return items - .filter((item) => choices.includes(item.id)) - .map((item) => item.path); -} + this.$logger.clearScreen(); -async function handleAndroidTypings( - context: TypingsCommandContext, - services: ITypingsCommandServices, -) { - // The gradle targets are positional arguments this command reads off the - // raw argv rather than declaring, so that they keep working alongside the - // --jar and --aar flags. - const targets = services.$options.argv._.slice(2) ?? []; - const paths: string[] = []; + return items + .filter((item) => choices.includes(item.id)) + .map((item) => item.path); + } - if (targets.length) { - for (const target of targets) { - try { - paths.push(...(await resolveGradleDependencies(services, target))); - } catch (err) { - services.$logger.trace( - `Failed to resolve gradle dependencies for target "${target}"`, - err, - ); + private async handleAndroidTypings() { + // The gradle targets are positional arguments this command reads off the + // raw argv rather than declaring, so that they keep working alongside the + // --jar and --aar flags. + const targets = this.$options.argv._.slice(2) ?? []; + const paths: string[] = []; + + if (targets.length) { + for (const target of targets) { + try { + paths.push(...(await this.resolveGradleDependencies(target))); + } catch (err) { + this.$logger.trace( + `Failed to resolve gradle dependencies for target "${target}"`, + err, + ); + } } } - } - if (!paths.length && !(context.options.jar || context.options.aar)) { - services.$logger.warn( - [ - "No .jar or .aar file specified. Please specify at least one of the following:", - " - path to .jar file with --jar ", - " - path to .aar file with --aar ", - ].join("\n"), - ); - return false; - } - - services.$fs.ensureDirectoryExists( - path.resolve(services.$projectData.projectDir, "typings", "android"), - ); + if (!paths.length && !(this.options.jar || this.options.aar)) { + this.$logger.warn( + [ + "No .jar or .aar file specified. Please specify at least one of the following:", + " - path to .jar file with --jar ", + " - path to .aar file with --aar ", + ].join("\n"), + ); + return false; + } - const dtsGeneratorPath = path.resolve( - services.$projectData.platformsDir, - "android", - "build-tools", - "dts-generator.jar", - ); - if (!services.$fs.exists(dtsGeneratorPath)) { - services.$logger.warn( - "No platforms folder found, preparing project now...", + this.$fs.ensureDirectoryExists( + path.resolve(this.$projectData.projectDir, "typings", "android"), ); - await services.$childProcess.spawnFromEvent( - services.$hostInfo.isWindows ? "ns.cmd" : "ns", - ["prepare", "android"], - "exit", - { stdio: "inherit", shell: services.$hostInfo.isWindows }, - ); - } - const asArray = (input: string | string[]) => { - if (!input) { - return []; + const dtsGeneratorPath = path.resolve( + this.$projectData.platformsDir, + "android", + "build-tools", + "dts-generator.jar", + ); + if (!this.$fs.exists(dtsGeneratorPath)) { + this.$logger.warn("No platforms folder found, preparing project now..."); + await this.$childProcess.spawnFromEvent( + this.$hostInfo.isWindows ? "ns.cmd" : "ns", + ["prepare", "android"], + "exit", + { stdio: "inherit", shell: this.$hostInfo.isWindows }, + ); } - if (typeof input === "string") { - return [input]; - } + const asArray = (input: string | string[]) => { + if (!input) { + return []; + } - return input; - }; + if (typeof input === "string") { + return [input]; + } - const inputs: string[] = [ - ...asArray(context.options.jar), - ...asArray(context.options.aar), - ...paths, - ]; + return input; + }; - await services.$childProcess.spawnFromEvent( - "java", - [ - "-jar", - dtsGeneratorPath, - "-input", - ...inputs, - "-output", - path.resolve(services.$projectData.projectDir, "typings", "android"), - ], - "exit", - { stdio: "inherit" }, - ); -} + const inputs: string[] = [ + ...asArray(this.options.jar), + ...asArray(this.options.aar), + ...paths, + ]; -async function handleiOSTypings( - context: TypingsCommandContext, - services: ITypingsCommandServices, -) { - if (context.options.filter !== undefined) { - services.$logger.warn("--filter flag is not supported yet."); + await this.$childProcess.spawnFromEvent( + "java", + [ + "-jar", + dtsGeneratorPath, + "-input", + ...inputs, + "-output", + path.resolve(this.$projectData.projectDir, "typings", "android"), + ], + "exit", + { stdio: "inherit" }, + ); } - services.$fs.ensureDirectoryExists( - path.resolve(services.$projectData.projectDir, "typings", "ios"), - ); - - await services.$childProcess.spawnFromEvent( - "node", - [services.$staticConfig.cliBinPath, "build", "ios"], - "exit", - { - env: { - ...process.env, - TNS_TYPESCRIPT_DECLARATIONS_PATH: path.resolve( - services.$projectData.projectDir, - "typings", - "ios", - ), - }, - stdio: "inherit", - }, - ); -} - -export function canExecuteTypingsCommand( - context: TypingsCommandContext, - services: ITypingsCommandServices, -): boolean { - services.$mobileHelper.validatePlatformName(context.args[0]); - return true; -} + private async handleiOSTypings() { + if (this.options.filter !== undefined) { + this.$logger.warn("--filter flag is not supported yet."); + } -export async function runTypingsCommand( - context: TypingsCommandContext, - services: ITypingsCommandServices, -): Promise { - const platform = context.args[0]; - let result; - if (services.$mobileHelper.isAndroidPlatform(platform)) { - result = await handleAndroidTypings(context, services); - } else if (services.$mobileHelper.isiOSPlatform(platform)) { - result = await handleiOSTypings(context, services); - } - let typingsFolder = "./typings"; - if (context.options.copyTo) { - services.$fs.copyFile( - path.resolve(services.$projectData.projectDir, "typings"), - context.options.copyTo, + this.$fs.ensureDirectoryExists( + path.resolve(this.$projectData.projectDir, "typings", "ios"), ); - typingsFolder = context.options.copyTo; - } - if (result !== false) { - services.$logger.info( - "Typings have been generated in the following directory:", - typingsFolder, + await this.$childProcess.spawnFromEvent( + "node", + [this.$staticConfig.cliBinPath, "build", "ios"], + "exit", + { + env: { + ...process.env, + TNS_TYPESCRIPT_DECLARATIONS_PATH: path.resolve( + this.$projectData.projectDir, + "typings", + "ios", + ), + }, + stdio: "inherit", + }, ); } } - -export const typingsCommandDefinition = defineCommand({ - name: "typings", - description: "Generates typings for the native platform APIs.", - options: typingsCommandOptions, - // Only the first argument is read; the rest are gradle targets this command - // takes off the raw argv, so the policy must not reject them. - arguments: "any", - setup: setupTypingsCommand, - canExecute: canExecuteTypingsCommand, - run: runTypingsCommand, -}); diff --git a/lib/commands/update-platform.ts b/lib/commands/update-platform.ts index 85eb8e61f7..eec8fd1f0e 100644 --- a/lib/commands/update-platform.ts +++ b/lib/commands/update-platform.ts @@ -10,97 +10,76 @@ import { ICheckEnvironmentRequirementsInput, } from "../definitions/platform"; import { IErrors } from "../common/declarations"; -import { CommandContext, defineCommand } from "../common/define-command"; +import { Command } from "../common/define-command"; import { inject } from "../common/di"; -export interface IUpdatePlatformCommandServices { - $errors: IErrors; - $options: IOptions; - $platformEnvironmentRequirements: IPlatformEnvironmentRequirements; - $platformCommandHelper: IPlatformCommandHelper; - $platformValidationService: IPlatformValidationService; - $projectData: IProjectData; -} - -export function setupUpdatePlatformCommand(): IUpdatePlatformCommandServices { - const services = { - $errors: inject("errors"), - $options: inject("options"), - $platformEnvironmentRequirements: inject( - "platformEnvironmentRequirements", - ), - $platformCommandHelper: inject( - "platformCommandHelper", - ), - $platformValidationService: inject( - "platformValidationService", - ), - $projectData: inject("projectData"), - }; - services.$projectData.initializeProjectData(); - - return services; -} +export class UpdatePlatformCommand extends Command({ + name: "platform|update", + description: "Updates the NativeScript runtime for the specified platform.", + arguments: "any", +}) { + private $errors = inject("errors"); + private $options = inject("options"); + private $platformEnvironmentRequirements = + inject("platformEnvironmentRequirements"); + private $platformCommandHelper = inject( + "platformCommandHelper", + ); + private $platformValidationService = inject( + "platformValidationService", + ); + private $projectData = inject("projectData"); -export async function canExecuteUpdatePlatformCommand( - context: CommandContext, - services: IUpdatePlatformCommandServices, -): Promise { - const args = context.args; - if (!args || args.length === 0) { - services.$errors.failWithHelp( - "No platform specified. Please specify platforms to update.", - ); + constructor() { + super(); + this.$projectData.initializeProjectData(); } - _.each(args, (arg) => { - const platform = arg.split("@")[0]; - services.$platformValidationService.validatePlatform( - platform, - services.$projectData, - ); - }); + public async canExecute(): Promise { + const args = this.args; + if (!args || args.length === 0) { + this.$errors.failWithHelp( + "No platform specified. Please specify platforms to update.", + ); + } - for (const arg of args) { - const [platform, versionToBeInstalled] = arg.split("@"); - const checkEnvironmentRequirementsInput: ICheckEnvironmentRequirementsInput = - { + _.each(args, (arg) => { + const platform = arg.split("@")[0]; + this.$platformValidationService.validatePlatform( platform, - options: services.$options, - }; - // If version is not specified, we know the command will install the latest compatible Android runtime. - // The latest compatible Android runtime supports Java version, so we do not need to pass it here. - // Passing projectDir to the @nativescript/doctor validation will cause it to check the runtime from the current package.json - // So in this case, where we do not want to validate the runtime, just do not pass both projectDir and runtimeVersion. - if (versionToBeInstalled) { - checkEnvironmentRequirementsInput.projectDir = - services.$projectData.projectDir; - checkEnvironmentRequirementsInput.runtimeVersion = versionToBeInstalled; + this.$projectData, + ); + }); + + for (const arg of args) { + const [platform, versionToBeInstalled] = arg.split("@"); + const checkEnvironmentRequirementsInput: ICheckEnvironmentRequirementsInput = + { + platform, + options: this.$options, + }; + // If version is not specified, we know the command will install the latest compatible Android runtime. + // The latest compatible Android runtime supports Java version, so we do not need to pass it here. + // Passing projectDir to the @nativescript/doctor validation will cause it to check the runtime from the current package.json + // So in this case, where we do not want to validate the runtime, just do not pass both projectDir and runtimeVersion. + if (versionToBeInstalled) { + checkEnvironmentRequirementsInput.projectDir = + this.$projectData.projectDir; + checkEnvironmentRequirementsInput.runtimeVersion = versionToBeInstalled; + } + + await this.$platformEnvironmentRequirements.checkEnvironmentRequirements( + checkEnvironmentRequirementsInput, + ); } - await services.$platformEnvironmentRequirements.checkEnvironmentRequirements( - checkEnvironmentRequirementsInput, - ); + return true; } - return true; -} - -export async function runUpdatePlatformCommand( - context: CommandContext, - services: IUpdatePlatformCommandServices, -): Promise { - await services.$platformCommandHelper.updatePlatforms( - context.args, - services.$projectData, - ); + public async run(): Promise { + await this.$platformCommandHelper.updatePlatforms( + this.args, + this.$projectData, + ); + } } - -export const updatePlatformCommandDefinition = defineCommand({ - name: "platform|update", - description: "Updates the NativeScript runtime for the specified platform.", - arguments: "any", - setup: setupUpdatePlatformCommand, - canExecute: canExecuteUpdatePlatformCommand, - run: runUpdatePlatformCommand, -}); diff --git a/lib/commands/update.ts b/lib/commands/update.ts index 71524be88d..589ad5ecb1 100644 --- a/lib/commands/update.ts +++ b/lib/commands/update.ts @@ -3,9 +3,8 @@ import { IMigrateController } from "../definitions/migrate"; import { IErrors } from "../common/declarations"; import { booleanOption, - CommandContext, + Command, CommandOptionsSchema, - defineCommand, stringOption, } from "../common/define-command"; import { inject } from "../common/di"; @@ -19,92 +18,70 @@ const updateCommandOptions = { frameworkPath: stringOption(), } satisfies CommandOptionsSchema; -export type UpdateCommandContext = CommandContext; - -export interface IUpdateCommandServices { - $devicePlatformsConstants: Mobile.IDevicePlatformsConstants; - $updateController: IUpdateController; - $migrateController: IMigrateController; - $errors: IErrors; - $logger: ILogger; - $projectData: IProjectData; - $markingModeService: IMarkingModeService; -} +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"); -export function setupUpdateCommand(): IUpdateCommandServices { - const services = { - $devicePlatformsConstants: inject( - "devicePlatformsConstants", - ), - $updateController: inject("updateController"), - $migrateController: inject("migrateController"), - $errors: inject("errors"), - $logger: inject("logger"), - $projectData: inject("projectData"), - $markingModeService: inject("markingModeService"), - }; - services.$projectData.initializeProjectData(); + constructor() { + super(); + this.$projectData.initializeProjectData(); + } - return services; -} + public async canExecute(): Promise { + const shouldMigrate = await this.$migrateController.shouldMigrate({ + projectDir: this.$projectData.projectDir, + platforms: [ + this.$devicePlatformsConstants.Android, + this.$devicePlatformsConstants.iOS, + ], + loose: true, + }); -export async function canExecuteUpdateCommand( - context: UpdateCommandContext, - services: IUpdateCommandServices, -): Promise { - const shouldMigrate = await services.$migrateController.shouldMigrate({ - projectDir: services.$projectData.projectDir, - platforms: [ - services.$devicePlatformsConstants.Android, - services.$devicePlatformsConstants.iOS, - ], - loose: true, - }); + if (shouldMigrate) { + this.$errors.fail(SHOULD_MIGRATE_PROJECT_MESSAGE); + } - if (shouldMigrate) { - services.$errors.fail(SHOULD_MIGRATE_PROJECT_MESSAGE); + return this.args.length < 2 && this.$projectData.projectDir !== ""; } - return context.args.length < 2 && services.$projectData.projectDir !== ""; -} + public async run(): Promise { + if (this.options.markingMode) { + // ns update --markingMode + await this.$markingModeService.handleMarkingModeFullDeprecation({ + projectDir: this.$projectData.projectDir, + forceSwitch: true, + }); + return; + } -export async function runUpdateCommand( - context: UpdateCommandContext, - services: IUpdateCommandServices, -): Promise { - if (context.options.markingMode) { - // ns update --markingMode - await services.$markingModeService.handleMarkingModeFullDeprecation({ - projectDir: services.$projectData.projectDir, - forceSwitch: true, - }); - return; - } + if ( + !(await this.$updateController.shouldUpdate({ + projectDir: this.$projectData.projectDir, + version: this.args[0], + })) + ) { + this.$logger.printMarkdown(`__${PROJECT_UP_TO_DATE_MESSAGE}__`); + return; + } - if ( - !(await services.$updateController.shouldUpdate({ - projectDir: services.$projectData.projectDir, - version: context.args[0], - })) - ) { - services.$logger.printMarkdown(`__${PROJECT_UP_TO_DATE_MESSAGE}__`); - return; + await this.$updateController.update({ + projectDir: this.$projectData.projectDir, + version: this.args[0], + frameworkPath: this.options.frameworkPath, + }); } - - await services.$updateController.update({ - projectDir: services.$projectData.projectDir, - version: context.args[0], - frameworkPath: context.options.frameworkPath, - }); } - -export const updateCommandDefinition = defineCommand({ - name: "update", - description: - "Updates the project with the latest versions of its NativeScript dependencies.", - options: updateCommandOptions, - arguments: "any", - setup: setupUpdateCommand, - canExecute: canExecuteUpdateCommand, - run: runUpdateCommand, -}); diff --git a/lib/commands/widget.ts b/lib/commands/widget.ts index 033d6942ff..1347e3ba4e 100644 --- a/lib/commands/widget.ts +++ b/lib/commands/widget.ts @@ -894,35 +894,29 @@ declare class AppleWidgetUtils extends NSObject { } } -interface IWidgetCommandServices { - generator: IOSWidgetGenerator; -} - // No flat "widget": the subcommand registration below synthesizes the parent // dispatcher. export const widgetIOSCommandDefinition = defineCommand({ name: "widget|ios", description: "Generates an iOS widget extension for the project.", arguments: "any", - setup(): IWidgetCommandServices { - const $projectData = inject("projectData"); - $projectData.initializeProjectData(); - - return { - generator: new IOSWidgetGenerator( - $projectData, - inject("projectConfigService"), - inject("logger"), - inject("errors"), - ), - }; - }, - canExecute(): boolean { - return true; + // In setup, not run: it lands ahead of the arguments policy, so being + // outside a project is what a bad invocation reports first. + setup(): void { + inject("projectData").initializeProjectData(); }, - run(context, services: IWidgetCommandServices): void { + run(ctx): void { + const $projectData = inject("projectData"); + + const generator = new IOSWidgetGenerator( + $projectData, + inject("projectConfigService"), + inject("logger"), + inject("errors"), + ); + // Not awaited: the command has always reported completion before the // prompts it opens are answered. - services.generator.startPrompt(context.args); + generator.startPrompt(ctx.args); }, }); diff --git a/lib/common/bootstrap.ts b/lib/common/bootstrap.ts index 4a401ec5ac..ba51125a76 100644 --- a/lib/common/bootstrap.ts +++ b/lib/common/bootstrap.ts @@ -91,16 +91,16 @@ registerBuiltInCommand< ); registerBuiltInCommand< - typeof import("./commands/device/list-devices").listDevicesCommandDefinition + typeof import("./commands/device/list-devices").ListDevicesCommand >( "device|*list", - () => require("./commands/device/list-devices").listDevicesCommandDefinition, + () => require("./commands/device/list-devices").ListDevicesCommand, ); registerBuiltInCommand< - typeof import("./commands/device/list-devices").listDevicesCommandDefinition + typeof import("./commands/device/list-devices").ListDevicesCommand >( "devices|*list", - () => require("./commands/device/list-devices").listDevicesCommandDefinition, + () => require("./commands/device/list-devices").ListDevicesCommand, ); registerBuiltInCommand< typeof import("./commands/device/list-devices").androidListDevicesCommand @@ -314,11 +314,8 @@ registerBuiltInCommand< () => require("./commands/proxy/proxy-get").proxyGetCommandDefinition, ); registerBuiltInCommand< - typeof import("./commands/proxy/proxy-set").proxySetCommandDefinition ->( - "proxy|set", - () => require("./commands/proxy/proxy-set").proxySetCommandDefinition, -); + typeof import("./commands/proxy/proxy-set").ProxySetCommand +>("proxy|set", () => require("./commands/proxy/proxy-set").ProxySetCommand); registerBuiltInCommand< typeof import("./commands/proxy/proxy-clear").proxyClearCommandDefinition >( diff --git a/lib/common/commands/analytics.ts b/lib/common/commands/analytics.ts index 36e39f76b8..ee1a4c50f7 100644 --- a/lib/common/commands/analytics.ts +++ b/lib/common/commands/analytics.ts @@ -18,35 +18,13 @@ interface IAnalyticsSetting { humanReadableSettingName: string; } -export const analyticsCommandOptions = { +const analyticsCommandOptions = { json: booleanOption(), } satisfies CommandOptionsSchema; -export type AnalyticsCommandContext = CommandContext< - typeof analyticsCommandOptions ->; +type AnalyticsCommandContext = CommandContext; -export interface IAnalyticsCommandServices { - settingName: string; - humanReadableSettingName: string; - $analyticsService: IAnalyticsService; - $logger: ILogger; -} - -export function setupAnalyticsCommand( - setting: IAnalyticsSetting, -): IAnalyticsCommandServices { - const $staticConfig = inject("staticConfig"); - - return { - settingName: $staticConfig[setting.staticConfigKey], - humanReadableSettingName: setting.humanReadableSettingName, - $analyticsService: inject("analyticsService"), - $logger: inject("logger"), - }; -} - -export function validateAnalyticsState(value: string): boolean | string { +function validateAnalyticsState(value: string): boolean | string { switch ((value || "").toLowerCase()) { case "enable": case "disable": @@ -58,33 +36,35 @@ export function validateAnalyticsState(value: string): boolean | string { } } -export async function runAnalyticsCommand( +async function runAnalyticsCommand( context: AnalyticsCommandContext, - services: IAnalyticsCommandServices, + setting: IAnalyticsSetting, ): Promise { + const $analyticsService = inject("analyticsService"); + const $logger = inject("logger"); + const $staticConfig = inject("staticConfig"); + const settingName = $staticConfig[setting.staticConfigKey]; + const { humanReadableSettingName } = setting; + const arg = context.args[0] || ""; switch (arg.toLowerCase()) { case "enable": - await services.$analyticsService.setStatus(services.settingName, true); + await $analyticsService.setStatus(settingName, true); // TODO(Analytics): await this.$analyticsService.track(this.settingName, "enabled"); - services.$logger.info( - `${services.humanReadableSettingName} is now enabled.`, - ); + $logger.info(`${humanReadableSettingName} is now enabled.`); break; case "disable": // TODO(Analytics): await this.$analyticsService.track(this.settingName, "disabled"); - await services.$analyticsService.setStatus(services.settingName, false); - services.$logger.info( - `${services.humanReadableSettingName} is now disabled.`, - ); + await $analyticsService.setStatus(settingName, false); + $logger.info(`${humanReadableSettingName} is now disabled.`); break; case "status": case "": - services.$logger.info( - await services.$analyticsService.getStatusMessage( - services.settingName, + $logger.info( + await $analyticsService.getStatusMessage( + settingName, context.options.json, - services.humanReadableSettingName, + humanReadableSettingName, ), ); break; @@ -101,8 +81,7 @@ const defineAnalyticsCommand = ( options: analyticsCommandOptions, arguments: [{ name: "state", validate: validateAnalyticsState }], disableAnalytics: true, - setup: () => setupAnalyticsCommand(setting), - run: runAnalyticsCommand, + run: (context) => runAnalyticsCommand(context, setting), }); export const usageReportingCommand = defineAnalyticsCommand("usage-reporting", { diff --git a/lib/common/commands/autocompletion.ts b/lib/common/commands/autocompletion.ts index 30d6ebed5f..1e47462689 100644 --- a/lib/common/commands/autocompletion.ts +++ b/lib/common/commands/autocompletion.ts @@ -3,53 +3,41 @@ import { IAutoCompletionService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -export interface IAutoCompleteCommandServices { - $autoCompletionService: IAutoCompletionService; - $logger: ILogger; -} - -export function injectAutoCompleteCommandServices(): IAutoCompleteCommandServices { - return { - $autoCompletionService: inject( - "autoCompletionService", - ), - $logger: inject("logger"), - }; -} - export const autoCompleteCommandDefinition = defineCommand({ name: "autocomplete|*default", description: "Prompts to enable command-line completion for the CLI.", arguments: "none", disableAnalytics: true, - setup: () => ({ - ...injectAutoCompleteCommandServices(), - $prompter: inject("prompter"), - }), - async run(context, services): Promise { + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); + const $prompter = inject("prompter"); + if (helpers.isInteractive()) { - if (services.$autoCompletionService.isAutoCompletionEnabled()) { - if (services.$autoCompletionService.isObsoleteAutoCompletionEnabled()) { + if ($autoCompletionService.isAutoCompletionEnabled()) { + if ($autoCompletionService.isObsoleteAutoCompletionEnabled()) { // obsolete autocompletion is enabled, update it to the new one: - await services.$autoCompletionService.enableAutoCompletion(); + await $autoCompletionService.enableAutoCompletion(); } else { - services.$logger.info("Autocompletion is already enabled"); + $logger.info("Autocompletion is already enabled"); } } else { - services.$logger.info( + $logger.info( "If you are using bash or zsh, you can enable command-line completion.", ); const message = "Do you want to enable it now?"; - const autoCompetionStatus = await services.$prompter.confirm( + const autoCompetionStatus = await $prompter.confirm( message, () => true, ); if (autoCompetionStatus) { - await services.$autoCompletionService.enableAutoCompletion(); + await $autoCompletionService.enableAutoCompletion(); } else { // make sure we've removed all autocompletion code from all shell profiles - services.$autoCompletionService.disableAutoCompletion(); + $autoCompletionService.disableAutoCompletion(); } } } @@ -61,12 +49,16 @@ export const disableAutoCompleteCommandDefinition = defineCommand({ description: "Disables command-line completion for the CLI.", arguments: "none", disableAnalytics: true, - setup: injectAutoCompleteCommandServices, - async run(context, services): Promise { - if (services.$autoCompletionService.isAutoCompletionEnabled()) { - services.$autoCompletionService.disableAutoCompletion(); + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); + + if ($autoCompletionService.isAutoCompletionEnabled()) { + $autoCompletionService.disableAutoCompletion(); } else { - services.$logger.info("Autocompletion is already disabled."); + $logger.info("Autocompletion is already disabled."); } }, }); @@ -76,12 +68,16 @@ export const enableAutoCompleteCommandDefinition = defineCommand({ description: "Enables command-line completion for the CLI.", arguments: "none", disableAnalytics: true, - setup: injectAutoCompleteCommandServices, - async run(context, services): Promise { - if (services.$autoCompletionService.isAutoCompletionEnabled()) { - services.$logger.info("Autocompletion is already enabled."); + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); + + if ($autoCompletionService.isAutoCompletionEnabled()) { + $logger.info("Autocompletion is already enabled."); } else { - await services.$autoCompletionService.enableAutoCompletion(); + await $autoCompletionService.enableAutoCompletion(); } }, }); @@ -91,12 +87,16 @@ export const autoCompleteStatusCommandDefinition = defineCommand({ description: "Prints whether command-line completion is enabled.", arguments: "none", disableAnalytics: true, - setup: injectAutoCompleteCommandServices, - async run(context, services): Promise { - if (services.$autoCompletionService.isAutoCompletionEnabled()) { - services.$logger.info("Autocompletion is enabled."); + async run(): Promise { + const $autoCompletionService = inject( + "autoCompletionService", + ); + const $logger = inject("logger"); + + if ($autoCompletionService.isAutoCompletionEnabled()) { + $logger.info("Autocompletion is enabled."); } else { - services.$logger.info("Autocompletion is disabled."); + $logger.info("Autocompletion is disabled."); } }, }); diff --git a/lib/common/commands/device/device-log-stream.ts b/lib/common/commands/device/device-log-stream.ts index 8c0dcda6f6..ad165f6bc7 100644 --- a/lib/common/commands/device/device-log-stream.ts +++ b/lib/common/commands/device/device-log-stream.ts @@ -1,7 +1,7 @@ import { ICleanupService } from "../../../definitions/cleanup-service"; +import { CommandsService } from "../../contracts/commands-service"; import { IErrors } from "../../declarations"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -15,62 +15,41 @@ const openDeviceLogStreamCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type OpenDeviceLogStreamCommandContext = CommandContext< - typeof openDeviceLogStreamCommandOptions ->; - -export interface IOpenDeviceLogStreamCommandServices { - $commandsService: ICommandsService; - $deviceLogProvider: Mobile.IDeviceLogProvider; - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $loggingLevels: Mobile.ILoggingLevels; -} - -export function setupOpenDeviceLogStreamCommand(): IOpenDeviceLogStreamCommandServices { - // The log stream is the command's whole output, so neither the simulator log - // provider nor the cleanup process may be torn down while it is open. The - // legacy command did this from its constructor, which ran before anything - // looked at the command line. - inject( - "iOSSimulatorLogProvider", - ).setShouldDispose(false); - inject("cleanupService").setShouldDispose(false); - - return { - $commandsService: inject("commandsService"), - $deviceLogProvider: inject("deviceLogProvider"), - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $loggingLevels: inject("loggingLevels"), - }; -} - -export async function runOpenDeviceLogStreamCommand( - context: OpenDeviceLogStreamCommandContext, - services: IOpenDeviceLogStreamCommandServices, -): Promise { - services.$deviceLogProvider.setLogLevel(services.$loggingLevels.full); - - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - - if (services.$devicesService.deviceCount > 1) { - await services.$commandsService.tryExecuteCommand("device", []); - services.$errors.failWithHelp(NOT_SPECIFIED_DEVICE_ERROR_MESSAGE); - } - - const action = (device: Mobile.IiOSDevice) => device.openDeviceLogStream(); - await services.$devicesService.execute(action); -} - export const openDeviceLogStreamCommandDefinition = defineCommand({ name: ["device|log", "devices|log"], description: "Opens the device log stream for a connected device.", options: openDeviceLogStreamCommandOptions, arguments: "none", - setup: setupOpenDeviceLogStreamCommand, - run: runOpenDeviceLogStreamCommand, + // The log stream is the command's whole output, so neither the simulator log + // provider nor the cleanup process may be torn down while it is open. In + // setup, so the flags are set at the point in the invocation they always were. + setup(): void { + inject( + "iOSSimulatorLogProvider", + ).setShouldDispose(false); + inject("cleanupService").setShouldDispose(false); + }, + async run(context): Promise { + const $commandsService = inject(CommandsService); + const $deviceLogProvider = + inject("deviceLogProvider"); + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $loggingLevels = inject("loggingLevels"); + + $deviceLogProvider.setLogLevel($loggingLevels.full); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + + if ($devicesService.deviceCount > 1) { + await $commandsService.runCommand("device"); + $errors.failWithHelp(NOT_SPECIFIED_DEVICE_ERROR_MESSAGE); + } + + const action = (device: Mobile.IiOSDevice) => device.openDeviceLogStream(); + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/device/get-file.ts b/lib/common/commands/device/get-file.ts index a005cd5361..c6c2a2d760 100644 --- a/lib/common/commands/device/get-file.ts +++ b/lib/common/commands/device/get-file.ts @@ -1,7 +1,6 @@ import { IProjectData } from "../../../definitions/project"; import { IErrors } from "../../declarations"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -13,67 +12,47 @@ const getFileCommandOptions = { file: stringOption(), } satisfies CommandOptionsSchema; -export type GetFileCommandContext = CommandContext< - typeof getFileCommandOptions ->; - -export interface IGetFileCommandServices { - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $projectData: IProjectData; -} - -export function setupGetFileCommand(): IGetFileCommandServices { - return { - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $projectData: inject("projectData"), - }; -} - -export async function runGetFileCommand( - context: GetFileCommandContext, - services: IGetFileCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - let appIdentifier = context.args[1]; - - if (!appIdentifier) { - try { - services.$projectData.initializeProjectData(); - } catch (err) { - // ignore the error - } - if (!services.$projectData.projectIdentifiers) { - services.$errors.fail( - "Please enter application identifier or execute this command in project.", - ); - } - } - - const action = async (device: Mobile.IDevice) => { - appIdentifier = - appIdentifier || - services.$projectData.projectIdentifiers[ - device.deviceInfo.platform.toLowerCase() - ]; - await device.fileSystem.getFile( - context.args[0], - appIdentifier, - context.options.file, - ); - }; - await services.$devicesService.execute(action); -} - export const getFileCommandDefinition = defineCommand({ name: ["device|get-file", "devices|get-file"], description: "Downloads a file from a connected device.", options: getFileCommandOptions, arguments: [{ name: "path" }, { name: "appId" }], - setup: setupGetFileCommand, - run: runGetFileCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $projectData = inject("projectData"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + let appIdentifier = context.args[1]; + + if (!appIdentifier) { + try { + $projectData.initializeProjectData(); + } catch (err) { + // ignore the error + } + if (!$projectData.projectIdentifiers) { + $errors.fail( + "Please enter application identifier or execute this command in project.", + ); + } + } + + const action = async (device: Mobile.IDevice) => { + appIdentifier = + appIdentifier || + $projectData.projectIdentifiers[ + device.deviceInfo.platform.toLowerCase() + ]; + await device.fileSystem.getFile( + context.args[0], + appIdentifier, + context.options.file, + ); + }; + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/device/list-applications.ts b/lib/common/commands/device/list-applications.ts index b53f6af372..2b2a375353 100644 --- a/lib/common/commands/device/list-applications.ts +++ b/lib/common/commands/device/list-applications.ts @@ -2,7 +2,6 @@ import * as _ from "lodash"; import { EOL } from "os"; import * as util from "util"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -13,54 +12,37 @@ const listApplicationsCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type ListApplicationsCommandContext = CommandContext< - typeof listApplicationsCommandOptions ->; - -export interface IListApplicationsCommandServices { - $devicesService: Mobile.IDevicesService; - $logger: ILogger; -} - -export function setupListApplicationsCommand(): IListApplicationsCommandServices { - return { - $devicesService: inject("devicesService"), - $logger: inject("logger"), - }; -} - -export async function runListApplicationsCommand( - context: ListApplicationsCommandContext, - services: IListApplicationsCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - const output: string[] = []; - - const action = async (device: Mobile.IDevice) => { - const applications = - await device.applicationManager.getInstalledApplications(); - output.push( - util.format( - "%s=====Installed applications on device with UDID '%s' are:", - EOL, - device.deviceInfo.identifier, - ), - ); - _.each(applications, (applicationId: string) => output.push(applicationId)); - }; - await services.$devicesService.execute(action); - - services.$logger.info(output.join(EOL)); -} - export const listApplicationsCommandDefinition = defineCommand({ name: ["device|list-applications", "devices|list-applications"], description: "Lists the installed applications on all connected devices.", options: listApplicationsCommandOptions, arguments: "none", - setup: setupListApplicationsCommand, - run: runListApplicationsCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $logger = inject("logger"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + const output: string[] = []; + + const action = async (device: Mobile.IDevice) => { + const applications = + await device.applicationManager.getInstalledApplications(); + output.push( + util.format( + "%s=====Installed applications on device with UDID '%s' are:", + EOL, + device.deviceInfo.identifier, + ), + ); + _.each(applications, (applicationId: string) => + output.push(applicationId), + ); + }; + await $devicesService.execute(action); + + $logger.info(output.join(EOL)); + }, }); diff --git a/lib/common/commands/device/list-devices.ts b/lib/common/commands/device/list-devices.ts index 3564288a9e..24ed93670d 100644 --- a/lib/common/commands/device/list-devices.ts +++ b/lib/common/commands/device/list-devices.ts @@ -3,6 +3,7 @@ import { DeviceConnectionType } from "../../../constants"; import { IErrors } from "../../declarations"; import { booleanOption, + Command, CommandContext, CommandName, CommandOptionsSchema, @@ -16,30 +17,12 @@ const listDevicesCommandOptions = { json: booleanOption(), } satisfies CommandOptionsSchema; -export type ListDevicesCommandContext = CommandContext< +type ListDevicesCommandContext = CommandContext< typeof listDevicesCommandOptions >; -export interface IListDevicesCommandServices { - $devicesService: Mobile.IDevicesService; - $emulatorHelper: Mobile.IEmulatorHelper; - $errors: IErrors; - $logger: ILogger; - $mobileHelper: Mobile.IMobileHelper; -} - -export function setupListDevicesCommand(): IListDevicesCommandServices { - return { - $devicesService: inject("devicesService"), - $emulatorHelper: inject("emulatorHelper"), - $errors: inject("errors"), - $logger: inject("logger"), - $mobileHelper: inject("mobileHelper"), - }; -} - function printEmulators( - services: IListDevicesCommandServices, + $logger: ILogger, emulators: Mobile.IDeviceInfo[], ): void { const table: any = createTable( @@ -64,14 +47,22 @@ function printEmulators( ]); } - services.$logger.info(table.toString()); + $logger.info(table.toString()); } -export async function runListDevicesCommand( +async function listDevices( context: ListDevicesCommandContext, - services: IListDevicesCommandServices, platformFilter: string, ): Promise { + const $devicesService = + context.injector.get("devicesService"); + const $emulatorHelper = + context.injector.get("emulatorHelper"); + const $errors = context.injector.get("errors"); + const $logger = context.injector.get("logger"); + const $mobileHelper = + context.injector.get("mobileHelper"); + const devices: { available?: any[]; devices: any[]; @@ -80,32 +71,31 @@ export async function runListDevicesCommand( }; if (context.options.availableDevices) { - const platform = - services.$mobileHelper.normalizePlatformName(platformFilter); + const platform = $mobileHelper.normalizePlatformName(platformFilter); if (!platform && platformFilter) { - services.$errors.fail( + $errors.fail( `${platformFilter} is not a valid device platform. The valid platforms are ${formatListOfNames( - services.$mobileHelper.platformNames, + $mobileHelper.platformNames, )}`, ); } - const availableEmulatorsOutput = - await services.$devicesService.getEmulatorImages({ platform }); - const emulators = - services.$emulatorHelper.getEmulatorsFromAvailableEmulatorsOutput( - availableEmulatorsOutput, - ); + const availableEmulatorsOutput = await $devicesService.getEmulatorImages({ + platform, + }); + const emulators = $emulatorHelper.getEmulatorsFromAvailableEmulatorsOutput( + availableEmulatorsOutput, + ); devices.available = emulators; if (!context.options.json) { - services.$logger.info(color.bold("\n Available emulators")); - printEmulators(services, emulators); + $logger.info(color.bold("\n Available emulators")); + printEmulators($logger, emulators); } } let index = 1; - await services.$devicesService.initialize({ + await $devicesService.initialize({ platform: platformFilter, deviceId: null, skipInferPlatform: true, @@ -115,7 +105,7 @@ export async function runListDevicesCommand( }); if (!context.options.json) { - services.$logger.info(color.bold("\n Connected devices & emulators")); + $logger.info(color.bold("\n Connected devices & emulators")); } const table: any = createTable( @@ -151,34 +141,32 @@ export async function runListDevicesCommand( }; } - await services.$devicesService.execute(action, undefined, { + await $devicesService.execute(action, undefined, { allowNoDevices: true, }); if (context.options.json) { - return services.$logger.info(JSON.stringify(devices, null, 2)); + return $logger.info(JSON.stringify(devices, null, 2)); } if (table.length) { - services.$logger.info(table.toString()); + $logger.info(table.toString()); } } -export const listDevicesCommandDefinition = defineCommand({ +export class ListDevicesCommand extends Command({ name: ["device|*list", "devices|*list"], description: "Lists the connected devices and emulators.", options: listDevicesCommandOptions, arguments: [{ name: "platform" }], - setup: setupListDevicesCommand, - run(context, services): Promise { - return runListDevicesCommand(context, services, context.args[0]); - }, -}); - -interface IListPlatformDevicesCommandServices extends IListDevicesCommandServices { - platform: string; +}) { + public run(): Promise { + return listDevices(this.context, this.args[0]); + } } +// One definition per platform, generated: the object form is what a family of +// commands needs, where the class form fits a single named command. const defineListPlatformDevicesCommand = ( name: TName, listedPlatform: "iOS" | "Android", @@ -188,17 +176,12 @@ const defineListPlatformDevicesCommand = ( description: "Lists the connected devices and emulators for one platform.", options: listDevicesCommandOptions, arguments: "none", - setup(): IListPlatformDevicesCommandServices { - const $devicePlatformsConstants = - inject("devicePlatformsConstants"); - - return { - ...setupListDevicesCommand(), - platform: $devicePlatformsConstants[listedPlatform], - }; - }, - run(context, services): Promise { - return runListDevicesCommand(context, services, services.platform); + run(context): Promise { + const platform = inject( + "devicePlatformsConstants", + )[listedPlatform]; + + return listDevices(context, platform); }, }); diff --git a/lib/common/commands/device/list-files.ts b/lib/common/commands/device/list-files.ts index 29b02f3097..c20e591cd9 100644 --- a/lib/common/commands/device/list-files.ts +++ b/lib/common/commands/device/list-files.ts @@ -1,7 +1,6 @@ import { IProjectData } from "../../../definitions/project"; import { IErrors } from "../../declarations"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -12,64 +11,44 @@ const listFilesCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type ListFilesCommandContext = CommandContext< - typeof listFilesCommandOptions ->; - -export interface IListFilesCommandServices { - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $projectData: IProjectData; -} - -export function setupListFilesCommand(): IListFilesCommandServices { - return { - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $projectData: inject("projectData"), - }; -} - -export async function runListFilesCommand( - context: ListFilesCommandContext, - services: IListFilesCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - const pathToList = context.args[0]; - let appIdentifier = context.args[1]; - - if (!appIdentifier) { - try { - services.$projectData.initializeProjectData(); - } catch (err) { - // ignore the error - } - if (!services.$projectData.projectIdentifiers) { - services.$errors.fail( - "Please enter application identifier or execute this command in project.", - ); - } - } - - const action = async (device: Mobile.IDevice) => { - appIdentifier = - appIdentifier || - services.$projectData.projectIdentifiers[ - device.deviceInfo.platform.toLowerCase() - ]; - await device.fileSystem.listFiles(pathToList, appIdentifier); - }; - await services.$devicesService.execute(action); -} - export const listFilesCommandDefinition = defineCommand({ name: ["device|list-files", "devices|list-files"], description: "Lists the files in a directory on a connected device.", options: listFilesCommandOptions, arguments: [{ name: "path" }, { name: "appId" }], - setup: setupListFilesCommand, - run: runListFilesCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $projectData = inject("projectData"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + const pathToList = context.args[0]; + let appIdentifier = context.args[1]; + + if (!appIdentifier) { + try { + $projectData.initializeProjectData(); + } catch (err) { + // ignore the error + } + if (!$projectData.projectIdentifiers) { + $errors.fail( + "Please enter application identifier or execute this command in project.", + ); + } + } + + const action = async (device: Mobile.IDevice) => { + appIdentifier = + appIdentifier || + $projectData.projectIdentifiers[ + device.deviceInfo.platform.toLowerCase() + ]; + await device.fileSystem.listFiles(pathToList, appIdentifier); + }; + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/device/put-file.ts b/lib/common/commands/device/put-file.ts index b2ff383305..bd658db719 100644 --- a/lib/common/commands/device/put-file.ts +++ b/lib/common/commands/device/put-file.ts @@ -1,7 +1,6 @@ import { IProjectData } from "../../../definitions/project"; import { IErrors } from "../../declarations"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -12,67 +11,47 @@ const putFileCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type PutFileCommandContext = CommandContext< - typeof putFileCommandOptions ->; - -export interface IPutFileCommandServices { - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $projectData: IProjectData; -} - -export function setupPutFileCommand(): IPutFileCommandServices { - return { - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $projectData: inject("projectData"), - }; -} - -export async function runPutFileCommand( - context: PutFileCommandContext, - services: IPutFileCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - let appIdentifier = context.args[2]; - - if (!appIdentifier) { - try { - services.$projectData.initializeProjectData(); - } catch (err) { - // ignore the error - } - if (!services.$projectData.projectIdentifiers) { - services.$errors.fail( - "Please enter application identifier or execute this command in project.", - ); - } - } - - const action = async (device: Mobile.IDevice) => { - appIdentifier = - appIdentifier || - services.$projectData.projectIdentifiers[ - device.deviceInfo.platform.toLowerCase() - ]; - await device.fileSystem.putFile( - context.args[0], - context.args[1], - appIdentifier, - ); - }; - await services.$devicesService.execute(action); -} - export const putFileCommandDefinition = defineCommand({ name: ["device|put-file", "devices|put-file"], description: "Uploads a file to a connected device.", options: putFileCommandOptions, arguments: [{ name: "localPath" }, { name: "devicePath" }, { name: "appId" }], - setup: setupPutFileCommand, - run: runPutFileCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $projectData = inject("projectData"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + let appIdentifier = context.args[2]; + + if (!appIdentifier) { + try { + $projectData.initializeProjectData(); + } catch (err) { + // ignore the error + } + if (!$projectData.projectIdentifiers) { + $errors.fail( + "Please enter application identifier or execute this command in project.", + ); + } + } + + const action = async (device: Mobile.IDevice) => { + appIdentifier = + appIdentifier || + $projectData.projectIdentifiers[ + device.deviceInfo.platform.toLowerCase() + ]; + await device.fileSystem.putFile( + context.args[0], + context.args[1], + appIdentifier, + ); + }; + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/device/run-application.ts b/lib/common/commands/device/run-application.ts index 147060988f..78b97ed2ad 100644 --- a/lib/common/commands/device/run-application.ts +++ b/lib/common/commands/device/run-application.ts @@ -1,6 +1,5 @@ import { IErrors } from "../../declarations"; import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -11,55 +10,35 @@ const runApplicationOnDeviceCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type RunApplicationOnDeviceCommandContext = CommandContext< - typeof runApplicationOnDeviceCommandOptions ->; - -export interface IRunApplicationOnDeviceCommandServices { - $devicesService: Mobile.IDevicesService; - $errors: IErrors; - $staticConfig: Config.IStaticConfig; -} - -export function setupRunApplicationOnDeviceCommand(): IRunApplicationOnDeviceCommandServices { - return { - $devicesService: inject("devicesService"), - $errors: inject("errors"), - $staticConfig: inject("staticConfig"), - }; -} - -export async function runRunApplicationOnDeviceCommand( - context: RunApplicationOnDeviceCommandContext, - services: IRunApplicationOnDeviceCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - - if (services.$devicesService.deviceCount > 1) { - services.$errors.failWithHelp( - "More than one device found. Specify device explicitly with --device option. To discover device ID, use $%s device command.", - services.$staticConfig.CLIENT_NAME.toLowerCase(), - ); - } - - await services.$devicesService.execute( - async (device: Mobile.IDevice) => - await device.applicationManager.startApplication({ - appId: context.args[0], - projectName: context.args[1], - projectDir: null, - }), - ); -} - export const runApplicationOnDeviceCommandDefinition = defineCommand({ name: ["device|run", "devices|run"], description: "Runs the selected application on a connected device.", options: runApplicationOnDeviceCommandOptions, arguments: [{ name: "appId" }, { name: "projectName" }], - setup: setupRunApplicationOnDeviceCommand, - run: runRunApplicationOnDeviceCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + const $errors = inject("errors"); + const $staticConfig = inject("staticConfig"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + + if ($devicesService.deviceCount > 1) { + $errors.failWithHelp( + "More than one device found. Specify device explicitly with --device option. To discover device ID, use $%s device command.", + $staticConfig.CLIENT_NAME.toLowerCase(), + ); + } + + await $devicesService.execute( + async (device: Mobile.IDevice) => + await device.applicationManager.startApplication({ + appId: context.args[0], + projectName: context.args[1], + projectDir: null, + }), + ); + }, }); diff --git a/lib/common/commands/device/stop-application.ts b/lib/common/commands/device/stop-application.ts index 7c9c7a0e48..e0463ca2df 100644 --- a/lib/common/commands/device/stop-application.ts +++ b/lib/common/commands/device/stop-application.ts @@ -1,5 +1,4 @@ import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -10,44 +9,26 @@ const stopApplicationOnDeviceCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type StopApplicationOnDeviceCommandContext = CommandContext< - typeof stopApplicationOnDeviceCommandOptions ->; - -export interface IStopApplicationOnDeviceCommandServices { - $devicesService: Mobile.IDevicesService; -} - -export function setupStopApplicationOnDeviceCommand(): IStopApplicationOnDeviceCommandServices { - return { - $devicesService: inject("devicesService"), - }; -} - -export async function runStopApplicationOnDeviceCommand( - context: StopApplicationOnDeviceCommandContext, - services: IStopApplicationOnDeviceCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - platform: context.args[1], - }); - - const action = (device: Mobile.IDevice) => - device.applicationManager.stopApplication({ - appId: context.args[0], - projectName: context.args[2], - projectDir: null, - }); - await services.$devicesService.execute(action); -} - export const stopApplicationOnDeviceCommandDefinition = defineCommand({ name: ["device|stop", "devices|stop"], description: "Stops the selected application on a connected device.", options: stopApplicationOnDeviceCommandOptions, arguments: [{ name: "appId" }, { name: "platform" }, { name: "projectName" }], - setup: setupStopApplicationOnDeviceCommand, - run: runStopApplicationOnDeviceCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + platform: context.args[1], + }); + + const action = (device: Mobile.IDevice) => + device.applicationManager.stopApplication({ + appId: context.args[0], + projectName: context.args[2], + projectDir: null, + }); + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/device/uninstall-application.ts b/lib/common/commands/device/uninstall-application.ts index b37cffaa2d..9d90e88f53 100644 --- a/lib/common/commands/device/uninstall-application.ts +++ b/lib/common/commands/device/uninstall-application.ts @@ -1,5 +1,4 @@ import { - CommandContext, CommandOptionsSchema, defineCommand, stringOption, @@ -10,39 +9,21 @@ const uninstallApplicationCommandOptions = { device: stringOption(), } satisfies CommandOptionsSchema; -export type UninstallApplicationCommandContext = CommandContext< - typeof uninstallApplicationCommandOptions ->; - -export interface IUninstallApplicationCommandServices { - $devicesService: Mobile.IDevicesService; -} - -export function setupUninstallApplicationCommand(): IUninstallApplicationCommandServices { - return { - $devicesService: inject("devicesService"), - }; -} - -export async function runUninstallApplicationCommand( - context: UninstallApplicationCommandContext, - services: IUninstallApplicationCommandServices, -): Promise { - await services.$devicesService.initialize({ - deviceId: context.options.device, - skipInferPlatform: true, - }); - - const action = (device: Mobile.IDevice) => - device.applicationManager.uninstallApplication(context.args[0]); - await services.$devicesService.execute(action); -} - export const uninstallApplicationCommandDefinition = defineCommand({ name: ["device|uninstall", "devices|uninstall"], description: "Uninstalls an application from all connected devices.", options: uninstallApplicationCommandOptions, arguments: [{ name: "appId" }], - setup: setupUninstallApplicationCommand, - run: runUninstallApplicationCommand, + async run(context): Promise { + const $devicesService = inject("devicesService"); + + await $devicesService.initialize({ + deviceId: context.options.device, + skipInferPlatform: true, + }); + + const action = (device: Mobile.IDevice) => + device.applicationManager.uninstallApplication(context.args[0]); + await $devicesService.execute(action); + }, }); diff --git a/lib/common/commands/doctor.ts b/lib/common/commands/doctor.ts index b780b0d9e5..5d0b04fe3d 100644 --- a/lib/common/commands/doctor.ts +++ b/lib/common/commands/doctor.ts @@ -3,22 +3,6 @@ import { CommandName, defineCommand } from "../define-command"; import { inject } from "../di"; import { PlatformTypes } from "../../constants"; -export interface IDoctorCommandServices { - platform: PlatformTypes; - $doctorService: IDoctorService; - $projectHelper: IProjectHelper; -} - -export function setupDoctorCommand( - platform?: PlatformTypes, -): IDoctorCommandServices { - return { - platform, - $doctorService: inject("doctorService"), - $projectHelper: inject("projectHelper"), - }; -} - const defineDoctorCommand = ( name: TName, platform?: PlatformTypes, @@ -28,13 +12,15 @@ const defineDoctorCommand = ( description: "Checks the local environment for configuration issues, and prints what it finds.", arguments: "none", - setup: () => setupDoctorCommand(platform), - run(context, services): Promise { - return services.$doctorService.printWarnings({ + run(): Promise { + const $doctorService = inject("doctorService"); + const $projectHelper = inject("projectHelper"); + + return $doctorService.printWarnings({ trackResult: false, - projectDir: services.$projectHelper.projectDir, + projectDir: $projectHelper.projectDir, forceCheck: true, - ...(services.platform ? { platform: services.platform } : {}), + ...(platform ? { platform } : {}), }); }, }); diff --git a/lib/common/commands/generate-messages.ts b/lib/common/commands/generate-messages.ts index 80cc52dfb5..e508491a86 100644 --- a/lib/common/commands/generate-messages.ts +++ b/lib/common/commands/generate-messages.ts @@ -19,14 +19,13 @@ export const generateMessagesCommandDefinition = defineCommand({ description: "Regenerates the CLI's message contracts.", options: generateMessagesCommandOptions, arguments: "none", - setup: () => ({ - $fs: inject("fs"), - $messageContractGenerator: inject( + async run(context): Promise { + const $fs = inject("fs"); + const $messageContractGenerator = inject( "messageContractGenerator", - ), - }), - async run(context, services): Promise { - const result = await services.$messageContractGenerator.generate(); + ); + + const result = await $messageContractGenerator.generate(); const innerMessagesDirectory = path.join(__dirname, "../messages"); const outerMessagesDirectory = path.join(__dirname, "../.."); let interfaceFilePath: string; @@ -52,7 +51,7 @@ export const generateMessagesCommandDefinition = defineCommand({ ); } - services.$fs.writeFile(interfaceFilePath, result.interfaceFile); - services.$fs.writeFile(implementationFilePath, result.implementationFile); + $fs.writeFile(interfaceFilePath, result.interfaceFile); + $fs.writeFile(implementationFilePath, result.implementationFile); }, }); diff --git a/lib/common/commands/help.ts b/lib/common/commands/help.ts index eba2fad259..f04f04dccb 100644 --- a/lib/common/commands/help.ts +++ b/lib/common/commands/help.ts @@ -1,69 +1,44 @@ import * as _ from "lodash"; import { CommandRegistry } from "../contracts/command-registry"; import { IHelpService } from "../declarations"; -import { - booleanOption, - CommandContext, - CommandOptionsSchema, - defineCommand, -} from "../define-command"; +import { booleanOption, defineCommand } from "../define-command"; import { inject } from "../di"; -export const helpCommandOptions = { - help: booleanOption(), -} satisfies CommandOptionsSchema; - -export type HelpCommandContext = CommandContext; - -export interface IHelpCommandServices { - $commandRegistry: CommandRegistry; - $helpService: IHelpService; -} - -export function setupHelpCommand(): IHelpCommandServices { - return { - $commandRegistry: inject(CommandRegistry), - $helpService: inject("helpService"), - }; -} - -export async function runHelpCommand( - context: HelpCommandContext, - services: IHelpCommandServices, -): Promise { - const args = context.args; - let commandName = (args[0] || "").toLowerCase(); - let commandArguments = _.tail(args); - const hierarchicalCommand = - services.$commandRegistry.buildHierarchicalCommand( - args[0], - commandArguments, - ); - if (hierarchicalCommand) { - commandName = hierarchicalCommand.commandName; - commandArguments = hierarchicalCommand.remainingArguments; - } - - const commandData: ICommandData = { - commandName, - commandArguments, - }; - - if (context.options.help) { - await services.$helpService.showCommandLineHelp(commandData); - } else { - await services.$helpService.openHelpForCommandInBrowser(commandData); - } -} - export const helpCommandDefinition = defineCommand({ name: ["help", "/?"], description: "Shows the help for a command.", - options: helpCommandOptions, + options: { + help: booleanOption(), + }, // The command names whatever command it explains, so every argument after // the first is that command's own. arguments: "any", enableHooks: false, - setup: setupHelpCommand, - run: runHelpCommand, + async run(context): Promise { + const $commandRegistry = inject(CommandRegistry); + const $helpService = inject("helpService"); + + const args = context.args; + let commandName = (args[0] || "").toLowerCase(); + let commandArguments = _.tail(args); + const hierarchicalCommand = $commandRegistry.buildHierarchicalCommand( + args[0], + commandArguments, + ); + if (hierarchicalCommand) { + commandName = hierarchicalCommand.commandName; + commandArguments = hierarchicalCommand.remainingArguments; + } + + const commandData: ICommandData = { + commandName, + commandArguments, + }; + + if (context.options.help) { + await $helpService.showCommandLineHelp(commandData); + } else { + await $helpService.openHelpForCommandInBrowser(commandData); + } + }, }); diff --git a/lib/common/commands/package-manager-get.ts b/lib/common/commands/package-manager-get.ts index f47eabaf0d..47c95460b1 100644 --- a/lib/common/commands/package-manager-get.ts +++ b/lib/common/commands/package-manager-get.ts @@ -2,29 +2,17 @@ import { IUserSettingsService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -export interface IPackageManagerGetCommandServices { - $logger: ILogger; - $userSettingsService: IUserSettingsService; -} - -export function setupPackageManagerGetCommand(): IPackageManagerGetCommandServices { - return { - $logger: inject("logger"), - $userSettingsService: inject("userSettingsService"), - }; -} - export const packageManagerGetCommandDefinition = defineCommand({ name: "package-manager|*get", description: "Prints the value of the current package manager.", - setup: setupPackageManagerGetCommand, - async run( - context, - services: IPackageManagerGetCommandServices, - ): Promise { - const result = - await services.$userSettingsService.getSettingValue("packageManager"); - services.$logger.printMarkdown( + async run(): Promise { + const $logger = inject("logger"); + const $userSettingsService = inject( + "userSettingsService", + ); + + const result = await $userSettingsService.getSettingValue("packageManager"); + $logger.printMarkdown( `Your current package manager is \`${result || "npm"}\`.`, ); }, diff --git a/lib/common/commands/package-manager-set.ts b/lib/common/commands/package-manager-set.ts index d192218634..64d09c5a8a 100644 --- a/lib/common/commands/package-manager-set.ts +++ b/lib/common/commands/package-manager-set.ts @@ -3,48 +3,36 @@ import { IErrors, IUserSettingsService } from "../declarations"; import { defineCommand } from "../define-command"; import { inject } from "../di"; -export interface IPackageManagerSetCommandServices { - $userSettingsService: IUserSettingsService; - $errors: IErrors; - $logger: ILogger; -} - -export function setupPackageManagerSetCommand(): IPackageManagerSetCommandServices { - return { - $userSettingsService: inject("userSettingsService"), - $errors: inject("errors"), - $logger: inject("logger"), - }; -} - export const packageManagerSetCommandDefinition = defineCommand({ name: "package-manager|set", description: "Sets the package manager the CLI installs dependencies with.", arguments: [{ name: "packageManager" }], - setup: setupPackageManagerSetCommand, - async run( - context, - services: IPackageManagerSetCommandServices, - ): Promise { + async run(context): Promise { + const $userSettingsService = inject( + "userSettingsService", + ); + const $errors = inject("errors"); + const $logger = inject("logger"); + const packageManagerName = context.args[0]; const supportedPackageManagers = Object.keys(PackageManagers); if (supportedPackageManagers.indexOf(packageManagerName) === -1) { - services.$errors.fail( + $errors.fail( `${packageManagerName} is not a valid package manager. Supported values are: ${supportedPackageManagers.join( ", ", )}.`, ); } - await services.$userSettingsService.saveSetting( + await $userSettingsService.saveSetting( "packageManager", packageManagerName, ); - services.$logger.printMarkdown( + $logger.printMarkdown( `Please ensure you have the directory containing \`${packageManagerName}\` executable available in your PATH.`, ); - services.$logger.printMarkdown( + $logger.printMarkdown( `You've successfully set \`${packageManagerName}\` as your package manager.`, ); }, diff --git a/lib/common/commands/post-install.ts b/lib/common/commands/post-install.ts index dfbdfe571d..fe027b6dd7 100644 --- a/lib/common/commands/post-install.ts +++ b/lib/common/commands/post-install.ts @@ -7,11 +7,10 @@ export const postInstallCommandDefinition = defineCommand({ description: "Deprecated; use `ns dev-post-install-cli`.", arguments: "none", disableAnalytics: true, - setup: () => ({ - $errors: inject("errors"), - }), - async run(context, services): Promise { - services.$errors.fail( + async run(): Promise { + const $errors = inject("errors"); + + $errors.fail( "This command is deprecated. Use `ns dev-post-install-cli` instead", ); }, diff --git a/lib/common/commands/preuninstall.ts b/lib/common/commands/preuninstall.ts index 0eaa95b4c2..7a1b823fb5 100644 --- a/lib/common/commands/preuninstall.ts +++ b/lib/common/commands/preuninstall.ts @@ -17,28 +17,6 @@ import { IExtensibilityService } from "../definitions/extensibility"; // disabled for now (6/24/2020) // const FEEDBACK_FORM_URL = "https://www.nativescript.org/uninstall-feedback"; -export interface IPreUninstallCommandServices { - $analyticsService: IAnalyticsService; - $extensibilityService: IExtensibilityService; - $fs: IFileSystem; - $packageInstallationManager: IPackageInstallationManager; - $settingsService: ISettingsService; -} - -export function setupPreUninstallCommand(): IPreUninstallCommandServices { - return { - $analyticsService: inject("analyticsService"), - $extensibilityService: inject( - "extensibilityService", - ), - $fs: inject("fs"), - $packageInstallationManager: inject( - "packageInstallationManager", - ), - $settingsService: inject("settingsService"), - }; -} - async function handleFeedbackForm(): Promise { // disabled for now (6/24/2020) // if (isInteractive()) { @@ -48,10 +26,11 @@ async function handleFeedbackForm(): Promise { } async function handleIntentionalUninstall( - services: IPreUninstallCommandServices, + $extensibilityService: IExtensibilityService, + $packageInstallationManager: IPackageInstallationManager, ): Promise { - services.$extensibilityService.removeAllExtensions(); - services.$packageInstallationManager.clearInspectorCache(); + $extensibilityService.removeAllExtensions(); + $packageInstallationManager.clearInspectorCache(); await handleFeedbackForm(); } @@ -59,8 +38,17 @@ export const preUninstallCommandDefinition = defineCommand({ name: "dev-preuninstall", description: "Runs the CLI's own uninstall bookkeeping.", arguments: "none", - setup: setupPreUninstallCommand, - async run(context, services): Promise { + async run(): Promise { + const $analyticsService = inject("analyticsService"); + const $extensibilityService = inject( + "extensibilityService", + ); + const $fs = inject("fs"); + const $packageInstallationManager = inject( + "packageInstallationManager", + ); + const $settingsService = inject("settingsService"); + const isIntentionalUninstall = doesCurrentNpmCommandMatch([ /^uninstall$/, /^remove$/, @@ -70,22 +58,21 @@ export const preUninstallCommandDefinition = defineCommand({ /^unlink$/, ]); - await services.$analyticsService.trackEventActionInGoogleAnalytics({ + await $analyticsService.trackEventActionInGoogleAnalytics({ action: TrackActionNames.UninstallCLI, additionalData: `isIntentionalUninstall${AnalyticsEventLabelDelimiter}${isIntentionalUninstall}${AnalyticsEventLabelDelimiter}isInteractive${AnalyticsEventLabelDelimiter}${!!isInteractive()}`, }); if (isIntentionalUninstall) { - await handleIntentionalUninstall(services); + await handleIntentionalUninstall( + $extensibilityService, + $packageInstallationManager, + ); } - services.$fs.deleteFile( - path.join( - services.$settingsService.getProfileDir(), - "KillSwitches", - "cli", - ), + $fs.deleteFile( + path.join($settingsService.getProfileDir(), "KillSwitches", "cli"), ); - await services.$analyticsService.finishTracking(); + await $analyticsService.finishTracking(); }, }); diff --git a/lib/common/commands/proxy/proxy-base.ts b/lib/common/commands/proxy/proxy-base.ts index e9fbd68597..f7908b51a1 100644 --- a/lib/common/commands/proxy/proxy-base.ts +++ b/lib/common/commands/proxy/proxy-base.ts @@ -1,31 +1,14 @@ -import { IAnalyticsService, IProxyService } from "../../declarations"; -import { inject } from "../../di"; - -export interface IProxyCommandServices { - $analyticsService: IAnalyticsService; - $logger: ILogger; - $proxyService: IProxyService; -} - -export function injectProxyCommandServices(): IProxyCommandServices { - return { - $analyticsService: inject("analyticsService"), - $logger: inject("logger"), - $proxyService: inject("proxyService"), - }; -} - export async function tryTrackProxyCommandUsage( - services: IProxyCommandServices, + $logger: ILogger, commandName: string, ): Promise { try { // TODO(Analytics): Check why we have set the `disableAnalytics` to true and we track the command as separate one // instead of tracking it through the commandsService. - services.$logger.trace(commandName); - // await services.$analyticsService.trackFeature(commandName); + $logger.trace(commandName); + // await $analyticsService.trackFeature(commandName); } catch (ex) { - services.$logger.trace("Error in trying to track proxy command usage:"); - services.$logger.trace(ex); + $logger.trace("Error in trying to track proxy command usage:"); + $logger.trace(ex); } } diff --git a/lib/common/commands/proxy/proxy-clear.ts b/lib/common/commands/proxy/proxy-clear.ts index a48655f22f..80624762ab 100644 --- a/lib/common/commands/proxy/proxy-clear.ts +++ b/lib/common/commands/proxy/proxy-clear.ts @@ -1,9 +1,7 @@ +import { IProxyService } from "../../declarations"; import { defineCommand } from "../../define-command"; -import { - injectProxyCommandServices, - IProxyCommandServices, - tryTrackProxyCommandUsage, -} from "./proxy-base"; +import { inject } from "../../di"; +import { tryTrackProxyCommandUsage } from "./proxy-base"; const proxyClearCommandName = "proxy|clear"; @@ -12,10 +10,12 @@ export const proxyClearCommandDefinition = defineCommand({ description: "Clears the currently configured proxy settings.", arguments: "none", disableAnalytics: true, - setup: injectProxyCommandServices, - async run(context, services: IProxyCommandServices): Promise { - await services.$proxyService.clearCache(); - services.$logger.info("Successfully cleared proxy."); - await tryTrackProxyCommandUsage(services, proxyClearCommandName); + async run(): Promise { + const $logger = inject("logger"); + const $proxyService = inject("proxyService"); + + await $proxyService.clearCache(); + $logger.info("Successfully cleared proxy."); + await tryTrackProxyCommandUsage($logger, proxyClearCommandName); }, }); diff --git a/lib/common/commands/proxy/proxy-get.ts b/lib/common/commands/proxy/proxy-get.ts index 7325648ed2..263a1e70c2 100644 --- a/lib/common/commands/proxy/proxy-get.ts +++ b/lib/common/commands/proxy/proxy-get.ts @@ -1,9 +1,7 @@ +import { IProxyService } from "../../declarations"; import { defineCommand } from "../../define-command"; -import { - injectProxyCommandServices, - IProxyCommandServices, - tryTrackProxyCommandUsage, -} from "./proxy-base"; +import { inject } from "../../di"; +import { tryTrackProxyCommandUsage } from "./proxy-base"; const proxyGetCommandName = "proxy|*get"; @@ -12,9 +10,11 @@ export const proxyGetCommandDefinition = defineCommand({ description: "Prints the current proxy settings.", arguments: "none", disableAnalytics: true, - setup: injectProxyCommandServices, - async run(context, services: IProxyCommandServices): Promise { - services.$logger.info(await services.$proxyService.getInfo()); - await tryTrackProxyCommandUsage(services, proxyGetCommandName); + async run(): Promise { + const $logger = inject("logger"); + const $proxyService = inject("proxyService"); + + $logger.info(await $proxyService.getInfo()); + await tryTrackProxyCommandUsage($logger, proxyGetCommandName); }, }); diff --git a/lib/common/commands/proxy/proxy-set.ts b/lib/common/commands/proxy/proxy-set.ts index fd99b24ba9..cea5c96280 100644 --- a/lib/common/commands/proxy/proxy-set.ts +++ b/lib/common/commands/proxy/proxy-set.ts @@ -1,25 +1,21 @@ import { EOL, platform } from "os"; -import { parse } from "url"; +import { parse, UrlWithStringQuery } from "url"; import { HttpProtocolToPort } from "../../constants"; import { IErrors, IHostInfo, IProxyLibSettings, + IProxyService, IPrompterQuestion, } from "../../declarations"; import { booleanOption, - CommandContext, + Command, CommandOptionsSchema, - defineCommand, } from "../../define-command"; import { inject } from "../../di"; import { isInteractive } from "../../helpers"; -import { - injectProxyCommandServices, - IProxyCommandServices, - tryTrackProxyCommandUsage, -} from "./proxy-base"; +import { tryTrackProxyCommandUsage } from "./proxy-base"; const { getCredentialsFromAuth } = require("proxy-lib/lib/utils"); const proxySetCommandName = "proxy|set"; @@ -28,27 +24,6 @@ const proxySetCommandOptions = { insecure: booleanOption(), } satisfies CommandOptionsSchema; -export type ProxySetCommandContext = CommandContext< - typeof proxySetCommandOptions ->; - -export interface IProxySetCommandServices extends IProxyCommandServices { - $errors: IErrors; - $hostInfo: IHostInfo; - $prompter: IPrompter; - $staticConfig: Config.IStaticConfig; -} - -export function setupProxySetCommand(): IProxySetCommandServices { - return { - ...injectProxyCommandServices(), - $errors: inject("errors"), - $hostInfo: inject("hostInfo"), - $prompter: inject("prompter"), - $staticConfig: inject("staticConfig"), - }; -} - function isPasswordRequired(username: string, password: string): boolean { return !!(username && !password); } @@ -61,151 +36,178 @@ function getInvalidPortMessage(port: number): string { return `Specified port ${port} is not valid. Please enter a value between 1 and 65535.`; } -async function getPortFromUserInput( - services: IProxySetCommandServices, -): Promise { - const schemaName = "port"; - const schema: IPrompterQuestion = { - message: "Port", - type: "text", - name: schemaName, - validate: (value: any) => { - return !value || !isValidPort(value) - ? getInvalidPortMessage(value) - : true; - }, - }; - - const prompterResult = await services.$prompter.get([schema]); - return parseInt(prompterResult[schemaName]); -} - -export async function runProxySetCommand( - context: ProxySetCommandContext, - services: IProxySetCommandServices, -): Promise { - let urlString = context.args[0]; - let username = context.args[1]; - let password = context.args[2]; +export class ProxySetCommand extends Command({ + name: proxySetCommandName, + description: "Configures a proxy for the CLI to use.", + options: proxySetCommandOptions, + arguments: [{ name: "url" }, { name: "username" }, { name: "password" }], + disableAnalytics: true, +}) { + private $logger = inject("logger"); + private $proxyService = inject("proxyService"); + private $errors = inject("errors"); + private $hostInfo = inject("hostInfo"); + private $prompter = inject("prompter"); + private $staticConfig = inject("staticConfig"); + + public async run(): Promise { + let username = this.args[1]; + let password = this.args[2]; + + const { urlString, urlObj } = await this.resolveUrl(this.args[0]); + + let port = + (urlObj.port && +urlObj.port) || HttpProtocolToPort[urlObj.protocol]; + const noPort = !port || !isValidPort(port); + + const credentials = this.resolveCredentials( + urlObj.auth || "", + username, + password, + ); + username = credentials.username; + password = credentials.password; - const noUrl = !urlString; - if (noUrl) { if (!isInteractive()) { - services.$errors.failWithHelp( - "Console is not interactive - you need to supply all command parameters.", + if (noPort) { + this.$errors.fail( + `The port you have specified (${port || "none"}) is not valid.`, + ); + } else if (isPasswordRequired(username, password)) { + this.$errors.failWithHelp( + "Console is not interactive - you need to supply all command parameters.", + ); + } + } + + if (noPort) { + if (port) { + this.$logger.warn(getInvalidPortMessage(port)); + } + + port = await this.getPortFromUserInput(); + } + + if (!username) { + this.$logger.info( + "In case your proxy requires authentication, please specify username and password. If authentication is not required, just leave it empty.", ); - } else { - urlString = await services.$prompter.getString("Url", { - allowEmpty: false, + username = await this.$prompter.getString("Username", { + defaultAction: () => "", }); } - } - let urlObj = parse(urlString); - if ((!urlObj.protocol || !urlObj.hostname) && !isInteractive()) { - services.$errors.fail( - "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname.", - ); - } + if (isPasswordRequired(username, password)) { + password = await this.$prompter.getPassword("Password"); + } - while (!urlObj.protocol || !urlObj.hostname) { - services.$logger.warn( - "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname.", - ); - urlString = await services.$prompter.getString("Url", { - allowEmpty: false, + await this.saveSettings({ + proxyUrl: urlString, + username, + password, + rejectUnauthorized: !this.options.insecure, }); - urlObj = parse(urlString); } - let port = - (urlObj.port && +urlObj.port) || HttpProtocolToPort[urlObj.protocol]; - const noPort = !port || !isValidPort(port); - const authCredentials = getCredentialsFromAuth(urlObj.auth || ""); - if ( - (username && - authCredentials.username && - username !== authCredentials.username) || - (password && - authCredentials.password && - password !== authCredentials.password) - ) { - services.$errors.fail( - "The credentials you have provided in the url address mismatch those passed as command line arguments.", - ); - } - username = username || authCredentials.username; - password = password || authCredentials.password; + private async resolveUrl( + urlString: string, + ): Promise<{ urlString: string; urlObj: UrlWithStringQuery }> { + const noUrl = !urlString; + if (noUrl) { + if (!isInteractive()) { + this.$errors.failWithHelp( + "Console is not interactive - you need to supply all command parameters.", + ); + } else { + urlString = await this.$prompter.getString("Url", { + allowEmpty: false, + }); + } + } - if (!isInteractive()) { - if (noPort) { - services.$errors.fail( - `The port you have specified (${port || "none"}) is not valid.`, + let urlObj = parse(urlString); + if ((!urlObj.protocol || !urlObj.hostname) && !isInteractive()) { + this.$errors.fail( + "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname.", ); - } else if (isPasswordRequired(username, password)) { - services.$errors.failWithHelp( - "Console is not interactive - you need to supply all command parameters.", + } + + while (!urlObj.protocol || !urlObj.hostname) { + this.$logger.warn( + "The url you have entered is invalid please enter a valid url containing a valid protocol and hostname.", ); + urlString = await this.$prompter.getString("Url", { + allowEmpty: false, + }); + urlObj = parse(urlString); } + + return { urlString, urlObj }; } - if (noPort) { - if (port) { - services.$logger.warn(getInvalidPortMessage(port)); + private resolveCredentials( + auth: string, + username: string, + password: string, + ): { username: string; password: string } { + const authCredentials = getCredentialsFromAuth(auth); + if ( + (username && + authCredentials.username && + username !== authCredentials.username) || + (password && + authCredentials.password && + password !== authCredentials.password) + ) { + this.$errors.fail( + "The credentials you have provided in the url address mismatch those passed as command line arguments.", + ); } - port = await getPortFromUserInput(services); + return { + username: username || authCredentials.username, + password: password || authCredentials.password, + }; } - if (!username) { - services.$logger.info( - "In case your proxy requires authentication, please specify username and password. If authentication is not required, just leave it empty.", - ); - username = await services.$prompter.getString("Username", { - defaultAction: () => "", - }); + private async getPortFromUserInput(): Promise { + const schemaName = "port"; + const schema: IPrompterQuestion = { + message: "Port", + type: "text", + name: schemaName, + validate: (value: any) => { + return !value || !isValidPort(value) + ? getInvalidPortMessage(value) + : true; + }, + }; + + const prompterResult = await this.$prompter.get([schema]); + return parseInt(prompterResult[schemaName]); } - if (isPasswordRequired(username, password)) { - password = await services.$prompter.getPassword("Password"); - } + private async saveSettings(settings: IProxyLibSettings): Promise { + if (!this.$hostInfo.isWindows) { + this.$logger.warn( + `Note that storing credentials is not supported on ${platform()} yet.`, + ); + } - const settings: IProxyLibSettings = { - proxyUrl: urlString, - username, - password, - rejectUnauthorized: !context.options.insecure, - }; + const clientName = this.$staticConfig.CLIENT_NAME.toLowerCase(); + const messageNote = + (clientName === "tns" + ? "Note that 'npm' and 'Gradle' need to be configured separately to work with a proxy." + : "Note that `npm` needs to be configured separately to work with a proxy.") + + EOL; - if (!services.$hostInfo.isWindows) { - services.$logger.warn( - `Note that storing credentials is not supported on ${platform()} yet.`, + this.$logger.warn( + `${messageNote}Run '${clientName} proxy set --help' for more information.`, ); - } - const clientName = services.$staticConfig.CLIENT_NAME.toLowerCase(); - const messageNote = - (clientName === "tns" - ? "Note that 'npm' and 'Gradle' need to be configured separately to work with a proxy." - : "Note that `npm` needs to be configured separately to work with a proxy.") + - EOL; - - services.$logger.warn( - `${messageNote}Run '${clientName} proxy set --help' for more information.`, - ); - - await services.$proxyService.setCache(settings); - services.$logger.info(`Successfully setup proxy.${EOL}`); - services.$logger.info(await services.$proxyService.getInfo()); - await tryTrackProxyCommandUsage(services, proxySetCommandName); + await this.$proxyService.setCache(settings); + this.$logger.info(`Successfully setup proxy.${EOL}`); + this.$logger.info(await this.$proxyService.getInfo()); + await tryTrackProxyCommandUsage(this.$logger, proxySetCommandName); + } } - -export const proxySetCommandDefinition = defineCommand({ - name: proxySetCommandName, - description: "Configures a proxy for the CLI to use.", - options: proxySetCommandOptions, - arguments: [{ name: "url" }, { name: "username" }, { name: "password" }], - disableAnalytics: true, - setup: setupProxySetCommand, - run: runProxySetCommand, -}); diff --git a/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/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 94e37d0104..3267f1d173 100644 --- a/lib/common/contracts/index.ts +++ b/lib/common/contracts/index.ts @@ -14,5 +14,7 @@ export type { DeferredCommandRejection, DeferredCommandResult, } from "./command-registry"; +export { COMMAND_CONTEXT } from "./command-context"; +export { CommandsService } from "./commands-service"; export { ModuleRegistry } from "./module-registry"; export { PublicApiBuilder } from "./public-api-builder"; diff --git a/lib/common/define-command.ts b/lib/common/define-command.ts index 10730417ef..0f985e449a 100644 --- a/lib/common/define-command.ts +++ b/lib/common/define-command.ts @@ -6,7 +6,9 @@ * lib/common/services/command-definition-adapter. */ +import { COMMAND_CONTEXT } from "./contracts/command-context"; import type { KeyShortcut } from "./contracts/key-shortcuts"; +import { inject } from "./di/inject"; import type { Injector } from "./di/injector"; /** @@ -255,7 +257,10 @@ const OPTION_TYPES: CommandOptionType[] = [ const ACCEPTED_FORM = 'defineCommand({ name: "widget|add", run(ctx) { ... } }) — with the ' + "optional fields description, options, arguments, allowUnknownOptions, " + - "setup, canExecute, shortcuts, postRun, disableAnalytics and enableHooks."; + "setup, canExecute, shortcuts, postRun, disableAnalytics and enableHooks. " + + 'Or the class form, class WidgetAdd extends Command({ name: "widget|add" }) ' + + "{ run() { ... } }, which declares the same fields except the handlers and " + + "implements run, and optionally canExecute, postRun and shortcuts, as methods."; const describeDefinition = (definition: any): string => { const name = definition && definition.name; @@ -556,12 +561,18 @@ export type CommandName = string | readonly string[]; * be checked against them. */ export type CommandNamesOf = TDefinition extends { - name: infer TName; + definition: infer TClassDefinition; } - ? TName extends readonly (infer TAlias)[] - ? TAlias - : TName - : never; + ? // A constructor's own `name` is Function.name, so the class form has to be + // read through its static definition before the `name` branch sees it. + CommandNamesOf + : TDefinition extends { + name: infer TName; + } + ? TName extends readonly (infer TAlias)[] + ? TAlias + : TName + : never; export function defineCommand< TSchema extends CommandOptionsSchema = {}, @@ -583,3 +594,210 @@ export function isCommandDefinition( ): value is DefinedCommand { return !!value && (value)[COMMAND_DEFINITION_MARKER] === true; } + +/** + * Marks a constructor produced by `Command()`. Same `Symbol.for` reasoning as + * COMMAND_DEFINITION_MARKER, and the same reason it is read rather than + * `instanceof`: an extension bundles its own copy of this module. + */ +export const COMMAND_CLASS_MARKER: unique symbol = Symbol.for( + "nativescript:cli:commandClass", +); + +/** The meta `Command()` was called with, inherited by every subclass. */ +const COMMAND_CLASS_META = Symbol.for("nativescript:cli:commandClassMeta"); + +/** Per-constructor cache of the derived definition; own-property only. */ +const COMMAND_CLASS_DEFINITION = Symbol.for( + "nativescript:command:classDefinition", +); + +/** + * What the class form declares up front: a definition without the handlers, + * which the class supplies as methods instead. + */ +export type CommandMeta< + TName extends CommandName = CommandName, + TSchema extends CommandOptionsSchema = {}, +> = Omit< + CommandDefinition, + "name" | "setup" | "canExecute" | "run" | "postRun" | "shortcuts" +> & { name: TName }; + +/** + * The instance side of the class form. Exported because it names the base of + * every `Command()` class — a subclass's declaration emit refers to it — not + * because anything should extend it directly. + */ +export abstract class CommandBase< + TSchema extends CommandOptionsSchema = {}, + TResult = void, +> { + /** + * The instance is built once per invocation, as that invocation's `setup`, + * so the context captured here is the one its own run was handed. + */ + protected readonly context: CommandContext = inject(COMMAND_CONTEXT); + + protected get options(): CommandOptionValues { + return this.context.options; + } + + protected get args(): string[] { + return this.context.args; + } + + abstract run(): Promise | TResult; + canExecute?(): Promise | boolean; + postRun?(result: Awaited): Promise | void; + shortcuts?(): KeyShortcut[]; +} + +/** + * The static side. An abstract construct signature, so the compiler still + * requires a subclass to implement `run`, and a named type, so declaration + * emit for `class X extends Command({ ... })` has something to refer to. + */ +export type CommandClass< + TName extends CommandName = CommandName, + TSchema extends CommandOptionsSchema = {}, + TResult = void, +> = (abstract new () => CommandBase) & { + readonly definition: NamedCommand< + TSchema, + TResult, + CommandBase, + TName + >; + readonly [COMMAND_CLASS_MARKER]: true; +}; + +/** Either accepted form of a command, as a registration site takes it. */ +export type RegisterableCommand = + DefinedCommand | CommandClass; + +export function isCommandClass( + value: any, +): value is CommandClass { + return ( + typeof value === "function" && (value)[COMMAND_CLASS_MARKER] === true + ); +} + +const buildClassDefinition = (ctor: any): DefinedCommand => { + const meta = ctor[COMMAND_CLASS_META]; + const prototype = ctor.prototype; + const implementsMethod = (method: string): boolean => + typeof prototype[method] === "function"; + + if (!implementsMethod("run")) { + invalid( + meta, + `the class '${ctor.name || ""}' implements no 'run' method`, + ); + } + + // The instance IS the setup result, so every handler reaches it as the + // second argument the adapter already threads through. + const definition: any = { + ...meta, + setup: () => new ctor(), + run: (context: any, instance: any) => instance.run(), + }; + + if (implementsMethod("canExecute")) { + definition.canExecute = (context: any, instance: any) => + instance.canExecute(); + } + + if (implementsMethod("postRun")) { + definition.postRun = (context: any, result: any, instance: any) => + instance.postRun(result); + } + + if (implementsMethod("shortcuts")) { + definition.shortcuts = (context: any, instance: any) => + instance.shortcuts(); + } + + return defineCommand(definition); +}; + +/** + * The definition a `Command()` class stands for, cached on the constructor it + * was read from. The cache entry is an own property so a class extending + * another command class never serves its parent's definition. + */ +export function classCommandDefinition( + ctor: any, +): DefinedCommand { + if (!isCommandClass(ctor)) { + throw new Error( + `${describeDefinition(ctor)} is not a command class: it did not come ` + + `from Command(). Accepted form: ${ACCEPTED_FORM}`, + ); + } + + const target: any = ctor; + if (Object.prototype.hasOwnProperty.call(target, COMMAND_CLASS_DEFINITION)) { + return target[COMMAND_CLASS_DEFINITION]; + } + + const definition = buildClassDefinition(target); + Object.defineProperty(target, COMMAND_CLASS_DEFINITION, { + value: definition, + }); + + return definition; +} + +/** The definition behind either form, or null for anything else. */ +export function toCommandDefinition( + value: any, +): DefinedCommand | null { + if (isCommandClass(value)) { + return classCommandDefinition(value); + } + + return isCommandDefinition(value) ? value : null; +} + +/** + * 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 c4e9b05fca..f6ed426658 100644 --- a/lib/common/definitions/commands-service.d.ts +++ b/lib/common/definitions/commands-service.d.ts @@ -19,10 +19,28 @@ interface ICommandsService { * 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/json-file-settings-service.d.ts b/lib/common/definitions/json-file-settings-service.d.ts index 3c20e52e83..8fea9c1b63 100644 --- a/lib/common/definitions/json-file-settings-service.d.ts +++ b/lib/common/definitions/json-file-settings-service.d.ts @@ -13,6 +13,11 @@ interface IJsonFileSettingsService { settingName: string, cacheOpts?: ICacheTimeoutOpts ): Promise; + /** + * Reads a setting without taking the settings lock. Suitable for values that + * only change through explicit user commands, where a torn read is harmless. + */ + getSettingValueSync(settingName: string): T; saveSetting( key: string, value: T, diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index cfe0a4ca53..b967131d1a 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -5,6 +5,8 @@ import { getCurrentInjector, runInInjectionContext } from "../di/inject"; import { Injector } from "../di/injector"; import { IDictionary, IDashedOption, IErrors } from "../declarations"; import { ICommand } from "../definitions/commands"; +import { COMMAND_CONTEXT } from "../contracts/command-context"; +import { CommandsService } from "../contracts/commands-service"; import { COMMAND_OWNER, CommandRegistry, @@ -22,13 +24,17 @@ import { CommandArgumentValues, CommandContext, CommandDefinition, + CommandClass, + CommandName, CommandNamesOf, CommandOptionSpec, CommandOptionType, CommandOptionsSchema, + CommandReference, DefinedCommand, + RegisterableCommand, defineCommand, - isCommandDefinition, + toCommandDefinition, } from "../define-command"; const OPTION_TYPES: IDictionary = { @@ -250,8 +256,9 @@ export function createCommandFromDefinition< return values; }; - // Read per call rather than snapshotted here: the options service only holds - // this command's parsed values once validateOptions has run for it. + // Read when an invocation opens rather than at definition time: the options + // service only holds this command's parsed values once validateOptions has + // run for it. const buildContext = (args: string[]): CommandContext => { const options: any = {}; for (const optionName of optionNames) { @@ -335,6 +342,9 @@ export function createCommandFromDefinition< // The state of one invocation. The command object itself is cached for the // process, so nothing invocation-scoped may live outside one of these. interface Invocation { + /** Built once when the invocation opens; every stage and COMMAND_CONTEXT share it. */ + context: CommandContext; + injector: Injector; setup: Promise>; hasRun: boolean; runResult?: Awaited; @@ -342,6 +352,7 @@ export function createCommandFromDefinition< const startSetup = ( context: CommandContext, + injector: Injector, ): Promise> => // The executor runs synchronously, so setup keeps its injection context // up to its first await, while a synchronous failure - ctx.fail() is one - @@ -350,7 +361,7 @@ export function createCommandFromDefinition< resolve( definition.setup ? ( - runInInjectionContext(targetInjector, () => + runInInjectionContext(injector, () => definition.setup.call(definition, context), ) ) @@ -365,9 +376,18 @@ export function createCommandFromDefinition< let currentInvocation: Invocation = null; const beginInvocation = (context: CommandContext): Invocation => { - currentInvocation = { setup: startSetup(context), hasRun: false }; + 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 currentInvocation; + return invocation; }; /** @@ -377,6 +397,7 @@ export function createCommandFromDefinition< * take the host's keys with it. */ const attachShortcuts = ( + invocation: Invocation, context: CommandContext, setupResult: Awaited, ): void => { @@ -384,16 +405,16 @@ export function createCommandFromDefinition< return; } - const commandsService = targetInjector.get( - "commandsService", - { optional: true }, - ); + const commandsService = targetInjector.get(CommandsService, { + optional: true, + }); if (commandsService && commandsService.isExecutingInProcess) { return; } - const shortcuts: KeyShortcut[] = runInInjectionContext(targetInjector, () => - definition.shortcuts.call(definition, context, setupResult), + const shortcuts: KeyShortcut[] = runInInjectionContext( + invocation.injector, + () => definition.shortcuts.call(definition, context, setupResult), ); if (!shortcuts || !shortcuts.length) { return; @@ -426,10 +447,11 @@ export function createCommandFromDefinition< ? {} : { postCommandAction: async (args: string[]): Promise => { - const context = buildContext(args); - const invocation = currentInvocation || beginInvocation(context); + const invocation = + currentInvocation || beginInvocation(buildContext(args)); + const context = invocation.context; const setupResult = await invocation.setup; - await runInInjectionContext(targetInjector, () => + await runInInjectionContext(invocation.injector, () => definition.postRun.call( definition, context, @@ -446,7 +468,8 @@ export function createCommandFromDefinition< // arguments - so an argument validator can rely on it, and a command // run in the wrong place still reports that before complaining about // arity. - const setupResult = await beginInvocation(context).setup; + const invocation = beginInvocation(context); + const setupResult = await invocation.setup; await enforceArguments(context); @@ -457,25 +480,26 @@ export function createCommandFromDefinition< // Same first-await rule as execute: runInInjectionContext is // synchronous, so inject() is available up to the first await. - return await runInInjectionContext(targetInjector, () => + return await runInInjectionContext(invocation.injector, () => refine.call(definition, context, setupResult), ); }, execute: async (args: string[]): Promise => { - const context = buildContext(args); const invocation = currentInvocation && !currentInvocation.hasRun ? currentInvocation - : beginInvocation(context); + : beginInvocation(buildContext(args)); + const context = invocation.context; invocation.hasRun = true; const setupResult = await invocation.setup; - invocation.runResult = await runInInjectionContext(targetInjector, () => - definition.run.call(definition, context, setupResult), + invocation.runResult = await runInInjectionContext( + invocation.injector, + () => definition.run.call(definition, context, setupResult), ); if (definition.shortcuts) { - attachShortcuts(context, setupResult); + attachShortcuts(invocation, context, setupResult); } }, }; @@ -520,25 +544,34 @@ const contextInjector = (): Injector => getCurrentInjector() || (getRootInjector()); /** - * Runs a registered command in the current process. The command gets what a - * typed command line gives it — its declared options primed with their - * defaults, the arguments policy, `canExecute`, hooks and `postRun` — and a - * failure throws instead of exiting, so a process that has to keep running - * (`ns start`, dispatching a key shortcut) can catch it. + * Convenience over `CommandsService.runCommand` for code that has no injected + * service at hand, such as a key shortcut action or an inline handler; the + * contract is the API, this only resolves it from the current context. */ export async function runCommand( - name: string, + command: CommandReference, args: string[] = [], ): Promise { - const commandsService = - contextInjector().get("commandsService"); + await contextInjector().get(CommandsService).runCommand(command, args); +} - await commandsService.executeCommandInProcess(name, 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 either the result of defineCommand() - * or the definition itself, which it defines on the caller's behalf. + * Registers a command with the CLI. Takes a Command() class, the result of + * defineCommand(), or a bare definition, which it defines on the caller's + * behalf. * * Registration targets the injector of the current injection context, and * `providers` scope the command to a child of it. To register against some @@ -556,13 +589,14 @@ export function registerCommand< TSetup = any, >( definition: + | CommandClass | DefinedCommand | CommandDefinition, providers: Provider[] = [], ): DeferredCommandResult { - const defined = isCommandDefinition(definition) - ? definition - : defineCommand(>definition); + const defined = + toCommandDefinition(definition) || + defineCommand(>definition); const target = contextInjector(); const scope = providers.length ? target.createChild(providers) : target; const owner = target.get(COMMAND_OWNER, { optional: true }) || CLI_OWNER; @@ -621,7 +655,7 @@ type MissingTypeArgument = * aborts startup instead of returning a result nobody would check. */ export function registerBuiltInCommand< - TDefinition extends DefinedCommand = never, + TDefinition extends RegisterableCommand = never, >( name: [TDefinition] extends [never] ? MissingTypeArgument @@ -642,7 +676,7 @@ export function registerBuiltInCommand< } export function registerLazyCommand< - TDefinition extends DefinedCommand = never, + TDefinition extends RegisterableCommand = never, >( name: [TDefinition] extends [never] ? MissingTypeArgument @@ -657,13 +691,16 @@ export function registerLazyCommand< return registry.registerDeferredCommand(commandName, { owner: target.get(COMMAND_OWNER, { optional: true }) || CLI_OWNER, load: () => { - const definition = load(); + const loaded = load(); // The compile-time check above is only as good as the type argument the // call site passes, so the same mismatch is caught here as well. - if (!isCommandDefinition(definition)) { + const definition = toCommandDefinition(loaded); + if (!definition) { throw new Error( - "the loader did not return a defineCommand() definition", + typeof loaded === "function" + ? "the loader returned a class that did not come from Command()" + : "the loader did not return a defineCommand() definition or a Command() class", ); } diff --git a/lib/common/services/commands-service.ts b/lib/common/services/commands-service.ts index 2f57a18e99..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,7 +30,10 @@ class CommandArgumentsValidationHelper { public remainingArguments: string[]; } -export class CommandsService implements ICommandsService { +export class CommandsService + extends CommandsServiceContract + implements ICommandsService +{ public get currentCommandData(): ICommandData { return _.last(this.commands); } @@ -48,7 +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( @@ -191,7 +199,7 @@ export class CommandsService implements ICommandsService { ); } - return this.canExecuteCommand(commandName, commandArguments); + return this.canExecuteResolvedCommand(commandName, commandArguments); } public async tryExecuteCommand( @@ -239,23 +247,30 @@ export class CommandsService implements ICommandsService { * Analytics stay out of it: this is not a new CLI invocation, and * `checkConsent` may prompt on a terminal the caller has put in raw mode. */ - public async executeCommandInProcess( - commandName: string, + public async runCommand( + 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 command = this.$injector.resolveCommand(commandName); - if (!command) { - this.$errors.failWithHelp( - `Unknown command '${helpers.stringReplaceAll(commandName, "|", " ")}'.`, - ); - } + const resolved = this.resolveReference(reference); + const command = resolved.command; + commandName = resolved.commandName; this.commands.push({ commandName, commandArguments }); const restoreOptions = this.primeOptions(command); try { - if (!(await this.canExecuteCommand(commandName, commandArguments))) { + if ( + !(await this.canExecuteResolvedCommand( + commandName, + commandArguments, + undefined, + command, + )) + ) { let commandWithArgs = commandName; if (commandArguments && commandArguments.length) { commandWithArgs += ` ${commandArguments.join(" ")}`; @@ -283,12 +298,97 @@ export class CommandsService implements ICommandsService { } } + /** + * 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; @@ -308,12 +408,13 @@ export class CommandsService implements ICommandsService { }; } - private async canExecuteCommand( + 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/json-file-settings-service.ts b/lib/common/services/json-file-settings-service.ts index d58da1e8fa..81d0e38e43 100644 --- a/lib/common/services/json-file-settings-service.ts +++ b/lib/common/services/json-file-settings-service.ts @@ -56,6 +56,28 @@ export class JsonFileSettingsService implements IJsonFileSettingsService { ); } + public getSettingValueSync(settingName: string): T { + if (!this.jsonSettingsData && this.$fs.exists(this.jsonSettingsFilePath)) { + try { + this.jsonSettingsData = parseJson( + this.$fs.readText(this.jsonSettingsFilePath) + ); + } catch (err) { + this.$logger.trace( + `Error while trying to parse ${this.jsonSettingsFilePath}. Err is: ${err}` + ); + return null; + } + } + + if (this.jsonSettingsData && _.has(this.jsonSettingsData, settingName)) { + const data = this.jsonSettingsData[settingName]; + return data.modifiedByCacheMechanism ? data.value : data; + } + + return null; + } + public async saveSetting( key: string, value: T, diff --git a/lib/common/test/unit-tests/services/json-file-settings-service.ts b/lib/common/test/unit-tests/services/json-file-settings-service.ts index 9dac594142..e37673c6d1 100644 --- a/lib/common/test/unit-tests/services/json-file-settings-service.ts +++ b/lib/common/test/unit-tests/services/json-file-settings-service.ts @@ -65,6 +65,37 @@ describe("jsonFileSettingsService", () => { Date.now = originalDateNow; }); + describe("getSettingValueSync", () => { + it("returns the stored value without going through the lock", () => { + const testInjector = createTestInjector(); + dataInFile[jsonFileSettingsPath] = { prop1: "value1" }; + const lockService = testInjector.resolve("lockService"); + lockService.executeActionWithLock = () => { + throw new Error("lock must not be used for sync reads"); + }; + + const jsonFileSettingsService = + testInjector.resolve( + "jsonFileSettingsService", + { jsonFileSettingsPath } + ); + assert.equal(jsonFileSettingsService.getSettingValueSync("prop1"), "value1"); + assert.isNull(jsonFileSettingsService.getSettingValueSync("missing")); + }); + + it("returns null when the settings file does not exist", () => { + const testInjector = createTestInjector(); + const fs = testInjector.resolve("fs"); + fs.exists = () => false; + const jsonFileSettingsService = + testInjector.resolve( + "jsonFileSettingsService", + { jsonFileSettingsPath } + ); + assert.isNull(jsonFileSettingsService.getSettingValueSync("prop1")); + }); + }); + describe("getSettingValue", () => { it("returns correct data without cache", async () => { dataInFile = { [jsonFileSettingsPath]: { prop1: 1 } }; diff --git a/lib/constants.ts b/lib/constants.ts index 7420893fab..1c97df4171 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -126,13 +126,6 @@ export const TemplatesV2PackageJsonKeysToRemove: Array = [ "nativescript", ]; -export class SaveOptions { - static PRODUCTION = "save"; - static DEV = "save-dev"; - static OPTIONAL = "save-optional"; - static EXACT = "save-exact"; -} - export class ReleaseType { static MAJOR = "major"; static PREMAJOR = "premajor"; diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts index 2a6137e2e7..54aac0ffa3 100644 --- a/lib/contracts/index.ts +++ b/lib/contracts/index.ts @@ -55,8 +55,13 @@ export { PBXPROJ_DOM_XCODE } from "./pbxproj-dom-xcode"; export { XCODE } from "./xcode"; export { + Command, + CommandBase, + COMMAND_CLASS_MARKER, defineCommand, + isCommandClass, isCommandDefinition, + toCommandDefinition, booleanOption, stringOption, numberOption, @@ -67,11 +72,14 @@ export type { ArgumentSpec, ArgumentsPolicy, CommandArgumentValues, + CommandClass, CommandDefinition, + CommandMeta, CommandName, CommandNamesOf, DefinedCommand, NamedCommand, + RegisterableCommand, CommandContext, CommandOptionSpec, DefaultedCommandOptionSpec, @@ -80,6 +88,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/contracts/package-manager.ts b/lib/contracts/package-manager.ts index cae1a8eed2..010fffbd46 100644 --- a/lib/contracts/package-manager.ts +++ b/lib/contracts/package-manager.ts @@ -1,7 +1,7 @@ import { Contract } from "../common/di/contract"; -import type { IDictionary } from "../common/declarations"; import type { - INodePackageManagerInstallOptions, + IPackageInstallOptions, + IPackageUninstallOptions, INpmInstallResultInfo, INpmPackageNameParts, INpmsResult, @@ -17,35 +17,35 @@ export abstract class PackageManager { * Installs dependency * @param {string} packageName The name of the dependency - can be a path, a url or a string. * @param {string} pathToSave The destination of the installation. - * @param {INodePackageManagerInstallOptions} config Additional options that can be passed to manipulate installation. + * @param {IPackageInstallOptions} options Package-manager-agnostic installation options. * @return {Promise} Information about installed package. */ abstract install( packageName: string, pathToSave: string, - config: INodePackageManagerInstallOptions, + options: IPackageInstallOptions, ): Promise; /** * Uninstalls a dependency * @param {string} packageName The name of the dependency. - * @param {IDictionary} config Additional options that can be passed to manipulate uninstallation. + * @param {IPackageUninstallOptions} options Package-manager-agnostic uninstallation options. * @param {string} path The destination of the uninstallation. * @return {Promise} The output of the uninstallation. */ abstract uninstall( packageName: string, - config?: IDictionary, + options?: IPackageUninstallOptions, path?: string, ): Promise; /** * Provides information about a given package. - * @param {string} packageName The name of the package. - * @param {IDictionary} config Additional options that can be passed to manipulate view. - * @return {Promise} Object, containing information about the package. + * @param {string} packageName The name of the package, optionally with a version. + * @param {string} field @optional A single registry field (e.g. "versions" or "dist-tags") to return instead of the whole document. + * @return {Promise} The parsed registry data, or null when it cannot be parsed. */ - abstract view(packageName: string, config: Object): Promise; + abstract view(packageName: string, field?: string): Promise; /** * Checks if the specified string is name of a packaged published in the NPM registry. @@ -74,14 +74,10 @@ export abstract class PackageManager { /** * Searches for a package. - * @param {string[]} filter Keywords with which to perform the search. - * @param {IDictionary} config Additional options that can be passed to manipulate search. - * @return {Promise} The output of the uninstallation. + * @param {string[]} filter Keywords with which to perform the search. + * @return {Promise} The raw search output. */ - abstract search( - filter: string[], - config: IDictionary, - ): Promise; + abstract search(filter: string[]): Promise; /** * Searches for npm packages in npms by keyword. @@ -103,6 +99,17 @@ export abstract class PackageManager { */ abstract getCachePath(): Promise; + /** + * Locates a package the way the package manager laid it out on disk. + * @param {string} packageName The name of the package. + * @param {string} fromDir The directory whose dependencies are searched, usually the project directory. + * @return {string} The absolute path of the package directory, or null when it is not installed. + */ + abstract getInstalledPackagePath( + packageName: string, + fromDir: string, + ): string; + /** * Gets the name of the package manager used for the current process. * It can be read from the user settings or by passing -- option. diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index 4cc149ba04..fc730ddeab 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -25,7 +25,11 @@ import { SupportedPlatform, TrackActionNames, } from "../constants"; -import { IOptions, IWatchIgnoreListService } from "../declarations"; +import { + IOptions, + IWatchIgnoreListService, + IPackageManager, +} from "../declarations"; import { INodeModulesDependenciesBuilder, IPlatformController, @@ -40,7 +44,6 @@ import { IProjectDataService, IProjectService, } from "../definitions/project"; -import { resolvePackageJSONPath } from "@rigor789/resolve-package-path"; interface IPlatformWatcherData { hasWebpackCompilerProcess: boolean; @@ -82,6 +85,7 @@ export class PrepareController private $markingModeService: IMarkingModeService, private $projectConfigService: IProjectConfigService, private $projectService: IProjectService, + private $packageManager: IPackageManager, ) { super(); } @@ -490,16 +494,14 @@ export class PrepareController SCOPED_ANDROID_RUNTIME_NAME; } // try reading from installed runtime first before reading from the npm registry... - const installedRuntimePackageJSONPath = resolvePackageJSONPath( + const installedRuntimePath = this.$packageManager.getInstalledPackagePath( runtimePackageName, - { - paths: [projectData.projectDir], - }, + projectData.projectDir, ); - if (installedRuntimePackageJSONPath) { + if (installedRuntimePath) { installedRuntimePackageJSON = this.$fs.readJson( - installedRuntimePackageJSONPath, + path.join(installedRuntimePath, "package.json"), ); } const packageData: any = { diff --git a/lib/declarations.d.ts b/lib/declarations.d.ts index 466f3830fe..a686771506 100644 --- a/lib/declarations.d.ts +++ b/lib/declarations.d.ts @@ -27,25 +27,25 @@ interface INodePackageManager { * Installs dependency * @param {string} packageName The name of the dependency - can be a path, a url or a string. * @param {string} pathToSave The destination of the installation. - * @param {INodePackageManagerInstallOptions} config Additional options that can be passed to manipulate installation. + * @param {IPackageInstallOptions} options Package-manager-agnostic installation options. * @return {Promise} Information about installed package. */ install( packageName: string, pathToSave: string, - config: INodePackageManagerInstallOptions, + options: IPackageInstallOptions, ): Promise; /** * Uninstalls a dependency * @param {string} packageName The name of the dependency. - * @param {IDictionary} config Additional options that can be passed to manipulate uninstallation. + * @param {IPackageUninstallOptions} options Package-manager-agnostic uninstallation options. * @param {string} path The destination of the uninstallation. * @return {Promise} The output of the uninstallation. */ uninstall( packageName: string, - config?: IDictionary, + options?: IPackageUninstallOptions, path?: string, ): Promise; @@ -55,7 +55,13 @@ interface INodePackageManager { * @param {IDictionary} config Additional options that can be passed to manipulate view. * @return {Promise} Object, containing information about the package. */ - view(packageName: string, config: Object): Promise; + /** + * Provides registry information about a package. + * @param {string} packageName The name of the package, optionally with a version. + * @param {string} field @optional A single registry field (e.g. "versions" or "dist-tags") to return instead of the whole document. + * @return {Promise} The parsed registry data, or null when it cannot be parsed. + */ + view(packageName: string, field?: string): Promise; /** * Checks if the specified string is name of a packaged published in the NPM registry. @@ -84,10 +90,7 @@ interface INodePackageManager { * @param {IDictionary} config Additional options that can be passed to manipulate search. * @return {Promise} The output of the uninstallation. */ - search( - filter: string[], - config: IDictionary, - ): Promise; + search(filter: string[]): Promise; /** * Searches for npm packages in npms by keyword. @@ -108,6 +111,14 @@ interface INodePackageManager { * @returns {string} The full path to npm cache directory */ getCachePath(): Promise; + + /** + * Locates a package the way the package manager laid it out on disk. + * @param {string} packageName The name of the package. + * @param {string} fromDir The directory whose dependencies are searched, usually the project directory. + * @return {string} The absolute path of the package directory, or null when it is not installed. + */ + getInstalledPackagePath(packageName: string, fromDir: string): string; } /** @deprecated Kept so existing annotations compile; use the {@link PackageManager} contract. */ @@ -167,18 +178,42 @@ interface IPackageInstallationManager { } /** - * Describes options that can be passed to manipulate package installation. + * Package-manager-agnostic installation options. Each package manager maps + * these onto its own command line flags; options a manager has no flag for + * are dropped rather than passed through. */ -interface INodePackageManagerInstallOptions - extends INpmInstallConfigurationOptions, IDictionary { - /** - * Destination of the installation. - * @type {string} - * @optional - */ +interface IPackageInstallOptions { + /** + * Record the package in package.json. Every supported package manager + * does this by default, so only `false` changes behaviour. + */ + save?: boolean; + /** Record the package under devDependencies. */ + dev?: boolean; + /** Record the package under optionalDependencies. */ + optional?: boolean; + /** Pin the exact resolved version instead of a semver range. */ + exact?: boolean; + /** Suppress the package manager's own output. */ + silent?: boolean; + /** Do not run lifecycle scripts. */ + ignoreScripts?: boolean; + /** Skip the installation entirely (the --disable-npm-install CLI flag). */ + disableNpmInstall?: boolean; + /** Local runtime location (the --frameworkPath CLI flag). */ + frameworkPath?: string; + /** Destination of the installation (the --path CLI flag). */ path?: string; } +/** + * Package-manager-agnostic uninstallation options. + */ +interface IPackageUninstallOptions { + /** Remove the package from package.json. */ + save?: boolean; +} + /** * Describes information about dependency packages. */ @@ -396,7 +431,8 @@ interface INpmInstallResultInfo { interface INpmInstallOptions { pathToSave?: string; version?: string; - dependencyType?: string; + /** Record the package under devDependencies. */ + dev?: boolean; } /** diff --git a/lib/base-package-manager.ts b/lib/package-managers/base-package-manager.ts similarity index 67% rename from lib/base-package-manager.ts rename to lib/package-managers/base-package-manager.ts index 5ee9a96abd..f44a7b85c4 100644 --- a/lib/base-package-manager.ts +++ b/lib/package-managers/base-package-manager.ts @@ -1,34 +1,46 @@ -import { isInteractive } from "./common/helpers"; +import { isInteractive } from "../common/helpers"; +import { resolvePackagePath } from "../helpers/package-path-helper"; import { INodePackageManager, - INodePackageManagerInstallOptions, + IPackageInstallOptions, + IPackageUninstallOptions, INpmInstallResultInfo, INpmsResult, INpmPackageNameParts, -} from "./declarations"; -import { - IDictionary, - IChildProcess, - IFileSystem, - IHostInfo, -} from "./common/declarations"; +} from "../declarations"; +import { IChildProcess, IFileSystem, IHostInfo } from "../common/declarations"; + +/** + * How one package manager spells each IPackageInstallOptions flag on its + * command line. A missing entry means the manager has no such flag and the + * option is dropped rather than passed through. + */ +export interface IPackageManagerFlags { + save?: string; + noSave?: string; + dev?: string; + optional?: string; + exact?: string; + silent?: string; + ignoreScripts?: string; +} export abstract class BasePackageManager implements INodePackageManager { + protected abstract readonly installFlags: IPackageManagerFlags; + protected abstract readonly uninstallFlags: IPackageManagerFlags; + public abstract install( packageName: string, pathToSave: string, - config: INodePackageManagerInstallOptions, + options: IPackageInstallOptions, ): Promise; public abstract uninstall( packageName: string, - config?: IDictionary, + options?: IPackageUninstallOptions, path?: string, ): Promise; - public abstract view(packageName: string, config: Object): Promise; - public abstract search( - filter: string[], - config: IDictionary, - ): Promise; + public abstract view(packageName: string, field?: string): Promise; + public abstract search(filter: string[]): Promise; public abstract searchNpms(keyword: string): Promise; public abstract getRegistryPackageData(packageName: string): Promise; public abstract getCachePath(): Promise; @@ -51,7 +63,7 @@ export abstract class BasePackageManager implements INodePackageManager { } try { - const viewResult = await this.view(packageName, { name: true }); + const viewResult = await this.view(packageName, "name"); // `npm view nonExistingPackageName` will return `nativescript` // if executed in the root dir of the CLI (npm 6.4.1) @@ -133,39 +145,39 @@ export abstract class BasePackageManager implements INodePackageManager { }; } - protected getFlagsString(config: any, asArray: boolean): any { - const array: Array = []; - for (const flag in config) { - if ( - flag === "global" && - this.packageManager !== "yarn" && - this.packageManager !== "yarn2" - ) { - array.push(`--${flag}`); - array.push(`${config[flag]}`); - } else if (config[flag]) { - if ( - flag === "dist-tags" || - flag === "versions" || - flag === "name" || - flag === "gradle" || - flag === "version_info" - ) { - if (this.packageManager === "yarn2") { - array.push(`--fields ${flag}`); - } else { - array.push(` ${flag}`); - } - continue; - } - array.push(`--${flag}`); - } - } - if (asArray) { - return array; - } + public getInstalledPackagePath(packageName: string, fromDir: string): string { + return resolvePackagePath(packageName, { paths: [fromDir] }) || null; + } - return array.join(" "); + protected getInstallFlags(options: IPackageInstallOptions): string[] { + return this.mapFlags(options, this.installFlags); + } + + protected getUninstallFlags(options: IPackageUninstallOptions): string[] { + return this.mapFlags(options, this.uninstallFlags); + } + + private mapFlags( + options: IPackageInstallOptions, + flags: IPackageManagerFlags, + ): string[] { + const result: string[] = []; + if (!options) { + return result; + } + const push = (flag?: string) => { + if (flag) { + result.push(flag); + } + }; + if (options.save === true) push(flags.save); + if (options.save === false) push(flags.noSave); + if (options.dev) push(flags.dev); + if (options.optional) push(flags.optional); + if (options.exact) push(flags.exact); + if (options.silent) push(flags.silent); + if (options.ignoreScripts) push(flags.ignoreScripts); + return result; } private isTgz(packageName: string): boolean { diff --git a/lib/bun-package-manager.ts b/lib/package-managers/bun.ts similarity index 78% rename from lib/bun-package-manager.ts rename to lib/package-managers/bun.ts index cfd5ffc057..65911014a5 100644 --- a/lib/bun-package-manager.ts +++ b/lib/package-managers/bun.ts @@ -1,23 +1,38 @@ import * as path from "path"; import { BasePackageManager } from "./base-package-manager"; -import { exported, cache } from "./common/decorators"; -import { CACACHE_DIRECTORY_NAME } from "./constants"; +import { exported, cache } from "../common/decorators"; +import { CACACHE_DIRECTORY_NAME } from "../constants"; import * as _ from "lodash"; import { - INodePackageManagerInstallOptions, + IPackageInstallOptions, + IPackageUninstallOptions, INpmInstallResultInfo, INpmsResult, -} from "./declarations"; +} from "../declarations"; import { IChildProcess, IErrors, IFileSystem, IHostInfo, Server, -} from "./common/declarations"; -import { injector } from "./common/yok"; +} from "../common/declarations"; +import { injector } from "../common/yok"; export class BunPackageManager extends BasePackageManager { + protected readonly installFlags = { + save: "--save", + noSave: "--no-save", + dev: "--dev", + optional: "--optional", + exact: "--exact", + silent: "--silent", + ignoreScripts: "--ignore-scripts", + }; + protected readonly uninstallFlags = { + save: "--save", + noSave: "--no-save", + }; + constructor( $childProcess: IChildProcess, private $errors: IErrors, @@ -34,19 +49,16 @@ export class BunPackageManager extends BasePackageManager { public async install( packageName: string, pathToSave: string, - config: INodePackageManagerInstallOptions + options: IPackageInstallOptions ): Promise { - if (config.disableNpmInstall) { + if (options.disableNpmInstall) { return; } - if (config.ignoreScripts) { - config["ignore-scripts"] = true; - } const packageJsonPath = path.join(pathToSave, "package.json"); const jsonContentBefore = this.$fs.readJson(packageJsonPath); - const flags = this.getFlagsString(config, true); + const flags = this.getInstallFlags(options); let params = ["install"]; const isInstallingAllDependencies = packageName === pathToSave; if (!isInstallingAllDependencies) { @@ -73,26 +85,22 @@ export class BunPackageManager extends BasePackageManager { @exported("bun") public async uninstall( packageName: string, - config?: any, + options?: IPackageUninstallOptions, cwd?: string ): Promise { - const flags = this.getFlagsString(config, false); + const flags = this.getUninstallFlags(options).join(" "); return this.$childProcess.exec(`bun remove ${packageName} ${flags}`, { cwd, }); } - // Bun does not have a `view` command; use npm. @exported("bun") - public async view(packageName: string, config: Object): Promise { - const wrappedConfig = _.extend({}, config, { json: true }); // always require view response as JSON - - const flags = this.getFlagsString(wrappedConfig, false); + // Bun does not have a `view` command; use npm. + public async view(packageName: string, field?: string): Promise { + const args = [packageName, field, "--json"].filter(Boolean).join(" "); let viewResult: any; try { - viewResult = await this.$childProcess.exec( - `npm view ${packageName} ${flags}` - ); + viewResult = await this.$childProcess.exec(`npm view ${args}`); } catch (e) { this.$errors.fail(e.message); } @@ -104,11 +112,10 @@ export class BunPackageManager extends BasePackageManager { } } - // Bun does not have a `search` command; use npm. @exported("bun") - public async search(filter: string[], config: any): Promise { - const flags = this.getFlagsString(config, false); - return this.$childProcess.exec(`npm search ${filter.join(" ")} ${flags}`); + // Bun does not have a `search` command; use npm. + public async search(filter: string[]): Promise { + return this.$childProcess.exec(`npm search ${filter.join(" ")}`); } public async searchNpms(keyword: string): Promise { diff --git a/lib/package-manager.ts b/lib/package-managers/index.ts similarity index 56% rename from lib/package-manager.ts rename to lib/package-managers/index.ts index df6d18aa92..180e855841 100644 --- a/lib/package-manager.ts +++ b/lib/package-managers/index.ts @@ -1,25 +1,23 @@ -import { cache, exported, invokeInit } from "./common/decorators"; -import { performanceLog } from "./common/decorators"; -import { PackageManagers } from "./constants"; +import { exported } from "../common/decorators"; +import { performanceLog } from "../common/decorators"; +import { PackageManagers } from "../constants"; import { IPackageManager, INodePackageManager, IOptions, - INodePackageManagerInstallOptions, + IPackageInstallOptions, + IPackageUninstallOptions, INpmInstallResultInfo, INpmsResult, INpmPackageNameParts, -} from "./declarations"; -import { - IErrors, - IUserSettingsService, - IDictionary, -} from "./common/declarations"; -import { injector } from "./common/yok"; -import { IProjectConfigService } from "./definitions/project"; +} from "../declarations"; +import { IErrors, IUserSettingsService } from "../common/declarations"; +import { injector } from "../common/yok"; +import { IProjectConfigService } from "../definitions/project"; + export class PackageManager implements IPackageManager { - private packageManager: INodePackageManager; - private _packageManagerName: string; + private selected: INodePackageManager; + private selectedName: string; constructor( private $errors: IErrors, @@ -31,89 +29,79 @@ export class PackageManager implements IPackageManager { private $bun: INodePackageManager, private $logger: ILogger, private $userSettingsService: IUserSettingsService, - private $projectConfigService: IProjectConfigService + private $projectConfigService: IProjectConfigService, ) {} - @cache() - protected async init(): Promise { - this.packageManager = await this._determinePackageManager(); - } - - @invokeInit() public async getPackageManagerName(): Promise { - return this._packageManagerName; + this.packageManager; + return this.selectedName; } @exported("packageManager") @performanceLog() - @invokeInit() public install( packageName: string, pathToSave: string, - config: INodePackageManagerInstallOptions + options: IPackageInstallOptions, ): Promise { - return this.packageManager.install(packageName, pathToSave, config); + return this.packageManager.install(packageName, pathToSave, options); } + @exported("packageManager") - @invokeInit() public uninstall( packageName: string, - config?: IDictionary, - path?: string + options?: IPackageUninstallOptions, + path?: string, ): Promise { - return this.packageManager.uninstall(packageName, config, path); + return this.packageManager.uninstall(packageName, options, path); } + @exported("packageManager") - @invokeInit() - public view(packageName: string, config: Object): Promise { - return this.packageManager.view(packageName, config); + public view(packageName: string, field?: string): Promise { + return this.packageManager.view(packageName, field); } + @exported("packageManager") - @invokeInit() - public search( - filter: string[], - config: IDictionary - ): Promise { - return this.packageManager.search(filter, config); + public search(filter: string[]): Promise { + return this.packageManager.search(filter); } - @invokeInit() public searchNpms(keyword: string): Promise { return this.packageManager.searchNpms(keyword); } - @invokeInit() - public async isRegistered(packageName: string): Promise { + public isRegistered(packageName: string): Promise { return this.packageManager.isRegistered(packageName); } - @invokeInit() - public async getPackageFullName( - packageNameParts: INpmPackageNameParts + public getPackageFullName( + packageNameParts: INpmPackageNameParts, ): Promise { return this.packageManager.getPackageFullName(packageNameParts); } - @invokeInit() - public async getPackageNameParts( - fullPackageName: string + public getPackageNameParts( + fullPackageName: string, ): Promise { return this.packageManager.getPackageNameParts(fullPackageName); } - @invokeInit() public getRegistryPackageData(packageName: string): Promise { return this.packageManager.getRegistryPackageData(packageName); } - @invokeInit() public getCachePath(): Promise { return this.packageManager.getCachePath(); } + @exported("packageManager") + public getInstalledPackagePath(packageName: string, fromDir: string): string { + return this.packageManager.getInstalledPackagePath(packageName, fromDir); + } + public async getTagVersion( packageName: string, - tag: string + tag: string, ): Promise { let version: string = null; if (!tag) { @@ -121,11 +109,11 @@ export class PackageManager implements IPackageManager { } try { - const result = await this.view(packageName, { "dist-tags": true }); + const result = await this.view(packageName, "dist-tags"); version = result[tag]; } catch (err) { this.$logger.trace( - `Error while getting tag version from view command: ${err}` + `Error while getting tag version from view command: ${err}`, ); const registryData = await this.getRegistryPackageData(packageName); version = registryData["dist-tags"][tag]; @@ -134,13 +122,21 @@ export class PackageManager implements IPackageManager { return version; } - private async _determinePackageManager(): Promise { - let pm = null; + private get packageManager(): INodePackageManager { + if (!this.selected) { + this.selected = this.determinePackageManager(); + } + + return this.selected; + } + + private determinePackageManager(): INodePackageManager { + let pm: string = null; try { - pm = await this.$userSettingsService.getSettingValue("packageManager"); + pm = this.$userSettingsService.getSettingValueSync("packageManager"); } catch (err) { this.$errors.fail( - `Unable to read package manager config from user settings ${err}` + `Unable to read package manager config from user settings ${err}`, ); } @@ -150,7 +146,7 @@ export class PackageManager implements IPackageManager { if (configPm) { this.$logger.trace( - `Determined packageManager to use from user config is: ${configPm}` + `Determined packageManager to use from user config is: ${configPm}`, ); pm = configPm; } @@ -158,25 +154,25 @@ export class PackageManager implements IPackageManager { // ignore error, but log info this.$logger.trace( "Tried to read cli.packageManager from project config and failed. Error is: ", - err + err, ); } if (pm === PackageManagers.yarn || this.$options.yarn) { - this._packageManagerName = PackageManagers.yarn; + this.selectedName = PackageManagers.yarn; return this.$yarn; } if (pm === PackageManagers.yarn2 || this.$options.yarn2) { - this._packageManagerName = PackageManagers.yarn2; + this.selectedName = PackageManagers.yarn2; return this.$yarn2; } else if (pm === PackageManagers.pnpm || this.$options.pnpm) { - this._packageManagerName = PackageManagers.pnpm; + this.selectedName = PackageManagers.pnpm; return this.$pnpm; } else if (pm === PackageManagers.bun) { - this._packageManagerName = PackageManagers.bun; + this.selectedName = PackageManagers.bun; return this.$bun; } else { - this._packageManagerName = PackageManagers.npm; + this.selectedName = PackageManagers.npm; return this.$npm; } } diff --git a/lib/node-package-manager.ts b/lib/package-managers/npm.ts similarity index 77% rename from lib/node-package-manager.ts rename to lib/package-managers/npm.ts index bf63cae523..9b55fe3336 100644 --- a/lib/node-package-manager.ts +++ b/lib/package-managers/npm.ts @@ -1,23 +1,38 @@ import { join, relative } from "path"; import { BasePackageManager } from "./base-package-manager"; -import { exported, cache } from "./common/decorators"; -import { CACACHE_DIRECTORY_NAME } from "./constants"; +import { exported, cache } from "../common/decorators"; +import { CACACHE_DIRECTORY_NAME } from "../constants"; import * as _ from "lodash"; import { - INodePackageManagerInstallOptions, + IPackageInstallOptions, + IPackageUninstallOptions, INpmInstallResultInfo, INpmsResult, -} from "./declarations"; +} from "../declarations"; import { IChildProcess, IErrors, IFileSystem, IHostInfo, Server, -} from "./common/declarations"; -import { injector } from "./common/yok"; +} from "../common/declarations"; +import { injector } from "../common/yok"; + +export class NpmPackageManager extends BasePackageManager { + protected readonly installFlags = { + save: "--save", + noSave: "--no-save", + dev: "--save-dev", + optional: "--save-optional", + exact: "--save-exact", + silent: "--silent", + ignoreScripts: "--ignore-scripts", + }; + protected readonly uninstallFlags = { + save: "--save", + noSave: "--no-save", + }; -export class NodePackageManager extends BasePackageManager { constructor( $childProcess: IChildProcess, private $errors: IErrors, @@ -34,19 +49,16 @@ export class NodePackageManager extends BasePackageManager { public async install( packageName: string, pathToSave: string, - config: INodePackageManagerInstallOptions + options: IPackageInstallOptions ): Promise { - if (config.disableNpmInstall) { + if (options.disableNpmInstall) { return; } - if (config.ignoreScripts) { - config["ignore-scripts"] = true; - } const packageJsonPath = join(pathToSave, "package.json"); const jsonContentBefore = this.$fs.readJson(packageJsonPath); - const flags = this.getFlagsString(config, true); + const flags = this.getInstallFlags(options); let params = ["install"]; const isInstallingAllDependencies = packageName === pathToSave; if (!isInstallingAllDependencies) { @@ -62,11 +74,11 @@ export class NodePackageManager extends BasePackageManager { const etcExistsPriorToInstallation = this.$fs.exists(etcDirectoryLocation); //TODO: plamen5kov: workaround is here for a reason (remove whole file later) - if (config.path) { + if (options.path) { let relativePathFromCwdToSource = ""; - if (config.frameworkPath) { + if (options.frameworkPath) { relativePathFromCwdToSource = relative( - config.frameworkPath, + options.frameworkPath, pathToSave ); if (this.$fs.exists(relativePathFromCwdToSource)) { @@ -103,31 +115,26 @@ export class NodePackageManager extends BasePackageManager { @exported("npm") public async uninstall( packageName: string, - config?: any, + options?: IPackageUninstallOptions, path?: string ): Promise { - const flags = this.getFlagsString(config, false); + const flags = this.getUninstallFlags(options).join(" "); return this.$childProcess.exec(`npm uninstall ${packageName} ${flags}`, { cwd: path, }); } @exported("npm") - public async search(filter: string[], config: any): Promise { - const flags = this.getFlagsString(config, false); - return this.$childProcess.exec(`npm search ${filter.join(" ")} ${flags}`); + public async search(filter: string[]): Promise { + return this.$childProcess.exec(`npm search ${filter.join(" ")}`); } @exported("npm") - public async view(packageName: string, config: Object): Promise { - const wrappedConfig = _.extend({}, config, { json: true }); // always require view response as JSON - - const flags = this.getFlagsString(wrappedConfig, false); + public async view(packageName: string, field?: string): Promise { + const args = [packageName, field, "--json"].filter(Boolean).join(" "); let viewResult: any; try { - viewResult = await this.$childProcess.exec( - `npm view ${packageName} ${flags}` - ); + viewResult = await this.$childProcess.exec(`npm view ${args}`); } catch (e) { this.$errors.fail(e.message); } @@ -172,4 +179,4 @@ export class NodePackageManager extends BasePackageManager { } } -injector.register("npm", NodePackageManager); +injector.register("npm", NpmPackageManager); diff --git a/lib/package-installation-manager.ts b/lib/package-managers/package-installation-manager.ts similarity index 86% rename from lib/package-installation-manager.ts rename to lib/package-managers/package-installation-manager.ts index 535ff2d942..807c4fc724 100644 --- a/lib/package-installation-manager.ts +++ b/lib/package-managers/package-installation-manager.ts @@ -1,20 +1,21 @@ import * as path from "path"; -import * as constants from "./constants"; +import * as constants from "../constants"; import { INpmInstallOptions, INpmInstallResultInfo, + IPackageInstallOptions, IPackageInstallationManager, IPackageManager, IStaticConfig, -} from "./declarations"; -import { IProjectDataService } from "./definitions/project"; +} from "../declarations"; +import { IProjectDataService } from "../definitions/project"; import { IChildProcess, IDictionary, IFileSystem, ISettingsService, -} from "./common/declarations"; -import { injector } from "./common/yok"; +} from "../common/declarations"; +import { injector } from "../common/yok"; import * as semver from "semver"; @@ -66,9 +67,7 @@ export class PackageInstallationManager implements IPackageInstallationManager { packageName: string, versionRange: string ): Promise { - const data = await this.$packageManager.view(packageName, { - versions: true, - }); + const data = await this.$packageManager.view(packageName, "versions"); let versions; @@ -152,13 +151,13 @@ export class PackageInstallationManager implements IPackageInstallationManager { try { const pathToSave = projectDir; const version = (opts && opts.version) || null; - const dependencyType = (opts && opts.dependencyType) || null; + const dev = !!(opts && opts.dev); return await this.installCore( packageToInstall, pathToSave, version, - dependencyType + dev ); } catch (error) { this.$logger.trace(error); @@ -189,14 +188,12 @@ export class PackageInstallationManager implements IPackageInstallationManager { inspectorNpmPackageName: string, projectDir: string ): Promise { - const inspectorPath = path.join( - projectDir, - constants.NODE_MODULES_FOLDER_NAME, - inspectorNpmPackageName - ); - // local installation takes precedence over cache - if (this.inspectorAlreadyInstalled(inspectorPath)) { + const inspectorPath = this.$packageManager.getInstalledPackagePath( + inspectorNpmPackageName, + projectDir + ); + if (inspectorPath) { return inspectorPath; } @@ -265,19 +262,11 @@ export class PackageInstallationManager implements IPackageInstallationManager { } } - private inspectorAlreadyInstalled(pathToInspector: string): Boolean { - if (this.$fs.exists(pathToInspector)) { - return true; - } - - return false; - } - private async installCore( packageName: string, pathToSave: string, version: string, - dependencyType: string + dev: boolean ): Promise { const possiblePackageName = path.resolve(packageName); if (this.$fs.exists(possiblePackageName)) { @@ -290,34 +279,29 @@ export class PackageInstallationManager implements IPackageInstallationManager { packageName, pathToSave, version, - dependencyType + dev ); - const installedPackageName = installResultInfo.name; - - const pathToInstalledPackage = path.join( - pathToSave, - "node_modules", - installedPackageName + return this.$packageManager.getInstalledPackagePath( + installResultInfo.name, + pathToSave ); - - return pathToInstalledPackage; } private async npmInstall( packageName: string, pathToSave: string, version: string, - dependencyType: string + dev: boolean ): Promise { this.$logger.info(`Installing ${packageName}`); packageName = packageName + (version ? `@${version}` : ""); - const npmOptions: any = { silent: true, "save-exact": true }; - - if (dependencyType) { - npmOptions[dependencyType] = true; - } + const npmOptions: IPackageInstallOptions = { + silent: true, + exact: true, + dev, + }; return await this.$packageManager.install( packageName, @@ -334,9 +318,7 @@ export class PackageInstallationManager implements IPackageInstallationManager { packageName: string, version: string ): Promise { - let data: any = await this.$packageManager.view(packageName, { - "dist-tags": true, - }); + let data: any = await this.$packageManager.view(packageName, "dist-tags"); data = data?.["dist-tags"] ?? data; this.$logger.trace("Using version %s. ", data[version]); diff --git a/lib/pnpm-package-manager.ts b/lib/package-managers/pnpm.ts similarity index 78% rename from lib/pnpm-package-manager.ts rename to lib/package-managers/pnpm.ts index 5970d50c2e..1321e50a9e 100644 --- a/lib/pnpm-package-manager.ts +++ b/lib/package-managers/pnpm.ts @@ -1,24 +1,33 @@ import * as path from "path"; import * as _ from "lodash"; import { BasePackageManager } from "./base-package-manager"; -import { exported } from "./common/decorators"; -import { CACACHE_DIRECTORY_NAME } from "./constants"; +import { exported } from "../common/decorators"; +import { CACACHE_DIRECTORY_NAME } from "../constants"; import { - INodePackageManagerInstallOptions, + IPackageInstallOptions, + IPackageUninstallOptions, INpmInstallResultInfo, INpmsResult, -} from "./declarations"; +} from "../declarations"; import { IChildProcess, IErrors, IFileSystem, IHostInfo, Server, - IDictionary, -} from "./common/declarations"; -import { injector } from "./common/yok"; +} from "../common/declarations"; +import { injector } from "../common/yok"; export class PnpmPackageManager extends BasePackageManager { + protected readonly installFlags = { + dev: "--save-dev", + optional: "--save-optional", + exact: "--save-exact", + silent: "--silent", + ignoreScripts: "--ignore-scripts", + }; + protected readonly uninstallFlags = {}; + constructor( $childProcess: IChildProcess, private $errors: IErrors, @@ -35,25 +44,16 @@ export class PnpmPackageManager extends BasePackageManager { public async install( packageName: string, pathToSave: string, - config: INodePackageManagerInstallOptions, + options: IPackageInstallOptions, ): Promise { - if (config.disableNpmInstall) { + if (options.disableNpmInstall) { return; } - delete config.dev; // temporary fix for unsupported yarn flag - if (config.ignoreScripts) { - config["ignore-scripts"] = true; - } - // CLI-internal options must never reach the command line: pnpm, unlike - // npm, hard-fails on unknown options. - delete config.ignoreScripts; - delete config.path; - delete config.frameworkPath; const packageJsonPath = path.join(pathToSave, "package.json"); const jsonContentBefore = this.$fs.readJson(packageJsonPath); - const flags = this.getFlagsString(config, true); + const flags = this.getInstallFlags(options); let params = ["i"]; if (!this.projectManagesOwnHoisting(pathToSave)) { // With pnpm's default isolated layout some imports won't be found, so @@ -87,27 +87,21 @@ export class PnpmPackageManager extends BasePackageManager { @exported("pnpm") public uninstall( packageName: string, - config?: IDictionary, + options?: IPackageUninstallOptions, cwd?: string, ): Promise { - // pnpm does not want save option in remove. It saves it by default - delete config["save"]; - const flags = this.getFlagsString(config, false); + const flags = this.getUninstallFlags(options).join(" "); return this.$childProcess.exec(`pnpm remove ${packageName} ${flags}`, { cwd, }); } @exported("pnpm") - public async view(packageName: string, config: Object): Promise { - const wrappedConfig = _.extend({}, config, { json: true }); - - const flags = this.getFlagsString(wrappedConfig, false); + public async view(packageName: string, field?: string): Promise { + const args = [packageName, field, "--json"].filter(Boolean).join(" "); let viewResult: any; try { - viewResult = await this.$childProcess.exec( - `pnpm info ${packageName} ${flags}`, - ); + viewResult = await this.$childProcess.exec(`pnpm info ${args}`); } catch (e) { this.$errors.fail(e.message); } @@ -120,12 +114,8 @@ export class PnpmPackageManager extends BasePackageManager { } @exported("pnpm") - public search( - filter: string[], - config: IDictionary, - ): Promise { - const flags = this.getFlagsString(config, false); - return this.$childProcess.exec(`pnpm search ${filter.join(" ")} ${flags}`); + public async search(filter: string[]): Promise { + return this.$childProcess.exec(`pnpm search ${filter.join(" ")}`); } public async searchNpms(keyword: string): Promise { diff --git a/lib/yarn-package-manager.ts b/lib/package-managers/yarn.ts similarity index 78% rename from lib/yarn-package-manager.ts rename to lib/package-managers/yarn.ts index d4d08ad7f0..aecffc77ca 100644 --- a/lib/yarn-package-manager.ts +++ b/lib/package-managers/yarn.ts @@ -1,23 +1,32 @@ import * as path from "path"; import * as _ from "lodash"; import { BasePackageManager } from "./base-package-manager"; -import { exported } from "./common/decorators"; +import { exported } from "../common/decorators"; import { - INodePackageManagerInstallOptions, + IPackageInstallOptions, + IPackageUninstallOptions, INpmInstallResultInfo, INpmsResult, -} from "./declarations"; +} from "../declarations"; import { IChildProcess, IErrors, IFileSystem, IHostInfo, Server, - IDictionary, -} from "./common/declarations"; -import { injector } from "./common/yok"; +} from "../common/declarations"; +import { injector } from "../common/yok"; export class YarnPackageManager extends BasePackageManager { + protected readonly installFlags = { + dev: "--dev", + optional: "--optional", + exact: "--exact", + silent: "--silent", + ignoreScripts: "--ignore-scripts", + }; + protected readonly uninstallFlags = {}; + constructor( $childProcess: IChildProcess, private $errors: IErrors, @@ -34,19 +43,16 @@ export class YarnPackageManager extends BasePackageManager { public async install( packageName: string, pathToSave: string, - config: INodePackageManagerInstallOptions + options: IPackageInstallOptions ): Promise { - if (config.disableNpmInstall) { + if (options.disableNpmInstall) { return; } - if (config.ignoreScripts) { - config["ignore-scripts"] = true; - } const packageJsonPath = path.join(pathToSave, "package.json"); const jsonContentBefore = this.$fs.readJson(packageJsonPath); - const flags = this.getFlagsString(config, true); + const flags = this.getInstallFlags(options); let params = []; const isInstallingAllDependencies = packageName === pathToSave; if (!isInstallingAllDependencies) { @@ -72,25 +78,21 @@ export class YarnPackageManager extends BasePackageManager { @exported("yarn") public uninstall( packageName: string, - config?: IDictionary, + options?: IPackageUninstallOptions, cwd?: string ): Promise { - const flags = this.getFlagsString(config, false); + const flags = this.getUninstallFlags(options).join(" "); return this.$childProcess.exec(`yarn remove ${packageName} ${flags}`, { cwd, }); } @exported("yarn") - public async view(packageName: string, config: Object): Promise { - const wrappedConfig = _.extend({}, config, { json: true }); - - const flags = this.getFlagsString(wrappedConfig, false); + public async view(packageName: string, field?: string): Promise { + const args = [packageName, field, "--json"].filter(Boolean).join(" "); let viewResult: any; try { - viewResult = await this.$childProcess.exec( - `yarn info ${packageName} ${flags}` - ); + viewResult = await this.$childProcess.exec(`yarn info ${args}`); } catch (e) { this.$errors.fail(e.message); } @@ -104,10 +106,7 @@ export class YarnPackageManager extends BasePackageManager { } @exported("yarn") - public search( - filter: string[], - config: IDictionary - ): Promise { + public search(filter: string[]): Promise { this.$errors.fail( "Method not implemented. Yarn does not support searching for packages in the registry." ); diff --git a/lib/yarn2-package-manager.ts b/lib/package-managers/yarn2.ts similarity index 76% rename from lib/yarn2-package-manager.ts rename to lib/package-managers/yarn2.ts index a8312abff3..4cbb9df362 100644 --- a/lib/yarn2-package-manager.ts +++ b/lib/package-managers/yarn2.ts @@ -1,23 +1,34 @@ import * as path from "path"; import * as _ from "lodash"; import { BasePackageManager } from "./base-package-manager"; -import { exported } from "./common/decorators"; +import { exported } from "../common/decorators"; import { - INodePackageManagerInstallOptions, + IPackageInstallOptions, + IPackageUninstallOptions, INpmInstallResultInfo, INpmsResult, -} from "./declarations"; +} from "../declarations"; import { IChildProcess, IErrors, IFileSystem, IHostInfo, Server, - IDictionary, -} from "./common/declarations"; -import { injector } from "./common/yok"; +} from "../common/declarations"; +import { injector } from "../common/yok"; export class Yarn2PackageManager extends BasePackageManager { + protected readonly installFlags = { + dev: "--dev", + optional: "--optional", + exact: "--exact", + silent: "--silent", + // yarn berry has no --ignore-scripts; skip-build is the mode that + // installs without running any build scripts. + ignoreScripts: "--mode=skip-build", + }; + protected readonly uninstallFlags = {}; + private $hostInfo_: IHostInfo; constructor( $childProcess: IChildProcess, @@ -46,23 +57,16 @@ export class Yarn2PackageManager extends BasePackageManager { public async install( packageName: string, pathToSave: string, - config: INodePackageManagerInstallOptions + options: IPackageInstallOptions ): Promise { - if (config.disableNpmInstall) { + if (options.disableNpmInstall) { return; } - if (config.ignoreScripts) { - config["ignore-scripts"] = true; - } const packageJsonPath = path.join(pathToSave, "package.json"); const jsonContentBefore = this.$fs.readJson(packageJsonPath); - // remove unsupported flags - // todo: refactor all package managers to map typed flags to the actual flags - const cleanedConfig = _.omit(config, ["save-dev", "save-exact"]); - - const flags = this.getFlagsString(cleanedConfig, true); + const flags = this.getInstallFlags(options); let params = []; const isInstallingAllDependencies = packageName === pathToSave; if (!isInstallingAllDependencies) { @@ -88,25 +92,23 @@ export class Yarn2PackageManager extends BasePackageManager { @exported("yarn2") public uninstall( packageName: string, - config?: IDictionary, + options?: IPackageUninstallOptions, cwd?: string ): Promise { - const flags = this.getFlagsString(config, false); + const flags = this.getUninstallFlags(options).join(" "); return this.$childProcess.exec(`yarn remove ${packageName} ${flags}`, { cwd, }); } @exported("yarn2") - public async view(packageName: string, config: Object): Promise { - const wrappedConfig = _.extend({}, config, { json: true }); - - const flags = this.getFlagsString(wrappedConfig, false); + public async view(packageName: string, field?: string): Promise { + const args = [packageName, field && `--fields ${field}`, "--json"] + .filter(Boolean) + .join(" "); let viewResult: any; try { - viewResult = await this.$childProcess.exec( - `yarn npm info ${packageName} ${flags}` - ); + viewResult = await this.$childProcess.exec(`yarn npm info ${args}`); } catch (e) { this.$errors.fail(e.message); } @@ -120,10 +122,7 @@ export class Yarn2PackageManager extends BasePackageManager { } @exported("yarn2") - public search( - filter: string[], - config: IDictionary - ): Promise { + public search(filter: string[]): Promise { this.$errors.fail( "Method not implemented. Yarn does not support searching for packages in the registry." ); diff --git a/lib/services/android-plugin-build-service.ts b/lib/services/android-plugin-build-service.ts index 88098d55b0..0d4021ac6e 100644 --- a/lib/services/android-plugin-build-service.ts +++ b/lib/services/android-plugin-build-service.ts @@ -36,7 +36,6 @@ import { IFilesHashService } from "../definitions/files-hash-service"; import { IInjector } from "../common/definitions/yok"; import { injector } from "../common/yok"; import * as _ from "lodash"; -import { resolvePackageJSONPath } from "@rigor789/resolve-package-path"; import { cwd } from "process"; export class AndroidPluginBuildService implements IAndroidPluginBuildService { @@ -492,9 +491,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { this.$projectData.nsConfig?.android?.runtimePackageName || SCOPED_ANDROID_RUNTIME_NAME; try { - let result = await this.$packageManager.view(packageName, { - "dist-tags": true, - }); + let result = await this.$packageManager.view(packageName, "dist-tags"); result = result?.["dist-tags"] ?? result; runtimeVersion = result.latest; } catch (err) { @@ -529,19 +526,17 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { this.$projectData.nsConfig?.android?.runtimePackageName || SCOPED_ANDROID_RUNTIME_NAME; // try reading from installed runtime first before reading from the npm registry... - const installedRuntimePackageJSONPath = resolvePackageJSONPath( + const installedRuntimePath = this.$packageManager.getInstalledPackagePath( packageName, - { - paths: [this.$projectData.projectDir], - }, + this.$projectData.projectDir, ); - if (!installedRuntimePackageJSONPath) { + if (!installedRuntimePath) { return null; } const installedRuntimePackageJSON: IRuntimePackageJSON = this.$fs.readJson( - installedRuntimePackageJSONPath, + path.join(installedRuntimePath, "package.json"), ); if (!installedRuntimePackageJSON) { @@ -590,7 +585,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { try { let output = await this.$packageManager.view( `${packageName}@${runtimeVersion}`, - { version_info: true }, + "version_info", ); output = output?.["version_info"] ?? output; @@ -605,7 +600,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { */ output = await this.$packageManager.view( `${packageName}@${runtimeVersion}`, - { gradle: true }, + "gradle", ); output = output?.["gradle"] ?? output; diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index ccf7df4bb7..2c05eb6183 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -35,10 +35,6 @@ import { import { ICleanupService } from "../../definitions/cleanup-service"; import { ViteHmrPortService } from "../../contracts/vite-hmr-port-service"; import { injector } from "../../common/yok"; -import { - resolvePackagePath, - resolvePackageJSONPath, -} from "../../helpers/package-path-helper"; // todo: move out of here interface IBundlerMessage { @@ -573,10 +569,12 @@ export class BundlerCompilerService additionalNodeArgs.unshift("--max_old_space_size=4096"); } + const bundlerExecutablePath = this.getBundlerExecutablePath(projectData); + const isModernBundler = this.isModernBundler(projectData); const args = [ ...additionalNodeArgs, - this.getBundlerExecutablePath(projectData), - isVite || this.isModernBundler(projectData) ? "build" : null, + bundlerExecutablePath, + isVite || isModernBundler ? "build" : null, `--config=${projectData.bundlerConfigPath}`, ...envParams, ].filter(Boolean); @@ -1168,19 +1166,20 @@ export class BundlerCompilerService private getBundlerExecutablePath(projectData: IProjectData): string { const bundler = this.getBundler(); + const resolve = (packageName: string) => + this.$packageManager.getInstalledPackagePath( + packageName, + projectData.projectDir, + ); if (bundler === "vite") { - const packagePath = resolvePackagePath(`vite`, { - paths: [projectData.projectDir], - }); + const packagePath = resolve("vite"); if (packagePath) { return path.resolve(packagePath, "bin", "vite.js"); } } else if (this.isModernBundler(projectData)) { - const packagePath = resolvePackagePath(this.getBundlerPackageName(), { - paths: [projectData.projectDir], - }); + const packagePath = resolve(this.getBundlerPackageName()); if (packagePath) { return path.resolve(packagePath, "dist", "bin", "index.js"); @@ -1200,9 +1199,7 @@ export class BundlerCompilerService ); } - const packagePath = resolvePackagePath("webpack", { - paths: [projectData.projectDir], - }); + const packagePath = resolve("webpack"); if (!packagePath) { return ""; @@ -1230,15 +1227,15 @@ export class BundlerCompilerService case "rspack": return true; default: - const packageJSONPath = resolvePackageJSONPath( + const packagePath = this.$packageManager.getInstalledPackagePath( this.getBundlerPackageName(), - { - paths: [projectData.projectDir], - }, + projectData.projectDir, ); - if (packageJSONPath) { - const packageData = this.$fs.readJson(packageJSONPath); + if (packagePath) { + const packageData = this.$fs.readJson( + path.join(packagePath, "package.json"), + ); const ver = semver.coerce(packageData.version); if (semver.satisfies(ver, ">= 5.0.0")) { diff --git a/lib/services/doctor-service.ts b/lib/services/doctor-service.ts index d57018efec..3c41698968 100644 --- a/lib/services/doctor-service.ts +++ b/lib/services/doctor-service.ts @@ -3,15 +3,11 @@ import * as path from "path"; import * as _ from "lodash"; import * as helpers from "../common/helpers"; import { cache } from "../common/decorators"; -import { - TrackActionNames, - NODE_MODULES_FOLDER_NAME, - TNS_CORE_MODULES_NAME, -} from "../constants"; +import { TrackActionNames, TNS_CORE_MODULES_NAME } from "../constants"; import { DoctorService } from "../contracts/doctor-service"; import { doctor, constants } from "@nativescript/doctor"; import { IProjectDataService } from "../definitions/project"; -import { IVersionsService, IOptions } from "../declarations"; +import { IVersionsService, IOptions, IPackageManager } from "../declarations"; import { IPlatformEnvironmentRequirements } from "../definitions/platform"; import { IAnalyticsService, @@ -73,6 +69,7 @@ export class DoctorServiceImpl implements DoctorService { private $terminalSpinnerService: ITerminalSpinnerService, private $versionsService: IVersionsService, private $settingsService: ISettingsService, + private $packageManager: IPackageManager, ) {} public async printWarnings(configOptions?: { @@ -278,6 +275,9 @@ export class DoctorServiceImpl implements DoctorService { ): { file: string; line: string }[] { const shortImportRegExp = this.getShortImportRegExp(projectDir); const shortImports: { file: string; line: string }[] = []; + if (!shortImportRegExp) { + return shortImports; + } for (const file of files) { const fileContent = this.$fs.readText(file); @@ -305,11 +305,13 @@ export class DoctorServiceImpl implements DoctorService { } private getShortImportRegExp(projectDir: string): RegExp { - const pathToTnsCoreModules = path.join( - projectDir, - NODE_MODULES_FOLDER_NAME, + const pathToTnsCoreModules = this.$packageManager.getInstalledPackagePath( TNS_CORE_MODULES_NAME, + projectDir, ); + if (!pathToTnsCoreModules) { + return null; + } const coreModulesSubDirs = this.$fs .readDirectory(pathToTnsCoreModules) .filter((entry) => diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index 48fc3584f6..54de9ba694 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -4,7 +4,11 @@ import { cache } from "../common/decorators"; import * as constants from "../constants"; import { createRegExp, regExpEscape } from "../common/helpers"; import { reportDeprecation } from "../common/deprecation"; -import { INodePackageManager, INpmsSingleResultData } from "../declarations"; +import { + INodePackageManager, + INpmsSingleResultData, + IPackageInstallOptions, +} from "../declarations"; import { IDictionary, IFileSystem, @@ -27,7 +31,7 @@ import { CommandRegistry, describeRejection, } from "../common/contracts"; -import { DefinedCommand, isCommandDefinition } from "../common/define-command"; +import { DefinedCommand, toCommandDefinition } from "../common/define-command"; import { registerDefinitionAs } from "../common/services/command-definition-adapter"; function isNonEmptyString(value: any): boolean { @@ -119,9 +123,9 @@ export class ExtensibilityService implements IExtensibilityService { await this.assertPackageJsonExists(); - const npmOpts: any = { + const npmOpts: IPackageInstallOptions = { save: true, - ["save-exact"]: true, + exact: true, }; const localPath = path.resolve(extensionName); @@ -449,9 +453,10 @@ export class ExtensibilityService implements IExtensibilityService { const exported = this.loadInExtensionScope(extensionName, () => require(absoluteModulePath), ); - const candidate = (exported && exported.default) ?? exported; + const exportedValue = (exported && exported.default) ?? exported; - if (!isCommandDefinition(candidate)) { + const candidate = toCommandDefinition(exportedValue); + if (!candidate) { return; } @@ -507,11 +512,12 @@ export class ExtensibilityService implements IExtensibilityService { extensionName: string, ): Promise { this.$logger.trace(`Asserting extension ${extensionName} is installed.`); - const installedExtensions = this.$fs.readDirectory( - path.join(this.pathToExtensions, constants.NODE_MODULES_FOLDER_NAME), + const installedPath = this.$packageManager.getInstalledPackagePath( + extensionName, + this.pathToExtensions, ); - if (installedExtensions.indexOf(extensionName) === -1) { + if (!installedPath) { this.$logger.trace( `Extension ${extensionName} is not installed, starting installation.`, ); diff --git a/lib/services/platform/add-platform-service.ts b/lib/services/platform/add-platform-service.ts index b93aba4ea7..28c45e45db 100644 --- a/lib/services/platform/add-platform-service.ts +++ b/lib/services/platform/add-platform-service.ts @@ -120,9 +120,8 @@ export class AddPlatformService implements IAddPlatformService { { silent: true, dev: true, - "save-dev": true, - "save-exact": true, - } as any + exact: true, + } ); if (!installedPackage.name) { diff --git a/lib/services/plugins-service.ts b/lib/services/plugins-service.ts index 43c843e59a..34880da5b2 100644 --- a/lib/services/plugins-service.ts +++ b/lib/services/plugins-service.ts @@ -17,7 +17,7 @@ import { } from "../definitions/platform"; import { IProjectDataService, IProjectData } from "../definitions/project"; import { - INodePackageManagerInstallOptions, + IPackageInstallOptions, INodePackageManager, IOptions, IDependencyData, @@ -32,10 +32,6 @@ import { IFilesHashService } from "../definitions/files-hash-service"; import * as _ from "lodash"; import { IInjector } from "../common/definitions/yok"; import { injector } from "../common/yok"; -import { - resolvePackagePath, - resolvePackageJSONPath, -} from "../helpers/package-path-helper"; import { color } from "../color"; export class PluginsService implements IPluginsService { @@ -59,7 +55,7 @@ export class PluginsService implements IPluginsService { return this.$injector.resolve("projectDataService"); } - private get npmInstallOptions(): INodePackageManagerInstallOptions { + private get npmInstallOptions(): IPackageInstallOptions { return _.merge( { disableNpmInstall: this.$options.disableNpmInstall, @@ -84,7 +80,7 @@ export class PluginsService implements IPluginsService { ) {} public async add(plugin: string, projectData: IProjectData): Promise { - await this.ensure(projectData); + await this.ensureAllDependenciesAreInstalled(projectData); const possiblePackageName = path.resolve(plugin); if ( possiblePackageName.indexOf(".tgz") !== -1 && @@ -298,24 +294,18 @@ export class PluginsService implements IPluginsService { _.keys(packageJsonContent.devDependencies), ); - const notInstalledDependencies = allDependencies - .map((dep) => { - this.$logger.trace(`Checking if ${dep} is installed...`); - const pathToPackage = resolvePackagePath(dep, { - paths: [projectData.projectDir], - }); - - if (pathToPackage) { - // return false if the dependency is installed - we'll filter out boolean values - // and end up with an array of dep names that are not installed if we end up - // inside the catch block. - return false; - } - + const notInstalledDependencies: string[] = []; + for (const dep of allDependencies) { + this.$logger.trace(`Checking if ${dep} is installed...`); + const pathToPackage = this.$packageManager.getInstalledPackagePath( + dep, + projectData.projectDir, + ); + if (!pathToPackage) { this.$logger.trace(`${dep} is not installed, or couldn't be found`); - return dep; - }) - .filter(Boolean); + notInstalledDependencies.push(dep); + } + } if (this.$options.force || notInstalledDependencies.length) { this.$logger.trace( @@ -635,9 +625,7 @@ This framework comes from ${dependencyName} plugin, which is installed multiple pluginData.version = cacheData.version; pluginData.fullPath = (cacheData).directory || - path.dirname( - this.getPackageJsonFilePathForModule(cacheData.name, projectDir), - ); + (cacheData).fullPath; pluginData.isPlugin = !!cacheData.nativescript; pluginData.pluginPlatformsFolderPath = (platform: string) => { if (this.$mobileHelper.isvisionOSPlatform(platform)) { @@ -702,10 +690,6 @@ This framework comes from ${dependencyName} plugin, which is installed multiple })); } - private getNodeModulesPath(projectDir: string): string { - return path.join(projectDir, "node_modules"); - } - private getPackageJsonFilePath(projectDir: string): string { return path.join(projectDir, "package.json"); } @@ -714,10 +698,11 @@ This framework comes from ${dependencyName} plugin, which is installed multiple moduleName: string, projectDir: string, ): string { - const pathToJsonFile = resolvePackageJSONPath(moduleName, { - paths: [projectDir], - }); - return pathToJsonFile; + const pathToModule = this.$packageManager.getInstalledPackagePath( + moduleName, + projectDir, + ); + return pathToModule && path.join(pathToModule, "package.json"); } private getDependencies(projectDir: string): string[] { @@ -754,22 +739,17 @@ This framework comes from ${dependencyName} plugin, which is installed multiple }; } - private async ensure(projectData: IProjectData): Promise { - await this.ensureAllDependenciesAreInstalled(projectData); - this.$fs.ensureDirectoryExists( - this.getNodeModulesPath(projectData.projectDir), - ); - } - private async getAllInstalledModules( projectData: IProjectData, ): Promise { - await this.ensure(projectData); + await this.ensureAllDependenciesAreInstalled(projectData); const nodeModules = this.getDependencies(projectData.projectDir); - return _.map(nodeModules, (nodeModuleName) => - this.getNodeModuleData(nodeModuleName, projectData.projectDir), - ).filter(Boolean); + return nodeModules + .map((nodeModuleName) => + this.getNodeModuleData(nodeModuleName, projectData.projectDir), + ) + .filter(Boolean); } private async executeNpmCommand( diff --git a/lib/services/project-data-service.ts b/lib/services/project-data-service.ts index 31e5a2a342..d795c9cf38 100644 --- a/lib/services/project-data-service.ts +++ b/lib/services/project-data-service.ts @@ -27,6 +27,7 @@ import { import { IAndroidResourcesMigrationService, IStaticConfig, + IPackageManager, } from "../declarations"; import { IBasePluginData, IPluginsService } from "../definitions/plugins"; import { IDictionary, IFileSystem, IProjectDir } from "../common/declarations"; @@ -34,7 +35,6 @@ import * as _ from "lodash"; import { IInjector } from "../common/definitions/yok"; import { injector } from "../common/yok"; import * as semver from "semver"; -import { resolvePackageJSONPath } from "../helpers/package-path-helper"; interface IProjectFileData { projectData: any; @@ -653,20 +653,17 @@ export class ProjectDataService implements IProjectDataService { // in case we are using a local tgz for the runtime or a range like ~8.0.0, ^8.0.0 etc. or a tag like JSC if (runtimePackage.version.includes("tgz") || isRange || isTag) { try { - const runtimePackageJsonPath = resolvePackageJSONPath( - runtimePackage.name, - { - paths: [projectDir], - }, - ); + const runtimePackagePath = this.$injector + .resolve("packageManager") + .getInstalledPackagePath(runtimePackage.name, projectDir); - if (!runtimePackageJsonPath) { + if (!runtimePackagePath) { // caught below - throw new Error("Runtime package.json not found."); + throw new Error("Runtime package not found."); } runtimePackage.version = this.$fs.readJson( - runtimePackageJsonPath, + path.join(runtimePackagePath, constants.PACKAGE_JSON_FILE_NAME), ).version; } catch (err) { if (isRange) { diff --git a/lib/services/test-execution-service.ts b/lib/services/test-execution-service.ts index 8ba4eda276..2733aea4c5 100644 --- a/lib/services/test-execution-service.ts +++ b/lib/services/test-execution-service.ts @@ -7,14 +7,13 @@ import { IProjectDataService, IProjectData, } from "../definitions/project"; -import { IConfiguration, IOptions } from "../declarations"; +import { IConfiguration, IOptions, IPackageManager } from "../declarations"; import { IPluginsService } from "../definitions/plugins"; import { Server, IFileSystem, IChildProcess } from "../common/declarations"; import { ErrorCodes } from "../common/enums"; import * as _ from "lodash"; import { injector } from "../common/yok"; import { ICommandParameter } from "../common/definitions/commands"; -import { resolvePackagePath } from "../helpers/package-path-helper"; interface IKarmaConfigOptions { debugBrk: boolean; @@ -36,6 +35,7 @@ export class TestExecutionService implements ITestExecutionService { private $pluginsService: IPluginsService, private $projectDataService: IProjectDataService, private $childProcess: IChildProcess, + private $packageManager: IPackageManager, ) {} public platform: string; @@ -144,9 +144,10 @@ export class TestExecutionService implements ITestExecutionService { } }); - const pathToKarma = resolvePackagePath("karma", { - paths: [projectData.projectDir], - }); + const pathToKarma = this.$packageManager.getInstalledPackagePath( + "karma", + projectData.projectDir, + ); canStartKarmaServer = canStartKarmaServer && !!pathToKarma; diff --git a/lib/services/user-settings-service.ts b/lib/services/user-settings-service.ts index 2ba6be9831..64ed5c3f9f 100644 --- a/lib/services/user-settings-service.ts +++ b/lib/services/user-settings-service.ts @@ -39,6 +39,10 @@ export class UserSettingsService implements IUserSettingsService { ); } + public getSettingValueSync(settingName: string): T { + return this.$jsonFileSettingsService.getSettingValueSync(settingName); + } + public saveSetting( key: string, value: T, diff --git a/lib/services/versions-service.ts b/lib/services/versions-service.ts index 3710ab52bc..19c9f2621d 100644 --- a/lib/services/versions-service.ts +++ b/lib/services/versions-service.ts @@ -2,7 +2,11 @@ import * as constants from "../constants"; import * as helpers from "../common/helpers"; import * as semver from "semver"; import * as path from "path"; -import { IVersionsService, IPackageInstallationManager } from "../declarations"; +import { + IVersionsService, + IPackageInstallationManager, + IPackageManager, +} from "../declarations"; import { IProjectData, IProjectDataService } from "../definitions/project"; import { IPluginsService, IBasePluginData } from "../definitions/plugins"; import { IFileSystem, IVersionInformation } from "../common/declarations"; @@ -28,6 +32,7 @@ class VersionsService implements IVersionsService { constructor( private $fs: IFileSystem, private $packageInstallationManager: IPackageInstallationManager, + private $packageManager: IPackageManager, private $injector: IInjector, private $logger: ILogger, private $staticConfig: Config.IStaticConfig, @@ -63,18 +68,13 @@ class VersionsService implements IVersionsService { const versionInformations: IVersionInformation[] = []; if (this.projectData) { - const nodeModulesPath = path.join( - this.projectData.projectDir, - constants.NODE_MODULES_FOLDER_NAME - ); - const scopedPackagePath = path.join( - nodeModulesPath, - constants.SCOPED_TNS_CORE_MODULES - ); - const tnsCoreModulesPath = path.join( - nodeModulesPath, - constants.TNS_CORE_MODULES_NAME - ); + const resolve = (packageName: string) => + this.$packageManager.getInstalledPackagePath( + packageName, + this.projectData.projectDir + ); + let scopedPackagePath = resolve(constants.SCOPED_TNS_CORE_MODULES); + let tnsCoreModulesPath = resolve(constants.TNS_CORE_MODULES_NAME); const dependsOnNonScopedPackage = !!this.projectData.dependencies[ constants.TNS_CORE_MODULES_NAME @@ -83,18 +83,19 @@ class VersionsService implements IVersionsService { constants.SCOPED_TNS_CORE_MODULES ]; - // ensure the dependencies are installed, so we can get their actual versions from node_modules + // ensure the dependencies are installed, so we can read their actual versions if ( - !this.$fs.exists(nodeModulesPath) || - (dependsOnNonScopedPackage && !this.$fs.exists(tnsCoreModulesPath)) || - (dependsOnScopedPackage && !this.$fs.exists(scopedPackagePath)) + (dependsOnNonScopedPackage && !tnsCoreModulesPath) || + (dependsOnScopedPackage && !scopedPackagePath) ) { await this.$pluginsService.ensureAllDependenciesAreInstalled( this.projectData ); + scopedPackagePath = resolve(constants.SCOPED_TNS_CORE_MODULES); + tnsCoreModulesPath = resolve(constants.TNS_CORE_MODULES_NAME); } - if (dependsOnNonScopedPackage && this.$fs.exists(tnsCoreModulesPath)) { + if (dependsOnNonScopedPackage && tnsCoreModulesPath) { const currentTnsCoreModulesVersion = this.$fs.readJson( path.join(tnsCoreModulesPath, constants.PACKAGE_JSON_FILE_NAME) ).version; @@ -102,7 +103,7 @@ class VersionsService implements IVersionsService { versionInformations.push(nativescriptCoreModulesInfo); } - if (dependsOnScopedPackage && this.$fs.exists(scopedPackagePath)) { + if (dependsOnScopedPackage && scopedPackagePath) { const scopedModulesInformation: IVersionInformation = { componentName: constants.SCOPED_TNS_CORE_MODULES, latestVersion: await this.$packageInstallationManager.getLatestVersion( diff --git a/lib/services/vitest-execution-service.ts b/lib/services/vitest-execution-service.ts index f4811780e8..80c09e8235 100644 --- a/lib/services/vitest-execution-service.ts +++ b/lib/services/vitest-execution-service.ts @@ -1,9 +1,8 @@ import * as path from "path"; import { IProjectData, IVitestExecutionService } from "../definitions/project"; -import { IOptions } from "../declarations"; +import { IOptions, IPackageManager } from "../declarations"; import { IChildProcess, IErrors, IFileSystem } from "../common/declarations"; import { injector } from "../common/yok"; -import { resolvePackagePath } from "../helpers/package-path-helper"; const VITEST_CONFIG_FILES = [ "vitest.config.mts", @@ -19,6 +18,7 @@ export class VitestExecutionService implements IVitestExecutionService { private $fs: IFileSystem, private $logger: ILogger, private $options: IOptions, + private $packageManager: IPackageManager, ) {} public isVitestProject(projectData: IProjectData): boolean { @@ -28,7 +28,7 @@ export class VitestExecutionService implements IVitestExecutionService { public canStartTestRun(projectData: IProjectData): boolean { return ( this.isVitestProject(projectData) && - !!resolvePackagePath("vitest", { paths: [projectData.projectDir] }) + !!this.getVitestPackagePath(projectData) ); } @@ -36,9 +36,7 @@ export class VitestExecutionService implements IVitestExecutionService { platform: string, projectData: IProjectData, ): Promise { - const vitestPackagePath = resolvePackagePath("vitest", { - paths: [projectData.projectDir], - }); + const vitestPackagePath = this.getVitestPackagePath(projectData); if (!vitestPackagePath) { this.$errors.fail( "Unable to find 'vitest' in the project. Run '$ ns test init --framework vitest' first.", @@ -90,6 +88,14 @@ export class VitestExecutionService implements IVitestExecutionService { } return null; } + + private getVitestPackagePath(projectData: IProjectData): string { + return this.$packageManager.getInstalledPackagePath( + "vitest", + projectData.projectDir, + ); + } + } injector.register("vitestExecutionService", VitestExecutionService); diff --git a/lib/tools/node-modules/node-modules-dependencies-builder.ts b/lib/tools/node-modules/node-modules-dependencies-builder.ts index 9474b7eb79..abed73c376 100644 --- a/lib/tools/node-modules/node-modules-dependencies-builder.ts +++ b/lib/tools/node-modules/node-modules-dependencies-builder.ts @@ -1,11 +1,10 @@ import * as path from "path"; import { PACKAGE_JSON_FILE_NAME } from "../../constants"; import { INodeModulesDependenciesBuilder } from "../../definitions/platform"; -import { IDependencyData } from "../../declarations"; +import { IDependencyData, IPackageManager } from "../../declarations"; import { IFileSystem } from "../../common/declarations"; import * as _ from "lodash"; import { injector } from "../../common/yok"; -import { resolvePackagePath } from "@rigor789/resolve-package-path"; interface IDependencyDescription { parent: IDependencyDescription; @@ -17,7 +16,10 @@ interface IDependencyDescription { export class NodeModulesDependenciesBuilder implements INodeModulesDependenciesBuilder { - public constructor(private $fs: IFileSystem) {} + public constructor( + private $fs: IFileSystem, + private $packageManager: IPackageManager, + ) {} public getProductionDependencies( projectPath: string, @@ -96,16 +98,18 @@ export class NodeModulesDependenciesBuilder const parentModulesPath = depDescription?.parentDir ?? depDescription?.parent?.parentDir; - let modulePath: string = resolvePackagePath(depDescription.name, { - paths: [parentModulesPath], - }); + let modulePath = this.$packageManager.getInstalledPackagePath( + depDescription.name, + parentModulesPath, + ); // perhaps traverse up the tree here? if (!modulePath) { // fallback to searching in the root path - modulePath = resolvePackagePath(depDescription.name, { - paths: [rootPath], - }); + modulePath = this.$packageManager.getInstalledPackagePath( + depDescription.name, + rootPath, + ); } // if we failed to find the module... diff --git a/test/bun-package-manager.ts b/test/bun-package-manager.ts index 758b620f54..46fdfeb16b 100644 --- a/test/bun-package-manager.ts +++ b/test/bun-package-manager.ts @@ -1,7 +1,7 @@ import { Yok } from "../lib/common/yok"; import * as stubs from "./stubs"; import { assert } from "chai"; -import { BunPackageManager } from "../lib/bun-package-manager"; +import { BunPackageManager } from "../lib/package-managers/bun"; import { IInjector } from "../lib/common/definitions/yok"; function createTestInjector(configuration: {} = {}): IInjector { diff --git a/test/commands/post-install.ts b/test/commands/post-install.ts index d39cb11327..b0dd9ca476 100644 --- a/test/commands/post-install.ts +++ b/test/commands/post-install.ts @@ -1,6 +1,6 @@ import { Yok } from "../../lib/common/yok"; import { assert } from "chai"; -import { postInstallCliCommandDefinition } from "../../lib/commands/post-install"; +import { PostInstallCliCommand } from "../../lib/commands/post-install"; import { registerCommand } from "../../lib/common/services/command-definition-adapter"; import { SettingsService } from "../../lib/common/test/unit-tests/stubs"; import { IInjector } from "../../lib/common/definitions/yok"; @@ -17,7 +17,7 @@ const createTestInjector = (): IInjector => { testInjector.register("staticConfig", {}); testInjector.register("commandsService", { - tryExecuteCommand: async ( + runCommand: async ( commandName: string, commandArguments: string[], ): Promise => undefined, @@ -47,7 +47,7 @@ const createTestInjector = (): IInjector => { testInjector.register("settingsService", SettingsService); runInInjectionContext(testInjector, () => - registerCommand(postInstallCliCommandDefinition), + registerCommand(PostInstallCliCommand), ); testInjector.register("hostInfo", {}); @@ -85,7 +85,7 @@ describe("post-install command", () => { const commandsService = testInjector.resolve("commandsService"); let isTryExecuteCommandCalled = false; - commandsService.tryExecuteCommand = async (): Promise => { + commandsService.runCommand = async (): Promise => { isTryExecuteCommandCalled = true; }; @@ -110,7 +110,7 @@ describe("post-install command", () => { assert.equal( isTryExecuteCommandCalled, opts.shouldCallMethod, - `post-install-cli command must ${hasNotInMsg} call commandsService.tryExecuteCommand`, + `post-install-cli command must ${hasNotInMsg} call commandsService.runCommand`, ); }; diff --git a/test/controllers/add-platform-controller.ts b/test/controllers/add-platform-controller.ts index f7680f789f..934c84d26e 100644 --- a/test/controllers/add-platform-controller.ts +++ b/test/controllers/add-platform-controller.ts @@ -5,12 +5,12 @@ import { assert } from "chai"; import { format } from "util"; import * as _ from "lodash"; import { AddPlaformErrors } from "../../lib/constants"; -import { PackageManager } from "../../lib/package-manager"; -import { NodePackageManager } from "../../lib/node-package-manager"; -import { YarnPackageManager } from "../../lib/yarn-package-manager"; -import { Yarn2PackageManager } from "../../lib/yarn2-package-manager"; -import { PnpmPackageManager } from "../../lib/pnpm-package-manager"; -import { BunPackageManager } from "../../lib/bun-package-manager"; +import { PackageManager } from "../../lib/package-managers"; +import { NpmPackageManager } from "../../lib/package-managers/npm"; +import { YarnPackageManager } from "../../lib/package-managers/yarn"; +import { Yarn2PackageManager } from "../../lib/package-managers/yarn2"; +import { PnpmPackageManager } from "../../lib/package-managers/pnpm"; +import { BunPackageManager } from "../../lib/package-managers/bun"; import { MobileHelper } from "../../lib/common/mobile/mobile-helper"; let actualMessage: string = null; @@ -29,7 +29,7 @@ function createInjector(data?: { latestFrameworkVersion: string }) { trackEventActionInGoogleAnalytics: () => ({}), }); injector.register("packageManager", PackageManager); - injector.register("npm", NodePackageManager); + injector.register("npm", NpmPackageManager); injector.register("yarn", YarnPackageManager); injector.register("yarn2", Yarn2PackageManager); injector.register("pnpm", PnpmPackageManager); @@ -37,6 +37,7 @@ function createInjector(data?: { latestFrameworkVersion: string }) { injector.register("userSettingsService", { getSettingValue: async (settingName: string): Promise => undefined, + getSettingValueSync: (settingName: string): void => undefined, }); injector.register("tempService", TempServiceStub); injector.register("mobileHelper", MobileHelper); diff --git a/test/controllers/prepare-controller.ts b/test/controllers/prepare-controller.ts index e3982de1e4..553bcbe7bc 100644 --- a/test/controllers/prepare-controller.ts +++ b/test/controllers/prepare-controller.ts @@ -51,6 +51,9 @@ function createTestInjector(data: { hasNativeChanges: boolean }): IInjector { injector.register("mobileHelper", MobileHelper); injector.register("prepareController", PrepareController); + injector.register("packageManager", { + getInstalledPackagePath: (): string => null, + }); injector.register("nodeModulesDependenciesBuilder", { getProductionDependencies: () => [], diff --git a/test/define-command.ts b/test/define-command.ts index ae7f8698cc..1f28916dd7 100644 --- a/test/define-command.ts +++ b/test/define-command.ts @@ -8,11 +8,13 @@ import { 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"; @@ -20,13 +22,17 @@ import { LoggerStub, HooksServiceStub } from "./stubs"; import { arrayOption, booleanOption, + Command, defineCommand, + isCommandClass, isCommandDefinition, numberOption, stringOption, } from "../lib/common/define-command"; import { + canExecuteCommand, createCommandFromDefinition, + registerBuiltInCommand, registerCommand, registerLazyCommand, } from "../lib/common/services/command-definition-adapter"; @@ -1340,6 +1346,203 @@ describe("defineCommand", () => { }); }); + describe("canExecuteCommand", () => { + const createInProcessInjector = (): IInjector => { + const testInjector = new Yok(); + testInjector.register("errors", { + beginCommand: async (action: () => Promise) => action(), + failWithHelp: (message: string) => { + throw new Error(message); + }, + fail: (message: string) => { + throw new Error(message); + }, + reportCommandError: async (ex: Error) => { + throw ex; + }, + }); + testInjector.register("hooksService", HooksServiceStub); + testInjector.register("logger", LoggerStub); + testInjector.register("staticConfig", { + disableAnalytics: true, + disableCommandHooks: true, + }); + testInjector.register("extensibilityService", {}); + testInjector.register("optionsTracker", {}); + testInjector.register("options", { + validateOptions: (): void => undefined, + }); + testInjector.register("commandsService", CommandsService); + return testInjector; + }; + + it("returns the named command's own verdict without running it", async () => { + const testInjector = createInProcessInjector(); + let ran = false; + + runInInjectionContext(testInjector, () => { + registerCommand( + defineCommand({ + name: "dctest-can-yes", + arguments: "any", + canExecute: (context) => context.args[0] === "ok", + run: () => { + ran = true; + }, + }), + ); + }); + + const verdicts = [ + await runInInjectionContext(testInjector, () => + canExecuteCommand("dctest-can-yes", ["ok"]), + ), + await runInInjectionContext(testInjector, () => + canExecuteCommand("dctest-can-yes", ["nope"]), + ), + ]; + + assert.deepEqual(verdicts, [true, false]); + assert.isFalse(ran); + }); + + it("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( @@ -2275,6 +2478,352 @@ describe("defineCommand", () => { }); }); + describe("class form", () => { + it("runs with the declared options and arguments", async () => { + const testInjector = createTestInjector({ release: true }); + const seen: any[] = []; + + class Widget extends Command({ + name: "dctest-class", + options: { release: booleanOption({ default: false }) }, + arguments: "any", + }) { + public run(): void { + seen.push([this.options.release, this.args, this.context.params]); + } + } + + assert.isTrue(isCommandClass(Widget)); + assert.isTrue(isCommandDefinition(Widget.definition)); + + const command = createCommandFromDefinition( + Widget.definition, + testInjector, + ); + await command.execute(["android"]); + + assert.deepEqual(seen, [[true, ["android"], {}]]); + }); + + it("derives one definition per class, not per access", () => { + class Widget extends Command({ name: "dctest-class-cached" }) { + public run(): void { + /* intentionally left blank */ + } + } + + assert.strictEqual(Widget.definition, Widget.definition); + assert.equal(Widget.definition.name, "dctest-class-cached"); + }); + + it("builds one instance per invocation, with inject() fields resolved", async () => { + const testInjector = createTestInjector(); + testInjector.register("dcTestGreeter", { greet: () => "hello" }); + const instances: any[] = []; + + class Widget extends Command({ name: "dctest-class-instances" }) { + private $greeter = inject("dcTestGreeter"); + + public run(): void { + instances.push(this); + assert.equal(this.$greeter.greet(), "hello"); + } + } + + const command = createCommandFromDefinition( + Widget.definition, + testInjector, + ); + await command.execute([]); + await command.execute([]); + + assert.lengthOf(instances, 2); + assert.notStrictEqual(instances[0], instances[1]); + assert.instanceOf(instances[0], Widget); + }); + + it("honours an optional canExecute and leaves it out when undeclared", async () => { + const testInjector = createTestInjector(); + + class Refusing extends Command({ + name: "dctest-class-refuses", + arguments: "any", + }) { + public canExecute(): boolean { + return this.args[0] === "yes"; + } + + public run(): void { + /* intentionally left blank */ + } + } + + class Plain extends Command({ name: "dctest-class-plain" }) { + public run(): void { + /* intentionally left blank */ + } + } + + assert.isUndefined(Plain.definition.canExecute); + + const refusing = createCommandFromDefinition( + Refusing.definition, + testInjector, + ); + assert.isFalse(await refusing.canExecute(["no"])); + assert.isTrue(await refusing.canExecute(["yes"])); + + const plain = createCommandFromDefinition(Plain.definition, testInjector); + assert.isTrue(await plain.canExecute([])); + }); + + it("wires postRun to the result run returned", async () => { + const testInjector = createTestInjector(); + const order: string[] = []; + + class Widget extends Command<"dctest-class-postrun", {}, number>({ + name: "dctest-class-postrun", + }) { + public run(): number { + order.push("run"); + return 7; + } + + public postRun(result: number): void { + order.push(`postRun:${result}`); + } + } + + const command = createCommandFromDefinition( + Widget.definition, + testInjector, + ); + await command.execute([]); + await command.postCommandAction([]); + + assert.deepEqual(order, ["run", "postRun:7"]); + }); + + it("wires shortcuts, and declares none when the class has no method", async () => { + const savedSetting = process.env.NS_COMMAND_SHORTCUTS; + process.env.NS_COMMAND_SHORTCUTS = "true"; + + try { + const testInjector = createTestInjector(); + const attached: string[][] = []; + testInjector.register("keyShortcutService", { + attach(options: { shortcuts: KeyShortcut[] }): boolean { + attached.push(options.shortcuts.map((shortcut) => shortcut.key)); + return true; + }, + printHint: (): void => undefined, + }); + + class Widget extends Command({ name: "dctest-class-shortcuts" }) { + public run(): void { + /* intentionally left blank */ + } + + public shortcuts(): KeyShortcut[] { + return [ + { + key: "r", + description: `Restart ${this.args[0]}`, + action: (): void => undefined, + }, + ]; + } + } + + class Plain extends Command({ name: "dctest-class-no-shortcuts" }) { + public run(): void { + /* intentionally left blank */ + } + } + + assert.isUndefined(Plain.definition.shortcuts); + + const command = createCommandFromDefinition( + Widget.definition, + testInjector, + ); + await command.execute(["ios"]); + + assert.deepEqual(attached, [["r"]]); + } finally { + if (savedSetting === undefined) { + delete process.env.NS_COMMAND_SHORTCUTS; + } else { + process.env.NS_COMMAND_SHORTCUTS = savedSetting; + } + } + }); + + it("registers through registerCommand and registerBuiltInCommand", async () => { + const testInjector = createTestInjector(); + const ran: string[] = []; + + class Direct extends Command({ name: "dctest-class-direct" }) { + public run(): void { + ran.push("direct"); + } + } + + class Lazy extends Command({ name: "dctest-class-lazy" }) { + public run(): void { + ran.push("lazy"); + } + } + + runInInjectionContext(testInjector, () => registerCommand(Direct)); + runInInjectionContext(testInjector, () => + registerBuiltInCommand( + "dctest-class-lazy", + () => Lazy, + ), + ); + + await testInjector.resolveCommand("dctest-class-direct").execute([]); + await testInjector.resolveCommand("dctest-class-lazy").execute([]); + + assert.deepEqual(ran, ["direct", "lazy"]); + }); + + it("rejects a class that implements no run", () => { + const noRun: any = Command({ name: "dctest-class-norun" }); + + assert.throws( + () => noRun.definition, + /Invalid command definition for 'dctest-class-norun'.*implements no 'run' method.*Accepted form:/s, + ); + }); + + it("reports a class Command() did not produce, through the deferred loader", () => { + const testInjector = createTestInjector(); + + class Impostor { + public run(): void { + /* intentionally left blank */ + } + } + + assert.isFalse(isCommandClass(Impostor)); + + runInInjectionContext(testInjector, () => + registerLazyCommand("dctest-class-impostor2", () => Impostor), + ); + + assert.throws( + () => testInjector.resolveCommand("dctest-class-impostor2"), + /class that did not come from Command\(\)/, + ); + }); + }); + + describe("COMMAND_CONTEXT", () => { + it("resolves to the context the handlers of the same stage receive", async () => { + const testInjector = createTestInjector(); + 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"); diff --git a/test/extension-manifests.ts b/test/extension-manifests.ts index 9a213310d9..33bbe53d80 100644 --- a/test/extension-manifests.ts +++ b/test/extension-manifests.ts @@ -1,4 +1,5 @@ import { assert } from "chai"; +import { resolvePackagePath } from "../lib/helpers/package-path-helper"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -184,6 +185,8 @@ describe("extension manifests", () => { install: async (): Promise => { throw new Error("Extensions are expected to be installed already."); }, + getInstalledPackagePath: (packageName: string, fromDir: string): string => + resolvePackagePath(packageName, { paths: [fromDir] }) || null, uninstall: async (): Promise => undefined, searchNpms: async (): Promise => ({ results: [] }), getRegistryPackageData: async (): Promise => ({}), diff --git a/test/ios-project-service.ts b/test/ios-project-service.ts index e55c28f3f2..9d610a3d3f 100644 --- a/test/ios-project-service.ts +++ b/test/ios-project-service.ts @@ -24,9 +24,9 @@ import { IOSDeviceDiscovery } from "../lib/common/mobile/mobile-core/ios-device- import { AndroidDeviceDiscovery } from "../lib/common/mobile/mobile-core/android-device-discovery"; import { Utils } from "../lib/common/utils"; import { CocoaPodsService } from "../lib/services/cocoapods-service"; -import { PackageManager } from "../lib/package-manager"; -import { NodePackageManager } from "../lib/node-package-manager"; -import { YarnPackageManager } from "../lib/yarn-package-manager"; +import { PackageManager } from "../lib/package-managers"; +import { NpmPackageManager } from "../lib/package-managers/npm"; +import { YarnPackageManager } from "../lib/package-managers/yarn"; import { assert } from "chai"; import { SettingsService } from "../lib/common/test/unit-tests/stubs"; @@ -180,10 +180,11 @@ function createTestInjector( ); testInjector.register("userSettingsService", { getSettingValue: async (settingName: string): Promise => undefined, + getSettingValueSync: (settingName: string): void => undefined, }); testInjector.register("packageManager", PackageManager); testInjector.register("projectConfigService", ProjectConfigServiceStub); - testInjector.register("npm", NodePackageManager); + testInjector.register("npm", NpmPackageManager); testInjector.register("yarn", YarnPackageManager); testInjector.register("xcconfigService", XcconfigService); testInjector.register("settingsService", SettingsService); diff --git a/test/node-package-manager.ts b/test/node-package-manager.ts index 27efb270f3..36d1eb65a9 100644 --- a/test/node-package-manager.ts +++ b/test/node-package-manager.ts @@ -1,7 +1,7 @@ import { Yok } from "../lib/common/yok"; import * as stubs from "./stubs"; import { assert } from "chai"; -import { NodePackageManager } from "../lib/node-package-manager"; +import { NpmPackageManager } from "../lib/package-managers/npm"; import { IInjector } from "../lib/common/definitions/yok"; function createTestInjector(configuration: {} = {}): IInjector { @@ -12,7 +12,7 @@ function createTestInjector(configuration: {} = {}): IInjector { injector.register("childProcess", stubs.ChildProcessStub); injector.register("httpClient", {}); injector.register("fs", stubs.FileSystemStub); - injector.register("npm", NodePackageManager); + injector.register("npm", NpmPackageManager); injector.register("pacoteService", { manifest: () => Promise.resolve(), }); @@ -52,7 +52,7 @@ describe("node-package-manager", () => { ].forEach((testCase) => { it(testCase.name, async () => { const testInjector = createTestInjector(); - const npm = testInjector.resolve("npm"); + const npm = testInjector.resolve("npm"); const templateNameParts = await npm.getPackageNameParts( testCase.templateFullName ); @@ -87,7 +87,7 @@ describe("node-package-manager", () => { ].forEach((testCase) => { it(testCase.name, async () => { const testInjector = createTestInjector(); - const npm = testInjector.resolve("npm"); + const npm = testInjector.resolve("npm"); const templateFullName = await npm.getPackageFullName({ name: testCase.templateName, version: testCase.templateVersion, diff --git a/test/package-installation-manager.ts b/test/package-installation-manager.ts index ca2cbe409e..df8773c66e 100644 --- a/test/package-installation-manager.ts +++ b/test/package-installation-manager.ts @@ -4,13 +4,13 @@ import * as ErrorsLib from "../lib/common/errors"; import * as FsLib from "../lib/common/file-system"; import * as HostInfoLib from "../lib/common/host-info"; import * as LoggerLib from "../lib/common/logger/logger"; -import * as NpmLib from "../lib/node-package-manager"; -import * as YarnLib from "../lib/yarn-package-manager"; -import * as Yarn2Lib from "../lib/yarn2-package-manager"; -import * as PnpmLib from "../lib/pnpm-package-manager"; -import * as BunLib from "../lib/bun-package-manager"; -import * as PackageManagerLib from "../lib/package-manager"; -import * as PackageInstallationManagerLib from "../lib/package-installation-manager"; +import * as NpmLib from "../lib/package-managers/npm"; +import * as YarnLib from "../lib/package-managers/yarn"; +import * as Yarn2Lib from "../lib/package-managers/yarn2"; +import * as PnpmLib from "../lib/package-managers/pnpm"; +import * as BunLib from "../lib/package-managers/bun"; +import * as PackageManagerLib from "../lib/package-managers"; +import * as PackageInstallationManagerLib from "../lib/package-managers/package-installation-manager"; import * as OptionsLib from "../lib/options"; import * as StaticConfigLib from "../lib/config"; import * as yok from "../lib/common/yok"; @@ -45,8 +45,9 @@ function createTestInjector(): IInjector { }); testInjector.register("userSettingsService", { getSettingValue: async (settingName: string): Promise => undefined, + getSettingValueSync: (settingName: string): void => undefined, }); - testInjector.register("npm", NpmLib.NodePackageManager); + testInjector.register("npm", NpmLib.NpmPackageManager); testInjector.register("yarn", YarnLib.YarnPackageManager); testInjector.register("yarn2", Yarn2Lib.Yarn2PackageManager); testInjector.register("pnpm", PnpmLib.PnpmPackageManager); @@ -67,12 +68,12 @@ function mockNpm( latestVersion: string ) { testInjector.register("npm", { - view: async (packageName: string, config: any): Promise => { - if (config.versions) { + view: async (packageName: string, field?: string): Promise => { + if (field === "versions") { return versions; } - throw new Error(`Unable to find propertyName ${config}.`); + throw new Error(`Unable to find propertyName ${field}.`); }, }); } diff --git a/test/package-manager-flags.ts b/test/package-manager-flags.ts new file mode 100644 index 0000000000..23da5b55c2 --- /dev/null +++ b/test/package-manager-flags.ts @@ -0,0 +1,269 @@ +import * as path from "path"; +import { Yok } from "../lib/common/yok"; +import * as stubs from "./stubs"; +import { assert } from "chai"; +import { NpmPackageManager } from "../lib/package-managers/npm"; +import { YarnPackageManager } from "../lib/package-managers/yarn"; +import { Yarn2PackageManager } from "../lib/package-managers/yarn2"; +import { PnpmPackageManager } from "../lib/package-managers/pnpm"; +import { BunPackageManager } from "../lib/package-managers/bun"; +import { + INodePackageManager, + IPackageInstallOptions, +} from "../lib/declarations"; +import { IInjector } from "../lib/common/definitions/yok"; + +class RecordingChildProcessStub extends stubs.ChildProcessStub { + public spawnedArgs: string[][] = []; + public execCommands: string[] = []; + + public async exec( + command: string, + options?: any, + execOptions?: any, + ): Promise { + this.execCommands.push(command); + return super.exec(command, options, execOptions); + } + + public async spawnFromEvent( + command: string, + args: string[], + event: string, + options?: any, + spawnFromEventOptions?: any, + ): Promise { + this.spawnedArgs.push(args); + return super.spawnFromEvent( + command, + args, + event, + options, + spawnFromEventOptions, + ); + } +} + +class NoFilesFileSystemStub extends stubs.FileSystemStub { + exists(filePath: string): boolean { + return false; + } +} + +const managers: { name: string; ctor: any }[] = [ + { name: "npm", ctor: NpmPackageManager }, + { name: "yarn", ctor: YarnPackageManager }, + { name: "yarn2", ctor: Yarn2PackageManager }, + { name: "pnpm", ctor: PnpmPackageManager }, + { name: "bun", ctor: BunPackageManager }, +]; + +function createTestInjector(name: string, ctor: any): IInjector { + const injector = new Yok(); + injector.register("hostInfo", { isWindows: false }); + injector.register("errors", stubs.ErrorsStub); + injector.register("logger", stubs.LoggerStub); + injector.register("childProcess", RecordingChildProcessStub); + injector.register("httpClient", {}); + injector.register("fs", NoFilesFileSystemStub); + injector.register(name, ctor); + injector.register("pacoteService", { + manifest: () => Promise.resolve({ name: "left-pad", version: "1.3.0" }), + }); + + return injector; +} + +async function installArgs( + name: string, + ctor: any, + options: IPackageInstallOptions, +): Promise { + const injector = createTestInjector(name, ctor); + const manager = injector.resolve(name); + const childProcess = + injector.resolve("childProcess"); + await manager.install("left-pad", projectDir, options); + return childProcess.spawnedArgs[0]; +} + +async function uninstallCommand( + name: string, + ctor: any, + save: boolean, +): Promise { + const injector = createTestInjector(name, ctor); + const manager = injector.resolve(name); + const childProcess = + injector.resolve("childProcess"); + await manager.uninstall("left-pad", { save }, projectDir); + return childProcess.execCommands[0].trim(); +} + +const projectDir = path.join("/tmp", "some-project"); + +const allOptions: IPackageInstallOptions = { + save: true, + dev: true, + optional: true, + exact: true, + silent: true, + ignoreScripts: true, +}; + +describe("package manager flag mapping", () => { + const expectedInstallArgs: { [name: string]: string[] } = { + npm: [ + "install", + "left-pad", + "--save", + "--save-dev", + "--save-optional", + "--save-exact", + "--silent", + "--ignore-scripts", + ], + yarn: [ + "add", + "left-pad", + "--dev", + "--optional", + "--exact", + "--silent", + "--ignore-scripts", + ], + yarn2: [ + "add", + "left-pad", + "--dev", + "--optional", + "--exact", + "--silent", + "--mode=skip-build", + ], + pnpm: [ + "i", + "--shamefully-hoist", + "left-pad", + "--save-dev", + "--save-optional", + "--save-exact", + "--silent", + "--ignore-scripts", + ], + bun: [ + "install", + "left-pad", + "--save", + "--dev", + "--optional", + "--exact", + "--silent", + "--ignore-scripts", + ], + }; + + for (const { name, ctor } of managers) { + describe(name, () => { + it("maps every install option to its own flag", async () => { + const args = await installArgs(name, ctor, allOptions); + assert.deepEqual(args, expectedInstallArgs[name]); + }); + + it("passes no option flags when no options are set", async () => { + const args = await installArgs(name, ctor, {}); + const expected = expectedInstallArgs[name].filter( + (arg) => !arg.startsWith("--") || arg === "--shamefully-hoist", + ); + assert.deepEqual(args, expected); + }); + + it("never passes CLI-internal options to the command line", async () => { + const args = await installArgs(name, ctor, { + path: "/some/path", + frameworkPath: "/some/framework", + }); + for (const arg of args) { + assert.notMatch(arg, /path|framework/i); + } + }); + + it("skips the install when disableNpmInstall is set", async () => { + const injector = createTestInjector(name, ctor); + const manager = injector.resolve(name); + const childProcess = + injector.resolve("childProcess"); + await manager.install("left-pad", projectDir, { + disableNpmInstall: true, + }); + assert.lengthOf(childProcess.spawnedArgs, 0); + }); + }); + } + + describe("save: false", () => { + it("maps to --no-save where the package manager supports it", async () => { + assert.include( + await installArgs("npm", NpmPackageManager, { save: false }), + "--no-save", + ); + assert.include( + await installArgs("bun", BunPackageManager, { save: false }), + "--no-save", + ); + }); + + it("is dropped where the package manager has no such flag", async () => { + for (const { name, ctor } of managers.filter( + (m) => m.name !== "npm" && m.name !== "bun", + )) { + const args = await installArgs(name, ctor, { save: false }); + assert.notInclude(args, "--no-save", name); + } + }); + }); + + describe("getInstalledPackagePath", () => { + const repoRoot = path.join(__dirname, "..", ".."); + + for (const { name, ctor } of managers) { + it(`${name} resolves an installed package from the given directory`, async () => { + const manager = createTestInjector( + name, + ctor, + ).resolve(name); + const resolved = manager.getInstalledPackagePath("lodash", repoRoot); + assert.equal(resolved, path.join(repoRoot, "node_modules", "lodash")); + }); + + it(`${name} returns null for a package that is not installed`, async () => { + const manager = createTestInjector( + name, + ctor, + ).resolve(name); + assert.isNull( + manager.getInstalledPackagePath( + "definitely-not-installed-package", + repoRoot, + ), + ); + }); + } + }); + + describe("uninstall", () => { + const expected: { [name: string]: string } = { + npm: "npm uninstall left-pad --save", + yarn: "yarn remove left-pad", + yarn2: "yarn remove left-pad", + pnpm: "pnpm remove left-pad", + bun: "bun remove left-pad --save", + }; + + for (const { name, ctor } of managers) { + it(`${name} maps save onto its own remove command`, async () => { + assert.equal(await uninstallCommand(name, ctor, true), expected[name]); + }); + } + }); +}); diff --git a/test/platform-commands.ts b/test/platform-commands.ts index 35dd3d0836..bcdf340861 100644 --- a/test/platform-commands.ts +++ b/test/platform-commands.ts @@ -1,9 +1,9 @@ import * as yok from "../lib/common/yok"; import * as stubs from "./stubs"; -import { addPlatformCommandDefinition } from "../lib/commands/add-platform"; +import { AddPlatformCommand } from "../lib/commands/add-platform"; import { removePlatformCommandDefinition } from "../lib/commands/remove-platform"; -import { updatePlatformCommandDefinition } from "../lib/commands/update-platform"; -import { platformCleanCommandDefinition } from "../lib/commands/platform-clean"; +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"; @@ -162,16 +162,16 @@ function createTestInjector() { testInjector.register("sysInfo", {}); testInjector.register("commands-service", CommandsServiceLib.CommandsService); runInInjectionContext(testInjector, () => - registerCommand(addPlatformCommandDefinition), + registerCommand(AddPlatformCommand), ); runInInjectionContext(testInjector, () => registerCommand(removePlatformCommandDefinition), ); runInInjectionContext(testInjector, () => - registerCommand(updatePlatformCommandDefinition), + registerCommand(UpdatePlatformCommand), ); runInInjectionContext(testInjector, () => - registerCommand(platformCleanCommandDefinition), + registerCommand(PlatformCleanCommand), ); testInjector.register("resources", {}); testInjector.register("commandsService", { diff --git a/test/plugin-create.ts b/test/plugin-create.ts index 6c533a0ea8..0a5f66bfea 100644 --- a/test/plugin-create.ts +++ b/test/plugin-create.ts @@ -1,7 +1,7 @@ import { Yok } from "../lib/common/yok"; import * as stubs from "./stubs"; import { - createPluginCommandDefinition, + CreatePluginCommand, INCLUDE_ANGULAR_DEMO_MESSAGE, INCLUDE_TYPESCRIPT_DEMO_MESSAGE, NAME_MESSAGE, @@ -74,7 +74,7 @@ function createTestInjector() { }); runInInjectionContext(testInjector, () => - registerCommand(createPluginCommandDefinition), + registerCommand(CreatePluginCommand), ); return testInjector; diff --git a/test/plugins-service.ts b/test/plugins-service.ts index edf62e97b0..94b33dce79 100644 --- a/test/plugins-service.ts +++ b/test/plugins-service.ts @@ -1,12 +1,12 @@ import { Yok } from "../lib/common/yok"; import * as stubs from "./stubs"; -import { PackageManager } from "../lib/package-manager"; -import { PackageInstallationManager } from "../lib/package-installation-manager"; -import { NodePackageManager } from "../lib/node-package-manager"; -import { YarnPackageManager } from "../lib/yarn-package-manager"; -import { Yarn2PackageManager } from "../lib/yarn2-package-manager"; -import { PnpmPackageManager } from "../lib/pnpm-package-manager"; -import { BunPackageManager } from "../lib/bun-package-manager"; +import { PackageManager } from "../lib/package-managers"; +import { PackageInstallationManager } from "../lib/package-managers/package-installation-manager"; +import { NpmPackageManager } from "../lib/package-managers/npm"; +import { YarnPackageManager } from "../lib/package-managers/yarn"; +import { Yarn2PackageManager } from "../lib/package-managers/yarn2"; +import { PnpmPackageManager } from "../lib/package-managers/pnpm"; +import { BunPackageManager } from "../lib/package-managers/bun"; import { ProjectData } from "../lib/project-data"; import { ChildProcess } from "../lib/common/child-process"; import { Options } from "../lib/options"; @@ -69,13 +69,14 @@ function createTestInjector() { testInjector.register("messagesService", MessagesService); testInjector.register("userSettingsService", { getSettingValue: async (settingName: string): Promise => undefined, + getSettingValueSync: (settingName: string): void => undefined, }); testInjector.register("packageManager", PackageManager); testInjector.register( "projectConfigService", stubs.PackageInstallationManagerStub, ); - testInjector.register("npm", NodePackageManager); + testInjector.register("npm", NpmPackageManager); testInjector.register("yarn", YarnPackageManager); testInjector.register("yarn2", Yarn2PackageManager); testInjector.register("pnpm", PnpmPackageManager); diff --git a/test/pnpm-package-manager.ts b/test/pnpm-package-manager.ts index 3c825f877a..16f8b02ac8 100644 --- a/test/pnpm-package-manager.ts +++ b/test/pnpm-package-manager.ts @@ -3,7 +3,7 @@ import { Yok } from "../lib/common/yok"; import * as stubs from "./stubs"; import { assert } from "chai"; import { setIsInteractive } from "../lib/common/helpers"; -import { PnpmPackageManager } from "../lib/pnpm-package-manager"; +import { PnpmPackageManager } from "../lib/package-managers/pnpm"; import { IInjector } from "../lib/common/definitions/yok"; class RecordingChildProcessStub extends stubs.ChildProcessStub { @@ -146,7 +146,7 @@ describe("pnpm-package-manager", () => { fs.textFiles[npmrcPath] = `registry=https://example.com\n${layoutKey}=*types*\n`; - await pnpm.install(projectDir, projectDir, {} as any); + await pnpm.install(projectDir, projectDir, {}); assert.deepEqual(childProcess.spawnedArgs[0], ["i"]); }); @@ -180,7 +180,7 @@ describe("pnpm-package-manager", () => { ignoreScripts: true, path: "/some/path", frameworkPath: "/some/framework", - } as any); + }); const args = childProcess.spawnedArgs[0]; assert.include(args, "--ignore-scripts"); @@ -197,7 +197,7 @@ describe("pnpm-package-manager", () => { setIsInteractive(() => false); try { - await pnpm.install(projectDir, projectDir, {} as any); + await pnpm.install(projectDir, projectDir, {}); } finally { setIsInteractive(undefined); } @@ -217,13 +217,12 @@ describe("pnpm-package-manager", () => { const childProcess = testInjector.resolve("childProcess"); - await pnpm.install("left-pad", projectDir, { save: true } as any); + await pnpm.install("left-pad", projectDir, { save: true }); assert.deepEqual(childProcess.spawnedArgs[0], [ "i", "--shamefully-hoist", "left-pad", - "--save", ]); }); }); diff --git a/test/project-commands.ts b/test/project-commands.ts index 567d1294dc..a7b8d49379 100644 --- a/test/project-commands.ts +++ b/test/project-commands.ts @@ -1,6 +1,6 @@ import { Yok } from "../lib/common/yok"; import * as stubs from "./stubs"; -import { createProjectCommandDefinition } from "../lib/commands/create-project"; +import { CreateProjectCommand } from "../lib/commands/create-project"; import { registerCommand } from "../lib/common/services/command-definition-adapter"; import { StringCommandParameter } from "../lib/common/command-params"; import { setIsInteractive } from "../lib/common/helpers"; @@ -171,7 +171,7 @@ function createTestInjector() { template: undefined, }); runInInjectionContext(testInjector, () => - registerCommand(createProjectCommandDefinition), + registerCommand(CreateProjectCommand), ); testInjector.register("stringParameter", StringCommandParameter); testInjector.register("prompter", PrompterStub); diff --git a/test/project-templates-service.ts b/test/project-templates-service.ts index 10abc12520..6635526130 100644 --- a/test/project-templates-service.ts +++ b/test/project-templates-service.ts @@ -6,7 +6,7 @@ import * as path from "path"; import * as constants from "../lib/constants"; import { INpmInstallResultInfo, - INodePackageManagerInstallOptions, + IPackageInstallOptions, INpmPackageNameParts, INpmInstallOptions, } from "../lib/declarations"; @@ -38,7 +38,7 @@ function createTestInjector( public async install( packageName: string, pathToSave: string, - config: INodePackageManagerInstallOptions + config: IPackageInstallOptions ): Promise { if (configuration.shouldNpmInstallThrow) { throw new Error("NPM install throws error."); diff --git a/test/services/android-plugin-build-service.ts b/test/services/android-plugin-build-service.ts index c2107cf4c1..8e5a8c3748 100644 --- a/test/services/android-plugin-build-service.ts +++ b/test/services/android-plugin-build-service.ts @@ -10,6 +10,7 @@ import * as FsLib from "../../lib/common/file-system"; import * as path from "path"; import * as stubs from "../stubs"; import { mkdtempSync } from "fs"; +import { resolvePackagePath } from "../../lib/helpers/package-path-helper"; import { tmpdir } from "os"; import { IFileSystem, @@ -128,6 +129,8 @@ describe("androidPluginBuildService", () => { addProjectRuntime?: boolean; }): any { return { + getInstalledPackagePath: (packageName: string, fromDir: string): string => + resolvePackagePath(packageName, { paths: [fromDir] }) || null, getRegistryPackageData: async (packageName: string): Promise => { const result: any = []; result["dist-tags"] = { latest: "4.1.2" }; @@ -149,9 +152,9 @@ describe("androidPluginBuildService", () => { return result; }, - view: async (packageName: string, config: any): Promise => { + view: async (packageName: string, field?: string): Promise => { let result: any = null; - if (config && config.gradle) { + if (field === "gradle") { const packageNameParts = packageName.split("@"); const packageVersion = packageNameParts[packageNameParts.length - 1]; switch (packageVersion) { @@ -170,7 +173,7 @@ describe("androidPluginBuildService", () => { } } - if (config && config["dist-tags"]) { + if (field === "dist-tags") { result = { latest: "4.1.2", }; diff --git a/test/services/bundler/bundler-compiler-service.ts b/test/services/bundler/bundler-compiler-service.ts index 1ec3a6b057..94b1c84986 100644 --- a/test/services/bundler/bundler-compiler-service.ts +++ b/test/services/bundler/bundler-compiler-service.ts @@ -58,6 +58,7 @@ function createTestInjector( const testInjector = new Yok(); testInjector.register("packageManager", { getPackageManagerName: async () => packageManager, + getInstalledPackagePath: (): string => null, }); testInjector.register("bundlerCompilerService", BundlerCompilerService); testInjector.register("childProcess", {}); diff --git a/test/services/doctor-service.ts b/test/services/doctor-service.ts index 862480c02c..2adfc0eb2f 100644 --- a/test/services/doctor-service.ts +++ b/test/services/doctor-service.ts @@ -6,7 +6,7 @@ import * as path from "path"; import * as sinon from "sinon"; import * as _ from "lodash"; import { IProjectDataService } from "../../lib/definitions/project"; -import { IVersionsService } from "../../lib/declarations"; +import { IVersionsService, IPackageManager } from "../../lib/declarations"; import { ICheckEnvironmentRequirementsInput, ICheckEnvironmentRequirementsOutput, @@ -45,6 +45,7 @@ class DoctorServiceInheritor extends DoctorService { $terminalSpinnerService: ITerminalSpinnerService, $versionsService: IVersionsService, $settingsService: ISettingsService, + $packageManager: IPackageManager, ) { super( $analyticsService, @@ -57,6 +58,7 @@ class DoctorServiceInheritor extends DoctorService { $terminalSpinnerService, $versionsService, $settingsService, + $packageManager, ); } @@ -93,6 +95,12 @@ describe("doctorService", () => { }, }); testInjector.register("versionsService", {}); + testInjector.register("packageManager", { + getInstalledPackagePath: (packageName: string, fromDir: string): string => + packageName === "tns-core-modules" + ? path.join(fromDir, "node_modules", packageName) + : null, + }); testInjector.register("settingsService", { getProfileDir: (): string => "", }); @@ -349,7 +357,7 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl }, ]; - it("getDeprecatedShortImportsInFiles returns correct results", () => { + it("getDeprecatedShortImportsInFiles returns correct results", async () => { const testInjector = createTestInjector(); const doctorService = testInjector.resolve("doctorService"); @@ -365,15 +373,32 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl } }; - testData.forEach(({ filesContents, expectedShortImports }) => { + for (const { filesContents, expectedShortImports } of testData) { fs.readText = (filePath) => filesContents[filePath]; - const shortImports = doctorService.getDeprecatedShortImportsInFiles( - _.keys(filesContents), - "projectDir", - ); + const shortImports = + doctorService.getDeprecatedShortImportsInFiles( + _.keys(filesContents), + "projectDir", + ); assert.deepStrictEqual(shortImports, expectedShortImports); - }); + } + }); + + it("getDeprecatedShortImportsInFiles returns no results when tns-core-modules is not installed", async () => { + const testInjector = createTestInjector(); + const packageManager = testInjector.resolve("packageManager"); + packageManager.getInstalledPackagePath = (): string => null; + const doctorService = + testInjector.resolve("doctorService"); + const fs = testInjector.resolve("fs"); + fs.readText = () => 'const application = require("application");'; + + const shortImports = doctorService.getDeprecatedShortImportsInFiles( + ["file1"], + "projectDir", + ); + assert.deepStrictEqual(shortImports, []); }); }); diff --git a/test/services/extensibility-service.ts b/test/services/extensibility-service.ts index a6b1970ee5..132acff774 100644 --- a/test/services/extensibility-service.ts +++ b/test/services/extensibility-service.ts @@ -2,12 +2,12 @@ import { ExtensibilityService } from "../../lib/services/extensibility-service"; import { Yok } from "../../lib/common/yok"; import * as stubs from "../stubs"; import { assert } from "chai"; -import { NodePackageManager } from "../../lib/node-package-manager"; -import { PackageManager } from "../../lib/package-manager"; -import { YarnPackageManager } from "../../lib/yarn-package-manager"; -import { Yarn2PackageManager } from "../../lib/yarn2-package-manager"; -import { PnpmPackageManager } from "../../lib/pnpm-package-manager"; -import { BunPackageManager } from "../../lib/bun-package-manager"; +import { NpmPackageManager } from "../../lib/package-managers/npm"; +import { PackageManager } from "../../lib/package-managers"; +import { YarnPackageManager } from "../../lib/package-managers/yarn"; +import { Yarn2PackageManager } from "../../lib/package-managers/yarn2"; +import { PnpmPackageManager } from "../../lib/package-managers/pnpm"; +import { BunPackageManager } from "../../lib/package-managers/bun"; import * as constants from "../../lib/constants"; import { ChildProcess } from "../../lib/common/child-process"; import { CommandsDelimiters } from "../../lib/common/constants"; @@ -74,8 +74,9 @@ describe("extensibilityService", () => { }); testInjector.register("userSettingsService", { getSettingValue: async (settingName: string): Promise => undefined, + getSettingValueSync: (settingName: string): void => undefined, }); - testInjector.register("npm", NodePackageManager); + testInjector.register("npm", NpmPackageManager); testInjector.register("yarn", YarnPackageManager); testInjector.register("yarn2", Yarn2PackageManager); testInjector.register("pnpm", PnpmPackageManager); @@ -87,6 +88,17 @@ describe("extensibilityService", () => { return testInjector; }; + const stubInstalledExtensions = ( + testInjector: IInjector, + resolve: (extensionName: string, fromDir: string) => string, + ): void => { + const packageManager = testInjector.resolve("packageManager"); + packageManager.getInstalledPackagePath = ( + packageName: string, + fromDir: string, + ): string => resolve(packageName, fromDir); + }; + const getExpectedInstallationPathForExtension = ( testInjector: IInjector, extensionName: string, @@ -245,15 +257,16 @@ describe("extensibilityService", () => { ); }); - it("passes save and save-exact options to npm install", async () => { + it("passes save and exact options to the package manager", async () => { const extensionName = "extension1"; const argsPassedToNpmInstall = await getArgsPassedToNpmInstallDuringInstallExtensionCall( extensionName, ); - const expectedNpmConfg: any = { save: true }; - expectedNpmConfg["save-exact"] = true; - assert.deepStrictEqual(argsPassedToNpmInstall.config, expectedNpmConfg); + assert.deepStrictEqual(argsPassedToNpmInstall.config, { + save: true, + exact: true, + }); }); it("passes full path to extensions dir for installation", async () => { @@ -319,14 +332,9 @@ describe("extensibilityService", () => { const fs: IFileSystem = testInjector.resolve("fs"); const extensionNames = ["extension1", "extension2", "extension3"]; fs.exists = (pathToCheck: string): boolean => true; - fs.readDirectory = (dir: string): string[] => { - assert.deepStrictEqual( - path.basename(dir), - constants.NODE_MODULES_FOLDER_NAME, - ); - // Simulates extensions are installed in node_modules - return extensionNames; - }; + stubInstalledExtensions(testInjector, (name, fromDir) => + path.join(fromDir, constants.NODE_MODULES_FOLDER_NAME, name), + ); mockFsReadJson(testInjector, extensionNames); @@ -358,20 +366,15 @@ describe("extensibilityService", () => { fs.exists = (pathToCheck: string): boolean => path.basename(pathToCheck) !== extensionNames[0]; - let isFirstReadDirExecution = true; - fs.readDirectory = (dir: string): string[] => { - assert.deepStrictEqual( - path.basename(dir), - constants.NODE_MODULES_FOLDER_NAME, - ); - // Simulates extensions are installed in node_modules - if (isFirstReadDirExecution) { - isFirstReadDirExecution = false; - return extensionNames.filter((ext) => ext !== "extension1"); - } else { - return extensionNames; + // extension1 is missing until the service installs it + let isExtension1Installed = false; + stubInstalledExtensions(testInjector, (name, fromDir) => { + if (name === "extension1" && !isExtension1Installed) { + isExtension1Installed = true; + return null; } - }; + return path.join(fromDir, constants.NODE_MODULES_FOLDER_NAME, name); + }); mockFsReadJson(testInjector, extensionNames); @@ -414,14 +417,9 @@ describe("extensibilityService", () => { const fs: IFileSystem = testInjector.resolve("fs"); const extensionNames = ["extension1", "extension2", "extension3"]; fs.exists = (pathToCheck: string): boolean => true; - fs.readDirectory = (dir: string): string[] => { - assert.deepStrictEqual( - path.basename(dir), - constants.NODE_MODULES_FOLDER_NAME, - ); - // Simulates extensions are installed in node_modules - return extensionNames; - }; + stubInstalledExtensions(testInjector, (name, fromDir) => + path.join(fromDir, constants.NODE_MODULES_FOLDER_NAME, name), + ); mockFsReadJson(testInjector, extensionNames); @@ -468,7 +466,7 @@ describe("extensibilityService", () => { } }); - it("rejects all promises when unable to read node_modules dir (simulate EPERM error)", async () => { + it("rejects all promises when the package manager cannot locate extensions (simulate EPERM error)", async () => { const testInjector = getTestInjector(); const extensionNames = ["extension1", "extension2", "extension3"]; const fs: IFileSystem = testInjector.resolve("fs"); @@ -479,14 +477,10 @@ describe("extensibilityService", () => { mockFsReadJson(testInjector, extensionNames); let isReadDirCalled = false; - fs.readDirectory = (dir: string): string[] => { + stubInstalledExtensions(testInjector, () => { isReadDirCalled = true; - assert.deepStrictEqual( - path.basename(dir), - constants.NODE_MODULES_FOLDER_NAME, - ); throw new Error(expectedErrorMessage); - }; + }); const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); @@ -531,14 +525,10 @@ describe("extensibilityService", () => { mockFsReadJson(testInjector, extensionNames); let isReadDirCalled = false; - fs.readDirectory = (dir: string): string[] => { + stubInstalledExtensions(testInjector, () => { isReadDirCalled = true; - assert.deepStrictEqual( - path.basename(dir), - constants.NODE_MODULES_FOLDER_NAME, - ); - return []; - }; + return null; + }); let isNpmInstallCalled = false; const npm: INodePackageManager = testInjector.resolve("npm"); diff --git a/test/services/key-shortcuts.ts b/test/services/key-shortcuts.ts index fe7a05ee1a..4be80230ae 100644 --- a/test/services/key-shortcuts.ts +++ b/test/services/key-shortcuts.ts @@ -1,6 +1,7 @@ import { assert } from "chai"; import { EventEmitter } from "events"; import { RunOnDeviceEvents } from "../../lib/constants"; +import { getContractName } from "../../lib/common/di/contract"; import { runInInjectionContext } from "../../lib/common/di/inject"; import { Injector } from "../../lib/common/di/injector"; import { runCommand } from "../../lib/common/services/command-definition-adapter"; @@ -41,8 +42,10 @@ class FakeStdin extends EventEmitter { const fakeInjector = ( registrations: Map = new Map(), -): Injector => - ({ get: (token: any) => registrations.get(token) }); +): Injector => ({ + get: (token: any) => + registrations.get(token) ?? registrations.get(getContractName(token)), + }); const baseContext = (): KeyContextBase => ({ injector: fakeInjector() }); @@ -217,7 +220,7 @@ describe("key shortcuts", () => { [ "commandsService", { - executeCommandInProcess: async (name: string): Promise => + runCommand: async (name: string): Promise => void invoked.push(name), }, ], @@ -958,10 +961,8 @@ describe("key shortcuts", () => { [ "commandsService", { - executeCommandInProcess: async ( - name: string, - args: string[], - ): Promise => void dispatched.push({ name, args }), + runCommand: async (name: string, args: string[]): Promise => + void dispatched.push({ name, args }), }, ], ]), @@ -984,7 +985,7 @@ describe("key shortcuts", () => { [ "commandsService", { - executeCommandInProcess: async (): Promise => { + runCommand: async (): Promise => { throw new Error("Unable to execute command 'open ios'."); }, }, diff --git a/test/services/test-execution-service.ts b/test/services/test-execution-service.ts index ad6ad5c62a..bd85b8b3f6 100644 --- a/test/services/test-execution-service.ts +++ b/test/services/test-execution-service.ts @@ -8,10 +8,18 @@ import { IDictionary } from "../../lib/common/declarations"; const karmaPluginName = "karma"; const unitTestsPluginName = "@nativescript/unit-test-runner"; -function getTestExecutionService(): ITestExecutionService { +function getTestExecutionService( + installedPackages: string[], +): ITestExecutionService { const injector = new InjectorStub(); injector.register("testExecutionService", TestExecutionService); injector.register("runController", {}); + injector.register("packageManager", { + getInstalledPackagePath: (packageName: string, fromDir: string): string => + installedPackages.indexOf(packageName) !== -1 + ? `${fromDir}/node_modules/${packageName}` + : null, + }); return injector.resolve("testExecutionService"); } @@ -28,8 +36,7 @@ function getDependenciesObj(deps: string[]): IDictionary { describe("testExecutionService", () => { const testCases = [ { - name: - "should return false when the project has no dependencies and dev dependencies", + name: "should return false when the project has no dependencies and dev dependencies", expectedCanStartKarmaServer: false, projectData: { dependencies: {}, devDependencies: {} }, }, @@ -50,8 +57,7 @@ describe("testExecutionService", () => { }, }, { - name: - "should return true when the project has the required plugins as dependencies", + name: "should return true when the project has the required plugins as dependencies", expectedCanStartKarmaServer: true, projectData: { dependencies: getDependenciesObj([ @@ -62,8 +68,7 @@ describe("testExecutionService", () => { }, }, { - name: - "should return true when the project has the required plugins as dev dependencies", + name: "should return true when the project has the required plugins as dev dependencies", expectedCanStartKarmaServer: true, projectData: { dependencies: {}, @@ -74,8 +79,7 @@ describe("testExecutionService", () => { }, }, { - name: - "should return true when the project has the required plugins as dev and normal dependencies", + name: "should return true when the project has the required plugins as dev and normal dependencies", expectedCanStartKarmaServer: true, projectData: { dependencies: getDependenciesObj([karmaPluginName]), @@ -87,35 +91,15 @@ describe("testExecutionService", () => { describe("canStartKarmaServer", () => { _.each(testCases, (testCase: any) => { it(`${testCase.name}`, async () => { - const testExecutionService = getTestExecutionService(); - - // todo: cleanup monkey-patch with a friendlier syntax (util?) - // MOCK require.resolve - const Module = require("module"); - const originalResolveFilename = Module._resolveFilename; - - Module._resolveFilename = function (...args: any) { - if ( - args[0].startsWith(karmaPluginName) && - (testCase.projectData.dependencies[karmaPluginName] || - testCase.projectData.devDependencies[karmaPluginName]) - ) { - // override with a "random" built-in module to - // ensure the module can be resolved - args[0] = "fs"; - } + const installedPackages = _.keys({ + ...testCase.projectData.dependencies, + ...testCase.projectData.devDependencies, + }); + const testExecutionService = getTestExecutionService(installedPackages); - return originalResolveFilename.apply(this, args); - }; - // END MOCK - - const canStartKarmaServer = await testExecutionService.canStartKarmaServer( - testCase.projectData - ); + const canStartKarmaServer = + await testExecutionService.canStartKarmaServer(testCase.projectData); assert.equal(canStartKarmaServer, testCase.expectedCanStartKarmaServer); - - // restore mock - Module._resolveFilename = originalResolveFilename; }); }); }); diff --git a/test/stubs.ts b/test/stubs.ts index 4b28326a29..6be3af35fc 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -17,7 +17,7 @@ import { INpmInstallOptions, INodePackageManager, INpmInstallResultInfo, - INodePackageManagerInstallOptions, + IPackageInstallOptions, INpmPackageNameParts, INpmsResult, IAndroidToolsInfoData, @@ -457,10 +457,14 @@ export class PackageInstallationManagerStub implements IPackageInstallationManag export class NodePackageManagerStub implements INodePackageManager { constructor() {} + public getInstalledPackagePath(packageName: string, fromDir: string): string { + return null; + } + public async install( packageName: string, pathToSave: string, - config: INodePackageManagerInstallOptions, + options: IPackageInstallOptions, ): Promise { return { name: packageName, @@ -476,11 +480,11 @@ export class NodePackageManagerStub implements INodePackageManager { return ""; } - public async search(filter: string[], config: any): Promise { + public async search(filter: string[]): Promise { return ""; } - public async view(packageName: string, config: Object): Promise { + public async view(packageName: string, field?: string): Promise { return {}; } @@ -1328,13 +1332,34 @@ export class CommandsService implements ICommandsService { return Promise.resolve(true); } - public executeCommandInProcess( + 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/tns-appstore-upload.ts b/test/tns-appstore-upload.ts index e6ca259ec3..4773ce8a8c 100644 --- a/test/tns-appstore-upload.ts +++ b/test/tns-appstore-upload.ts @@ -1,4 +1,4 @@ -import { publishIOSCommandDefinition } from "../lib/commands/appstore-upload"; +import { PublishIOSCommand } from "../lib/commands/appstore-upload"; import { registerCommand } from "../lib/common/services/command-definition-adapter"; import { Injector } from "../lib/common/di"; import { @@ -104,7 +104,7 @@ class AppStore { } runInInjectionContext((this.injector), () => - registerCommand({ ...publishIOSCommandDefinition, name: "appstore" }), + registerCommand({ ...PublishIOSCommand.definition, name: "appstore" }), ); this.injector.register("projectDataService", ProjectDataServiceStub); diff --git a/test/tools/node-modules/node-modules-dependencies-builder.ts b/test/tools/node-modules/node-modules-dependencies-builder.ts index 8ea64163ef..8f6898ddc7 100644 --- a/test/tools/node-modules/node-modules-dependencies-builder.ts +++ b/test/tools/node-modules/node-modules-dependencies-builder.ts @@ -14,6 +14,7 @@ import { import * as os from "os"; import * as fs from "fs"; import { FileSystem } from "../../../lib/common/file-system"; +import { resolvePackagePath } from "../../../lib/helpers/package-path-helper"; interface IDependencyInfo { name: string; @@ -39,6 +40,10 @@ describe("nodeModulesDependenciesBuilder", () => { const getTestInjector = (): IInjector => { const testInjector = new Yok(); testInjector.register("fs", FileSystem); + testInjector.register("packageManager", { + getInstalledPackagePath: (packageName: string, fromDir: string): string => + resolvePackagePath(packageName, { paths: [fromDir] }) || null, + }); return testInjector; }; diff --git a/test/type-fixtures/define-command-types.ts b/test/type-fixtures/define-command-types.ts index 85fd7107d2..34cf91698a 100644 --- a/test/type-fixtures/define-command-types.ts +++ b/test/type-fixtures/define-command-types.ts @@ -9,12 +9,16 @@ import { arrayOption, booleanOption, + Command, defineCommand, numberOption, stringOption, } from "../../lib/common/define-command"; import type { CommandArgumentValues } from "../../lib/common/define-command"; -import { registerLazyCommand } from "../../lib/common/services/command-definition-adapter"; +import { + registerBuiltInCommand, + registerLazyCommand, +} from "../../lib/common/services/command-definition-adapter"; import type { Injector } from "../../lib/common/di/injector"; type IsExact = @@ -244,3 +248,76 @@ registerLazyCommand( ); declare function setupLazyCommand(): { projectDir: string }; + +// The class form types this.options, this.args and this.context off the schema +// the meta declares, exactly as the object form types ctx. +class TypefixturePlatformClean extends Command({ + name: "typefixture|class-clean", + options: { + frameworkPath: stringOption({ default: "platforms" }), + verbose: booleanOption(), + }, + arguments: "any", +}) { + run(): void { + const frameworkPath = this.options.frameworkPath; + const verbose = this.options.verbose; + const args = this.args; + const fail = this.context.fail; + + expectExactType>(); + expectExactType>(); + expectExactType>(); + expectExactType, never>>(); + + // @ts-expect-error - the schema types this.options and nothing else + this.options.undeclared; + } +} + +class TypefixtureResult extends Command<"typefixture|class-result", {}, number>( + { name: "typefixture|class-result" }, +) { + run(): number { + return 1; + } + + postRun(result: number): void { + expectExactType>(); + } +} + +// @ts-expect-error - run is abstract; a command class has to implement it +class TypefixtureNoRun extends Command({ name: "typefixture|class-no-run" }) {} + +// The static definition is what a registration site is checked against, so the +// literal name has to survive from the meta through to the call. +registerBuiltInCommand( + "typefixture|class-clean", + () => require("./commands/clean").TypefixturePlatformClean, +); + +registerBuiltInCommand( + // @ts-expect-error - the class declares 'typefixture|class-clean' + "typefixture|class-cleann", + () => require("./commands/clean").TypefixturePlatformClean, +); + +class TypefixtureAliased extends Command({ + name: ["typefixture|class-vision", "typefixture|class-visionos"], +}) { + run(): void { + return undefined; + } +} + +registerLazyCommand( + "typefixture|class-visionos", + () => require("./commands/clean").TypefixtureAliased, +); + +registerLazyCommand( + // @ts-expect-error - not one of the names the class declares + "typefixture|class-vision2", + () => require("./commands/clean").TypefixtureAliased, +); diff --git a/test/update.ts b/test/update.ts index 1cd0eda4fc..820ef09eef 100644 --- a/test/update.ts +++ b/test/update.ts @@ -1,6 +1,6 @@ import * as stubs from "./stubs"; import * as yok from "../lib/common/yok"; -import { updateCommandDefinition } from "../lib/commands/update"; +import { UpdateCommand } from "../lib/commands/update"; import { registerCommand } from "../lib/common/services/command-definition-adapter"; import { ICommand } from "../lib/common/definitions/commands"; import { assert } from "chai"; @@ -45,9 +45,7 @@ function createTestInjector(projectDir: string = projectFolder): IInjector { }, }); - runInInjectionContext(testInjector, () => - registerCommand(updateCommandDefinition), - ); + runInInjectionContext(testInjector, () => registerCommand(UpdateCommand)); return testInjector; }