From 68921bc094005681bd416b7d108388c22f5b93a5 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 21:59:53 -0300 Subject: [PATCH 01/12] feat(extensions): lazy per-command loading via a nativescript.commands map An extension whose package.json declares nativescript.commands as a map of command name to module path is no longer require()d at startup. Each entry is registered with injector.requireCommand against the module's absolute path, so a command's implementation loads only when that command is first resolved, and the CLI stops paying every installed extension's load cost on every invocation. Entries are validated: a command name or module path that is not a non-empty string is warned about and skipped, and a name already claimed by another extension is reported as a warning naming both extensions rather than propagating the injector's "require'd twice" failure. The legacy array shape (and a missing commands key) keeps today's behavior verbatim - eager require of the extension main plus the extensions.require-time-registration deprecation report. Both shapes now feed IExtensionData.commands and the npm install suggestion for unknown commands. --- extensions.md | 179 +++++++++ lib/common/definitions/extensibility.d.ts | 7 + lib/services/extensibility-service.ts | 144 ++++++- test/extension-manifests.ts | 460 ++++++++++++++++++++++ 4 files changed, 780 insertions(+), 10 deletions(-) create mode 100644 extensions.md create mode 100644 test/extension-manifests.ts diff --git a/extensions.md b/extensions.md new file mode 100644 index 0000000000..f92c00f1dc --- /dev/null +++ b/extensions.md @@ -0,0 +1,179 @@ +Writing a CLI Extension +======================= + +An extension adds new commands to the NativeScript CLI. Extensions are ordinary +npm packages: they are published to npm, installed per user rather than per +project, and are available from every project on the machine. + +```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. + +## Making a package discoverable + +Add the `nativescript:extension` keyword to `package.json`. The CLI searches npm +for that keyword when it needs to suggest an extension for an unknown command +(see [Suggesting an extension](#suggesting-an-extension-for-an-unknown-command)). + +```json +{ + "name": "nativescript-hello", + "version": "1.0.0", + "keywords": ["nativescript:extension"] +} +``` + +## 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 is a path to the module implementing it, +resolved relative to the extension's root directory. + +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. +* **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 or +module path is not a non-empty string is reported as a warning naming the +extension and the offending entry, and the extension's remaining commands are +still registered. + +### 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 declaring no commands at all) 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. + +```js +// index.js of a legacy extension +const path = require("path"); + +global.$injector.requireCommand( + "hello|world", + path.join(__dirname, "commands", "hello-world"), +); +``` + +This path remains supported, but it is tracked for eventual deprecation. 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 + +A command module must register itself when it is loaded, by calling +`$injector.registerCommand` at the top level with the same name it is declared +under in the manifest. The CLI resolves the command through the injector right +after loading the module, so registration has to happen as a side effect of the +`require`. + +```js +// dist/commands/hello-world.js +class HelloWorldCommand { + constructor($logger) { + this.$logger = $logger; + this.allowedParameters = []; + } + + async execute(args) { + this.$logger.info(`Hello, ${args[0] || "world"}!`); + } +} + +global.$injector.registerCommand("hello|world", HelloWorldCommand); +``` + +Constructor parameters are injected by name: a parameter called `$logger` +receives the CLI's logger, `$fs` its file system service, and so on. A command +class must expose `allowedParameters` and an `execute(args)` method. + +## 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`. + +When an extension contributes several commands under the same parent, declare +the default command before its siblings — the CLI creates the parent dispatcher +from the first entry it sees, and a default command registered after that parent +already exists is rejected. + +## 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/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/services/extensibility-service.ts b/lib/services/extensibility-service.ts index 4129c2b9d6..44bb1f2378 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -19,9 +19,40 @@ import { } from "../common/definitions/extensibility"; import { injector } from "../common/yok"; +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); +} + +/** + * 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; + /** Command name -> name of the extension whose manifest claimed it first. */ + private manifestCommandOwners: IStringDictionary = {}; + private get pathToPackageJson(): string { return path.join(this.pathToExtensions, constants.PACKAGE_JSON_FILE_NAME); } @@ -132,12 +163,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 +187,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 +253,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 +305,74 @@ 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 path. 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): IStringDictionary { + 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 require of its own module, so + * nothing from the extension is loaded until one of its commands is executed. + * Each module is expected to register itself on load, by calling + * `$injector.registerCommand(, )` at the top level. + */ + private registerDeclaredCommands( + extensionName: string, + pathToExtension: string, + commands: IStringDictionary, + ): void { + for (const commandName of _.keys(commands)) { + const modulePath = commands[commandName]; + + if (!isNonEmptyString(commandName) || !isNonEmptyString(modulePath)) { + this.$logger.warn( + `Extension ${extensionName} declares an invalid command in its nativescript.commands: '${commandName}': ${JSON.stringify( + modulePath, + )}. Both the command name and the path to its module must be non-empty strings. Skipping this command.`, + ); + continue; + } + + try { + injector.requireCommand( + commandName, + path.join(pathToExtension, modulePath), + ); + } catch (err) { + const owner = this.manifestCommandOwners[commandName]; + const ownerInfo = owner + ? ` It is already registered by extension ${owner}.` + : ""; + this.$logger.warn( + `Extension ${extensionName} is unable to register command ${commandName}.${ownerInfo} Error: ${err.message}`, + ); + continue; + } + + this.manifestCommandOwners[commandName] = extensionName; + } + } + 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..f15b76a40f --- /dev/null +++ b/test/extension-manifests.ts @@ -0,0 +1,460 @@ +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 } from "../lib/common/yok"; +import { LoggerStub } from "./stubs"; +import { clearReportedDeprecations } from "../lib/common/deprecation"; +import { CommandsDelimiters } from "../lib/common/constants"; +import { ICliGlobal } from "../lib/common/definitions/cli-global"; +import { IInjector } from "../lib/common/definitions/yok"; +import { + IExtensibilityService, + IExtensionData, +} from "../lib/common/definitions/extensibility"; +import { IStringDictionary } from "../lib/common/declarations"; + +// The service registers commands on the global injector imported by +// lib/common/yok, so every assertion about registered commands goes through it. +// That injector is shared by the whole file, so command names must be unique per +// test - a name reused across tests would collide like a real conflict does. +const cliGlobal = (global); + +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; + + beforeEach(() => { + profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-ext-manifest-")); + requiredPaths = []; + capture = (global).__nsmCapture = { + loadedModules: [], + executed: [], + }; + fs.mkdirSync(path.join(profileDir, "extensions", "node_modules"), { + recursive: true, + }); + writeExtensionsPackageJson({}); + clearReportedDeprecations(); + }); + + afterEach(() => { + 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 testInjector = getTestInjector(); + 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 = cliGlobal.$injector.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 testInjector = getTestInjector(); + 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(cliGlobal.$injector.resolveCommand("nsmbad|good")); + assert.isNull(cliGlobal.$injector.resolveCommand("nsmbad|number")); + assert.isNull(cliGlobal.$injector.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 testInjector = getTestInjector(); + 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 = cliGlobal.$injector.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 testInjector = getTestInjector(); + 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(cliGlobal.$injector.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 testInjector = getTestInjector(); + 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 testInjector = getTestInjector(); + 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 testInjector = getTestInjector(); + 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); + }); + }); +}); From 90e482dd3e62966ad0182f311a5f99a6f762f8b8 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:08:47 -0300 Subject: [PATCH 02/12] feat(extensions): load defineCommand modules from manifest entries A manifest entry may now point at a module that exports a defineCommand definition instead of registering itself on load: the deferred loader adapts and registers the export under the manifest key. The override also lands on a parent record the entry just created, because dispatch resolves the hierarchical parent before any child module has loaded and the dispatcher only comes into existence once a child registers. Also cross-links the authoring guides from dependency-injection.md. --- defining-commands.md | 7 +-- dependency-injection.md | 7 +++ extensions.md | 20 +++++++- lib/services/extensibility-service.ts | 48 +++++++++++++++--- test/extension-manifests.ts | 70 +++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 10 deletions(-) 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 index f92c00f1dc..787d94ac87 100644 --- a/extensions.md +++ b/extensions.md @@ -106,7 +106,25 @@ or set `NS_DEPRECATIONS=warn` to have those reports printed as warnings. ## Writing a command module -A command module must register itself when it is loaded, by calling +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"}!`); + }, +}); +``` + +Alternatively, a module may register itself when it is loaded, by calling `$injector.registerCommand` at the top level with the same name it is declared under in the manifest. The CLI resolves the command through the injector right after loading the module, so registration has to happen as a side effect of the diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index 44bb1f2378..48ff78141e 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -18,6 +18,9 @@ import { IGetExtensionCommandInfoParams, } from "../common/definitions/extensibility"; import { injector } from "../common/yok"; +import { CommandsDelimiters } from "../common/constants"; +import { isCommandDefinition } from "../common/define-command"; +import { createCommandFromDefinition } from "../common/services/command-definition-adapter"; function isNonEmptyString(value: any): boolean { return typeof value === "string" && value.trim().length > 0; @@ -333,8 +336,10 @@ export class ExtensibilityService implements IExtensibilityService { /** * Registers each declared command as a deferred require of its own module, so * nothing from the extension is loaded until one of its commands is executed. - * Each module is expected to register itself on load, by calling - * `$injector.registerCommand(, )` at the top level. + * 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, @@ -353,11 +358,16 @@ export class ExtensibilityService implements IExtensibilityService { continue; } + const absoluteModulePath = path.join(pathToExtension, modulePath); + const parentName = commandName.split( + CommandsDelimiters.HierarchicalCommand, + )[0]; + const container = (injector).di; + const parentWasAbsent = + parentName !== commandName && !container.has(`commands.${parentName}`); + try { - injector.requireCommand( - commandName, - path.join(pathToExtension, modulePath), - ); + injector.requireCommand(commandName, absoluteModulePath); } catch (err) { const owner = this.manifestCommandOwners[commandName]; const ownerInfo = owner @@ -369,6 +379,32 @@ export class ExtensibilityService implements IExtensibilityService { continue; } + // requireCommand's own loader only require()s the module for its side + // effects, which covers self-registering modules but not definition + // exports. The override must also land on a parent record this entry + // just created: dispatch resolves the parent BEFORE any child module + // has loaded, and the parent dispatcher only comes into existence once + // a child's registerCommand runs. + const loader = () => { + const exported = require(absoluteModulePath); + const candidate = (exported && exported.default) ?? exported; + if (isCommandDefinition(candidate)) { + injector.registerCommand(commandName, () => + createCommandFromDefinition(candidate), + ); + } + }; + container.register({ + provide: `commands.${commandName}`, + useLazyRequire: loader, + }); + if (parentWasAbsent) { + container.register({ + provide: `commands.${parentName}`, + useLazyRequire: loader, + }); + } + this.manifestCommandOwners[commandName] = extensionName; } } diff --git a/test/extension-manifests.ts b/test/extension-manifests.ts index f15b76a40f..770639702f 100644 --- a/test/extension-manifests.ts +++ b/test/extension-manifests.ts @@ -457,4 +457,74 @@ describe("extension manifests", () => { assert.isNull(result); }); }); + + describe("commands declared as defineCommand modules", () => { + const contractsPath = require.resolve("../lib/contracts"); + + const definitionModule = (commandName: string, marker: string): string => + `const { defineCommand } = require(${JSON.stringify(contractsPath)}); + global.__nsmCapture.loadedModules.push(${JSON.stringify(marker)}); + module.exports = 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 testInjector = getTestInjector(); + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + assert.deepEqual(capture.loadedModules, []); + + const command = cliGlobal.$injector.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 before any child module has loaded", async () => { + const extensionName = "nsm-defp-ext"; + writeExtension( + extensionName, + { commands: { "nsmdefp|go": "./dist/go.js" } }, + { + "main.js": mainModule("defp-main"), + "dist/go.js": definitionModule("nsmdefp|go", "defp-go"), + }, + ); + + const testInjector = getTestInjector(); + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + // Dispatch hits the parent first; its record must load the child module + // so the dispatcher the child's registration synthesizes exists. + const parent = cliGlobal.$injector.resolveCommand("nsmdefp"); + assert.isOk(parent); + assert.isTrue(parent.isHierarchicalCommand); + assert.include(capture.loadedModules, "defp-go"); + + const child = cliGlobal.$injector.resolveCommand("nsmdefp|go"); + assert.isOk(child); + }); + }); }); From 36bdfe4a13b064a84640e7c8d31eb186d1cf9f7d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:41:00 -0300 Subject: [PATCH 03/12] refactor(extensions): inject the injector; keep globals to the legacy seam The service takes $injector as a constructor dependency instead of the module-level import, so manifest registration and the definition-aware loaders target the instance that resolved it. Tests assert on their own per-test injector; the process-wide injector is swapped only because legacy-shape fixture modules register through the published global surface at load, and that seam is labeled as such. extensions.md no longer teaches the global-injector patterns: the legacy array path and self-registering modules are described under their deprecation framing without runnable samples. --- extensions.md | 52 +++++++-------------------- lib/services/extensibility-service.ts | 8 +++-- test/extension-manifests.ts | 48 ++++++++++++------------- 3 files changed, 41 insertions(+), 67 deletions(-) diff --git a/extensions.md b/extensions.md index 787d94ac87..b8feb9a035 100644 --- a/extensions.md +++ b/extensions.md @@ -88,21 +88,14 @@ The array is a discovery aid only — it lists the names the CLI may suggest you extension for, but it says nothing about where the implementations live. An extension declaring commands this way (or declaring no commands at all) 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. +invocation and expects the module's top-level code to register everything +through the injector global. -```js -// index.js of a legacy extension -const path = require("path"); - -global.$injector.requireCommand( - "hello|world", - path.join(__dirname, "commands", "hello-world"), -); -``` - -This path remains supported, but it is tracked for eventual deprecation. 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. +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 @@ -124,31 +117,12 @@ module.exports = defineCommand({ }); ``` -Alternatively, a module may register itself when it is loaded, by calling -`$injector.registerCommand` at the top level with the same name it is declared -under in the manifest. The CLI resolves the command through the injector right -after loading the module, so registration has to happen as a side effect of the -`require`. - -```js -// dist/commands/hello-world.js -class HelloWorldCommand { - constructor($logger) { - this.$logger = $logger; - this.allowedParameters = []; - } - - async execute(args) { - this.$logger.info(`Hello, ${args[0] || "world"}!`); - } -} - -global.$injector.registerCommand("hello|world", HelloWorldCommand); -``` - -Constructor parameters are injected by name: a parameter called `$logger` -receives the CLI's logger, `$fs` its file system service, and so on. A command -class must expose `allowedParameters` and an `execute(args)` method. +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. ## Command names diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index 48ff78141e..37ec05ef07 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -18,6 +18,7 @@ import { IGetExtensionCommandInfoParams, } from "../common/definitions/extensibility"; import { injector } from "../common/yok"; +import { IInjector } from "../common/definitions/yok"; import { CommandsDelimiters } from "../common/constants"; import { isCommandDefinition } from "../common/define-command"; import { createCommandFromDefinition } from "../common/services/command-definition-adapter"; @@ -77,6 +78,7 @@ export class ExtensibilityService implements IExtensibilityService { private $packageManager: INodePackageManager, private $settingsService: ISettingsService, private $requireService: IRequireService, + private $injector: IInjector, ) {} public async installExtension( @@ -362,12 +364,12 @@ export class ExtensibilityService implements IExtensibilityService { const parentName = commandName.split( CommandsDelimiters.HierarchicalCommand, )[0]; - const container = (injector).di; + const container = (this.$injector).di; const parentWasAbsent = parentName !== commandName && !container.has(`commands.${parentName}`); try { - injector.requireCommand(commandName, absoluteModulePath); + this.$injector.requireCommand(commandName, absoluteModulePath); } catch (err) { const owner = this.manifestCommandOwners[commandName]; const ownerInfo = owner @@ -389,7 +391,7 @@ export class ExtensibilityService implements IExtensibilityService { const exported = require(absoluteModulePath); const candidate = (exported && exported.default) ?? exported; if (isCommandDefinition(candidate)) { - injector.registerCommand(commandName, () => + this.$injector.registerCommand(commandName, () => createCommandFromDefinition(candidate), ); } diff --git a/test/extension-manifests.ts b/test/extension-manifests.ts index 770639702f..4bfd387d44 100644 --- a/test/extension-manifests.ts +++ b/test/extension-manifests.ts @@ -3,11 +3,10 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { ExtensibilityService } from "../lib/services/extensibility-service"; -import { Yok } from "../lib/common/yok"; +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 { ICliGlobal } from "../lib/common/definitions/cli-global"; import { IInjector } from "../lib/common/definitions/yok"; import { IExtensibilityService, @@ -15,11 +14,13 @@ import { } from "../lib/common/definitions/extensibility"; import { IStringDictionary } from "../lib/common/declarations"; -// The service registers commands on the global injector imported by -// lib/common/yok, so every assertion about registered commands goes through it. -// That injector is shared by the whole file, so command names must be unique per -// test - a name reused across tests would collide like a real conflict does. -const cliGlobal = (global); +// 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[]; @@ -32,9 +33,14 @@ 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: [], @@ -48,6 +54,7 @@ describe("extension manifests", () => { }); afterEach(() => { + setGlobalInjector(previousProcessInjector); fs.rmSync(profileDir, { recursive: true, force: true }); delete (global).__nsmCapture; }); @@ -195,7 +202,6 @@ describe("extension manifests", () => { }, ); - const testInjector = getTestInjector(); const extensibilityService = resolveService(testInjector); const extensionData = await extensibilityService.loadExtension(extensionName); @@ -212,7 +218,7 @@ describe("extension manifests", () => { "nsmlazy|clean", ]); - const command = cliGlobal.$injector.resolveCommand("nsmlazy|run"); + const command = testInjector.resolveCommand("nsmlazy|run"); assert.isOk(command); assert.deepStrictEqual(capture.loadedModules, ["lazy-run"]); @@ -244,7 +250,6 @@ describe("extension manifests", () => { }, ); - const testInjector = getTestInjector(); const extensibilityService = resolveService(testInjector); await extensibilityService.loadExtension(extensionName); @@ -253,9 +258,9 @@ describe("extension manifests", () => { assert.include(warnOutput, "nsmbad|empty"); assert.include(warnOutput, extensionName); - assert.isOk(cliGlobal.$injector.resolveCommand("nsmbad|good")); - assert.isNull(cliGlobal.$injector.resolveCommand("nsmbad|number")); - assert.isNull(cliGlobal.$injector.resolveCommand("nsmbad|empty")); + assert.isOk(testInjector.resolveCommand("nsmbad|good")); + assert.isNull(testInjector.resolveCommand("nsmbad|number")); + assert.isNull(testInjector.resolveCommand("nsmbad|empty")); assert.deepStrictEqual(capture.loadedModules, ["malformed-good"]); }); @@ -279,7 +284,6 @@ describe("extension manifests", () => { }, ); - const testInjector = getTestInjector(); const extensibilityService = resolveService(testInjector); await extensibilityService.loadExtension(firstExtension); await extensibilityService.loadExtension(secondExtension); @@ -289,7 +293,7 @@ describe("extension manifests", () => { assert.include(warnOutput, firstExtension); assert.include(warnOutput, secondExtension); - const command = cliGlobal.$injector.resolveCommand("nsmconflict|run"); + const command = testInjector.resolveCommand("nsmconflict|run"); await command.execute([]); assert.deepStrictEqual(capture.executed, [ { marker: "first-run", args: [] }, @@ -309,7 +313,6 @@ describe("extension manifests", () => { }, ); - const testInjector = getTestInjector(); const extensibilityService = resolveService(testInjector); const extensionData = await extensibilityService.loadExtension(extensionName); @@ -325,7 +328,7 @@ describe("extension manifests", () => { assert.include(traceOutput, extensionName); assert.deepStrictEqual(extensionData.commands, ["nsmeager|run"]); - assert.isOk(cliGlobal.$injector.resolveCommand("nsmeager|run")); + assert.isOk(testInjector.resolveCommand("nsmeager|run")); }); it("keeps the eager path when the extension declares no commands", async () => { @@ -336,7 +339,6 @@ describe("extension manifests", () => { { "main.js": mainModule("no-commands-main") }, ); - const testInjector = getTestInjector(); const extensibilityService = resolveService(testInjector); const extensionData = await extensibilityService.loadExtension(extensionName); @@ -357,7 +359,6 @@ describe("extension manifests", () => { writeExtension("nsm-data-array-ext", { commands: ["nsmdata|three"] }, {}); writeExtension("nsm-data-plain-ext", {}, {}); - const testInjector = getTestInjector(); const extensibilityService = resolveService(testInjector); const extensionsData = extensibilityService.getInstalledExtensionsData(); const dataByName: { [name: string]: IExtensionData } = {}; @@ -382,7 +383,6 @@ describe("extension manifests", () => { inputStrings: string[], ): Promise => { const extensionName = "nsm-registry-ext"; - const testInjector = getTestInjector(); const packageManager = testInjector.resolve("packageManager"); packageManager.searchNpms = async (keyword: string): Promise => { assert.equal(keyword, "nativescript:extension"); @@ -485,13 +485,12 @@ describe("extension manifests", () => { }, ); - const testInjector = getTestInjector(); const extensibilityService = resolveService(testInjector); await extensibilityService.loadExtension(extensionName); assert.deepEqual(capture.loadedModules, []); - const command = cliGlobal.$injector.resolveCommand("nsmdef|hello"); + const command = testInjector.resolveCommand("nsmdef|hello"); assert.isOk(command); assert.deepEqual(capture.loadedModules, ["def-hello"]); @@ -512,18 +511,17 @@ describe("extension manifests", () => { }, ); - const testInjector = getTestInjector(); const extensibilityService = resolveService(testInjector); await extensibilityService.loadExtension(extensionName); // Dispatch hits the parent first; its record must load the child module // so the dispatcher the child's registration synthesizes exists. - const parent = cliGlobal.$injector.resolveCommand("nsmdefp"); + const parent = testInjector.resolveCommand("nsmdefp"); assert.isOk(parent); assert.isTrue(parent.isHierarchicalCommand); assert.include(capture.loadedModules, "defp-go"); - const child = cliGlobal.$injector.resolveCommand("nsmdefp|go"); + const child = testInjector.resolveCommand("nsmdefp|go"); assert.isOk(child); }); }); From a69e4dfb3eaa0d136b79692cb7d447f9c40c0a67 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:44:52 -0300 Subject: [PATCH 04/12] refactor(extensions): use the typed di bridge on IInjector --- lib/services/extensibility-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index 37ec05ef07..9c2309c0b3 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -364,7 +364,7 @@ export class ExtensibilityService implements IExtensibilityService { const parentName = commandName.split( CommandsDelimiters.HierarchicalCommand, )[0]; - const container = (this.$injector).di; + const container = this.$injector.di; const parentWasAbsent = parentName !== commandName && !container.has(`commands.${parentName}`); From 137a6cd9ad4974a2a3c795fc85dfa7a90c5cb84b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 23:28:26 -0300 Subject: [PATCH 05/12] refactor(extensions): inject the CommandRegistry face Registry operations go through the narrow subsystem contract; the full facade stays only for container-record operations (has, provider registration). First consumer of the per-face tokens. --- lib/services/extensibility-service.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index 9c2309c0b3..417eaf3481 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -20,6 +20,8 @@ import { import { injector } from "../common/yok"; import { IInjector } from "../common/definitions/yok"; import { CommandsDelimiters } from "../common/constants"; +import { inject } from "../common/di/inject"; +import { CommandRegistry } from "../common/contracts"; import { isCommandDefinition } from "../common/define-command"; import { createCommandFromDefinition } from "../common/services/command-definition-adapter"; @@ -57,6 +59,8 @@ export class ExtensibilityService implements IExtensibilityService { /** Command name -> name of the extension whose manifest claimed it first. */ private manifestCommandOwners: IStringDictionary = {}; + private commandRegistry = inject(CommandRegistry); + private get pathToPackageJson(): string { return path.join(this.pathToExtensions, constants.PACKAGE_JSON_FILE_NAME); } @@ -364,12 +368,12 @@ export class ExtensibilityService implements IExtensibilityService { const parentName = commandName.split( CommandsDelimiters.HierarchicalCommand, )[0]; - const container = this.$injector.di; const parentWasAbsent = - parentName !== commandName && !container.has(`commands.${parentName}`); + parentName !== commandName && + !this.$injector.has(`commands.${parentName}`); try { - this.$injector.requireCommand(commandName, absoluteModulePath); + this.commandRegistry.requireCommand(commandName, absoluteModulePath); } catch (err) { const owner = this.manifestCommandOwners[commandName]; const ownerInfo = owner @@ -391,17 +395,17 @@ export class ExtensibilityService implements IExtensibilityService { const exported = require(absoluteModulePath); const candidate = (exported && exported.default) ?? exported; if (isCommandDefinition(candidate)) { - this.$injector.registerCommand(commandName, () => + this.commandRegistry.registerCommand(commandName, () => createCommandFromDefinition(candidate), ); } }; - container.register({ + this.$injector.register({ provide: `commands.${commandName}`, useLazyRequire: loader, }); if (parentWasAbsent) { - container.register({ + this.$injector.register({ provide: `commands.${parentName}`, useLazyRequire: loader, }); From c57a3e038287c9649739acdc94f6b9cf15796cf6 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 5 Aug 2026 16:41:39 -0300 Subject: [PATCH 06/12] refactor(di): keep the deferred-loader provider internal to the container A record carrying only a lazy-require loader resolves to an error until the loader registers something onto it, so the form is not one callers should be offered: drop ILazyRequireProvider from the exported Provider union and keep it in an InternalProvider alias the container accepts. Add hasResolver() so the deferred paths can tell a record that a loader has filled in from one it left empty. --- lib/common/di/index.ts | 1 + lib/common/di/injector.ts | 24 +++++++++++++++++++++--- lib/common/di/providers.ts | 10 +++++++--- 3 files changed, 29 insertions(+), 6 deletions(-) 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 = ( From b05fa427abdc14168e3e54a304926101651507cc Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 5 Aug 2026 16:41:50 -0300 Subject: [PATCH 07/12] feat(commands): add registerDeferredCommand to the command registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claiming a command name and loading its implementation are now separate: the registry builds routing — the command record, the parent's subcommand list and the parent dispatcher — from the name alone, and runs the loader only when that one command is resolved. A sibling's dispatch no longer drags in the first claimant's module, and the outcome comes back as a structured result instead of a thrown message callers have to match on. Names that are not lower case are rejected: dispatch lower-cases what the user typed, so they could never be reached. A loader that throws, or that leaves the command without a resolver, fails naming the owner and the source. Extract registerDefinitionAs so a definition registered under a name chosen by its registrant is built exactly like one registered under its own. --- lib/common/contracts/command-registry.ts | 50 +++++++++- lib/common/contracts/index.ts | 5 + .../services/command-definition-adapter.ts | 29 ++++-- lib/common/yok.ts | 97 ++++++++++++++++++- 4 files changed, 168 insertions(+), 13 deletions(-) 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/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), }); From c17e1350f36bbea6dab1e9e6d56287599a6c2bc1 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 5 Aug 2026 16:42:06 -0300 Subject: [PATCH 08/12] feat(extensions): route manifest commands through the deferred registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest loader no longer writes injector records or reads exception text to detect conflicts; it hands each entry to registerDeferredCommand and reports the rejection it gets back. A command claimed by another extension names that extension, one the CLI provides says so without exposing internals, and re-loading an already loaded extension is silent rather than a conflict with itself. Entry values may now be an object carrying the module path under `path`, with unrecognised keys ignored, so the shape can grow without stranding manifests on released CLIs. Default commands are registered ahead of their siblings so JSON key order carries no meaning. The manifest key is what the command is dispatched as — routing happens before the module exists — so a definition whose own name disagrees runs under the key and warns naming both, and definitions register through the same helper as registerCommandDefinition. --- lib/services/extensibility-service.ts | 171 ++++++++---- test/extension-manifests.ts | 358 +++++++++++++++++++++++++- 2 files changed, 465 insertions(+), 64 deletions(-) diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index 417eaf3481..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, @@ -22,8 +23,9 @@ import { IInjector } from "../common/definitions/yok"; import { CommandsDelimiters } from "../common/constants"; import { inject } from "../common/di/inject"; import { CommandRegistry } from "../common/contracts"; -import { isCommandDefinition } from "../common/define-command"; -import { createCommandFromDefinition } from "../common/services/command-definition-adapter"; +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; @@ -33,6 +35,44 @@ 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 @@ -56,9 +96,6 @@ function getDeclaredCommandNames( export class ExtensibilityService implements IExtensibilityService { private customPathToExtensions: string = null; - /** Command name -> name of the extension whose manifest claimed it first. */ - private manifestCommandOwners: IStringDictionary = {}; - private commandRegistry = inject(CommandRegistry); private get pathToPackageJson(): string { @@ -316,11 +353,11 @@ export class ExtensibilityService implements IExtensibilityService { /** * Returns the `nativescript.commands` value of an extension only when it is a - * map of command name to module path. Any other shape (the legacy array of + * 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): IStringDictionary { + private getDeclaredCommandsMap(extensionName: string): IDictionary { let commands: any; try { @@ -340,7 +377,7 @@ export class ExtensibilityService implements IExtensibilityService { } /** - * Registers each declared command as a deferred require of its own module, so + * 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 @@ -350,69 +387,93 @@ export class ExtensibilityService implements IExtensibilityService { private registerDeclaredCommands( extensionName: string, pathToExtension: string, - commands: IStringDictionary, + commands: IDictionary, ): void { - for (const commandName of _.keys(commands)) { - const modulePath = commands[commandName]; + // 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) || !isNonEmptyString(modulePath)) { + if (!isNonEmptyString(commandName) || !modulePath) { this.$logger.warn( `Extension ${extensionName} declares an invalid command in its nativescript.commands: '${commandName}': ${JSON.stringify( - modulePath, - )}. Both the command name and the path to its module must be non-empty strings. Skipping this command.`, + 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 parentName = commandName.split( - CommandsDelimiters.HierarchicalCommand, - )[0]; - const parentWasAbsent = - parentName !== commandName && - !this.$injector.has(`commands.${parentName}`); + const result = this.commandRegistry.registerDeferredCommand(commandName, { + owner: extensionName, + source: absoluteModulePath, + load: () => + this.loadDeclaredCommand( + extensionName, + commandName, + absoluteModulePath, + ), + }); - try { - this.commandRegistry.requireCommand(commandName, absoluteModulePath); - } catch (err) { - const owner = this.manifestCommandOwners[commandName]; - const ownerInfo = owner - ? ` It is already registered by extension ${owner}.` - : ""; + if (!result.registered) { this.$logger.warn( - `Extension ${extensionName} is unable to register command ${commandName}.${ownerInfo} Error: ${err.message}`, + `Extension ${extensionName} is unable to register command '${commandName}': ${describeRejection( + result.rejection, + )}.`, ); - continue; } + } + } - // requireCommand's own loader only require()s the module for its side - // effects, which covers self-registering modules but not definition - // exports. The override must also land on a parent record this entry - // just created: dispatch resolves the parent BEFORE any child module - // has loaded, and the parent dispatcher only comes into existence once - // a child's registerCommand runs. - const loader = () => { - const exported = require(absoluteModulePath); - const candidate = (exported && exported.default) ?? exported; - if (isCommandDefinition(candidate)) { - this.commandRegistry.registerCommand(commandName, () => - createCommandFromDefinition(candidate), - ); - } - }; - this.$injector.register({ - provide: `commands.${commandName}`, - useLazyRequire: loader, - }); - if (parentWasAbsent) { - this.$injector.register({ - provide: `commands.${parentName}`, - useLazyRequire: loader, - }); - } + /** + * 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); + } - this.manifestCommandOwners[commandName] = extensionName; + 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 { diff --git a/test/extension-manifests.ts b/test/extension-manifests.ts index 4bfd387d44..ef7c3cf684 100644 --- a/test/extension-manifests.ts +++ b/test/extension-manifests.ts @@ -461,10 +461,14 @@ describe("extension manifests", () => { describe("commands declared as defineCommand modules", () => { const contractsPath = require.resolve("../lib/contracts"); - const definitionModule = (commandName: string, marker: string): string => + const definitionModule = ( + commandName: string, + marker: string, + exportAs: string = "module.exports", + ): string => `const { defineCommand } = require(${JSON.stringify(contractsPath)}); global.__nsmCapture.loadedModules.push(${JSON.stringify(marker)}); - module.exports = defineCommand({ + ${exportAs} = defineCommand({ name: ${JSON.stringify(commandName)}, arguments: "any", async run(ctx) { @@ -500,29 +504,365 @@ describe("extension manifests", () => { ]); }); - it("resolves the hierarchical parent dispatcher before any child module has loaded", async () => { + 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" } }, + { + 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); - // Dispatch hits the parent first; its record must load the child module - // so the dispatcher the child's registration synthesizes exists. const parent = testInjector.resolveCommand("nsmdefp"); assert.isOk(parent); assert.isTrue(parent.isHierarchicalCommand); - assert.include(capture.loadedModules, "defp-go"); + 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 child = testInjector.resolveCommand("nsmdefp|go"); - assert.isOk(child); + 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"]); }); }); }); From 95e5779d4c393c5b3826f337d57fae6f8027d5a8 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 5 Aug 2026 16:42:07 -0300 Subject: [PATCH 09/12] docs(extensions): document the manifest contract and the CLI dependency Lead with the peerDependency + devDependency pair that makes `require("nativescript/contracts")` resolve and keeps a second CLI copy out of the tree, and teach inject() as the way to reach a CLI service. Cover what the manifest actually promises: the key is authoritative for routing, aliases are duplicate entries pointing at one module, entry values may be envelopes, an empty map opts out of loading, keys must be lower case, and "first" in first-wins is the order extensions load in. Drop the JSON key-order constraint, which no longer exists. --- extensions.md | 157 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 132 insertions(+), 25 deletions(-) diff --git a/extensions.md b/extensions.md index b8feb9a035..074d262c07 100644 --- a/extensions.md +++ b/extensions.md @@ -2,8 +2,7 @@ Writing a CLI Extension ======================= An extension adds new commands to the NativeScript CLI. Extensions are ordinary -npm packages: they are published to npm, installed per user rather than per -project, and are available from every project on the machine. +npm packages. ```bash ns extension install @@ -16,20 +15,50 @@ 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. -## Making a package discoverable +## Depending on the CLI -Add the `nativescript:extension` keyword to `package.json`. The CLI searches npm -for that keyword when it needs to suggest an extension for an unknown command -(see [Suggesting an extension](#suggesting-an-extension-for-an-unknown-command)). +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"] + "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. + +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 @@ -48,31 +77,63 @@ extension's `package.json`. Two shapes are accepted. } ``` -Each key is a command name; each value is a path to the module implementing it, -resolved relative to the extension's root directory. +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 +- **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. -* **Early, named conflict detection.** Two extensions claiming the same command + 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 +- **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 or -module path is not a non-empty string is reported as a warning naming the -extension and the offending entry, and the extension's remaining commands are -still registered. +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) @@ -86,10 +147,10 @@ still registered. 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 declaring no commands at all) 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. +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 @@ -117,6 +178,14 @@ module.exports = defineCommand({ }); ``` +`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 @@ -124,17 +193,55 @@ 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`. +`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. -When an extension contributes several commands under the same parent, declare -the default command before its siblings — the CLI creates the parent dispatcher -from the first entry it sees, and a default command registered after that parent -already exists is rejected. +"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 From da7147bf3801b590b40d4d1c88b9e9b3a36ddbd1 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 5 Aug 2026 17:19:56 -0300 Subject: [PATCH 10/12] docs(extensions): note how prerelease CLIs interact with the peer range --- extensions.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extensions.md b/extensions.md index 074d262c07..2c19680b09 100644 --- a/extensions.md +++ b/extensions.md @@ -42,6 +42,10 @@ types — needs `nativescript` declared twice: - 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. From 82309268fd2500885a2e3d13461de619d685ee08 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 6 Aug 2026 17:41:08 -0300 Subject: [PATCH 11/12] fix(commands): reject unreachable subcommands, isolate name-keyed maps A subcommand whose direct parent is already a command of its own can never be dispatched: no dispatcher is built for the parent, because building one would hide it. Registering the child anyway reported success for a name that could not run, so the registry rejects it with a reason naming the parent that took the name. Command names also come from third-party manifests, where a name such as 'constructor' resolved to an inherited member of the name-keyed maps: a false conflict from the deferred owners map, and a TypeError from the hierarchical one. --- lib/common/contracts/command-registry.ts | 7 +- lib/common/yok.ts | 26 ++++-- lib/services/extensibility-service.ts | 2 + test/extension-manifests.ts | 105 +++++++++++++++++++++++ 4 files changed, 134 insertions(+), 6 deletions(-) diff --git a/lib/common/contracts/command-registry.ts b/lib/common/contracts/command-registry.ts index 2f7f628798..d6484f4e7d 100644 --- a/lib/common/contracts/command-registry.ts +++ b/lib/common/contracts/command-registry.ts @@ -26,7 +26,12 @@ export type DeferredCommandRejection = /** The CLI itself provides the command. */ | { reason: "built-in" } /** The name is in use as the dispatcher for subcommands under it. */ - | { reason: "subcommand-parent" }; + | { reason: "subcommand-parent" } + /** + * The name's direct parent is a command of its own, so no dispatcher can be + * built for it and the name could never be reached. + */ + | { reason: "parent-is-command"; parent: string }; /** * Outcome of a deferred registration. Callers branch on `rejection.reason` diff --git a/lib/common/yok.ts b/lib/common/yok.ts index 0f4d77f45d..13642ad417 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -103,9 +103,11 @@ export class Yok extends Injector implements IInjector { */ private placeholderParents = new Set(); private KEY_COMMANDS_NAMESPACE: string = "keyCommands"; - private hierarchicalCommands: IDictionary = {}; + // Keyed by command names, which extensions choose freely: a null prototype + // keeps a name like 'constructor' from reading back as an inherited member. + private hierarchicalCommands: IDictionary = Object.create(null); /** Deferred command name -> the owner that claimed it first. */ - private deferredCommandOwners: IDictionary = {}; + private deferredCommandOwners: IDictionary = Object.create(null); /** * @deprecated Path-based command registration; use registerDeferredCommand, @@ -185,6 +187,22 @@ export class Yok extends Injector implements IInjector { ); } + const commands = name.split(CommandsDelimiters.HierarchicalCommand); + const parentCommandName = commands.length > 1 ? commands[0] : null; + if ( + parentCommandName && + this.has(this.createCommandName(parentCommandName)) && + !this.synthesizedParents.has(parentCommandName) && + !this.placeholderParents.has(parentCommandName) + ) { + // Mirrors createHierarchicalCommand's refusal to overwrite a real + // command: no dispatcher gets created, so this name is unreachable. + return rejected({ + reason: "parent-is-command", + parent: parentCommandName, + }); + } + super.register({ provide: commandRecordName, useLazyRequire: () => { @@ -208,9 +226,7 @@ export class Yok extends Injector implements IInjector { }); this.deferredCommandOwners[name] = options.owner; - const commands = name.split(CommandsDelimiters.HierarchicalCommand); - if (commands.length > 1) { - const parentCommandName = commands[0]; + if (parentCommandName) { const subCommandName = _.tail(commands).join( CommandsDelimiters.HierarchicalCommand, ); diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index e58daff365..fe02c8d3ff 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -70,6 +70,8 @@ function describeRejection(rejection: DeferredCommandRejection): string { return "it is already provided by the CLI"; case "subcommand-parent": return "it is already in use as the parent of its subcommands"; + case "parent-is-command": + return `'${rejection.parent}' is already registered as a command of its own, so the subcommand could never be reached`; } } diff --git a/test/extension-manifests.ts b/test/extension-manifests.ts index ef7c3cf684..de902a81e4 100644 --- a/test/extension-manifests.ts +++ b/test/extension-manifests.ts @@ -714,6 +714,54 @@ describe("extension manifests", () => { assert.deepEqual(capture.loadedModules, []); }); + it("rejects a subcommand whose parent is a command of its own", async () => { + const extensionName = "nsm-parentcmd-ext"; + writeExtension( + extensionName, + { + commands: { + nsmparentcmd: "./flat.js", + "nsmparentcmd|run": "./run.js", + }, + }, + { + "main.js": mainModule("parentcmd-main"), + "flat.js": commandModule("nsmparentcmd", "parentcmd-flat"), + "run.js": commandModule("nsmparentcmd|run", "parentcmd-run"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + const warnOutput = getLogger(testInjector).warnOutput; + assert.include(warnOutput, extensionName); + assert.include(warnOutput, "nsmparentcmd|run"); + assert.include(warnOutput, "already registered as a command of its own"); + assert.notInclude(warnOutput, "no subcommand dispatcher was created"); + + assert.isNull(testInjector.resolveCommand("nsmparentcmd|run")); + assert.isUndefined( + testInjector.getChildrenCommandsNames("nsmparentcmd"), + "The rejected subcommand must not be recorded under its parent.", + ); + + const command = testInjector.resolveCommand("nsmparentcmd"); + assert.isNotOk(command.isHierarchicalCommand); + await command.execute([]); + assert.deepEqual(capture.executed, [ + { marker: "parentcmd-flat", args: [] }, + ]); + + // Ownership of a rejected name is not recorded, so the same extension + // is told again rather than treated as the owner on the next load. + await extensibilityService.loadExtension(extensionName); + assert.equal( + getLogger(testInjector).warnOutput.split("nsmparentcmd|run").length - 1, + 2, + ); + }); + it("reports a command the CLI itself provides without naming internals", async () => { const extensionName = "nsm-builtin-ext"; class BuiltInCommand { @@ -749,6 +797,63 @@ describe("extension manifests", () => { }); }); + describe("manifest keys that name an Object prototype member", () => { + it("registers a flat command named after a prototype member", async () => { + const extensionName = "nsm-proto-flat-ext"; + writeExtension( + extensionName, + { commands: { constructor: "./run.js" } }, + { + "main.js": mainModule("proto-flat-main"), + "run.js": commandModule("constructor", "proto-flat-run"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + assert.equal(getLogger(testInjector).warnOutput, ""); + + const command = testInjector.resolveCommand("constructor"); + assert.isOk(command); + await command.execute([]); + assert.deepEqual(capture.executed, [ + { marker: "proto-flat-run", args: [] }, + ]); + }); + + it("routes a subcommand whose parent names a prototype member", async () => { + const extensionName = "nsm-proto-parent-ext"; + writeExtension( + extensionName, + { commands: { "constructor|run": "./run.js" } }, + { + "main.js": mainModule("proto-parent-main"), + "run.js": commandModule("constructor|run", "proto-parent-run"), + }, + ); + + const extensibilityService = resolveService(testInjector); + await extensibilityService.loadExtension(extensionName); + + assert.equal(getLogger(testInjector).warnOutput, ""); + assert.deepEqual(testInjector.getChildrenCommandsNames("constructor"), [ + "run", + ]); + + const parent = testInjector.resolveCommand("constructor"); + assert.isTrue(parent.isHierarchicalCommand); + assert.deepEqual(capture.loadedModules, []); + + const command = testInjector.resolveCommand("constructor|run"); + assert.isOk(command); + await command.execute([]); + assert.deepEqual(capture.executed, [ + { marker: "proto-parent-run", args: [] }, + ]); + }); + }); + describe("loading an already loaded extension", () => { it("does not report the extension as conflicting with itself", async () => { const extensionName = "nsm-reload-ext"; From 945528ab7076fe0944d315fa21076c833fa4923f Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 6 Aug 2026 17:41:14 -0300 Subject: [PATCH 12/12] docs(extensions): use setext headings and label the output block --- extensions.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/extensions.md b/extensions.md index 2c19680b09..93f882f1fb 100644 --- a/extensions.md +++ b/extensions.md @@ -15,7 +15,8 @@ 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 +Depending on the CLI +-------------------- An extension that imports anything from the CLI — `defineCommand`, `inject`, the types — needs `nativescript` declared twice: @@ -63,7 +64,8 @@ searches npm for it when it needs to suggest an extension for an unknown command > heading; declaring the peer dependency now is what makes an extension ready > for it. -## Declaring commands +Declaring commands +------------------ Commands are declared in the `commands` key of the `nativescript` key of the extension's `package.json`. Two shapes are accepted. @@ -162,7 +164,8 @@ 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 +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 @@ -202,7 +205,8 @@ 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 +------------- Command names use `|` to express hierarchy, so `"hello|world"` is invoked as `ns hello world`. Prefixing the last segment with `*` marks a default @@ -233,7 +237,8 @@ An alias is a second entry pointing at the same module: Both names route to the same module, which is loaded once. -## When two extensions want the same command +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 @@ -247,7 +252,8 @@ 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 +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` @@ -260,12 +266,13 @@ default command also matches its short form: an extension declaring 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: -``` +```text The command hello world is registered in extension nativescript-hello. You can install it by executing 'ns extension install nativescript-hello' ``` -## Documentation +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.