diff --git a/defining-commands.md b/defining-commands.md index b6f589cf9a..ef7fc024c1 100644 --- a/defining-commands.md +++ b/defining-commands.md @@ -322,9 +322,10 @@ side-effect-free contracts entry point deliberately does not pull it in. `defineCommand`, the option helpers and all the types are exported from both `nativescript/contracts` and `lib/common/define-command`. -Declaring commands from an extension manifest, so that an extension does not -have to call a registration function at load time, is being added separately. -Until then, extensions register definitions the same way the CLI does. +Extensions do not need `registerCommandDefinition` at all: a +`nativescript.commands` manifest entry may point straight at a module that +exports a definition, and the CLI adapts and registers it lazily under the +manifest key (see [extensions.md](extensions.md)). Relationship to `ICommand` -------------------------- diff --git a/dependency-injection.md b/dependency-injection.md index b8386c1f9b..98fcd7e08b 100644 --- a/dependency-injection.md +++ b/dependency-injection.md @@ -265,6 +265,13 @@ The first tranche, growing as services migrate: | `DoctorService` | `doctorService` | | `ProjectNameService` | `projectNameService` | +Related guides +-------------- + +- [defining-commands.md](defining-commands.md) — declarative, typed commands via `defineCommand`. +- [extensions.md](extensions.md) — extension authoring, including the `nativescript.commands` manifest. +- [extending-cli.md](extending-cli.md) — hooks, including the typed `defineHook` API. + Legacy → new quick reference ---------------------------- diff --git a/extensions.md b/extensions.md new file mode 100644 index 0000000000..2c19680b09 --- /dev/null +++ b/extensions.md @@ -0,0 +1,282 @@ +Writing a CLI Extension +======================= + +An extension adds new commands to the NativeScript CLI. Extensions are ordinary +npm packages. + +```bash +ns extension install +ns extension uninstall +``` + +Installed extensions live in the CLI profile directory, under +`extensions/node_modules/`, and every CLI invocation consults each +of them. That makes the manifest below the most important file in an extension: +it is what the CLI reads on startup, and it decides whether your code is loaded +eagerly or only when one of your commands is actually executed. + +## Depending on the CLI + +An extension that imports anything from the CLI — `defineCommand`, `inject`, the +types — needs `nativescript` declared twice: + +```json +{ + "name": "nativescript-hello", + "version": "1.0.0", + "keywords": ["nativescript:extension"], + "peerDependencies": { + "nativescript": ">=9.1.0" + }, + "devDependencies": { + "nativescript": "^9.1.0" + } +} +``` + +- The **peer dependency** declares which CLI versions the extension works with, + and keeps package managers from installing a second copy of the CLI next to + your extension. Your code must run against the _running_ CLI: a second copy + brings its own injector, and services resolved from it are not the ones + executing the command. +- The **dev dependency** is what makes `require("nativescript/contracts")` + resolve while you build and test the extension. It is not installed for your + users. +- Developing against a **prerelease** CLI? Semver ranges without a prerelease + tag never match one — `9.1.0-alpha.15` does not satisfy `>=9.1.0` — so pin + the exact prerelease as your dev dependency and keep the stable floor in + `peerDependencies`. + +Never declare `nativescript` as a plain dependency. + +`nativescript/contracts` is the entry point extensions import from. It is +side-effect free — importing it does not boot a CLI — and it exports +`defineCommand`, `inject`, the option helpers and the public types. + +The `nativescript:extension` keyword makes the package discoverable: the CLI +searches npm for it when it needs to suggest an extension for an unknown command +(see [Suggesting an extension](#suggesting-an-extension-for-an-unknown-command)). + +> Extensions are installed per user today, and are available from every project +> on the machine. Installing them as project `devDependencies` — pinned per +> project, reproducible in CI, shared with the team — is the direction this is +> heading; declaring the peer dependency now is what makes an extension ready +> for it. + +## Declaring commands + +Commands are declared in the `commands` key of the `nativescript` key of the +extension's `package.json`. Two shapes are accepted. + +### A map of command name to module (recommended) + +```json +{ + "nativescript": { + "commands": { + "hello|world": "./dist/commands/hello-world.js", + "hello|*default": "./dist/commands/hello.js" + } + } +} +``` + +Each key is a command name; each value says where the module implementing it +lives, resolved relative to the extension's root directory. A value is either +the path itself or an object carrying it under `path`: + +```json +{ + "nativescript": { + "commands": { + "hello|world": { "path": "./dist/commands/hello-world.js" } + } + } +} +``` + +The two forms mean exactly the same thing today. Keys the CLI does not +recognise inside the object form are ignored, so the object can carry +information a later CLI understands without breaking the one you have +installed. + +Declaring commands this way is strongly preferred: + +- **Per-command lazy loading.** Nothing in the extension is loaded when the CLI + starts. A command's module is required the first time that command is + resolved, so `ns build android` never pays the cost of loading an unrelated + extension. With a large or dependency-heavy extension installed, that is the + difference between a noticeable startup delay on every command and none. + Dispatching `ns hello world` loads only `hello-world.js` — not the sibling + `hello.js`, and not the extension's main entry. +- **Early, named conflict detection.** Two extensions claiming the same command + name is reported as a warning that names both extensions and the contested + command, and the extension that claimed it first keeps working. Under the + legacy shape the same collision surfaces as an opaque + `module '...' require'd twice.` failure from whichever extension happened to + load second. +- **The CLI knows what you contribute without running you.** The declared + command names are what the install suggestion for an unknown command matches + against, and they are available to the CLI as metadata about the installed + extension. + +Malformed entries are skipped rather than fatal: an entry whose command name is +not a non-empty string, or whose value carries no usable module path, is +reported as a warning naming the extension and the offending entry, and the +extension's remaining commands are still registered. + +An empty map opts out of loading entirely: + +```json +{ + "nativescript": { + "commands": {} + } +} +``` + +The extension contributes no commands, and — unlike omitting the key — its main +entry is never required. Use it for an extension that only ships documentation +or assets. + +### An array of command names (legacy) + +```json +{ + "nativescript": { + "commands": ["hello|world", "hello|*default"] + } +} +``` + +The array is a discovery aid only — it lists the names the CLI may suggest your +extension for, but it says nothing about where the implementations live. An +extension declaring commands this way (or omitting the `commands` key +altogether) is loaded the old way: the CLI `require()`s the package's main entry +on **every** invocation and expects the module's top-level code to register +everything through the injector global. + +This path remains supported for published extensions, but it is tracked for +eventual deprecation and new extensions should not use it — declare the map +instead. Run any command with `--log trace` to see which installed extensions +still rely on it, or set `NS_DEPRECATIONS=warn` to have those reports printed +as warnings. + +## Writing a command module + +The recommended shape is a module exporting a `defineCommand` definition (see +[defining-commands.md](defining-commands.md)) — the CLI adapts and registers it +under the manifest key when the command is first executed, and the module needs +no registration side effects at all: + +```js +// dist/commands/hello-world.js +const { defineCommand, inject } = require("nativescript/contracts"); + +module.exports = defineCommand({ + name: "hello|world", + arguments: "any", + async run(ctx) { + inject("logger").info(`Hello, ${ctx.args[0] || "world"}!`); + }, +}); +``` + +`inject()` resolves a CLI service against the injector running the command, and +works anywhere inside `run` up to the first `await`. It is why the peer +dependency above matters: with a second copy of the CLI installed alongside your +extension, `inject()` warns and points at the duplicate. + +A definition exported as `module.exports.default` (what a TypeScript or ESM +build emits) is picked up too. + +Legacy modules — command classes that register themselves at load time through +the injector global, with parameter-name constructor injection — keep working +when a manifest entry points at them, so existing extensions can adopt the map +without rewriting their commands. Both of those mechanisms are deprecated +(see [dependency-injection.md](dependency-injection.md)); write new modules as +definitions. + +If a module named by a manifest entry neither exports a definition nor registers +the command itself, executing that command fails with an error naming the +extension, the command and the module — the entry points at the wrong file, or +the file is not doing what the entry promises. + +## Command names + +Command names use `|` to express hierarchy, so `"hello|world"` is invoked as +`ns hello world`. Prefixing the last segment with `*` marks a default +subcommand: `"hello|*default"` runs both for `ns hello default` and for a bare +`ns hello`. Names must be lower case — the CLI matches what the user typed in +lower case, so a key with an upper-case letter could never be reached, and is +rejected with a warning. + +**The manifest key decides how a command is invoked.** It has to: the CLI routes +`ns hello world` to your module before that module has been loaded, so the key +is the only name it can know. A `name` inside the definition is metadata — it is +what `registerCommandDefinition` uses when a module registers itself, and it is +useful documentation, but a manifest entry overrides it. If the two disagree the +CLI warns, naming both, and runs the command under the manifest key. + +An alias is a second entry pointing at the same module: + +```json +{ + "nativescript": { + "commands": { + "hello|world": "./dist/commands/hello-world.js", + "hello|w": "./dist/commands/hello-world.js" + } + } +} +``` + +Both names route to the same module, which is loaded once. + +## When two extensions want the same command + +The first extension to claim a command name keeps it; later claimants are +reported with a warning naming both extensions and the command, and their entry +is skipped. A name the CLI itself provides is never taken over — the extension +is told the command is already provided by the CLI. + +"First" is the order extensions are loaded in, which is the order they appear in +the `dependencies` of the profile directory's `extensions/package.json` — npm +keeps that alphabetically sorted, so in practice the alphabetically first +extension name wins. The exception is `ns extension install`: that invocation +loads the freshly installed extension after all the others, so a conflict it +would win on the next invocation goes the other way that one time. + +## Suggesting an extension for an unknown command + +When a user types a command the CLI does not know, it searches npm for packages +carrying the `nativescript:extension` keyword, reads the `nativescript.commands` +key of each candidate's published `package.json`, and matches it against the +words the user typed — longest match first, so `ns valid command with args` +matches a declared `valid|command|with` before `valid|command`. A declared +default command also matches its short form: an extension declaring +`hello|*default` is suggested for a bare `ns hello`. + +Both manifest shapes participate in this matching. If a match is found, the CLI +tells the user which extension provides the command and how to install it: + +``` +The command hello world is registered in extension nativescript-hello. +You can install it by executing 'ns extension install nativescript-hello' +``` + +## Documentation + +Point the `docs` key of the `nativescript` key at a directory of `.md` files to +have the CLI's help system pick up the help for your commands. + +```json +{ + "nativescript": { + "docs": "./docs", + "commands": { + "hello|world": "./dist/commands/hello-world.js" + } + } +} +``` diff --git a/lib/common/contracts/command-registry.ts b/lib/common/contracts/command-registry.ts index 25ea0c8702..2f7f628798 100644 --- a/lib/common/contracts/command-registry.ts +++ b/lib/common/contracts/command-registry.ts @@ -1,6 +1,43 @@ import { Contract } from "../di/contract"; import type { ICommand } from "../definitions/commands"; +export interface DeferredCommandOptions { + /** + * Names the registrant in conflict and failure reports. Re-registering the + * same command under the same owner is a no-op rather than a conflict. + */ + owner: string; + /** Where the implementation comes from; named when loading it fails. */ + source: string; + /** + * Runs on first resolution of the command. It must leave a real resolver on + * the command name — by exporting a definition the caller registers, or by + * registering the command itself. + */ + load: () => void; +} + +/** Why a deferred registration did not take effect. */ +export type DeferredCommandRejection = + /** The name can never be dispatched; `detail` says why. */ + | { reason: "invalid-name"; detail: string } + /** Another owner registered the same command first. */ + | { reason: "claimed"; owner: string } + /** The CLI itself provides the command. */ + | { reason: "built-in" } + /** The name is in use as the dispatcher for subcommands under it. */ + | { reason: "subcommand-parent" }; + +/** + * Outcome of a deferred registration. Callers branch on `rejection.reason` + * rather than on message text, so the wording of the report stays theirs. + */ +export interface DeferredCommandResult { + registered: boolean; + /** Set exactly when `registered` is false. */ + rejection?: DeferredCommandRejection; +} + /** * The command-registry face of the injector facade. Transitional contract: it * mirrors what consumers call today, so that extracting the registry from the @@ -10,11 +47,20 @@ import type { ICommand } from "../definitions/commands"; @Contract({ name: "commandRegistry" }) export abstract class CommandRegistry { /** - * @deprecated Path-based command registration; slated for replacement by - * manifest-declared commands. + * @deprecated Path-based command registration; use registerDeferredCommand, + * which routes without loading and reports conflicts structurally. */ abstract requireCommand(names: string | string[], file: string): void; abstract registerCommand(names: string | string[], resolver: any): void; + /** + * Claims a command name for an owner without loading anything: routing — + * including the dispatcher of a hierarchical parent — is built from the name + * alone, and `load` runs only when that one command is resolved. + */ + abstract registerDeferredCommand( + name: string, + options: DeferredCommandOptions, + ): DeferredCommandResult; abstract resolveCommand(name: string): ICommand; abstract getRegisteredCommandsNames(includeDev: boolean): string[]; abstract getChildrenCommandsNames(commandName: string): string[]; diff --git a/lib/common/contracts/index.ts b/lib/common/contracts/index.ts index 3e0dafcdee..29e06c2109 100644 --- a/lib/common/contracts/index.ts +++ b/lib/common/contracts/index.ts @@ -5,6 +5,11 @@ // physically extracted — at which point the provider is swapped and consumers // keep working unchanged. export { CommandRegistry } from "./command-registry"; +export type { + DeferredCommandOptions, + DeferredCommandRejection, + DeferredCommandResult, +} from "./command-registry"; export { KeyCommandRegistry } from "./key-command-registry"; export { ModuleRegistry } from "./module-registry"; export { PublicApiBuilder } from "./public-api-builder"; diff --git a/lib/common/definitions/extensibility.d.ts b/lib/common/definitions/extensibility.d.ts index fbfd3be6d5..dceac2005c 100644 --- a/lib/common/definitions/extensibility.d.ts +++ b/lib/common/definitions/extensibility.d.ts @@ -30,6 +30,13 @@ interface IExtensionData extends IExtensionName { * Full path to the directory of the installed extension. */ pathToExtension: string; + + /** + * Names of the commands the extension contributes, as declared in the commands key of the nativescript key of its package.json. + * The key may be a map of command name to the module implementing it, in which case these are its keys, or the legacy array of command names, in which case these are its entries. + * The property is not set when the extension declares no commands. + */ + commands?: string[]; } /** diff --git a/lib/common/di/index.ts b/lib/common/di/index.ts index 1ddf6a1dca..88a5e6f9b6 100644 --- a/lib/common/di/index.ts +++ b/lib/common/di/index.ts @@ -12,6 +12,7 @@ export type { IContractOptions } from "./contract"; export { provide, provideLazy } from "./providers"; export type { Provider, + InternalProvider, ProviderToken, Type, AbstractType, diff --git a/lib/common/di/injector.ts b/lib/common/di/injector.ts index 55a89e2482..12daa16412 100644 --- a/lib/common/di/injector.ts +++ b/lib/common/di/injector.ts @@ -2,7 +2,12 @@ import { annotate } from "../helpers"; import { getContractName } from "./contract"; import { resolveForwardRef } from "./forward-ref"; import { runInInjectionContext } from "./inject"; -import type { Provider, ProviderToken, Type } from "./providers"; +import type { + InternalProvider, + Provider, + ProviderToken, + Type, +} from "./providers"; type TokenKey = string | Function; @@ -131,7 +136,7 @@ export class Injector { } /** Merge-mutate: re-registering a key updates the existing record in place. */ - public register(providers: Provider | Provider[]): void { + public register(providers: InternalProvider | InternalProvider[]): void { const list = Array.isArray(providers) ? providers : [providers]; for (const provider of list) { const keys = this.keysFor(provider.provide); @@ -172,6 +177,16 @@ export class Injector { return !!this.findRecord(token); } + /** + * Whether the token can actually produce a value. A record carrying only a + * pending loader answers `has()` but resolves to an error, so the deferred + * paths use this to tell "loaded and registered" from "loaded and silent". + */ + protected hasResolver(token: ProviderToken): boolean { + const found = this.findRecord(token); + return !!found && found.record.kind !== undefined; + } + /** First cached instance for a token, without triggering construction. */ public peek(token: ProviderToken): any { const found = this.findRecord(token); @@ -230,7 +245,10 @@ export class Injector { return name !== undefined ? [token, name] : [token]; } - private applyProvider(record: IProviderRecord, provider: Provider): void { + private applyProvider( + record: IProviderRecord, + provider: InternalProvider, + ): void { record.shared = provider.shared === undefined ? true : provider.shared; if ("useLazyRequire" in provider) { diff --git a/lib/common/di/providers.ts b/lib/common/di/providers.ts index 2395dfcb3d..42741eaaa8 100644 --- a/lib/common/di/providers.ts +++ b/lib/common/di/providers.ts @@ -37,7 +37,9 @@ export interface ILegacyClassProvider extends IBaseProvider { /** * Deferred side-effect loader (Yok's `require(name, path)`): running it is - * expected to register the real resolver onto this same record. + * expected to register the real resolver onto this same record. Container + * internals only — a record left with nothing but a loader resolves to an + * error, so it is deliberately kept out of `Provider`. */ export interface ILazyRequireProvider extends IBaseProvider { useLazyRequire: () => void; @@ -48,8 +50,10 @@ export type Provider = | IValueProvider | IFactoryProvider | ILazyClassProvider - | ILegacyClassProvider - | ILazyRequireProvider; + | ILegacyClassProvider; + +/** The provider forms the container accepts, including the unpublished ones. */ +export type InternalProvider = Provider | ILazyRequireProvider; /** Enforces at compile time that the implementation satisfies the token. */ export const provide = ( diff --git a/lib/common/services/command-definition-adapter.ts b/lib/common/services/command-definition-adapter.ts index 92c85429b1..5ccefc5116 100644 --- a/lib/common/services/command-definition-adapter.ts +++ b/lib/common/services/command-definition-adapter.ts @@ -203,6 +203,26 @@ export function createCommandFromDefinition< }; } +/** + * Registers a definition under an externally chosen command name. Extension + * manifests route by their own key, which need not be the definition's own + * name, so the name is a parameter rather than read off the definition. + */ +export function registerDefinitionAs( + name: string, + definition: DefinedCommand, + targetInjector: IInjector = injector, +): void { + // The registry facet rather than the injector itself, so a child injector + // that provides its own CommandRegistry receives the registration. + const registry = targetInjector.get(CommandRegistry); + // A prototype-less zero-parameter function registers as a useFactory + // provider, so the command is built on first resolution and cached. + registry.registerCommand(name, () => + createCommandFromDefinition(definition, targetInjector), + ); +} + export function registerCommandDefinition( definition: DefinedCommand, targetInjector: IInjector = injector, @@ -214,18 +234,11 @@ export function registerCommandDefinition( ); } - // The registry facet rather than the injector itself, so a child injector - // that provides its own CommandRegistry receives the registration. - const registry = targetInjector.get(CommandRegistry); const names = Array.isArray(definition.name) ? definition.name : [definition.name]; for (const name of names) { - // A prototype-less zero-parameter function registers as a useFactory - // provider, so the command is built on first resolution and cached. - registry.registerCommand(name, () => - createCommandFromDefinition(definition, targetInjector), - ); + registerDefinitionAs(name, definition, targetInjector); } } diff --git a/lib/common/yok.ts b/lib/common/yok.ts index 9a3c717ddc..0f4d77f45d 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -16,6 +16,11 @@ import { ModuleRegistry, PublicApiBuilder, } from "./contracts"; +import type { + DeferredCommandOptions, + DeferredCommandRejection, + DeferredCommandResult, +} from "./contracts"; /** * The legacy global facade binding. New code should obtain the container via @@ -24,6 +29,10 @@ import { */ export let injector: IInjector; +function rejected(rejection: DeferredCommandRejection): DeferredCommandResult { + return { registered: false, rejection }; +} + function forEachName(names: any, action: (name: string) => void): void { if (_.isString(names)) { action(names); @@ -95,10 +104,12 @@ export class Yok extends Injector implements IInjector { private placeholderParents = new Set(); private KEY_COMMANDS_NAMESPACE: string = "keyCommands"; private hierarchicalCommands: IDictionary = {}; + /** Deferred command name -> the owner that claimed it first. */ + private deferredCommandOwners: IDictionary = {}; /** - * @deprecated Path-based command registration; slated for replacement by - * manifest-declared commands. + * @deprecated Path-based command registration; use registerDeferredCommand, + * which routes without loading and reports conflicts structurally. */ public requireCommand(names: any, file: string): void { forEachName(names, (commandName) => { @@ -145,6 +156,86 @@ export class Yok extends Injector implements IInjector { }); } + public registerDeferredCommand( + name: string, + options: DeferredCommandOptions, + ): DeferredCommandResult { + if (name !== name.toLowerCase()) { + return rejected({ + reason: "invalid-name", + detail: + `command names are matched in lower case, so '${name}' can never ` + + `be dispatched; declare it as '${name.toLowerCase()}'`, + }); + } + + const claimedBy = this.deferredCommandOwners[name]; + if (claimedBy) { + return claimedBy === options.owner + ? { registered: true } + : rejected({ reason: "claimed", owner: claimedBy }); + } + + const commandRecordName = this.createCommandName(name); + if (this.has(commandRecordName)) { + return rejected( + this.synthesizedParents.has(name) + ? { reason: "subcommand-parent" } + : { reason: "built-in" }, + ); + } + + super.register({ + provide: commandRecordName, + useLazyRequire: () => { + try { + options.load(); + } catch (err) { + throw new Error( + `Unable to load command '${name}' of ${options.owner} from ` + + `${options.source}: ${err.message}`, + ); + } + + if (!this.hasResolver(commandRecordName)) { + throw new Error( + `Command '${name}' of ${options.owner} was not registered when ` + + `${options.source} loaded. The module must export a ` + + `defineCommand() definition or register the command itself.`, + ); + } + }, + }); + this.deferredCommandOwners[name] = options.owner; + + const commands = name.split(CommandsDelimiters.HierarchicalCommand); + if (commands.length > 1) { + const parentCommandName = commands[0]; + const subCommandName = _.tail(commands).join( + CommandsDelimiters.HierarchicalCommand, + ); + + if (!this.hierarchicalCommands[parentCommandName]) { + this.hierarchicalCommands[parentCommandName] = []; + } + + if ( + !_.includes( + this.hierarchicalCommands[parentCommandName], + subCommandName, + ) + ) { + this.hierarchicalCommands[parentCommandName].push(subCommandName); + } + + // The dispatcher routes off the recorded subcommand names alone, so + // reaching a sibling never loads this entry's module. + this.createHierarchicalCommand(parentCommandName, name); + } + + return { registered: true }; + } + /** * @deprecated Use provideLazy() from lib/common/di (via `Yok.di`) — the same * deferred loading, token-based. @@ -227,7 +318,7 @@ export class Yok extends Injector implements IInjector { // Yok replaced the whole record on an allowed re-require, dropping any // resolver and cached instances with it — preserved via remove(). this.remove(name); - this.register({ + super.register({ provide: name, useLazyRequire: () => require(dependencyPath), }); diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index 4129c2b9d6..e58daff365 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -6,6 +6,7 @@ import { createRegExp, regExpEscape } from "../common/helpers"; import { reportDeprecation } from "../common/deprecation"; import { INodePackageManager, INpmsSingleResultData } from "../declarations"; import { + IDictionary, IFileSystem, ISettingsService, IStringDictionary, @@ -18,10 +19,85 @@ import { IGetExtensionCommandInfoParams, } from "../common/definitions/extensibility"; import { injector } from "../common/yok"; +import { IInjector } from "../common/definitions/yok"; +import { CommandsDelimiters } from "../common/constants"; +import { inject } from "../common/di/inject"; +import { CommandRegistry } from "../common/contracts"; +import type { DeferredCommandRejection } from "../common/contracts"; +import { DefinedCommand, isCommandDefinition } from "../common/define-command"; +import { registerDefinitionAs } from "../common/services/command-definition-adapter"; + +function isNonEmptyString(value: any): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function isCommandsMap(commands: any): boolean { + return !!commands && typeof commands === "object" && !Array.isArray(commands); +} + +/** + * A manifest entry is either the module path or an envelope carrying it under + * `path`. Unknown envelope keys are ignored on purpose: a CLI released today + * must keep loading manifests that grow new keys tomorrow. + */ +function getEntryModulePath(value: any): string { + if (isNonEmptyString(value)) { + return value; + } + + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + isNonEmptyString(value.path) + ) { + return value.path; + } + + return null; +} + +const isDefaultCommandName = (name: string): boolean => + name.indexOf(CommandsDelimiters.DefaultHierarchicalCommand) !== -1; + +function describeRejection(rejection: DeferredCommandRejection): string { + switch (rejection.reason) { + case "invalid-name": + return rejection.detail; + case "claimed": + return `it is already registered by extension ${rejection.owner}`; + case "built-in": + return "it is already provided by the CLI"; + case "subcommand-parent": + return "it is already in use as the parent of its subcommands"; + } +} + +/** + * Reads the names of the commands an extension contributes out of either shape + * of `nativescript.commands` - the legacy array of names, or the map of name to + * module path. + */ +function getDeclaredCommandNames( + commands: any, + opts?: { copy: boolean }, +): string[] { + if (Array.isArray(commands)) { + return opts && opts.copy ? commands.slice() : commands; + } + + if (isCommandsMap(commands)) { + return _.keys(commands); + } + + return null; +} export class ExtensibilityService implements IExtensibilityService { private customPathToExtensions: string = null; + private commandRegistry = inject(CommandRegistry); + private get pathToPackageJson(): string { return path.join(this.pathToExtensions, constants.PACKAGE_JSON_FILE_NAME); } @@ -43,6 +119,7 @@ export class ExtensibilityService implements IExtensibilityService { private $packageManager: INodePackageManager, private $settingsService: ISettingsService, private $requireService: IRequireService, + private $injector: IInjector, ) {} public async installExtension( @@ -132,12 +209,23 @@ export class ExtensibilityService implements IExtensibilityService { packageJsonData.nativescript && packageJsonData.nativescript.docs && path.join(pathToExtension, packageJsonData.nativescript.docs); - return { + const result: IExtensionData = { extensionName: packageJsonData.name, version: packageJsonData.version, docs, pathToExtension, }; + + const commands = getDeclaredCommandNames( + packageJsonData && + packageJsonData.nativescript && + packageJsonData.nativescript.commands, + ); + if (commands) { + result.commands = commands; + } + + return result; } public async loadExtension(extensionName: string): Promise { @@ -145,12 +233,23 @@ export class ExtensibilityService implements IExtensibilityService { await this.assertExtensionIsInstalled(extensionName); const pathToExtension = this.getPathToExtension(extensionName); - reportDeprecation({ - api: "extensions.require-time-registration", - detail: extensionName, - logger: this.$logger, - }); - this.$requireService.require(pathToExtension); + const commandsMap = this.getDeclaredCommandsMap(extensionName); + + if (commandsMap) { + this.registerDeclaredCommands( + extensionName, + pathToExtension, + commandsMap, + ); + } else { + reportDeprecation({ + api: "extensions.require-time-registration", + detail: extensionName, + logger: this.$logger, + }); + this.$requireService.require(pathToExtension); + } + return this.getInstalledExtensionData(extensionName); } catch (error) { this.$logger.warn( @@ -200,10 +299,13 @@ export class ExtensibilityService implements IExtensibilityService { await this.$packageManager.getRegistryPackageData(extensionName); const latestPackageData = registryData.versions[registryData["dist-tags"].latest]; - const commands: string[] = + const commands = getDeclaredCommandNames( latestPackageData && - latestPackageData.nativescript && - latestPackageData.nativescript.commands; + latestPackageData.nativescript && + latestPackageData.nativescript.commands, + // The |* synthesis below pushes into this array. + { copy: true }, + ); if (commands && commands.length) { // For each default command we need to add its short syntax in the array of commands. // For example in case there's a default command called devices list, the commands array will contain devices|*list. @@ -249,6 +351,131 @@ export class ExtensibilityService implements IExtensibilityService { return null; } + /** + * Returns the `nativescript.commands` value of an extension only when it is a + * map of command name to module. Any other shape (the legacy array of + * command names, a missing key, an unreadable package.json) yields null and + * keeps the extension on the eager require path. + */ + private getDeclaredCommandsMap(extensionName: string): IDictionary { + let commands: any; + + try { + const packageJsonData = this.getExtensionPackageJsonData(extensionName); + commands = + packageJsonData && + packageJsonData.nativescript && + packageJsonData.nativescript.commands; + } catch (err) { + this.$logger.trace( + `Unable to read the package.json of extension ${extensionName}. Error is: ${err}`, + ); + return null; + } + + return isCommandsMap(commands) ? commands : null; + } + + /** + * Registers each declared command as a deferred load of its own module, so + * nothing from the extension is loaded until one of its commands is executed. + * A module may either register itself on load (a legacy-style + * `$injector.registerCommand(, )` at the top level) or export a + * `defineCommand` definition, which the deferred loader adapts and registers + * under the manifest key. + */ + private registerDeclaredCommands( + extensionName: string, + pathToExtension: string, + commands: IDictionary, + ): void { + // Manifest key order carries no meaning, so a parent's default command is + // registered before its siblings rather than wherever the author put it. + const commandNames = _.sortBy(_.keys(commands), (commandName) => + isDefaultCommandName(commandName) ? 0 : 1, + ); + + for (const commandName of commandNames) { + const modulePath = getEntryModulePath(commands[commandName]); + + if (!isNonEmptyString(commandName) || !modulePath) { + this.$logger.warn( + `Extension ${extensionName} declares an invalid command in its nativescript.commands: '${commandName}': ${JSON.stringify( + commands[commandName], + )}. The command name must be a non-empty string and its value either the path to its module or an object with a non-empty 'path'. Skipping this command.`, + ); + continue; + } + + const absoluteModulePath = path.join(pathToExtension, modulePath); + const result = this.commandRegistry.registerDeferredCommand(commandName, { + owner: extensionName, + source: absoluteModulePath, + load: () => + this.loadDeclaredCommand( + extensionName, + commandName, + absoluteModulePath, + ), + }); + + if (!result.registered) { + this.$logger.warn( + `Extension ${extensionName} is unable to register command '${commandName}': ${describeRejection( + result.rejection, + )}.`, + ); + } + } + } + + /** + * Runs on the first resolution of one declared command. Definition modules + * are registered here rather than by the module itself, which is what lets + * the manifest key stay authoritative for routing. + */ + private loadDeclaredCommand( + extensionName: string, + commandName: string, + absoluteModulePath: string, + ): void { + const exported = require(absoluteModulePath); + const candidate = (exported && exported.default) ?? exported; + + if (!isCommandDefinition(candidate)) { + return; + } + + this.warnOnDeclaredNameMismatch( + extensionName, + commandName, + absoluteModulePath, + candidate, + ); + registerDefinitionAs(commandName, candidate, this.$injector); + } + + private warnOnDeclaredNameMismatch( + extensionName: string, + commandName: string, + absoluteModulePath: string, + definition: DefinedCommand, + ): void { + const declaredNames = Array.isArray(definition.name) + ? definition.name + : [definition.name]; + + if (_.includes(declaredNames, commandName)) { + return; + } + + this.$logger.warn( + `Extension ${extensionName} declares command '${commandName}' in its package.json, but the definition in ${absoluteModulePath} names itself '${declaredNames.join( + "', '", + )}'. The command runs as '${commandName}' - the manifest decides how it is invoked.`, + ); + } + private getPathToExtension(extensionName: string): string { return path.join( this.pathToExtensions, diff --git a/test/extension-manifests.ts b/test/extension-manifests.ts new file mode 100644 index 0000000000..ef7c3cf684 --- /dev/null +++ b/test/extension-manifests.ts @@ -0,0 +1,868 @@ +import { assert } from "chai"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { ExtensibilityService } from "../lib/services/extensibility-service"; +import { Yok, getInjector, setGlobalInjector } from "../lib/common/yok"; +import { LoggerStub } from "./stubs"; +import { clearReportedDeprecations } from "../lib/common/deprecation"; +import { CommandsDelimiters } from "../lib/common/constants"; +import { IInjector } from "../lib/common/definitions/yok"; +import { + IExtensibilityService, + IExtensionData, +} from "../lib/common/definitions/extensibility"; +import { IStringDictionary } from "../lib/common/declarations"; + +// Every assertion about registered commands goes through the per-test +// injector: the service takes $injector as a constructor dependency. The +// process-wide injector is pointed at that same instance for each test's +// duration ONLY because legacy-shape fixture modules register through the +// published global surface when they load - that swap is the legacy-compat +// seam, not the assertion path. Command names stay unique per test since the +// module require cache outlives a test. + +interface ITestCapture { + loadedModules: string[]; + executed: any[]; +} + +const DEPRECATION_API = "extensions.require-time-registration"; + +describe("extension manifests", () => { + let profileDir: string; + let requiredPaths: string[]; + let capture: ITestCapture; + let testInjector: IInjector; + let previousProcessInjector: IInjector; + + beforeEach(() => { + profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-ext-manifest-")); + testInjector = getTestInjector(); + previousProcessInjector = getInjector(); + setGlobalInjector(testInjector); + requiredPaths = []; + capture = (global).__nsmCapture = { + loadedModules: [], + executed: [], + }; + fs.mkdirSync(path.join(profileDir, "extensions", "node_modules"), { + recursive: true, + }); + writeExtensionsPackageJson({}); + clearReportedDeprecations(); + }); + + afterEach(() => { + setGlobalInjector(previousProcessInjector); + fs.rmSync(profileDir, { recursive: true, force: true }); + delete (global).__nsmCapture; + }); + + const writeExtensionsPackageJson = ( + dependencies: IStringDictionary, + ): void => { + fs.writeFileSync( + path.join(profileDir, "extensions", "package.json"), + JSON.stringify({ + name: "nativescript-extensibility", + version: "1.0.0", + dependencies, + }), + ); + }; + + /** + * Lays out a real extension package: package.json with the given nativescript + * key plus the given files, and an entry in the extensions dir dependencies. + */ + const writeExtension = ( + extensionName: string, + nativescript: any, + files: IStringDictionary, + ): string => { + const pathToExtension = path.join( + profileDir, + "extensions", + "node_modules", + extensionName, + ); + fs.mkdirSync(pathToExtension, { recursive: true }); + fs.writeFileSync( + path.join(pathToExtension, "package.json"), + JSON.stringify({ + name: extensionName, + version: "1.0.0", + main: "main.js", + nativescript, + }), + ); + + for (const relativePath of Object.keys(files || {})) { + const pathToFile = path.join(pathToExtension, relativePath); + fs.mkdirSync(path.dirname(pathToFile), { recursive: true }); + fs.writeFileSync(pathToFile, files[relativePath]); + } + + const pathToExtensionsPackageJson = path.join( + profileDir, + "extensions", + "package.json", + ); + const packageJsonData = JSON.parse( + fs.readFileSync(pathToExtensionsPackageJson).toString(), + ); + packageJsonData.dependencies[extensionName] = "1.0.0"; + fs.writeFileSync( + pathToExtensionsPackageJson, + JSON.stringify(packageJsonData), + ); + + return pathToExtension; + }; + + const mainModule = (marker: string): string => + `global.__nsmCapture.loadedModules.push(${JSON.stringify(marker)});`; + + const commandModule = (commandName: string, marker: string): string => + `class TestCommand { + constructor() { + this.allowedParameters = []; + } + async execute(args) { + global.__nsmCapture.executed.push({ marker: ${JSON.stringify( + marker, + )}, args: args }); + } + } + global.__nsmCapture.loadedModules.push(${JSON.stringify(marker)}); + global.$injector.registerCommand(${JSON.stringify(commandName)}, TestCommand);`; + + const getTestInjector = (): IInjector => { + const testInjector = new Yok(); + testInjector.register("fs", { + exists: (pathToCheck: string): boolean => fs.existsSync(pathToCheck), + readJson: (pathToFile: string): any => + JSON.parse(fs.readFileSync(pathToFile).toString()), + readText: (pathToFile: string): string => + fs.readFileSync(pathToFile).toString(), + readDirectory: (dir: string): string[] => fs.readdirSync(dir), + createDirectory: (dir: string): void => { + fs.mkdirSync(dir, { recursive: true }); + }, + writeJson: (pathToFile: string, content: any): void => + fs.writeFileSync(pathToFile, JSON.stringify(content)), + }); + testInjector.register("logger", LoggerStub); + testInjector.register("packageManager", { + install: async (): Promise => { + throw new Error("Extensions are expected to be installed already."); + }, + uninstall: async (): Promise => undefined, + searchNpms: async (): Promise => ({ results: [] }), + getRegistryPackageData: async (): Promise => ({}), + }); + testInjector.register("settingsService", { + getProfileDir: (): string => profileDir, + }); + testInjector.register("requireService", { + require: (module: string): any => { + requiredPaths.push(module); + return require(module); + }, + }); + + return testInjector; + }; + + const resolveService = (testInjector: IInjector): IExtensibilityService => + testInjector.resolve(ExtensibilityService); + + const getLogger = (testInjector: IInjector): LoggerStub => + testInjector.resolve("logger"); + + describe("commands declared as a map", () => { + it("registers each command lazily and never loads the extension main", async () => { + const extensionName = "nsm-lazy-ext"; + writeExtension( + extensionName, + { + commands: { + "nsmlazy|run": "./dist/commands/run.js", + "nsmlazy|clean": "./dist/commands/clean.js", + }, + }, + { + "main.js": mainModule("lazy-main"), + "dist/commands/run.js": commandModule("nsmlazy|run", "lazy-run"), + "dist/commands/clean.js": commandModule( + "nsmlazy|clean", + "lazy-clean", + ), + }, + ); + + const extensibilityService = resolveService(testInjector); + const extensionData = + await extensibilityService.loadExtension(extensionName); + + assert.deepStrictEqual(capture.loadedModules, []); + assert.deepStrictEqual( + requiredPaths, + [], + "The extension main must not be required when its commands are declared as a map.", + ); + assert.notInclude(getLogger(testInjector).traceOutput, DEPRECATION_API); + assert.deepStrictEqual(extensionData.commands, [ + "nsmlazy|run", + "nsmlazy|clean", + ]); + + const command = testInjector.resolveCommand("nsmlazy|run"); + assert.isOk(command); + assert.deepStrictEqual(capture.loadedModules, ["lazy-run"]); + + await command.execute(["arg"]); + assert.deepStrictEqual(capture.executed, [ + { marker: "lazy-run", args: ["arg"] }, + ]); + + // The other command's module is still not loaded, and neither is main. + assert.deepStrictEqual(capture.loadedModules, ["lazy-run"]); + assert.deepStrictEqual(requiredPaths, []); + }); + + it("warns about and skips malformed entries, keeping the valid ones", async () => { + const extensionName = "nsm-malformed-ext"; + writeExtension( + extensionName, + { + commands: { + "nsmbad|good": "./good.js", + "nsmbad|number": 42, + "nsmbad|empty": " ", + "": "./unnamed.js", + }, + }, + { + "main.js": mainModule("malformed-main"), + "good.js": commandModule("nsmbad|good", "malformed-good"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + const warnOutput = getLogger(testInjector).warnOutput; + assert.include(warnOutput, "nsmbad|number"); + assert.include(warnOutput, "nsmbad|empty"); + assert.include(warnOutput, extensionName); + + assert.isOk(testInjector.resolveCommand("nsmbad|good")); + assert.isNull(testInjector.resolveCommand("nsmbad|number")); + assert.isNull(testInjector.resolveCommand("nsmbad|empty")); + assert.deepStrictEqual(capture.loadedModules, ["malformed-good"]); + }); + + it("warns instead of failing when two extensions claim the same command", async () => { + const firstExtension = "nsm-first-ext"; + const secondExtension = "nsm-second-ext"; + writeExtension( + firstExtension, + { commands: { "nsmconflict|run": "./run.js" } }, + { + "main.js": mainModule("first-main"), + "run.js": commandModule("nsmconflict|run", "first-run"), + }, + ); + writeExtension( + secondExtension, + { commands: { "nsmconflict|run": "./run.js" } }, + { + "main.js": mainModule("second-main"), + "run.js": commandModule("nsmconflict|run", "second-run"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(firstExtension); + await extensibilityService.loadExtension(secondExtension); + + const warnOutput = getLogger(testInjector).warnOutput; + assert.include(warnOutput, "nsmconflict|run"); + assert.include(warnOutput, firstExtension); + assert.include(warnOutput, secondExtension); + + const command = testInjector.resolveCommand("nsmconflict|run"); + await command.execute([]); + assert.deepStrictEqual(capture.executed, [ + { marker: "first-run", args: [] }, + ]); + }); + }); + + describe("commands declared as an array", () => { + it("requires the extension main eagerly and reports the deprecated registration", async () => { + const extensionName = "nsm-eager-ext"; + const pathToExtension = writeExtension( + extensionName, + { commands: ["nsmeager|run"] }, + { + "main.js": `${mainModule("eager-main")} + ${commandModule("nsmeager|run", "eager-run")}`, + }, + ); + + const extensibilityService = resolveService(testInjector); + const extensionData = + await extensibilityService.loadExtension(extensionName); + + assert.deepStrictEqual(requiredPaths, [pathToExtension]); + assert.deepStrictEqual(capture.loadedModules, [ + "eager-main", + "eager-run", + ]); + + const traceOutput = getLogger(testInjector).traceOutput; + assert.include(traceOutput, DEPRECATION_API); + assert.include(traceOutput, extensionName); + + assert.deepStrictEqual(extensionData.commands, ["nsmeager|run"]); + assert.isOk(testInjector.resolveCommand("nsmeager|run")); + }); + + it("keeps the eager path when the extension declares no commands", async () => { + const extensionName = "nsm-no-commands-ext"; + const pathToExtension = writeExtension( + extensionName, + { docs: "./docs" }, + { "main.js": mainModule("no-commands-main") }, + ); + + const extensibilityService = resolveService(testInjector); + const extensionData = + await extensibilityService.loadExtension(extensionName); + + assert.deepStrictEqual(requiredPaths, [pathToExtension]); + assert.include(getLogger(testInjector).traceOutput, DEPRECATION_API); + assert.isUndefined(extensionData.commands); + }); + }); + + describe("getInstalledExtensionsData", () => { + it("reports the declared command names for both manifest shapes", () => { + writeExtension( + "nsm-data-map-ext", + { commands: { "nsmdata|one": "./one.js", "nsmdata|two": "./two.js" } }, + {}, + ); + writeExtension("nsm-data-array-ext", { commands: ["nsmdata|three"] }, {}); + writeExtension("nsm-data-plain-ext", {}, {}); + + const extensibilityService = resolveService(testInjector); + const extensionsData = extensibilityService.getInstalledExtensionsData(); + const dataByName: { [name: string]: IExtensionData } = {}; + for (const extensionData of extensionsData) { + dataByName[extensionData.extensionName] = extensionData; + } + + assert.deepStrictEqual(dataByName["nsm-data-map-ext"].commands, [ + "nsmdata|one", + "nsmdata|two", + ]); + assert.deepStrictEqual(dataByName["nsm-data-array-ext"].commands, [ + "nsmdata|three", + ]); + assert.isUndefined(dataByName["nsm-data-plain-ext"].commands); + }); + }); + + describe("getExtensionNameWhereCommandIsRegistered", () => { + const getExtensionCommandInfo = async ( + registryCommands: any, + inputStrings: string[], + ): Promise => { + const extensionName = "nsm-registry-ext"; + const packageManager = testInjector.resolve("packageManager"); + packageManager.searchNpms = async (keyword: string): Promise => { + assert.equal(keyword, "nativescript:extension"); + return { results: [{ package: { name: extensionName } }] }; + }; + packageManager.getRegistryPackageData = async (): Promise => ({ + ["dist-tags"]: { latest: "1.0.0" }, + versions: { + "1.0.0": { nativescript: { commands: registryCommands } }, + }, + }); + + const extensibilityService = resolveService(testInjector); + return extensibilityService.getExtensionNameWhereCommandIsRegistered({ + inputStrings, + commandDelimiter: CommandsDelimiters.HierarchicalCommand, + defaultCommandDelimiter: CommandsDelimiters.DefaultHierarchicalCommand, + }); + }; + + it("suggests an extension whose registry data declares commands as a map", async () => { + const result = await getExtensionCommandInfo( + { "registry|command": "./registry-command.js" }, + ["registry", "command", "and", "args"], + ); + + assert.deepStrictEqual(result, { + extensionName: "nsm-registry-ext", + registeredCommandName: "registry|command", + installationMessage: + "The command registry command is registered in extension nsm-registry-ext. You can install it by executing 'ns extension install nsm-registry-ext'", + }); + }); + + it("synthesizes the short form of a default command declared as a map", async () => { + const result = await getExtensionCommandInfo( + { + "registry|*default": "./registry-default.js", + "registry|other": "./registry-other.js", + }, + ["registry", "and", "args"], + ); + + assert.deepStrictEqual(result, { + extensionName: "nsm-registry-ext", + registeredCommandName: "registry", + installationMessage: + "The command registry is registered in extension nsm-registry-ext. You can install it by executing 'ns extension install nsm-registry-ext'", + }); + }); + + it("still suggests an extension whose registry data declares commands as an array", async () => { + const result = await getExtensionCommandInfo( + ["registry|*default", "registry|other"], + ["registry", "and", "args"], + ); + + assert.deepStrictEqual(result, { + extensionName: "nsm-registry-ext", + registeredCommandName: "registry", + installationMessage: + "The command registry is registered in extension nsm-registry-ext. You can install it by executing 'ns extension install nsm-registry-ext'", + }); + }); + + it("returns null when the declared commands do not match the input", async () => { + const result = await getExtensionCommandInfo( + { "registry|command": "./registry-command.js" }, + ["some", "other", "command"], + ); + + assert.isNull(result); + }); + }); + + describe("commands declared as defineCommand modules", () => { + const contractsPath = require.resolve("../lib/contracts"); + + const definitionModule = ( + commandName: string, + marker: string, + exportAs: string = "module.exports", + ): string => + `const { defineCommand } = require(${JSON.stringify(contractsPath)}); + global.__nsmCapture.loadedModules.push(${JSON.stringify(marker)}); + ${exportAs} = defineCommand({ + name: ${JSON.stringify(commandName)}, + arguments: "any", + async run(ctx) { + global.__nsmCapture.executed.push({ marker: ${JSON.stringify( + marker, + )}, args: ctx.args }); + }, + });`; + + it("adapts and registers a pure definition module lazily", async () => { + const extensionName = "nsm-def-ext"; + writeExtension( + extensionName, + { commands: { "nsmdef|hello": "./dist/hello.js" } }, + { + "main.js": mainModule("def-main"), + "dist/hello.js": definitionModule("nsmdef|hello", "def-hello"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + assert.deepEqual(capture.loadedModules, []); + + const command = testInjector.resolveCommand("nsmdef|hello"); + assert.isOk(command); + assert.deepEqual(capture.loadedModules, ["def-hello"]); + + await command.execute(["fast"]); + assert.deepEqual(capture.executed, [ + { marker: "def-hello", args: ["fast"] }, + ]); + }); + + it("resolves the hierarchical parent dispatcher without loading any child module", async () => { + const extensionName = "nsm-defp-ext"; + writeExtension( + extensionName, + { + commands: { + "nsmdefp|go": "./dist/go.js", + "nsmdefp|stop": "./dist/stop.js", + }, + }, + { + "main.js": mainModule("defp-main"), + "dist/go.js": definitionModule("nsmdefp|go", "defp-go"), + "dist/stop.js": definitionModule("nsmdefp|stop", "defp-stop"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + const parent = testInjector.resolveCommand("nsmdefp"); + assert.isOk(parent); + assert.isTrue(parent.isHierarchicalCommand); + assert.deepEqual(capture.loadedModules, []); + assert.deepEqual(testInjector.getChildrenCommandsNames("nsmdefp"), [ + "go", + "stop", + ]); + + assert.isOk(testInjector.resolveCommand("nsmdefp|go")); + assert.deepEqual(capture.loadedModules, ["defp-go"]); + }); + + it("registers a definition under the manifest key and warns about a disagreeing name", async () => { + const extensionName = "nsm-defname-ext"; + writeExtension( + extensionName, + { commands: { "nsmdefname|run": "./dist/run.js" } }, + { + "main.js": mainModule("defname-main"), + "dist/run.js": definitionModule("something|else", "defname-run"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + const command = testInjector.resolveCommand("nsmdefname|run"); + assert.isOk(command); + await command.execute([]); + assert.deepEqual(capture.executed, [{ marker: "defname-run", args: [] }]); + assert.isNull(testInjector.resolveCommand("something|else")); + + const warnOutput = getLogger(testInjector).warnOutput; + assert.include(warnOutput, "nsmdefname|run"); + assert.include(warnOutput, "something|else"); + assert.include(warnOutput, extensionName); + }); + + it("adapts a definition exported as the module's default", async () => { + const extensionName = "nsm-defdefault-ext"; + writeExtension( + extensionName, + { commands: { "nsmdefdefault|hello": "./dist/hello.js" } }, + { + "main.js": mainModule("defdefault-main"), + "dist/hello.js": definitionModule( + "nsmdefdefault|hello", + "defdefault-hello", + "exports.default", + ), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + const command = testInjector.resolveCommand("nsmdefdefault|hello"); + assert.isOk(command); + await command.execute([]); + assert.deepEqual(capture.executed, [ + { marker: "defdefault-hello", args: [] }, + ]); + }); + + it("routes two aliases of one command to the same module", async () => { + const extensionName = "nsm-alias-ext"; + writeExtension( + extensionName, + { + commands: { + "nsmalias|run": "./dist/run.js", + "nsmalias|r": "./dist/run.js", + }, + }, + { + "main.js": mainModule("alias-main"), + "dist/run.js": definitionModule("nsmalias|run", "alias-run"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + assert.isOk(testInjector.resolveCommand("nsmalias|run")); + const aliased = testInjector.resolveCommand("nsmalias|r"); + assert.isOk(aliased); + + await aliased.execute(["x"]); + assert.deepEqual(capture.executed, [ + { marker: "alias-run", args: ["x"] }, + ]); + }); + }); + + describe("manifest entry values", () => { + it("accepts an object entry carrying the module path and ignores its other keys", async () => { + const extensionName = "nsm-envelope-ext"; + writeExtension( + extensionName, + { + commands: { + "nsmenvelope|run": { + path: "./run.js", + somethingAddedLater: true, + }, + }, + }, + { + "main.js": mainModule("envelope-main"), + "run.js": commandModule("nsmenvelope|run", "envelope-run"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + assert.deepEqual(capture.loadedModules, []); + assert.isOk(testInjector.resolveCommand("nsmenvelope|run")); + assert.deepEqual(capture.loadedModules, ["envelope-run"]); + }); + + it("warns about and skips an object entry without a usable path", async () => { + const extensionName = "nsm-envelope-bad-ext"; + writeExtension( + extensionName, + { commands: { "nsmenvelopebad|run": { module: "./run.js" } } }, + { "main.js": mainModule("envelope-bad-main") }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + assert.include(getLogger(testInjector).warnOutput, "nsmenvelopebad|run"); + assert.isNull(testInjector.resolveCommand("nsmenvelopebad|run")); + }); + }); + + describe("manifest keys the CLI cannot route", () => { + it("rejects a key that is not lower case", async () => { + const extensionName = "nsm-case-ext"; + writeExtension( + extensionName, + { commands: { "nsmCase|Run": "./run.js" } }, + { + "main.js": mainModule("case-main"), + "run.js": commandModule("nsmcase|run", "case-run"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + const warnOutput = getLogger(testInjector).warnOutput; + assert.include(warnOutput, "nsmCase|Run"); + assert.include(warnOutput, "nsmcase|run"); + assert.include(warnOutput, extensionName); + assert.isNull(testInjector.resolveCommand("nsmCase|Run")); + assert.isNull(testInjector.resolveCommand("nsmcase|run")); + }); + + it("rejects a key already in use as the parent of its own subcommands", async () => { + const extensionName = "nsm-parentclash-ext"; + writeExtension( + extensionName, + { + commands: { + "nsmparentclash|run": "./run.js", + nsmparentclash: "./flat.js", + }, + }, + { + "main.js": mainModule("parentclash-main"), + "run.js": commandModule("nsmparentclash|run", "parentclash-run"), + "flat.js": commandModule("nsmparentclash", "parentclash-flat"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + const warnOutput = getLogger(testInjector).warnOutput; + assert.include(warnOutput, "nsmparentclash"); + assert.include(warnOutput, "parent of its subcommands"); + + const parent = testInjector.resolveCommand("nsmparentclash"); + assert.isTrue(parent.isHierarchicalCommand); + assert.deepEqual(capture.loadedModules, []); + }); + + it("reports a command the CLI itself provides without naming internals", async () => { + const extensionName = "nsm-builtin-ext"; + class BuiltInCommand { + public allowedParameters: any[] = []; + public async execute(): Promise { + return undefined; + } + } + testInjector.registerCommand("nsmbuiltin", BuiltInCommand); + + writeExtension( + extensionName, + { commands: { nsmbuiltin: "./run.js" } }, + { + "main.js": mainModule("builtin-main"), + "run.js": commandModule("nsmbuiltin", "builtin-run"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + const warnOutput = getLogger(testInjector).warnOutput; + assert.include(warnOutput, "already provided by the CLI"); + assert.include(warnOutput, extensionName); + assert.notInclude(warnOutput, "commands."); + + assert.instanceOf( + testInjector.resolveCommand("nsmbuiltin"), + BuiltInCommand, + ); + assert.deepEqual(capture.loadedModules, []); + }); + }); + + describe("loading an already loaded extension", () => { + it("does not report the extension as conflicting with itself", async () => { + const extensionName = "nsm-reload-ext"; + writeExtension( + extensionName, + { commands: { "nsmreload|run": "./run.js" } }, + { + "main.js": mainModule("reload-main"), + "run.js": commandModule("nsmreload|run", "reload-run"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + await extensibilityService.loadExtension(extensionName); + + assert.equal(getLogger(testInjector).warnOutput, ""); + assert.isOk(testInjector.resolveCommand("nsmreload|run")); + }); + }); + + describe("a manifest declaring no commands to load", () => { + it("loads nothing at all for an empty commands map", async () => { + const extensionName = "nsm-optout-ext"; + writeExtension( + extensionName, + { commands: {} }, + { "main.js": mainModule("optout-main") }, + ); + + const extensibilityService = resolveService(testInjector); + const extensionData = + await extensibilityService.loadExtension(extensionName); + + assert.deepStrictEqual(requiredPaths, []); + assert.deepStrictEqual(capture.loadedModules, []); + assert.notInclude(getLogger(testInjector).traceOutput, DEPRECATION_API); + assert.deepStrictEqual(extensionData.commands, []); + }); + }); + + describe("a module that fails to provide its command", () => { + it("names the extension and the module when the module throws", async () => { + const extensionName = "nsm-throwing-ext"; + writeExtension( + extensionName, + { commands: { "nsmthrowing|run": "./run.js" } }, + { + "main.js": mainModule("throwing-main"), + "run.js": `throw new Error("kaboom");`, + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + assert.throws( + () => testInjector.resolveCommand("nsmthrowing|run"), + /nsmthrowing\|run[\s\S]*nsm-throwing-ext[\s\S]*run\.js[\s\S]*kaboom/, + ); + }); + + it("names the extension and the module when the module registers nothing", async () => { + const extensionName = "nsm-silent-ext"; + writeExtension( + extensionName, + { commands: { "nsmsilent|run": "./run.js" } }, + { + "main.js": mainModule("silent-main"), + "run.js": `module.exports = { notADefinition: true };`, + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + assert.throws( + () => testInjector.resolveCommand("nsmsilent|run"), + /nsmsilent\|run[\s\S]*nsm-silent-ext[\s\S]*run\.js/, + ); + }); + }); + + describe("default commands", () => { + it("registers the default before its siblings whatever the key order", async () => { + const extensionName = "nsm-defaults-ext"; + writeExtension( + extensionName, + { + commands: { + "nsmdefaults|other": "./other.js", + "nsmdefaults|*default": "./default.js", + }, + }, + { + "main.js": mainModule("defaults-main"), + "other.js": commandModule("nsmdefaults|other", "defaults-other"), + "default.js": commandModule( + "nsmdefaults|*default", + "defaults-default", + ), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + assert.equal(getLogger(testInjector).warnOutput, ""); + assert.deepEqual(testInjector.getChildrenCommandsNames("nsmdefaults"), [ + "*default", + "other", + ]); + assert.isOk(testInjector.resolveCommand("nsmdefaults|*default")); + assert.deepEqual(capture.loadedModules, ["defaults-default"]); + }); + }); +});