Skip to content

Commit bcd08f0

Browse files
committed
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.
1 parent e9f5b9e commit bcd08f0

1 file changed

Lines changed: 132 additions & 25 deletions

File tree

extensions.md

Lines changed: 132 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ Writing a CLI Extension
22
=======================
33

44
An extension adds new commands to the NativeScript CLI. Extensions are ordinary
5-
npm packages: they are published to npm, installed per user rather than per
6-
project, and are available from every project on the machine.
5+
npm packages.
76

87
```bash
98
ns extension install <package-name>
@@ -16,20 +15,50 @@ of them. That makes the manifest below the most important file in an extension:
1615
it is what the CLI reads on startup, and it decides whether your code is loaded
1716
eagerly or only when one of your commands is actually executed.
1817

19-
## Making a package discoverable
18+
## Depending on the CLI
2019

21-
Add the `nativescript:extension` keyword to `package.json`. The CLI searches npm
22-
for that keyword when it needs to suggest an extension for an unknown command
23-
(see [Suggesting an extension](#suggesting-an-extension-for-an-unknown-command)).
20+
An extension that imports anything from the CLI — `defineCommand`, `inject`, the
21+
types — needs `nativescript` declared twice:
2422

2523
```json
2624
{
2725
"name": "nativescript-hello",
2826
"version": "1.0.0",
29-
"keywords": ["nativescript:extension"]
27+
"keywords": ["nativescript:extension"],
28+
"peerDependencies": {
29+
"nativescript": ">=9.1.0"
30+
},
31+
"devDependencies": {
32+
"nativescript": "^9.1.0"
33+
}
3034
}
3135
```
3236

37+
- The **peer dependency** declares which CLI versions the extension works with,
38+
and keeps package managers from installing a second copy of the CLI next to
39+
your extension. Your code must run against the _running_ CLI: a second copy
40+
brings its own injector, and services resolved from it are not the ones
41+
executing the command.
42+
- The **dev dependency** is what makes `require("nativescript/contracts")`
43+
resolve while you build and test the extension. It is not installed for your
44+
users.
45+
46+
Never declare `nativescript` as a plain dependency.
47+
48+
`nativescript/contracts` is the entry point extensions import from. It is
49+
side-effect free — importing it does not boot a CLI — and it exports
50+
`defineCommand`, `inject`, the option helpers and the public types.
51+
52+
The `nativescript:extension` keyword makes the package discoverable: the CLI
53+
searches npm for it when it needs to suggest an extension for an unknown command
54+
(see [Suggesting an extension](#suggesting-an-extension-for-an-unknown-command)).
55+
56+
> Extensions are installed per user today, and are available from every project
57+
> on the machine. Installing them as project `devDependencies` — pinned per
58+
> project, reproducible in CI, shared with the team — is the direction this is
59+
> heading; declaring the peer dependency now is what makes an extension ready
60+
> for it.
61+
3362
## Declaring commands
3463

3564
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.
4877
}
4978
```
5079

51-
Each key is a command name; each value is a path to the module implementing it,
52-
resolved relative to the extension's root directory.
80+
Each key is a command name; each value says where the module implementing it
81+
lives, resolved relative to the extension's root directory. A value is either
82+
the path itself or an object carrying it under `path`:
83+
84+
```json
85+
{
86+
"nativescript": {
87+
"commands": {
88+
"hello|world": { "path": "./dist/commands/hello-world.js" }
89+
}
90+
}
91+
}
92+
```
93+
94+
The two forms mean exactly the same thing today. Keys the CLI does not
95+
recognise inside the object form are ignored, so the object can carry
96+
information a later CLI understands without breaking the one you have
97+
installed.
5398

5499
Declaring commands this way is strongly preferred:
55100

56-
* **Per-command lazy loading.** Nothing in the extension is loaded when the CLI
101+
- **Per-command lazy loading.** Nothing in the extension is loaded when the CLI
57102
starts. A command's module is required the first time that command is
58103
resolved, so `ns build android` never pays the cost of loading an unrelated
59104
extension. With a large or dependency-heavy extension installed, that is the
60105
difference between a noticeable startup delay on every command and none.
61-
* **Early, named conflict detection.** Two extensions claiming the same command
106+
Dispatching `ns hello world` loads only `hello-world.js` — not the sibling
107+
`hello.js`, and not the extension's main entry.
108+
- **Early, named conflict detection.** Two extensions claiming the same command
62109
name is reported as a warning that names both extensions and the contested
63110
command, and the extension that claimed it first keeps working. Under the
64111
legacy shape the same collision surfaces as an opaque
65112
`module '...' require'd twice.` failure from whichever extension happened to
66113
load second.
67-
* **The CLI knows what you contribute without running you.** The declared
114+
- **The CLI knows what you contribute without running you.** The declared
68115
command names are what the install suggestion for an unknown command matches
69116
against, and they are available to the CLI as metadata about the installed
70117
extension.
71118

