Skip to content

Commit 220e027

Browse files
committed
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.
1 parent ac3c744 commit 220e027

5 files changed

Lines changed: 142 additions & 10 deletions

File tree

defining-commands.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,10 @@ side-effect-free contracts entry point deliberately does not pull it in.
226226
`defineCommand`, the option helpers and all the types are exported from both
227227
`nativescript/contracts` and `lib/common/define-command`.
228228

229-
Declaring commands from an extension manifest, so that an extension does not
230-
have to call a registration function at load time, is being added separately.
231-
Until then, extensions register definitions the same way the CLI does.
229+
Extensions do not need `registerCommandDefinition` at all: a
230+
`nativescript.commands` manifest entry may point straight at a module that
231+
exports a definition, and the CLI adapts and registers it lazily under the
232+
manifest key (see [extensions.md](extensions.md)).
232233

233234
Relationship to `ICommand`
234235
--------------------------

dependency-injection.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,13 @@ The first tranche, growing as services migrate:
241241
| `DoctorService` | `doctorService` |
242242
| `ProjectNameService` | `projectNameService` |
243243

244+
Related guides
245+
--------------
246+
247+
- [defining-commands.md](defining-commands.md) — declarative, typed commands via `defineCommand`.
248+
- [extensions.md](extensions.md) — extension authoring, including the `nativescript.commands` manifest.
249+
- [extending-cli.md](extending-cli.md) — hooks, including the typed `defineHook` API.
250+
244251
Legacy → new quick reference
245252
----------------------------
246253

extensions.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,25 @@ or set `NS_DEPRECATIONS=warn` to have those reports printed as warnings.
106106

107107
## Writing a command module
108108

109-
A command module must register itself when it is loaded, by calling
109+
The recommended shape is a module exporting a `defineCommand` definition (see
110+
[defining-commands.md](defining-commands.md)) — the CLI adapts and registers it
111+
under the manifest key when the command is first executed, and the module needs
112+
no registration side effects at all:
113+
114+
```js
115+
// dist/commands/hello-world.js
116+
const { defineCommand, inject } = require("nativescript/contracts");
117+
118+
module.exports = defineCommand({
119+
name: "hello|world",
120+
arguments: "any",
121+
async run(ctx) {
122+
inject("logger").info(`Hello, ${ctx.args[0] || "world"}!`);
123+
},
124+
});
125+
```
126+
127+
Alternatively, a module may register itself when it is loaded, by calling
110128
`$injector.registerCommand` at the top level with the same name it is declared
111129
under in the manifest. The CLI resolves the command through the injector right
112130
after loading the module, so registration has to happen as a side effect of the

lib/services/extensibility-service.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import {
1818
IGetExtensionCommandInfoParams,
1919
} from "../common/definitions/extensibility";
2020
import { injector } from "../common/yok";
21+
import { CommandsDelimiters } from "../common/constants";
22+
import { isCommandDefinition } from "../common/define-command";
23+
import { createCommandFromDefinition } from "../common/services/command-definition-adapter";
2124

