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
feat(commands): definition providers, multi providers and preconditions
A definition declares `providers` for its invocation injector; the container
takes `multi: true` providers, which shadow per level as in Angular; and
COMMAND_PRECONDITIONS is a multi token run before setup and before the
arguments policy. `provideProject()` is how a command declares that it runs
inside a project.
  • Loading branch information
edusperoni committed Sep 23, 2026
commit d7de64847207443fd04d820e725a4520e25f8152
65 changes: 48 additions & 17 deletions defining-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,16 +446,53 @@ A handler resolves what it needs itself, at the top of its own body:
export default defineCommand({
name: "widget|add",
arguments: "any",
providers: [provideProject()],
async run(ctx) {
const widgets = inject(WidgetService);
const projectData = inject(ProjectData);

projectData.initializeProjectData();
await widgets.add(ctx.args);
await widgets.add(ctx.args, projectData);
},
});
```

### Declaring what the command needs: `providers` and preconditions

A definition may carry `providers`, added to each invocation's own injector
next to the context, so a factory or class among them can inject the
invocation and is built once per invocation. One token in that list is
special: `COMMAND_PRECONDITIONS` is a multi token, and every
`{ provide: COMMAND_PRECONDITIONS, multi: true, useValue: check }` contributes
a **precondition** — a check on the environment the command runs in, as
opposed to `canExecute`, which judges the arguments. Preconditions run when
the invocation opens, in declaration order, before `setup` and before the
arguments policy, inside the injection context, and a throw fails the
invocation. That fixed order is the point: being outside a project is what a
bad invocation reports first.

The precondition every project command declares comes from a helper:

```ts
import { provideProject } from "../command-base";

