Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ae2f54e
feat(commands): extend defineCommand for the CLI's own commands
edusperoni Sep 14, 2026
e4bf7da
refactor(commands): migrate every command to defineCommand
edusperoni Sep 14, 2026
13db77e
feat(commands): register commands lazily against a checked definition…
edusperoni Sep 14, 2026
70ff020
feat(commands)!: dispatch in process, rename ctx.arguments to ctx.params
edusperoni Sep 14, 2026
83381c5
feat(key-shortcuts): declarative table with a generic engine
edusperoni Sep 14, 2026
6d8cc6e
feat(key-shortcuts): registry contract and command-declared shortcuts
edusperoni Sep 14, 2026
53551c7
fix(bundler): tear the bundler down before starting another one
edusperoni Sep 14, 2026
2b1ee61
feat(key-shortcuts): r/R/B restart ladder, hint reprint, session scoping
edusperoni Sep 14, 2026
217a38b
feat(commands): a class form built on defineCommand
edusperoni Sep 14, 2026
3361798
refactor(commands): one context per invocation and the class-form sweep
edusperoni Sep 14, 2026
693e34d
feat(commands): the in-process dispatcher as a contract
edusperoni Sep 14, 2026
d987e0b
refactor(commands): fail through the context and read options from it
edusperoni Sep 23, 2026
d7de648
feat(commands): definition providers, multi providers and preconditions
edusperoni Sep 23, 2026
b41cadd
fix(commands): close the review gaps in the command API
edusperoni Sep 23, 2026
3549b4c
refactor(commands): drop what never shipped and the leftovers the rev…
edusperoni Sep 23, 2026
ffa6afa
chore: leave declarations.d.ts untouched
edusperoni Sep 23, 2026
145c39f
fix: address the first review round on the command API and shortcuts
edusperoni Sep 23, 2026
61f4997
feat(commands)!: declare positional parameters as `params`, options a…
edusperoni Sep 23, 2026
95ec594
docs(commands): name the reserved word the params spelling avoids
edusperoni Sep 23, 2026
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
Prev Previous commit
Next Next commit
refactor(commands): fail through the context and read options from it
`ctx.fail(message, { help })` replaces the commands' direct use of $errors,
and signing and install options are read from the typed `ctx.options` instead
of the global options object.
  • Loading branch information
