|
| 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 | +``` |
0 commit comments