Skip to content

Commit d7de648

Browse files
committed
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.
1 parent d987e0b commit d7de648

50 files changed

Lines changed: 743 additions & 270 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎defining-commands.md‎

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -446,16 +446,53 @@ A handler resolves what it needs itself, at the top of its own body:
446446
export default defineCommand({
447447
name: "widget|add",
448448
arguments: "any",
449+
providers: [provideProject()],
449450
async run(ctx) {
450451
const widgets = inject(WidgetService);
451452
const projectData = inject(ProjectData);
452453

453-
projectData.initializeProjectData();
454-
await widgets.add(ctx.args);
454+
await widgets.add(ctx.args, projectData);
455455
},
456456
});
457457
```
458458

459+
### Declaring what the command needs: `providers` and preconditions
460+
461+
A definition may carry `providers`, added to each invocation's own injector
462+
next to the context, so a factory or class among them can inject the
463+
invocation and is built once per invocation. One token in that list is
464+
special: `COMMAND_PRECONDITIONS` is a multi token, and every
465+
`{ provide: COMMAND_PRECONDITIONS, multi: true, useValue: check }` contributes
466+
a **precondition** — a check on the environment the command runs in, as
467+
opposed to `canExecute`, which judges the arguments. Preconditions run when
468+
the invocation opens, in declaration order, before `setup` and before the
469+
arguments policy, inside the injection context, and a throw fails the
470+
invocation. That fixed order is the point: being outside a project is what a
471+
bad invocation reports first.
472+
473+
The precondition every project command declares comes from a helper:
474+
475+
```ts
476+
import { provideProject } from "../command-base";
477+
478+
export default defineCommand({
479+
name: "platform|clean",
480+
providers: [provideProject()],
481+
run(ctx) {
482+
const projectData = inject(ProjectData); // the project the command line names
483+
},
484+
});
485+
```
486+
487+
`provideProject()` resolves the project from `--path` or the working
488+
directory and fails the invocation with the usual "no project found" error
489+
when there is none. A command that does not declare it — `doctor`, `create`,
490+
the `device` family — pays nothing, and a command that needs the project only
491+
when it is there, like `clean`, resolves it itself behind its own check. Never
492+
call `initializeProjectData()` from a command; declare the provider. A plugin
493+
adds its own preconditions the same way, with its own helper returning a
494+
multi provider for the token.
495+
459496
The injection context is synchronous, so the `inject()` calls belong **above
460497
the first `await`** — see [Injection, and the first
461498
`await`](#injection-and-the-first-await). Resolve everything the handler needs
@@ -552,16 +589,12 @@ export class PlatformCleanCommand extends Command({
552589
description: "Removes and adds again the selected platform.",
553590
options: { frameworkPath: stringOption() },
554591
arguments: "any",
592+
providers: [provideProject()],
555593
}) {
556594
private $platformCommandHelper = inject<IPlatformCommandHelper>(
557595
"platformCommandHelper",
558596
);
559-
private $projectData = inject<IProjectData>("projectData");
560-
561-
constructor() {
562-
super();
563-
this.$projectData.initializeProjectData();
564-
}
597+
private $projectData = inject(ProjectData);
565598

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

627660
```ts
628-
export class PlatformAddCommand extends Command({ name: "platform|add" }) {
629-
private $projectData = inject<IProjectData>("projectData");
661+
export class PlatformAddCommand extends Command({
662+
name: "platform|add",
663+
providers: [provideProject()],
664+
}) {
665+
private $projectData = inject(ProjectData);
630666
private $platformHelper = inject<IPlatformCommandHelper>(
631667
"platformCommandHelper",
632668
);
633-
634-
constructor() {
635-
super();
636-
this.$projectData.initializeProjectData();
637-
}
638669
// ...
639670
}
640671
```
@@ -700,8 +731,8 @@ the registry. It claims every name the definition declares, through the
700731
is built by a factory on first resolution and cached.
701732

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

706737
```ts
707738
for (const [name, platform] of buildCommandPlatforms) {

‎lib/commands/add-platform.ts‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import { canExecuteCommandBase } from "./command-base";
1+
import { canExecuteCommandBase, provideProject } from "./command-base";
22
import {
33
IPlatformCommandHelper,
44
IPlatformValidationService,
55
} from "../declarations";
6-
import { IProjectData } from "../definitions/project";
76
import {
87
Command,
98
CommandOptionsSchema,
109
stringOption,
1110
} from "../common/define-command";
1211
import { inject } from "../common/di";
12+
import { ProjectData } from "../contracts/project-data";
1313

1414
const addPlatformCommandOptions = {
1515
frameworkPath: stringOption(),
@@ -21,19 +21,15 @@ export class AddPlatformCommand extends Command({
2121
"Configures the current project to target the selected platform.",
2222
options: addPlatformCommandOptions,
2323
arguments: "any",
24+
providers: [provideProject()],
2425
}) {
2526
private $platformCommandHelper = inject<IPlatformCommandHelper>(
2627
"platformCommandHelper",
2728
);
2829
private $platformValidationService = inject<IPlatformValidationService>(
2930
"platformValidationService",
3031
);
31-
private $projectData = inject<IProjectData>("projectData");
32-
33-
constructor() {
34-
super();
35-
this.$projectData.initializeProjectData();
36-
}
32+
private $projectData = inject(ProjectData);
3733

3834
public async canExecute(): Promise<boolean> {
3935
const args = this.args;

‎lib/commands/appstore-list.ts‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import {
66
import { inject } from "../common/di";
77
import { createTable } from "../common/helpers";
88
import { IPlatformValidationService } from "../declarations";
9-
import { IProjectData } from "../definitions/project";
109
import {
1110
IApplePortalApplicationService,
1211
IApplePortalSessionService,
1312
} from "../services/apple-portal/definitions";
13+
import { ProjectData } from "../contracts/project-data";
14+
import { provideProject } from "./command-base";
1415

1516
const listiOSAppsCommandOptions = {
1617
appleSessionBase64: stringOption(),
@@ -21,6 +22,7 @@ export class ListiOSAppsCommand extends Command({
2122
description: "Lists the applications in App Store Connect.",
2223
options: listiOSAppsCommandOptions,
2324
arguments: [{ name: "appleId" }, { name: "password" }],
25+
providers: [provideProject()],
2426
}) {
2527
private $applePortalApplicationService =
2628
inject<IApplePortalApplicationService>("applePortalApplicationService");
@@ -34,14 +36,9 @@ export class ListiOSAppsCommand extends Command({
3436
private $platformValidationService = inject<IPlatformValidationService>(
3537
"platformValidationService",
3638
);
37-
private $projectData = inject<IProjectData>("projectData");
39+
private $projectData = inject(ProjectData);
3840
private $prompter = inject<IPrompter>("prompter");
3941

40-
constructor() {
41-
super();
42-
this.$projectData.initializeProjectData();
43-
}
44-
4542
public async run(): Promise<void> {
4643
if (
4744
!this.$platformValidationService.isPlatformSupportedForOS(

‎lib/commands/appstore-upload.ts‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import {
1515
IOptions,
1616
IPlatformValidationService,
1717
} from "../declarations";
18-
import { IProjectData } from "../definitions/project";
1918
import { IApplePortalSessionService } from "../services/apple-portal/definitions";
19+
import { ProjectData } from "../contracts/project-data";
20+
import { provideProject } from "./command-base";
2021

2122
const publishIOSCommandOptions = {
2223
appleApplicationSpecificPassword: stringOption(),
@@ -33,6 +34,7 @@ export class PublishIOSCommand extends Command({
3334
options: publishIOSCommandOptions,
3435
// Arguments have never been rejected here, only ignored past the third.
3536
arguments: "any",
37+
providers: [provideProject()],
3638
}) {
3739
private $applePortalSessionService = inject<IApplePortalSessionService>(
3840
"applePortalSessionService",
@@ -50,14 +52,9 @@ export class PublishIOSCommand extends Command({
5052
private $platformValidationService = inject<IPlatformValidationService>(
5153
"platformValidationService",
5254
);
53-
private $projectData = inject<IProjectData>("projectData");
55+
private $projectData = inject(ProjectData);
5456
private $prompter = inject<IPrompter>("prompter");
5557

56-
constructor() {
57-
super();
58-
this.$projectData.initializeProjectData();
59-
}
60-
6158
public canExecute(): boolean {
6259
if (!this.$hostInfo.isDarwin) {
6360
this.context.fail("iOS publishing is only available on macOS.", {

‎lib/commands/build.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
import {
66
canExecuteCommandBase,
77
platformSigningOptions,
8+
provideProject,
89
validatePlatformOptions,
910
} from "./command-base";
1011
import { hasValidAndroidSigning } from "../common/helpers";
@@ -15,7 +16,6 @@ import {
1516
} from "../declarations";
1617
import { IBuildController, IBuildDataService } from "../definitions/build";
1718
import { IMigrateController } from "../definitions/migrate";
18-
import { IProjectData } from "../definitions/project";
1919
import {
2020
booleanOption,
2121
CommandName,
@@ -24,6 +24,7 @@ import {
2424
stringOption,
2525
} from "../common/define-command";
2626
import { inject } from "../common/di";
27+
import { ProjectData } from "../contracts/project-data";
2728

2829
/**
2930
* Which `$devicePlatformsConstants` entry a command builds for. The constants
@@ -53,6 +54,7 @@ const defineBuildCommand = <const TName extends CommandName>(
5354
description: "Builds the project for the selected target platform.",
5455
options: buildCommandOptions,
5556
arguments: "none",
57+
providers: [provideProject()],
5658
async canExecute(context): Promise<boolean> {
5759
const $devicePlatformsConstants =
5860
inject<Mobile.IDevicePlatformsConstants>("devicePlatformsConstants");
@@ -61,14 +63,13 @@ const defineBuildCommand = <const TName extends CommandName>(
6163
const $platformValidationService = inject<IPlatformValidationService>(
6264
"platformValidationService",
6365
);
64-
const $projectData = inject<IProjectData>("projectData");
66+
const $projectData = inject(ProjectData);
6567
const platform = $devicePlatformsConstants[buildPlatform];
6668
const isAndroid = $devicePlatformsConstants.isAndroid(platform);
6769
// Only the android build checks the runtime version.
6870
const $androidBundleValidatorHelper = isAndroid
6971
? inject<IAndroidBundleValidatorHelper>("androidBundleValidatorHelper")
7072
: null;
71-
$projectData.initializeProjectData();
7273

7374
if (!context.options.force) {
7475
await $migrateController.validate({
@@ -112,10 +113,9 @@ const defineBuildCommand = <const TName extends CommandName>(
112113
inject<Mobile.IDevicePlatformsConstants>("devicePlatformsConstants");
113114
const $logger = inject<ILogger>("logger");
114115
const $options = inject<IOptions>("options");
115-
const $projectData = inject<IProjectData>("projectData");
116+
const $projectData = inject(ProjectData);
116117
const platform = $devicePlatformsConstants[buildPlatform];
117118
const isAndroid = $devicePlatformsConstants.isAndroid(platform);
118-
$projectData.initializeProjectData();
119119

120120
const buildData = $buildDataService.getBuildData(
121121
$projectData.projectDir,

‎lib/commands/clean.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ import {
1818
IProjectCleanupResult,
1919
IProjectCleanupService,
2020
IProjectConfigService,
21-
IProjectData,
2221
IProjectService,
2322
} from "../definitions/project";
2423
import {
2524
ITerminalSpinner,
2625
ITerminalSpinnerService,
2726
} from "../definitions/terminal-spinner-service";
27+
import { ProjectData } from "../contracts/project-data";
2828

2929
function bytesToHumanReadable(bytes: number): string {
3030
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -323,7 +323,6 @@ export const cleanCommandDefinition = defineCommand({
323323
const $projectConfigService = inject<IProjectConfigService>(
324324
"projectConfigService",
325325
);
326-
const $projectData = inject<IProjectData>("projectData");
327326
const $projectService = inject<IProjectService>("projectService");
328327
const $terminalSpinnerService = inject<ITerminalSpinnerService>(
329328
"terminalSpinnerService",
@@ -340,6 +339,11 @@ export const cleanCommandDefinition = defineCommand({
340339
return cleanMultipleProjects(context, spinner);
341340
}
342341

342+
// The project is optional: outside one the command cleans the projects
343+
// below, so it is resolved behind the check rather than declared.
344+
const $projectData = inject(ProjectData);
345+
$projectData.initializeProjectData();
346+
343347
spinner.start("Cleaning project...\n");
344348

345349
let pathsToClean = [

‎lib/commands/command-base.ts‎

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import {
1111
CommandOptionsSchema,
1212
objectOption,
1313
} from "../common/define-command";
14-
import { Injector } from "../common/di";
14+
import { Injector, inject } from "../common/di";
15+
import type { Provider } from "../common/di/providers";
16+
import { ProjectData } from "../contracts/project-data";
17+
import {
18+
COMMAND_PRECONDITIONS,
19+
CommandPrecondition,
20+
} from "../common/contracts/command-preconditions";
1521

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

3339
/**
34-
* The declarative form of `$platformCommandParameter`. Initializing the
35-
* project data is what makes the platform check possible, so it stays part of
36-
* validating the argument instead of moving to the command's own handlers,
37-
* which the adapter runs only after argument enforcement.
40+
* Declares that a command runs inside a project: the project the command line
41+
* names, through `--path` or the working directory, is resolved before the
42+
* command's setup and arguments policy, and its absence fails the invocation
43+
* with the "no project found" error. `inject(ProjectData)` then reads it.
44+
*/
45+
export function provideProject(): Provider {
46+
return {
47+
provide: COMMAND_PRECONDITIONS,
48+
multi: true,
49+
useValue: requireProject,
50+
};
51+
}
52+
53+
const requireProject: CommandPrecondition = () => {
54+
const projectData = inject<IProjectData>("projectData");
55+
if (typeof projectData.initializeProjectData === "function") {
56+
projectData.initializeProjectData();
57+
}
58+
};
59+
60+
/**
61+
* The declarative form of `$platformCommandParameter`. The command declares
62+
* `provideProject()`, so resolving the project here is what makes the
63+
* platform check possible before the command's own handlers run.
3864
*/
3965
export function validatePlatformArgument(
4066
targetInjector: Injector,
4167
platform: string,
4268
): void {
43-
const projectData = targetInjector.get<IProjectData>("projectData");
44-
projectData.initializeProjectData();
69+
const projectData = targetInjector.get(ProjectData);
4570
targetInjector
4671
.get<IPlatformValidationService>("platformValidationService")
4772
.validatePlatform(platform, projectData);
@@ -60,7 +85,7 @@ export function validatePlatformOptions(
6085
context: PlatformSigningContext,
6186
platform: string,
6287
): Promise<boolean> {
63-
const $projectData = context.injector.get<IProjectData>("projectData");
88+
const $projectData = context.injector.get(ProjectData);
6489

6590
return context.injector
6691
.get<IPlatformValidationService>("platformValidationService")
@@ -78,7 +103,7 @@ async function validatePlatformBase(
78103
notConfiguredEnvOptions: INotConfiguredEnvOptions,
79104
): Promise<IValidatePlatformOutput> {
80105
const $options = context.injector.get<IOptions>("options");
81-
const $projectData = context.injector.get<IProjectData>("projectData");
106+
const $projectData = context.injector.get(ProjectData);
82107
const platformData = context.injector
83108
.get<IPlatformsDataService>("platformsDataService")
84109
.getPlatformData(platform, $projectData);

0 commit comments

Comments
 (0)