export default defineCommand({
name: "platform|clean",
providers: [provideProject()],
run(ctx) {
const projectData = inject(ProjectData); // the project the command line names
},
});
```

`provideProject()` resolves the project from `--path` or the working
directory and fails the invocation with the usual "no project found" error
when there is none. A command that does not declare it — `doctor`, `create`,
the `device` family — pays nothing, and a command that needs the project only
when it is there, like `clean`, resolves it itself behind its own check. Never
call `initializeProjectData()` from a command; declare the provider. A plugin
adds its own preconditions the same way, with its own helper returning a
multi provider for the token.

The injection context is synchronous, so the `inject()` calls belong **above
the first `await`** — see [Injection, and the first
`await`](#injection-and-the-first-await). Resolve everything the handler needs
Expand Down Expand Up @@ -552,16 +589,12 @@ export class PlatformCleanCommand extends Command({
description: "Removes and adds again the selected platform.",
options: { frameworkPath: stringOption() },
arguments: "any",
providers: [provideProject()],
}) {
private $platformCommandHelper = inject<IPlatformCommandHelper>(
"platformCommandHelper",
);
private $projectData = inject<IProjectData>("projectData");

constructor() {
super();
this.$projectData.initializeProjectData();
}
private $projectData = inject(ProjectData);

public async run(): Promise<void> {
await this.$platformCommandHelper.cleanPlatforms(
Expand Down Expand Up @@ -625,16 +658,14 @@ across invocations, and resolves nothing outside a running one.
read as `this.$x`:

```ts
export class PlatformAddCommand extends Command({ name: "platform|add" }) {
private $projectData = inject<IProjectData>("projectData");
export class PlatformAddCommand extends Command({
name: "platform|add",
providers: [provideProject()],
}) {
private $projectData = inject(ProjectData);
private $platformHelper = inject<IPlatformCommandHelper>(
"platformCommandHelper",
);

constructor() {
super();
this.$projectData.initializeProjectData();
}
// ...
}
```
Expand Down Expand Up @@ -700,8 +731,8 @@ the registry. It claims every name the definition declares, through the
is built by a factory on first resolution and cached.

Pass providers as the second argument to add them to each invocation's child
injector, the one `ctx.injector` names — how a definition is parameterized per
registration:
injector, the one `ctx.injector` names, next to the definition's own
`providers` — how a definition is parameterized per registration:

```ts
for (const [name, platform] of buildCommandPlatforms) {
Expand Down
12 changes: 4 additions & 8 deletions lib/commands/add-platform.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { canExecuteCommandBase } from "./command-base";
import { canExecuteCommandBase, provideProject } from "./command-base";
import {
IPlatformCommandHelper,
IPlatformValidationService,
} from "../declarations";
import { IProjectData } from "../definitions/project";
import {
Command,
CommandOptionsSchema,
stringOption,
} from "../common/define-command";
import { inject } from "../common/di";
import { ProjectData } from "../contracts/project-data";

const addPlatformCommandOptions = {
frameworkPath: stringOption(),
Expand All @@ -21,19 +21,15 @@ export class AddPlatformCommand extends Command({
"Configures the current project to target the selected platform.",
options: addPlatformCommandOptions,
arguments: "any",
providers: [provideProject()],
}) {
private $platformCommandHelper = inject<IPlatformCommandHelper>(
"platformCommandHelper",
);
private $platformValidationService = inject<IPlatformValidationService>(
"platformValidationService",
);
private $projectData = inject<IProjectData>("projectData");

constructor() {
super();
this.$projectData.initializeProjectData();
}
private $projectData = inject(ProjectData);

public async canExecute(): Promise<boolean> {
const args = this.args;
Expand Down
11 changes: 4 additions & 7 deletions lib/commands/appstore-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import {
import { inject } from "../common/di";
import { createTable } from "../common/helpers";
import { IPlatformValidationService } from "../declarations";
import { IProjectData } from "../definitions/project";
import {
IApplePortalApplicationService,
IApplePortalSessionService,
} from "../services/apple-portal/definitions";
import { ProjectData } from "../contracts/project-data";
import { provideProject } from "./command-base";

const listiOSAppsCommandOptions = {
appleSessionBase64: stringOption(),
Expand All @@ -21,6 +22,7 @@ export class ListiOSAppsCommand extends Command({
description: "Lists the applications in App Store Connect.",
options: listiOSAppsCommandOptions,
arguments: [{ name: "appleId" }, { name: "password" }],
providers: [provideProject()],
}) {
private $applePortalApplicationService =
inject<IApplePortalApplicationService>("applePortalApplicationService");
Expand All @@ -34,14 +36,9 @@ export class ListiOSAppsCommand extends Command({
private $platformValidationService = inject<IPlatformValidationService>(
"platformValidationService",
);
private $projectData = inject<IProjectData>("projectData");
private $projectData = inject(ProjectData);
private $prompter = inject<IPrompter>("prompter");

constructor() {
super();
this.$projectData.initializeProjectData();
}

public async run(): Promise<void> {
if (
!this.$platformValidationService.isPlatformSupportedForOS(
Expand Down
11 changes: 4 additions & 7 deletions lib/commands/appstore-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import {
IOptions,
IPlatformValidationService,
} from "../declarations";
import { IProjectData } from "../definitions/project";
import { IApplePortalSessionService } from "../services/apple-portal/definitions";
import { ProjectData } from "../contracts/project-data";
import { provideProject } from "./command-base";

const publishIOSCommandOptions = {
appleApplicationSpecificPassword: stringOption(),
Expand All @@ -33,6 +34,7 @@ export class PublishIOSCommand extends Command({
options: publishIOSCommandOptions,
// Arguments have never been rejected here, only ignored past the third.
arguments: "any",
providers: [provideProject()],
}) {
private $applePortalSessionService = inject<IApplePortalSessionService>(
"applePortalSessionService",
Expand All @@ -50,14 +52,9 @@ export class PublishIOSCommand extends Command({
private $platformValidationService = inject<IPlatformValidationService>(
"platformValidationService",
);
private $projectData = inject<IProjectData>("projectData");
private $projectData = inject(ProjectData);
private $prompter = inject<IPrompter>("prompter");

constructor() {
super();
this.$projectData.initializeProjectData();
}

public canExecute(): boolean {
if (!this.$hostInfo.isDarwin) {
this.context.fail("iOS publishing is only available on macOS.", {
Expand Down
10 changes: 5 additions & 5 deletions lib/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
import {
canExecuteCommandBase,
platformSigningOptions,
provideProject,
validatePlatformOptions,
} from "./command-base";
import { hasValidAndroidSigning } from "../common/helpers";
Expand All @@ -15,7 +16,6 @@ import {
} from "../declarations";
import { IBuildController, IBuildDataService } from "../definitions/build";
import { IMigrateController } from "../definitions/migrate";
import { IProjectData } from "../definitions/project";
import {
booleanOption,
CommandName,
Expand All @@ -24,6 +24,7 @@ import {
stringOption,
} from "../common/define-command";
import { inject } from "../common/di";
import { ProjectData } from "../contracts/project-data";

/**
* Which `$devicePlatformsConstants` entry a command builds for. The constants
Expand Down Expand Up @@ -53,6 +54,7 @@ const defineBuildCommand = <const TName extends CommandName>(
description: "Builds the project for the selected target platform.",
options: buildCommandOptions,
arguments: "none",
providers: [provideProject()],
async canExecute(context): Promise<boolean> {
const $devicePlatformsConstants =
inject<Mobile.IDevicePlatformsConstants>("devicePlatformsConstants");
Expand All @@ -61,14 +63,13 @@ const defineBuildCommand = <const TName extends CommandName>(
const $platformValidationService = inject<IPlatformValidationService>(
"platformValidationService",
);
const $projectData = inject<IProjectData>("projectData");
const $projectData = inject(ProjectData);
const platform = $devicePlatformsConstants[buildPlatform];
const isAndroid = $devicePlatformsConstants.isAndroid(platform);
// Only the android build checks the runtime version.
const $androidBundleValidatorHelper = isAndroid
? inject<IAndroidBundleValidatorHelper>("androidBundleValidatorHelper")
: null;
$projectData.initializeProjectData();

if (!context.options.force) {
await $migrateController.validate({
Expand Down Expand Up @@ -112,10 +113,9 @@ const defineBuildCommand = <const TName extends CommandName>(
inject<Mobile.IDevicePlatformsConstants>("devicePlatformsConstants");
const $logger = inject<ILogger>("logger");
const $options = inject<IOptions>("options");
const $projectData = inject<IProjectData>("projectData");
const $projectData = inject(ProjectData);
const platform = $devicePlatformsConstants[buildPlatform];
const isAndroid = $devicePlatformsConstants.isAndroid(platform);
$projectData.initializeProjectData();

const buildData = $buildDataService.getBuildData(
$projectData.projectDir,
Expand Down
8 changes: 6 additions & 2 deletions lib/commands/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ import {
IProjectCleanupResult,
IProjectCleanupService,
IProjectConfigService,
IProjectData,
IProjectService,
} from "../definitions/project";
import {
ITerminalSpinner,
ITerminalSpinnerService,
} from "../definitions/terminal-spinner-service";
import { ProjectData } from "../contracts/project-data";

function bytesToHumanReadable(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
Expand Down Expand Up @@ -323,7 +323,6 @@ export const cleanCommandDefinition = defineCommand({
const $projectConfigService = inject<IProjectConfigService>(
"projectConfigService",
);
const $projectData = inject<IProjectData>("projectData");
const $projectService = inject<IProjectService>("projectService");
const $terminalSpinnerService = inject<ITerminalSpinnerService>(
"terminalSpinnerService",
Expand All @@ -340,6 +339,11 @@ export const cleanCommandDefinition = defineCommand({
return cleanMultipleProjects(context, spinner);
}

// The project is optional: outside one the command cleans the projects
// below, so it is resolved behind the check rather than declared.
const $projectData = inject(ProjectData);
$projectData.initializeProjectData();

spinner.start("Cleaning project...\n");

let pathsToClean = [
Expand Down
43 changes: 34 additions & 9 deletions lib/commands/command-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {
CommandOptionsSchema,
objectOption,
} from "../common/define-command";
import { Injector } from "../common/di";
import { Injector, inject } from "../common/di";
import type { Provider } from "../common/di/providers";
import { ProjectData } from "../contracts/project-data";
import {
COMMAND_PRECONDITIONS,
CommandPrecondition,
} from "../common/contracts/command-preconditions";

/**
* The CLI-wide signing options `validatePlatformOptions` checks. A command
Expand All @@ -31,17 +37,36 @@ type PlatformSigningContext = Pick<
>;

/**
* The declarative form of `$platformCommandParameter`. Initializing the
* project data is what makes the platform check possible, so it stays part of
* validating the argument instead of moving to the command's own handlers,
* which the adapter runs only after argument enforcement.
* Declares that a command runs inside a project: the project the command line
* names, through `--path` or the working directory, is resolved before the
* command's setup and arguments policy, and its absence fails the invocation
* with the "no project found" error. `inject(ProjectData)` then reads it.
*/
export function provideProject(): Provider {
return {
provide: COMMAND_PRECONDITIONS,
multi: true,
useValue: requireProject,
};
}

const requireProject: CommandPrecondition = () => {
const projectData = inject<IProjectData>("projectData");
if (typeof projectData.initializeProjectData === "function") {
projectData.initializeProjectData();
}
};

/**
* The declarative form of `$platformCommandParameter`. The command declares
* `provideProject()`, so resolving the project here is what makes the
* platform check possible before the command's own handlers run.
*/
export function validatePlatformArgument(
targetInjector: Injector,
platform: string,
): void {
const projectData = targetInjector.get<IProjectData>("projectData");
projectData.initializeProjectData();
const projectData = targetInjector.get(ProjectData);
targetInjector
.get<IPlatformValidationService>("platformValidationService")
.validatePlatform(platform, projectData);
Expand All @@ -60,7 +85,7 @@ export function validatePlatformOptions(
context: PlatformSigningContext,
platform: string,
): Promise<boolean> {
const $projectData = context.injector.get<IProjectData>("projectData");
const $projectData = context.injector.get(ProjectData);

return context.injector
.get<IPlatformValidationService>("platformValidationService")
Expand All @@ -78,7 +103,7 @@ async function validatePlatformBase(
notConfiguredEnvOptions: INotConfiguredEnvOptions,
): Promise<IValidatePlatformOutput> {
const $options = context.injector.get<IOptions>("options");
const $projectData = context.injector.get<IProjectData>("projectData");
const $projectData = context.injector.get(ProjectData);
const platformData = context.injector
.get<IPlatformsDataService>("platformsDataService")
.getPlatformData(platform, $projectData);
Expand Down
Loading