Skip to content

Commit 9322fdc

Browse files
authored
feat(extensions): nativescript.commands map - per-command lazy loading for extensions (#6102)
1 parent 2f81d7f commit 9322fdc

13 files changed

Lines changed: 1738 additions & 33 deletions

defining-commands.md

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

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

329330
Relationship to `ICommand`
330331
--------------------------

dependency-injection.md

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

268+
Related guides
269+
--------------
270+
271+
- [defining-commands.md](defining-commands.md) — declarative, typed commands via `defineCommand`.
272+
- [extensions.md](extensions.md) — extension authoring, including the `nativescript.commands` manifest.
273+
- [extending-cli.md](extending-cli.md) — hooks, including the typed `defineHook` API.
274+
268275
Legacy → new quick reference
269276
----------------------------
270277

extensions.md

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
Writing a CLI Extension
2+
=======================
3+
4+
An extension adds new commands to the NativeScript CLI. Extensions are ordinary
5+
npm packages.
6+
7+
```bash
8+
ns extension install <package-name>
9+
ns extension uninstall <package-name>
10+
```
11+
12+
Installed extensions live in the CLI profile directory, under
13+
`extensions/node_modules/<package-name>`, and every CLI invocation consults each
14+
of them. That makes the manifest below the most important file in an extension:
15+
it is what the CLI reads on startup, and it decides whether your code is loaded
16+
eagerly or only when one of your commands is actually executed.
17+
18+
Depending on the CLI
19+
--------------------
20+
21+
An extension that imports anything from the CLI — `defineCommand`, `inject`, the
22+
types — needs `nativescript` declared twice:
23+
24+
```json
25+
{
26+
"name": "nativescript-hello",
27+
"version": "1.0.0",
28+
"keywords": ["nativescript:extension"],
29+
"peerDependencies": {
30+
"nativescript": ">=9.1.0"
31+
},
32+
"devDependencies": {
33+
"nativescript": "^9.1.0"
34+
}
35+
}
36+
```
37+
38+
- The **peer dependency** declares which CLI versions the extension works with,
39+
and keeps package managers from installing a second copy of the CLI next to
40+
your extension. Your code must run against the _running_ CLI: a second copy
41+
brings its own injector, and services resolved from it are not the ones
42+
executing the command.
43+
- The **dev dependency** is what makes `require("nativescript/contracts")`
44+
resolve while you build and test the extension. It is not installed for your
45+
users.
46+
- Developing against a **prerelease** CLI? Semver ranges without a prerelease
47+
tag never match one — `9.1.0-alpha.15` does not satisfy `>=9.1.0` — so pin
48+
the exact prerelease as your dev dependency and keep the stable floor in
49+
`peerDependencies`.
50+
51+
Never declare `nativescript` as a plain dependency.
52+
53+
`nativescript/contracts` is the entry point extensions import from. It is
54+
side-effect free — importing it does not boot a CLI — and it exports
55+
`defineCommand`, `inject`, the option helpers and the public types.
56+
57+
The `nativescript:extension` keyword makes the package discoverable: the CLI
58+
searches npm for it when it needs to suggest an extension for an unknown command
59+
(see [Suggesting an extension](#suggesting-an-extension-for-an-unknown-command)).
60+
61+
> Extensions are installed per user today, and are available from every project
62+
> on the machine. Installing them as project `devDependencies` — pinned per
63+
> project, reproducible in CI, shared with the team — is the direction this is
64+
> heading; declaring the peer dependency now is what makes an extension ready
65+
> for it.
66+
67+
Declaring commands
68+
------------------
69+
70+
Commands are declared in the `commands` key of the `nativescript` key of the
71+
extension's `package.json`. Two shapes are accepted.
72+
73+
### A map of command name to module (recommended)
74+
75+
```json
76+
{
77+
"nativescript": {
78+
"commands": {
79+
"hello|world": "./dist/commands/hello-world.js",
80+
"hello|*default": "./dist/commands/hello.js"
81+
}
82+
}
83+
}
84+
```
85+
86+
Each key is a command name; each value says where the module implementing it
87+
lives, resolved relative to the extension's root directory. A value is either
88+
the path itself or an object carrying it under `path`:
89+
90+
```json
91+
{
92+
"nativescript": {
93+
"commands": {
94+
"hello|world": { "path": "./dist/commands/hello-world.js" }
95+
}
96+
}
97+
}
98+
```
99+
100+
The two forms mean exactly the same thing today. Keys the CLI does not
101+
recognise inside the object form are ignored, so the object can carry
102+
information a later CLI understands without breaking the one you have
103+
installed.
104+
105+
Declaring commands this way is strongly preferred:
106+
107+
- **Per-command lazy loading.** Nothing in the extension is loaded when the CLI
108+
starts. A command's module is required the first time that command is
109+
resolved, so `ns build android` never pays the cost of loading an unrelated
110+
extension. With a large or dependency-heavy extension installed, that is the
111+
difference between a noticeable startup delay on every command and none.
112+
Dispatching `ns hello world` loads only `hello-world.js` — not the sibling
113+
`hello.js`, and not the extension's main entry.
114+
- **Early, named conflict detection.** Two extensions claiming the same command
115+
name is reported as a warning that names both extensions and the contested
116+
command, and the extension that claimed it first keeps working. Under the
117+
legacy shape the same collision surfaces as an opaque
118+
`module '...' require'd twice.` failure from whichever extension happened to
119+
load second.
120+
- **The CLI knows what you contribute without running you.** The declared
121+
command names are what the install suggestion for an unknown command matches
122+
against, and they are available to the CLI as metadata about the installed
123+
extension.
124+
125+
Malformed entries are skipped rather than fatal: an entry whose command name is
126+
not a non-empty string, or whose value carries no usable module path, is
127+
reported as a warning naming the extension and the offending entry, and the
128+
extension's remaining commands are still registered.
129+
130+
An empty map opts out of loading entirely:
131+
132+
```json
133+
{
134+
"nativescript": {
135+
"commands": {}
136+
}
137+
}
138+
```
139+
140+
The extension contributes no commands, and — unlike omitting the key — its main
141+
entry is never required. Use it for an extension that only ships documentation
142+
or assets.
143+
144+
### An array of command names (legacy)
145+
146+
```json
147+
{
148+
"nativescript": {
149+
"commands": ["hello|world", "hello|*default"]
150+
}
151+
}
152+
```
153+
154+
The array is a discovery aid only — it lists the names the CLI may suggest your
155+
extension for, but it says nothing about where the implementations live. An
156+
extension declaring commands this way (or omitting the `commands` key
157+
altogether) is loaded the old way: the CLI `require()`s the package's main entry
158+
on **every** invocation and expects the module's top-level code to register
159+
everything through the injector global.
160+
161+
This path remains supported for published extensions, but it is tracked for
162+
eventual deprecation and new extensions should not use it — declare the map
163+
instead. Run any command with `--log trace` to see which installed extensions
164+
still rely on it, or set `NS_DEPRECATIONS=warn` to have those reports printed
165+
as warnings.
166+
167+
Writing a command module
168+
------------------------
169+
170+
The recommended shape is a module exporting a `defineCommand` definition (see
171+
[defining-commands.md](defining-commands.md)) — the CLI adapts and registers it
172+
under the manifest key when the command is first executed, and the module needs
173+
no registration side effects at all:
174+
175+
```js
176+
// dist/commands/hello-world.js
177+
const { defineCommand, inject } = require("nativescript/contracts");
178+
179+
module.exports = defineCommand({
180+
name: "hello|world",
181+
arguments: "any",
182+
async run(ctx) {
183+
inject("logger").info(`Hello, ${ctx.args[0] || "world"}!`);
184+
},
185+
});
186+
```
187+
188+
`inject()` resolves a CLI service against the injector running the command, and
189+
works anywhere inside `run` up to the first `await`. It is why the peer
190+
dependency above matters: with a second copy of the CLI installed alongside your
191+
extension, `inject()` warns and points at the duplicate.
192+
193+
A definition exported as `module.exports.default` (what a TypeScript or ESM
194+
build emits) is picked up too.
195+
196+
Legacy modules — command classes that register themselves at load time through
197+
the injector global, with parameter-name constructor injection — keep working
198+
when a manifest entry points at them, so existing extensions can adopt the map
199+
without rewriting their commands. Both of those mechanisms are deprecated
200+
(see [dependency-injection.md](dependency-injection.md)); write new modules as
201+
definitions.
202+
203+
If a module named by a manifest entry neither exports a definition nor registers
204+
the command itself, executing that command fails with an error naming the
205+
extension, the command and the module — the entry points at the wrong file, or
206+
the file is not doing what the entry promises.
207+
208+
Command names
209+
-------------
210+
211+
Command names use `|` to express hierarchy, so `"hello|world"` is invoked as
212+
`ns hello world`. Prefixing the last segment with `*` marks a default
213+
subcommand: `"hello|*default"` runs both for `ns hello default` and for a bare
214+
`ns hello`. Names must be lower case — the CLI matches what the user typed in
215+
lower case, so a key with an upper-case letter could never be reached, and is
216+
rejected with a warning.
217+
218+
**The manifest key decides how a command is invoked.** It has to: the CLI routes
219+
`ns hello world` to your module before that module has been loaded, so the key
220+
is the only name it can know. A `name` inside the definition is metadata — it is
221+
what `registerCommandDefinition` uses when a module registers itself, and it is
222+
useful documentation, but a manifest entry overrides it. If the two disagree the
223+
CLI warns, naming both, and runs the command under the manifest key.
224+
225+
An alias is a second entry pointing at the same module:
226+
227+
```json
228+
{
229+
"nativescript": {
230+
"commands": {
231+
"hello|world": "./dist/commands/hello-world.js",
232+
"hello|w": "./dist/commands/hello-world.js"
233+
}
234+
}
235+
}
236+
```
237+
238+
Both names route to the same module, which is loaded once.
239+
240+
When two extensions want the same command
241+
-----------------------------------------
242+
243+
The first extension to claim a command name keeps it; later claimants are
244+
reported with a warning naming both extensions and the command, and their entry
245+
is skipped. A name the CLI itself provides is never taken over — the extension
246+
is told the command is already provided by the CLI.
247+
248+
"First" is the order extensions are loaded in, which is the order they appear in
249+
the `dependencies` of the profile directory's `extensions/package.json` — npm
250+
keeps that alphabetically sorted, so in practice the alphabetically first
251+
extension name wins. The exception is `ns extension install`: that invocation
252+
loads the freshly installed extension after all the others, so a conflict it
253+
would win on the next invocation goes the other way that one time.
254+
255+
Suggesting an extension for an unknown command
256+
----------------------------------------------
257+
258+
When a user types a command the CLI does not know, it searches npm for packages
259+
carrying the `nativescript:extension` keyword, reads the `nativescript.commands`
260+
key of each candidate's published `package.json`, and matches it against the
261+
words the user typed — longest match first, so `ns valid command with args`
262+
matches a declared `valid|command|with` before `valid|command`. A declared
263+
default command also matches its short form: an extension declaring
264+
`hello|*default` is suggested for a bare `ns hello`.
265+
266+
Both manifest shapes participate in this matching. If a match is found, the CLI
267+
tells the user which extension provides the command and how to install it:
268+
269+
```text
270+
The command hello world is registered in extension nativescript-hello.
271+
You can install it by executing 'ns extension install nativescript-hello'
272+
```
273+
274+
Documentation
275+
-------------
276+
277+
Point the `docs` key of the `nativescript` key at a directory of `.md` files to
278+
have the CLI's help system pick up the help for your commands.
279+
280+
```json
281+
{
282+
"nativescript": {
283+
"docs": "./docs",
284+
"commands": {
285+
"hello|world": "./dist/commands/hello-world.js"
286+
}
287+
}
288+
}
289+
```

lib/common/contracts/command-registry.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,48 @@
11
import { Contract } from "../di/contract";
22
import type { ICommand } from "../definitions/commands";
33

4+
export interface DeferredCommandOptions {
5+
/**
6+
* Names the registrant in conflict and failure reports. Re-registering the
7+
* same command under the same owner is a no-op rather than a conflict.
8+
*/
9+
owner: string;
10+
/** Where the implementation comes from; named when loading it fails. */
11+
source: string;
12+
/**
13+
* Runs on first resolution of the command. It must leave a real resolver on
14+
* the command name — by exporting a definition the caller registers, or by
15+
* registering the command itself.
16+
*/
17+
load: () => void;
18+
}
19+
20+
/** Why a deferred registration did not take effect. */
21+
export type DeferredCommandRejection =
22+
/** The name can never be dispatched; `detail` says why. */
23+
| { reason: "invalid-name"; detail: string }
24+
/** Another owner registered the same command first. */
25+
| { reason: "claimed"; owner: string }
26+
/** The CLI itself provides the command. */
27+
| { reason: "built-in" }
28+
/** The name is in use as the dispatcher for subcommands under it. */
29+
| { reason: "subcommand-parent" }
30+
/**
31+
* The name's direct parent is a command of its own, so no dispatcher can be
32+
* built for it and the name could never be reached.
33+
*/
34+
| { reason: "parent-is-command"; parent: string };
35+
36+
/**
37+
* Outcome of a deferred registration. Callers branch on `rejection.reason`
38+
* rather than on message text, so the wording of the report stays theirs.
39+
*/
40+
export interface DeferredCommandResult {
41+
registered: boolean;
42+
/** Set exactly when `registered` is false. */
43+
rejection?: DeferredCommandRejection;
44+
}
45+
446
/**
547
* The command-registry face of the injector facade. Transitional contract: it
648
* mirrors what consumers call today, so that extracting the registry from the
@@ -10,11 +52,20 @@ import type { ICommand } from "../definitions/commands";
1052
@Contract({ name: "commandRegistry" })
1153
export abstract class CommandRegistry {
1254
/**
13-
* @deprecated Path-based command registration; slated for replacement by
14-
* manifest-declared commands.
55+
* @deprecated Path-based command registration; use registerDeferredCommand,
56+
* which routes without loading and reports conflicts structurally.
1557
*/
1658
abstract requireCommand(names: string | string[], file: string): void;
1759
abstract registerCommand(names: string | string[], resolver: any): void;
60+
/**
61+
* Claims a command name for an owner without loading anything: routing —
62+
* including the dispatcher of a hierarchical parent — is built from the name
63+
* alone, and `load` runs only when that one command is resolved.
64+
*/
65+
abstract registerDeferredCommand(
66+
name: string,
67+
options: DeferredCommandOptions,
68+
): DeferredCommandResult;
1869
abstract resolveCommand(name: string): ICommand;
1970
abstract getRegisteredCommandsNames(includeDev: boolean): string[];
2071
abstract getChildrenCommandsNames(commandName: string): string[];

0 commit comments

Comments
 (0)