2225
function isNonEmptyString(value: any): boolean {
2326
return typeof value === "string" && value.trim().length > 0;
@@ -333,8 +336,10 @@ export class ExtensibilityService implements IExtensibilityService {
333336
/**
334337
* Registers each declared command as a deferred require of its own module, so
335338
* nothing from the extension is loaded until one of its commands is executed.
336-
* Each module is expected to register itself on load, by calling
337-
* `$injector.registerCommand(<name>, <class>)` at the top level.
339+
* A module may either register itself on load (a legacy-style
340+
* `$injector.registerCommand(<name>, <class>)` at the top level) or export a
341+
* `defineCommand` definition, which the deferred loader adapts and registers
342+
* under the manifest key.
338343
*/
339344
private registerDeclaredCommands(
340345
extensionName: string,
@@ -353,11 +358,16 @@ export class ExtensibilityService implements IExtensibilityService {
353358
continue;
354359
}
355360

361+
const absoluteModulePath = path.join(pathToExtension, modulePath);
362+
const parentName = commandName.split(
363+
CommandsDelimiters.HierarchicalCommand,
364+
)[0];
365+
const container = (<any>injector).di;
366+
const parentWasAbsent =
367+
parentName !== commandName && !container.has(`commands.${parentName}`);
368+
356369
try {
357-
injector.requireCommand(
358-
commandName,
359-
path.join(pathToExtension, modulePath),
360-
);
370+
injector.requireCommand(commandName, absoluteModulePath);
361371
} catch (err) {
362372
const owner = this.manifestCommandOwners[commandName];
363373
const ownerInfo = owner
@@ -369,6 +379,32 @@ export class ExtensibilityService implements IExtensibilityService {
369379
continue;
370380
}
371381

382+
// requireCommand's own loader only require()s the module for its side
383+
// effects, which covers self-registering modules but not definition
384+
// exports. The override must also land on a parent record this entry
385+
// just created: dispatch resolves the parent BEFORE any child module
386+
// has loaded, and the parent dispatcher only comes into existence once
387+
// a child's registerCommand runs.
388+
const loader = () => {
389+
const exported = require(absoluteModulePath);
390+
const candidate = (exported && exported.default) ?? exported;
391+
if (isCommandDefinition(candidate)) {
392+
injector.registerCommand(commandName, () =>
393+
createCommandFromDefinition(<any>candidate),
394+
);
395+
}
396+
};
397+
container.register({
398+
provide: `commands.${commandName}`,
399+
useLazyRequire: loader,
400+
});
401+
if (parentWasAbsent) {
402+
container.register({
403+
provide: `commands.${parentName}`,
404+
useLazyRequire: loader,
405+
});
406+
}
407+
372408
this.manifestCommandOwners[commandName] = extensionName;
373409
}
374410
}

test/extension-manifests.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,4 +457,74 @@ describe("extension manifests", () => {
457457
assert.isNull(result);
458458
});
459459
});
460+
461+
describe("commands declared as defineCommand modules", () => {
462+
const contractsPath = require.resolve("../lib/contracts");
463+
464+
const definitionModule = (commandName: string, marker: string): string =>
465+
`const { defineCommand } = require(${JSON.stringify(contractsPath)});
466+
global.__nsmCapture.loadedModules.push(${JSON.stringify(marker)});
467+
module.exports = defineCommand({
468+
name: ${JSON.stringify(commandName)},
469+
arguments: "any",
470+
async run(ctx) {
471+
global.__nsmCapture.executed.push({ marker: ${JSON.stringify(
472+
marker,
473+
)}, args: ctx.args });
474+
},
475+
});`;
476+
477+
it("adapts and registers a pure definition module lazily", async () => {
478+
const extensionName = "nsm-def-ext";
479+
writeExtension(
480+
extensionName,
481+
{ commands: { "nsmdef|hello": "./dist/hello.js" } },
482+
{
483+
"main.js": mainModule("def-main"),
484+
"dist/hello.js": definitionModule("nsmdef|hello", "def-hello"),
485+
},
486+
);
487+
488+
const testInjector = getTestInjector();
489+
const extensibilityService = resolveService(testInjector);
490+
await extensibilityService.loadExtension(extensionName);
491+
492+
assert.deepEqual(capture.loadedModules, []);
493+
494+
const command = cliGlobal.$injector.resolveCommand("nsmdef|hello");
495+
assert.isOk(command);
496+
assert.deepEqual(capture.loadedModules, ["def-hello"]);
497+
498+
await command.execute(["fast"]);
499+
assert.deepEqual(capture.executed, [
500+
{ marker: "def-hello", args: ["fast"] },
501+
]);
502+
});
503+
504+
it("resolves the hierarchical parent dispatcher before any child module has loaded", async () => {
505+
const extensionName = "nsm-defp-ext";
506+
writeExtension(
507+
extensionName,
508+
{ commands: { "nsmdefp|go": "./dist/go.js" } },
509+
{
510+
"main.js": mainModule("defp-main"),
511+
"dist/go.js": definitionModule("nsmdefp|go", "defp-go"),
512+
},
513+
);
514+
515+
const testInjector = getTestInjector();
516+
const extensibilityService = resolveService(testInjector);
517+
await extensibilityService.loadExtension(extensionName);
518+
519+
// Dispatch hits the parent first; its record must load the child module
520+
// so the dispatcher the child's registration synthesizes exists.
521+
const parent = <any>cliGlobal.$injector.resolveCommand("nsmdefp");
522+
assert.isOk(parent);
523+
assert.isTrue(parent.isHierarchicalCommand);
524+
assert.include(capture.loadedModules, "defp-go");
525+
526+
const child = cliGlobal.$injector.resolveCommand("nsmdefp|go");
527+
assert.isOk(child);
528+
});
529+
});
460530
});

0 commit comments

Comments
 (0)