edusperoni committed Sep 23, 2026
commit d987e0bd0d95654b03bd4c89712c0e024d8d53fb
34 changes: 23 additions & 11 deletions defining-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ spelling _means_:
stringOption({ alias: "p" })` steals `--path`'s shorthand. Restating an
option's own shorthand (`path: stringOption({ alias: "p" })`) is fine.

The merge replaces the CLI-wide entry rather than patching it, so a
redeclaration inherits nothing: restate the `alias` and `hasSensitiveValue` the
global declaration carries if the command still wants them.
A redeclaration that leaves `alias`, `default` or `hasSensitiveValue` unset
keeps what the CLI-wide declaration carries for them, so `path: stringOption()`
still answers to `-p` and stays out of the logs; set one only to change it.

### How validation behaves

Expand Down Expand Up @@ -349,15 +349,22 @@ The run context
- `ctx.injector` — this invocation's injector, a child of the one the command
was registered against; see
[Injection, and the first `await`](#injection-and-the-first-await).
- `ctx.fail(message)` — fails the command with `message` and a usage help
suggestion.
- `ctx.fail(message, options?)` — fails the command with `message`, followed
by a usage help suggestion unless `options.help` is `false`.

`run` may be synchronous or `async`; the CLI awaits the result and treats a
rejection as a command failure.

### Failing a command

`ctx.fail(message)` is the idiomatic way to stop a command:
`ctx.fail(message)` is the idiomatic way to stop a command. By default it
follows the message with the usage help suggestion — the "Run `ns widget add
--help`" line — which is what the user needs when they got the command line
wrong: a missing argument, an unknown value, an invalid combination of options.

When the command line was fine and something else is not — the environment,
the project, a file on disk — the help suggestion only gets in the way. Pass
`{ help: false }` to print the message alone:

```ts
defineCommand({
Expand All @@ -369,17 +376,22 @@ defineCommand({
ctx.fail("--output is required.");
}

if (fs.existsSync(ctx.options.output)) {
ctx.fail(`${ctx.options.output} already exists.`, { help: false });
}

/* ... */
},
});
```

It is available on the `canExecute` context as well, and it returns `never`, so
it can end a branch without a `return`. The message must be a non-empty string.
It is available on the `setup` and `canExecute` contexts as well, and it
returns `never`, so it can end a branch without a `return`. The message must be
a non-empty string, and `options`, when given, a plain object. The two forms
map onto the `errors` service's `failWithHelp` and `fail`.

Throwing is equivalent and keeps working — `ctx.fail` is sugar over the
`errors` service's `failWithHelp`, which is what adds the "Run `ns widget add
--help`" line. Throw when you already have an `Error` to propagate; call
Throwing keeps working too: an error thrown from a handler propagates
unchanged. Throw when you already have an `Error` to propagate; call
`ctx.fail` when you are writing the message.

Injection, and the first `await`
Expand Down
7 changes: 3 additions & 4 deletions lib/commands/add-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
IPlatformValidationService,
} from "../declarations";
import { IProjectData } from "../definitions/project";
import { IErrors } from "../common/declarations";
import {
Command,
CommandOptionsSchema,
Expand All @@ -23,7 +22,6 @@ export class AddPlatformCommand extends Command({
options: addPlatformCommandOptions,
arguments: "any",
}) {
private $errors = inject<IErrors>("errors");
private $platformCommandHelper = inject<IPlatformCommandHelper>(
"platformCommandHelper",
);
Expand All @@ -40,7 +38,7 @@ export class AddPlatformCommand extends Command({
public async canExecute(): Promise<boolean> {
const args = this.args;
if (!args || args.length === 0) {
this.$errors.failWithHelp(
this.context.fail(
"No platform specified. Please specify a platform to add.",
);
}
Expand All @@ -55,8 +53,9 @@ export class AddPlatformCommand extends Command({
this.$projectData,
)
) {
this.$errors.fail(
this.context.fail(
`Applications for platform ${arg} cannot be built on this OS`,
{ help: false },
);
}

Expand Down
5 changes: 2 additions & 3 deletions lib/commands/apple-login.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { IErrors } from "../common/declarations";
import { defineCommand } from "../common/define-command";
import { inject } from "../common/di";
import { IApplePortalSessionService } from "../services/apple-portal/definitions";
Expand All @@ -11,7 +10,6 @@ export const appleLoginCommandDefinition = defineCommand({
const $applePortalSessionService = inject<IApplePortalSessionService>(
"applePortalSessionService",
);
const $errors = inject<IErrors>("errors");
const $logger = inject<ILogger>("logger");
const $prompter = inject<IPrompter>("prompter");

Expand All @@ -32,8 +30,9 @@ export const appleLoginCommandDefinition = defineCommand({
password,
});
if (!user.areCredentialsValid) {
$errors.fail(
context.fail(
`Invalid username and password combination. Used '${username}' as the username.`,
{ help: false },
);
}

Expand Down
8 changes: 4 additions & 4 deletions lib/commands/appstore-list.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { IErrors } from "../common/declarations";
import {
Command,
CommandOptionsSchema,
Expand Down Expand Up @@ -31,7 +30,6 @@ export class ListiOSAppsCommand extends Command({
private $devicePlatformsConstants = inject<Mobile.IDevicePlatformsConstants>(
"devicePlatformsConstants",
);
private $errors = inject<IErrors>("errors");
private $logger = inject<ILogger>("logger");
private $platformValidationService = inject<IPlatformValidationService>(
"platformValidationService",
Expand All @@ -51,8 +49,9 @@ export class ListiOSAppsCommand extends Command({
this.$projectData,
)
) {
this.$errors.fail(
this.context.fail(
`Applications for platform ${this.$devicePlatformsConstants.iOS} can not be built on this OS`,
{ help: false },
);
}

Expand All @@ -76,8 +75,9 @@ export class ListiOSAppsCommand extends Command({
},
);
if (!user.areCredentialsValid) {
this.$errors.fail(
this.context.fail(
`Invalid username and password combination. Used '${username}' as the username.`,
{ help: false },
);
}

Expand Down
13 changes: 8 additions & 5 deletions lib/commands/appstore-upload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as path from "path";
import { IErrors, IHostInfo } from "../common/declarations";
import { IHostInfo } from "../common/declarations";
import {
booleanOption,
Command,
Expand Down Expand Up @@ -41,7 +41,6 @@ export class PublishIOSCommand extends Command({
private $devicePlatformsConstants = inject<Mobile.IDevicePlatformsConstants>(
"devicePlatformsConstants",
);
private $errors = inject<IErrors>("errors");
private $hostInfo = inject<IHostInfo>("hostInfo");
private $itmsTransporterService = inject<IITMSTransporterService>(
"itmsTransporterService",
Expand All @@ -61,7 +60,9 @@ export class PublishIOSCommand extends Command({

public canExecute(): boolean {
if (!this.$hostInfo.isDarwin) {
this.$errors.fail("iOS publishing is only available on macOS.");
this.context.fail("iOS publishing is only available on macOS.", {
help: false,
});
}

if (
Expand All @@ -70,8 +71,9 @@ export class PublishIOSCommand extends Command({
this.$projectData,
)
) {
this.$errors.fail(
this.context.fail(
`Applications for platform ${this.$devicePlatformsConstants.iOS} can not be built on this OS`,
{ help: false },
);
}

Expand Down Expand Up @@ -135,8 +137,9 @@ export class PublishIOSCommand extends Command({
},
);
if (!user.areCredentialsValid) {
this.$errors.fail(
this.context.fail(
`Invalid username and password combination. Used '${username}' as the username.`,
{ help: false },
);
}

Expand Down
14 changes: 9 additions & 5 deletions lib/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import {
ANDROID_RELEASE_BUILD_ERROR_MESSAGE,
AndroidAppBundleMessages,
} from "../constants";
import { canExecuteCommandBase, validatePlatformOptions } from "./command-base";
import {
canExecuteCommandBase,
platformSigningOptions,
validatePlatformOptions,
} from "./command-base";
import { hasValidAndroidSigning } from "../common/helpers";
import {
IAndroidBundleValidatorHelper,
Expand All @@ -12,7 +16,6 @@ import {
import { IBuildController, IBuildDataService } from "../definitions/build";
import { IMigrateController } from "../definitions/migrate";
import { IProjectData } from "../definitions/project";
import { IErrors } from "../common/declarations";
import {
booleanOption,
CommandName,
Expand All @@ -29,6 +32,7 @@ import { inject } from "../common/di";
type BuildPlatform = "iOS" | "Android" | "visionOS";

const buildCommandOptions = {
...platformSigningOptions,
watch: booleanOption({ default: false }),
hmr: booleanOption({ default: false }),
force: booleanOption(),
Expand All @@ -52,7 +56,6 @@ const defineBuildCommand = <const TName extends CommandName>(
async canExecute(context): Promise<boolean> {
const $devicePlatformsConstants =
inject<Mobile.IDevicePlatformsConstants>("devicePlatformsConstants");
const $errors = inject<IErrors>("errors");
const $migrateController =
inject<IMigrateController>("migrateController");
const $platformValidationService = inject<IPlatformValidationService>(
Expand Down Expand Up @@ -82,8 +85,9 @@ const defineBuildCommand = <const TName extends CommandName>(
$projectData,
)
) {
$errors.fail(
context.fail(
`Applications for platform ${platform} can not be built on this OS`,
{ help: false },
);
}

Expand All @@ -96,7 +100,7 @@ const defineBuildCommand = <const TName extends CommandName>(
context.options.release &&
!hasValidAndroidSigning(context.options)
) {
$errors.failWithHelp(ANDROID_RELEASE_BUILD_ERROR_MESSAGE);
context.fail(ANDROID_RELEASE_BUILD_ERROR_MESSAGE);
}

return validatePlatformOptions(context, platform);
Expand Down
45 changes: 38 additions & 7 deletions lib/commands/command-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,31 @@ import {
ICanExecuteCommandOptions,
INotConfiguredEnvOptions,
} from "../common/definitions/commands";
import { ArgumentSpec, CommandContext } from "../common/define-command";
import {
ArgumentSpec,
CommandContext,
CommandOptionsSchema,
objectOption,
} from "../common/define-command";
import { Injector } from "../common/di";

/**
* The CLI-wide signing options `validatePlatformOptions` checks. A command
* that validates them spreads this into its own schema.
*/
export const platformSigningOptions = {
provision: objectOption(),
teamId: objectOption(),
} satisfies CommandOptionsSchema;

/** The part of a command context these helpers read. */
type PlatformCommandContext = Pick<CommandContext<any>, "injector">;

type PlatformSigningContext = Pick<
CommandContext<typeof platformSigningOptions>,
"injector" | "options"
>;

/**
* The declarative form of `$platformCommandParameter`. Initializing the
* project data is what makes the platform check possible, so it stays part of
Expand Down Expand Up @@ -38,17 +57,16 @@ export const platformArgument: ArgumentSpec<any> = {
};

export function validatePlatformOptions(
context: PlatformCommandContext,
context: PlatformSigningContext,
platform: string,
): Promise<boolean> {
const $options = context.injector.get<IOptions>("options");
const $projectData = context.injector.get<IProjectData>("projectData");

return context.injector
.get<IPlatformValidationService>("platformValidationService")
.validateOptions(
$options.provision,
$options.teamId,
context.options.provision,
context.options.teamId,
$projectData,
platform,
);
Expand Down Expand Up @@ -82,9 +100,19 @@ function hasUsableEnvironment(
);
}

export async function canExecuteCommandBase(
export function canExecuteCommandBase(
context: PlatformSigningContext,
platform: string,
options: ICanExecuteCommandOptions & { validateOptions: true },
): Promise<boolean>;
export function canExecuteCommandBase(
context: PlatformCommandContext,
platform: string,
options?: ICanExecuteCommandOptions & { validateOptions?: false },
): Promise<boolean>;
export async function canExecuteCommandBase(
context: PlatformCommandContext | PlatformSigningContext,
platform: string,
options: ICanExecuteCommandOptions = {},
): Promise<boolean> {
const validatePlatformOutput = await validatePlatformBase(
Expand All @@ -96,7 +124,10 @@ export async function canExecuteCommandBase(
let result = canExecute;

if (canExecute && options.validateOptions) {
result = await validatePlatformOptions(context, platform);
result = await validatePlatformOptions(
<PlatformSigningContext>context,
platform,
);
}

return result;
Expand Down
Loading