Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 53 additions & 8 deletions extending-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,58 @@ Execute Hooks In-Process

When your hook is a Node.js script, the CLI executes it in-process. This gives you access to the entire internal state of the CLI and all of its functions.

The CLI assumes that this is a CommonJS module and calls its single exported function.
The CLI assumes that this is a CommonJS module and calls the hook it exports — either a hook definition (see below) or a plain function.

## Writing a hook

Hooks run inside an injection context, so services come from `inject()` — the same API used everywhere else (see [dependency-injection.md](dependency-injection.md)). Declare a `hookArgs` parameter only if you need the payload of the operation being hooked.
Export a hook definition built with `defineHook`. It takes the hook point in the usual naming convention (`before-prepare`, `after-watch`) and a handler that receives a context object.

```JavaScript
const { defineHook, inject, DoctorService } = require("nativescript/contracts");

module.exports = defineHook("before-prepare", async (ctx) => {
const doctorService = inject(DoctorService);
await doctorService.canExecuteLocalBuild();
});
```

Services come from `inject()` — the same API used everywhere else (see [dependency-injection.md](dependency-injection.md)):

* `inject()` is valid in the synchronous part of the handler — not after an `await`. Resolve what you need up front; for late lookups, grab the container first: `const injector = inject(Injector)` (`Injector` is exported from `nativescript/contracts` too), then `injector.get(...)` later.
* Tokens resolve by class first and by their canonical name on a miss, so this works even if your dependency tree carries its own copy of `nativescript` — a duplicated token class still resolves to the running CLI's service.
* Only a first tranche of services has typed tokens so far ([dependency-injection.md](dependency-injection.md#available-contracts) lists them); a service without a token is reachable by its registry name — `inject("logger")` — as a migration bridge.
* If you build your hook in TypeScript, add `nativescript` as a `devDependency` and import the same names: `import { defineHook, inject, DoctorService } from "nativescript/contracts"`. An `.mjs` hook can `export default defineHook(...)`.

`ctx.payload` holds the parameters of the CLI operation being hooked; its shape depends on the hook point. It is the CLI's own object, so mutating it influences the operation:

```JavaScript
module.exports = defineHook("before-build-task-args", (ctx) => {
ctx.payload.args.push("--offline");
});
```

`ctx.wrap(middleware)` puts a middleware around the hooked method. The middleware receives the method's arguments and a `next` callback; call `next` to continue, or return without calling it to short-circuit the method entirely. Register it from a `before-` hook.

```JavaScript
module.exports = defineHook("before-prepare", (ctx) => {
ctx.wrap(async (args, next) => {
const result = await next(...args);
return result;
});
});
```

`ctx.abort(message)` stops the hook and fails the command. Pass `{ asWarning: true }` to print the message as a warning and let the command continue instead.

```JavaScript
module.exports = defineHook("before-prepare", (ctx) => {
ctx.abort("Nothing to prepare.", { asWarning: true });
});
```

### Plain function hooks

Exporting a plain function is still supported. It runs in an injection context too, so `inject()` works the same way; declare a `hookArgs` parameter if you need the payload.

```JavaScript
const { inject, DoctorService } = require("nativescript/contracts");
Expand All @@ -92,12 +139,6 @@ module.exports = function (hookArgs) {
};
```

* `inject()` is valid in the synchronous part of the hook body — not after an `await`. Resolve what you need up front; for late lookups, grab the container first: `const injector = inject(Injector)` (`Injector` is exported from `nativescript/contracts` too), then `injector.get(...)` later.
* `hookArgs` contains the parameters of the CLI function being hooked; its shape depends on the hook point. Declare it only when you need it — a hook may also take no parameters at all. A future typed hook API (`defineHook` with an explicit context object) will replace this parameter; it is the one remaining piece of the legacy convention.
* Tokens resolve by class first and by their canonical name on a miss, so this works even if your dependency tree carries its own copy of `nativescript` — a duplicated token class still resolves to the running CLI's service.
* Only a first tranche of services has typed tokens so far ([dependency-injection.md](dependency-injection.md#available-contracts) lists them); a service without a token is reachable by its registry name — `inject("logger")` — as a migration bridge.
* If you build your hook in TypeScript, add `nativescript` as a `devDependency` and import the same names: `import { inject, DoctorService } from "nativescript/contracts"`.

## The hook contract

The hook must return a Promise. If the hook succeeds, it must fullfil the promise, but the fullfilment value is ignored.
Expand All @@ -110,6 +151,10 @@ Member | Type | Description

If these two members are not set, the CLI prints the returned error colored as fatal error and stops executing the current command.

A plain-function hook can also return a function, which the CLI folds into a middleware chain around the hooked method.

With `defineHook` neither convention is needed: `ctx.abort` replaces throwing an error carrying `stopExecution`/`errorAsWarning`, and `ctx.wrap` replaces returning a function.

## Legacy: parameter-name injection

Historically, a hook received CLI services by naming them as parameters: the CLI parses the exported function's parameter names and injects the service registered under each name. Existing hooks written this way keep working unchanged, but **new hooks should use the pattern above** — parameter-name service injection is slated for deprecation, and hooks that use it are reported through the CLI's deprecation tracer (visible with `--log trace`, or as warnings with `NS_DEPRECATIONS=warn`).
Expand Down
111 changes: 111 additions & 0 deletions lib/common/define-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* The typed hook-authoring API. Kept import-free so that a hook (or an
* extension carrying its own copy of the CLI) can load it without booting a
* second runtime — importing lib/common/yok creates global.$injector.
*/

/**
* `Symbol.for` rather than a module-local symbol: an extension may resolve a
* duplicated copy of the CLI from its own node_modules, and the running CLI
* still has to recognize definitions minted by that copy.
*/
export const HOOK_DEFINITION_MARKER = Symbol.for(
"nativescript:cli:hookDefinition",
);

/**
* Wraps the method the hook point decorates. `next` continues the chain — call
* it with `args` to run the original, or skip it to short-circuit.
*/
export type HookMiddleware = (
args: any[],
next: (...args: any[]) => any,
) => any;

export interface IHookContext {
/**
* The payload of the operation being hooked. Its shape depends on the hook
* point, and it is the caller's own object: mutating it is a supported
* channel for influencing the operation.
*/
payload: any;

/** Registers a middleware around the method this hook point decorates. */
wrap(middleware: HookMiddleware): void;

/**
* Stops the hook. With `asWarning`, the CLI logs the message and continues
* the command; otherwise the command fails.
*/
abort(message: string, opts?: { asWarning?: boolean }): never;
}

export type HookHandler = (ctx: IHookContext) => void | Promise<void>;

export interface IHookDefinition {
/** Hook point, in the hyphen convention: `before-prepare`, `after-watch`. */
readonly name: string;
readonly handler: HookHandler;
}

export interface IHookInvocation {
context: IHookContext;
/** Populated by `ctx.wrap()` while the handler runs. */
middlewares: HookMiddleware[];
}

export function defineHook(
name: string,
handler: HookHandler,
): IHookDefinition {
const definition: IHookDefinition = { name, handler };
Object.defineProperty(definition, HOOK_DEFINITION_MARKER, { value: true });
return definition;
}

export function isHookDefinition(value: any): boolean {
return (
!!value &&
(typeof value === "object" || typeof value === "function") &&
value[HOOK_DEFINITION_MARKER] === true &&
typeof value.handler === "function"
);
}

/**
* Derives the context from the raw hook argument bag: the `hookArgs` wrapper
* when the hook point supplies one, the bag itself for hook points that pass
* their keys at the top level, and nothing when there is no payload.
*/
export function createHookInvocation(hookArguments: any): IHookInvocation {
const middlewares: HookMiddleware[] = [];
const context: IHookContext = {
payload: derivePayload(hookArguments),
wrap(middleware: HookMiddleware): void {
middlewares.push(middleware);
},
abort(message: string, opts?: { asWarning?: boolean }): never {
const error: any = new Error(message);
if (opts && opts.asWarning) {
// The pair the hooks service checks for to downgrade a rejection.
error.stopExecution = false;
error.errorAsWarning = true;
}
throw error;
},
};

return { context, middlewares };
}

function derivePayload(hookArguments: any): any {
if (!hookArguments || typeof hookArguments !== "object") {
return undefined;
}

if ("hookArgs" in hookArguments) {
return hookArguments["hookArgs"];
}

return Object.keys(hookArguments).length ? hookArguments : undefined;
}
Loading