Skip to content

Commit 70ff020

Browse files
committed
feat(commands)!: dispatch in process, rename ctx.arguments to ctx.params
A command can run another in process without exiting on failure; a definition's setup state is scoped to one invocation; DeferredCommandResult is a discriminated union; the command-name types an extension needs are exported from the contracts. BREAKING CHANGE: ctx.arguments is now ctx.params on the command context.
1 parent 13db77e commit 70ff020

16 files changed

Lines changed: 712 additions & 143 deletions

defining-commands.md

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -253,16 +253,16 @@ defineCommand({
253253
{ name: "files", variadic: true },
254254
],
255255
async run(ctx) {
256-
ctx.arguments.platform; // "android"
257-
ctx.arguments.template; // "blank", or absent
258-
ctx.arguments.files; // string[], possibly empty
256+
ctx.params.platform; // "android"
257+
ctx.params.template; // "blank", or absent
258+
ctx.params.files; // string[], possibly empty
259259
},
260260
});
261261
```
262262

263263
A spec accepts:
264264

265-
- `name` — the key the value appears under on `ctx.arguments`, and the name
265+
- `name` — the key the value appears under on `ctx.params`, and the name
266266
messages use.
267267
- `required` — defaults to false. A required argument may not follow an
268268
optional one; positional matching would never be able to satisfy it.
@@ -288,11 +288,11 @@ and lets a mandatory parameter claim whichever argument happens to satisfy it
288288
so `ns command b a` could satisfy `[a, b]`. Nothing in the CLI depends on that
289289
behaviour, and positional is what the declaration reads like.
290290

291-
The practical consequence: `ctx.arguments.template` is `args[1]` whether or not
291+
The practical consequence: `ctx.params.template` is `args[1]` whether or not
292292
`args[1]` looks like a template. An argument that could be several things is a
293293
job for `validate` or for `canExecute`, not for the matcher.
294294

295-
`ctx.arguments` is always present, even with `arguments: "none"` or `"any"`
295+
`ctx.params` is always present, even with `arguments: "none"` or `"any"`
296296
it is simply `{}` when no specs are declared. An optional non-variadic argument
297297
the command line did not reach is absent from it; a variadic one is always
298298
there, as an array.
@@ -334,8 +334,10 @@ The run context
334334

335335
- `ctx.args``string[]`, the positional arguments left after the command name
336336
(including any subcommand segments) has been consumed.
337-
- `ctx.arguments` — the same arguments keyed by the names the `arguments` specs
338-
declare, `{}` when there are none.
337+
- `ctx.params` — the same arguments keyed by the names the `arguments` specs
338+
declare, `{}` when there are none. It is spelled `params` because
339+
`arguments` is a reserved binding name in strict mode, so a destructuring
340+
`const { args, arguments } = ctx` would not even parse.
339341
- `ctx.options` — the current value of each declared option, read at the moment
340342
the command executes.
341343
- `ctx.injector` — the injector this command was registered against; see
@@ -458,7 +460,7 @@ export default defineCommand({
458460
name: "create",
459461
arguments: [{ name: "appName", required: true }],
460462
async run(ctx) {
461-
const projectDir = await createProject(ctx.arguments.appName as string);
463+
const projectDir = await createProject(ctx.params.appName as string);
462464
return { projectDir };
463465
},
464466
postRun(ctx, { projectDir }) {
@@ -648,6 +650,71 @@ after the first `await` — and needs to know nothing else. The spread keeps the
648650
This replaces the class-inheritance pattern the legacy commands use, where a
649651
per-platform command subclasses a shared base to override one field.
650652

653+
Running a command in process
654+
----------------------------
655+
656+
`runCommand` dispatches a registered command from inside the process that is
657+
already running:
658+
659+
```ts
660+
import { runCommand } from "../common/services/command-definition-adapter";
661+
662+
await runCommand("open|ios");
663+
await runCommand("install", ["lodash"]);
664+
```
665+
666+
The command gets what a typed command line gives it, in the same order: its
667+
declared options are primed into the parser — so `ctx.options` holds this
668+
command's values and its declared defaults rather than the outer command
669+
line's — then the `arguments` policy, then `canExecute`, then `run`,
670+
`postRun`, and the command's hooks.
671+
672+
Two things differ, both because the caller is a process that has to keep
673+
running afterwards:
674+
675+
- **A failure throws instead of exiting.** A failed command line ends in
676+
`process.exit`. `runCommand` reports the failure the same way — the same
677+
message formatting, the same `ns … --help` suggestion — and then throws, so
678+
the caller decides what happens next.
679+
- **Analytics do not fire.** An in-process dispatch is not a new invocation of
680+
the CLI, and the consent check can prompt on a terminal the caller has put
681+
into raw mode. Hooks do fire: a project's `before-open-ios` hook is part of
682+
what `open|ios` means, however the command was reached.
683+
684+
The options service is put back the way it was found. Merging a command's
685+
declarations into it rewrites the values the host process is still running on
686+
`open|ios` declares `watch: false`, which would otherwise leave an `ns start`
687+
out of watch mode for the rest of its life.
688+
689+
Which injector it dispatches through follows the rule `registerCommand` does:
690+
the injector of the current injection context, and the CLI's own outside one.
691+
`runCommand` is a thin call onto `CommandsService.executeCommandInProcess`,
692+
where the pipeline itself lives.
693+
694+
### Key shortcuts
695+
696+
The interactive keys `ns start` and `ns run` offer are the CLI's own caller. A
697+
shortcut is a table entry with a `when` deciding whether the key is live, and
698+
an `action` that runs it:
699+
700+
```ts
701+
{
702+
key: "I",
703+
description: "Open project in Xcode",
704+
when: onPlatform("iOS"),
705+
action: () => runCommand("open|ios"),
706+
}
707+
```
708+
709+
The context an action receives carries state and nothing else — the platform
710+
being watched, whether this is `ns start` or an `ns run` child it spawned, and
711+
the injector. Capabilities are resolved from that injector rather than handed
712+
over as context methods:
713+
714+
```ts
715+
action: (ctx) => ctx.injector.get<IStartService>("startService").runIOS(),
716+
```
717+
651718
Relationship to `ICommand`
652719
--------------------------
653720

lib/common/contracts/command-registry.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,16 @@ export function describeRejection(rejection: DeferredCommandRejection): string {
6767
}
6868

6969
/**
70-
* Outcome of a deferred registration. Callers branch on `rejection.reason`
71-
* rather than on message text; describeRejection renders it when the report
72-
* is for a human.
70+
* Outcome of a deferred registration. Checking `registered` narrows the result,
71+
* so a rejected one carries its rejection without an assertion — inside the CLI
72+
* that check has to read `registered === false`, because the build leaves
73+
* strictNullChecks off and truthiness alone does not narrow a literal
74+
* discriminant there. Callers branch on `rejection.reason` rather than on
75+
* message text; describeRejection renders it when the report is for a human.
7376
*/
74-
export interface DeferredCommandResult {
75-
registered: boolean;
76-
/** Set exactly when `registered` is false. */
77-
rejection?: DeferredCommandRejection;
78-
}
77+
export type DeferredCommandResult =
78+
| { registered: true }
79+
| { registered: false; rejection: DeferredCommandRejection };
7980

8081
/**
8182
* The command-registry face of the injector facade. Transitional contract: it

lib/common/define-command.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export interface CommandArgumentValues {
8282
* spec takes the first argument, and so on.
8383
*/
8484
export interface ArgumentSpec<TSchema extends CommandOptionsSchema = {}> {
85-
/** Key under which the value appears on `ctx.arguments`. */
85+
/** Key under which the value appears on `ctx.params`. */
8686
name: string;
8787
/** Defaults to false. A required spec may not follow an optional one. */
8888
required?: boolean;
@@ -110,7 +110,7 @@ export interface CommandContext<TSchema extends CommandOptionsSchema = {}> {
110110
/** Positional arguments, after the command name has been consumed. */
111111
args: string[];
112112
/** The same arguments keyed by the names the `arguments` specs declare. */
113-
arguments: CommandArgumentValues;
113+
params: CommandArgumentValues;
114114
/** Current value of every option declared in the schema, and nothing else. */
115115
options: CommandOptionValues<TSchema>;
116116
/**
@@ -386,7 +386,7 @@ const validateArgumentSpecs = (definition: any, specs: any[]): void => {
386386
if (seen.indexOf(spec.name) !== -1) {
387387
invalid(
388388
definition,
389-
`'arguments' declares '${spec.name}' twice; argument names key ctx.arguments and must be unique`,
389+
`'arguments' declares '${spec.name}' twice; argument names key ctx.params and must be unique`,
390390
);
391391
}
392392
seen.push(spec.name);

lib/common/definitions/commands-service.d.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,20 @@ interface ICommandsService {
33
allCommands(opts: { includeDevCommands: boolean }): string[];
44
tryExecuteCommand(
55
commandName: string,
6-
commandArguments: string[]
6+
commandArguments: string[],
77
): Promise<void>;
88
executeCommandUnchecked(
99
commandName: string,
10-
commandArguments: string[]
10+
commandArguments: string[],
1111
): Promise<boolean>;
12+
/**
13+
* Runs a command inside the running process, throwing on failure rather
14+
* than exiting, so a long-lived host survives it.
15+
*/
16+
executeCommandInProcess(
17+
commandName: string,
18+
commandArguments?: string[],
19+
): Promise<void>;
1220
}
1321

1422
/**

lib/common/errors.ts

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -213,36 +213,41 @@ export class Errors implements IErrors {
213213
throw exception;
214214
}
215215

216+
public async reportCommandError(
217+
error: any,
218+
printCommandHelpSuggestion: () => Promise<void>,
219+
): Promise<void> {
220+
const logger = this.$injector.resolve("logger");
221+
const loggerLevel: string = logger.getLevel().toUpperCase();
222+
const printCallStack =
223+
this.printCallStack || loggerLevel === "TRACE" || loggerLevel === "DEBUG";
224+
const message = printCallStack
225+
? await resolveCallStack(error)
226+
: isInteractive()
227+
? `\x1B[31;1m${error.message}\x1B[0m`
228+
: error.message;
229+
230+
if (error.printOnStdout) {
231+
logger.info(message);
232+
} else {
233+
logger.error(message);
234+
}
235+
236+
if (error.suggestCommandHelp) {
237+
await printCommandHelpSuggestion();
238+
}
239+
240+
await tryTrackException(error, this.$injector);
241+
}
242+
216243
public async beginCommand(
217244
action: () => Promise<boolean>,
218245
printCommandHelpSuggestion: () => Promise<void>,
219246
): Promise<boolean> {
220247
try {
221248
return await action();
222249
} catch (ex) {
223-
const logger = this.$injector.resolve("logger");
224-
const loggerLevel: string = logger.getLevel().toUpperCase();
225-
const printCallStack =
226-
this.printCallStack ||
227-
loggerLevel === "TRACE" ||
228-
loggerLevel === "DEBUG";
229-
const message = printCallStack
230-
? await resolveCallStack(ex)
231-
: isInteractive()
232-
? `\x1B[31;1m${ex.message}\x1B[0m`
233-
: ex.message;
234-
235-
if (ex.printOnStdout) {
236-
logger.info(message);
237-
} else {
238-
logger.error(message);
239-
}
240-
241-
if (ex.suggestCommandHelp) {
242-
await printCommandHelpSuggestion();
243-
}
244-
245-
await tryTrackException(ex, this.$injector);
250+
await this.reportCommandError(ex, printCommandHelpSuggestion);
246251
process.exit(
247252
_.isNumber(ex.errorCode) ? ex.errorCode : ErrorCodes.UNKNOWN,
248253
);

0 commit comments

Comments
 (0)