From b8798e22bf12f0553f0925fef66d30b9feededa6 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 21:59:53 -0300 Subject: [PATCH 1/5] 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 080978ee2d702cf7ec105a18e186cd3c4538df8c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:08:47 -0300 Subject: [PATCH 2/5] 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 fa0143123b..29b95535b8 100644 --- a/defining-commands.md +++ b/defining-commands.md @@ -226,9 +226,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 e645ebdf56..d611fc850f 100644 --- a/dependency-injection.md +++ b/dependency-injection.md @@ -258,6 +258,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 157650720390a93078096fabca461c0b3bd05b7b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:41:00 -0300 Subject: [PATCH 3/5] 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 9eff3ef9fa1becb99ef1c4c0172ae94baf7bc288 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:44:52 -0300 Subject: [PATCH 4/5] 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 10aaa87c4d1d0568a4e62bc3f2b08f25d79ed901 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 23:28:26 -0300 Subject: [PATCH 5/5] 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, });