72-
Malformed entries are skipped rather than fatal: an entry whose command name or
73-
module path is not a non-empty string is reported as a warning naming the
74-
extension and the offending entry, and the extension's remaining commands are
75-
still registered.
119+
Malformed entries are skipped rather than fatal: an entry whose command name is
120+
not a non-empty string, or whose value carries no usable module path, is
121+
reported as a warning naming the extension and the offending entry, and the
122+
extension's remaining commands are still registered.
123+
124+
An empty map opts out of loading entirely:
125+
126+
```json
127+
{
128+
"nativescript": {
129+
"commands": {}
130+
}
131+
}
132+
```
133+
134+
The extension contributes no commands, and — unlike omitting the key — its main
135+
entry is never required. Use it for an extension that only ships documentation
136+
or assets.
76137

77138
### An array of command names (legacy)
78139

@@ -86,10 +147,10 @@ still registered.
86147

87148
The array is a discovery aid only — it lists the names the CLI may suggest your
88149
extension for, but it says nothing about where the implementations live. An
89-
extension declaring commands this way (or declaring no commands at all) is
90-
loaded the old way: the CLI `require()`s the package's main entry on **every**
91-
invocation and expects the module's top-level code to register everything
92-
through the injector global.
150+
extension declaring commands this way (or omitting the `commands` key
151+
altogether) is loaded the old way: the CLI `require()`s the package's main entry
152+
on **every** invocation and expects the module's top-level code to register
153+
everything through the injector global.
93154

94155
This path remains supported for published extensions, but it is tracked for
95156
eventual deprecation and new extensions should not use it — declare the map
@@ -117,24 +178,70 @@ module.exports = defineCommand({
117178
});
118179
```
119180

181+
`inject()` resolves a CLI service against the injector running the command, and
182+
works anywhere inside `run` up to the first `await`. It is why the peer
183+
dependency above matters: with a second copy of the CLI installed alongside your
184+
extension, `inject()` warns and points at the duplicate.
185+
186+
A definition exported as `module.exports.default` (what a TypeScript or ESM
187+
build emits) is picked up too.
188+
120189
Legacy modules — command classes that register themselves at load time through
121190
the injector global, with parameter-name constructor injection — keep working
122191
when a manifest entry points at them, so existing extensions can adopt the map
123192
without rewriting their commands. Both of those mechanisms are deprecated
124193
(see [dependency-injection.md](dependency-injection.md)); write new modules as
125194
definitions.
126195

196+
If a module named by a manifest entry neither exports a definition nor registers
197+
the command itself, executing that command fails with an error naming the
198+
extension, the command and the module — the entry points at the wrong file, or
199+
the file is not doing what the entry promises.
200+
127201
## Command names
128202

129203
Command names use `|` to express hierarchy, so `"hello|world"` is invoked as
130204
`ns hello world`. Prefixing the last segment with `*` marks a default
131205
subcommand: `"hello|*default"` runs both for `ns hello default` and for a bare
132-
`ns hello`.
206+
`ns hello`. Names must be lower case — the CLI matches what the user typed in
207+
lower case, so a key with an upper-case letter could never be reached, and is
208+
rejected with a warning.
209+
210+
**The manifest key decides how a command is invoked.** It has to: the CLI routes
211+
`ns hello world` to your module before that module has been loaded, so the key
212+
is the only name it can know. A `name` inside the definition is metadata — it is
213+
what `registerCommandDefinition` uses when a module registers itself, and it is
214+
useful documentation, but a manifest entry overrides it. If the two disagree the
215+
CLI warns, naming both, and runs the command under the manifest key.
216+
217+
An alias is a second entry pointing at the same module:
218+
219+
```json
220+
{
221+
"nativescript": {
222+
"commands": {
223+
"hello|world": "./dist/commands/hello-world.js",
224+
"hello|w": "./dist/commands/hello-world.js"
225+
}
226+
}
227+
}
228+
```
229+
230+
Both names route to the same module, which is loaded once.
231+
232+
## When two extensions want the same command
233+
234+
The first extension to claim a command name keeps it; later claimants are
235+
reported with a warning naming both extensions and the command, and their entry
236+
is skipped. A name the CLI itself provides is never taken over — the extension
237+
is told the command is already provided by the CLI.
133238

134-
When an extension contributes several commands under the same parent, declare
135-
the default command before its siblings — the CLI creates the parent dispatcher
136-
from the first entry it sees, and a default command registered after that parent
137-
already exists is rejected.
239+
"First" is the order extensions are loaded in, which is the order they appear in
240+
the `dependencies` of the profile directory's `extensions/package.json` — npm
241+
keeps that alphabetically sorted, so in practice the alphabetically first
242+
extension name wins. The exception is `ns extension install`: that invocation
243+
loads the freshly installed extension after all the others, so a conflict it
244+
would win on the next invocation goes the other way that one time.
138245

139246
## Suggesting an extension for an unknown command
140247

0 commit comments

Comments
 (0)