Skip to content

Commit 375bde5

Browse files
committed
feat(commands): definition providers, multi providers and preconditions
A command definition and a Command() meta may carry `providers`, merged into each invocation's child injector next to the context, so a command declares what it needs instead of setting it up by hand. The injector gains `multi: true`: every multi provider for a token contributes to an array, in registration order, a token is either multi or single, and a child's entries shadow the parent's array. COMMAND_PRECONDITIONS is the first multi token: each entry is a check on the environment a command runs in, run when the invocation opens, before setup and before the arguments policy, in the invocation's injection context. A throw fails the invocation, so being outside a project is what a bad invocation reports first. canExecute keeps judging the arguments.
1 parent b7c4696 commit 375bde5

9 files changed

Lines changed: 362 additions & 4 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { InjectionToken } from "../di/injection-token";
2+
import type { CommandContext } from "../define-command";
3+
4+
/**
5+
* A check on the environment a command runs in - a project to be inside, a
6+
* platform to be added, an account to be logged into - as opposed to a check
7+
* on its arguments, which is `canExecute`. It runs when the invocation opens,
8+
* before `setup` and before the arguments policy, in the invocation's
9+
* injection context, and a throw fails the invocation.
10+
*/
11+
export type CommandPrecondition = (
12+
context: CommandContext<any>,
13+
) => void | Promise<void>;
14+
15+
/**
16+
* Multi token: each `{ provide: COMMAND_PRECONDITIONS, multi: true, ... }` in
17+
* a command's `providers` contributes one precondition, and they run in the
18+
* order they were declared.
19+
*/
20+
export const COMMAND_PRECONDITIONS = new InjectionToken<CommandPrecondition[]>(
21+
"commandPreconditions",
22+
);

‎lib/common/contracts/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export type {
1515
DeferredCommandResult,
1616
} from "./command-registry";
1717
export { COMMAND_CONTEXT } from "./command-context";
18+
export { COMMAND_PRECONDITIONS } from "./command-preconditions";
19+
export type { CommandPrecondition } from "./command-preconditions";
1820
export { CommandsService } from "./commands-service";
1921
export { ModuleRegistry } from "./module-registry";
2022
export { PublicApiBuilder } from "./public-api-builder";

‎lib/common/define-command.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { COMMAND_CONTEXT } from "./contracts/command-context";
1010
import type { KeyShortcut } from "./contracts/key-shortcuts";
1111
import { inject } from "./di/inject";
1212
import type { Injector } from "./di/injector";
13+
import type { Provider } from "./di/providers";
1314

1415
/**
1516
* Symbol.for so that a definition produced by one copy of the CLI is still
@@ -159,6 +160,12 @@ export interface CommandDefinition<
159160
allowUnknownOptions?: boolean;
160161
disableAnalytics?: boolean;
161162
enableHooks?: boolean;
163+
/**
164+
* Providers added to each invocation's own injector, next to the context,
165+
* so a factory or class among them can inject the invocation. They are
166+
* built once per invocation, and `ctx.injector` resolves them.
167+
*/
168+
providers?: Provider[];
162169
/**
163170
* Runs once per invocation, before `canExecute`, and its result is handed to
164171
* `canExecute`, `run` and `postRun`. Sugar: a command may ignore it and call
@@ -234,6 +241,7 @@ const DEFINITION_FIELDS = [
234241
"canExecute",
235242
"disableAnalytics",
236243
"enableHooks",
244+
"providers",
237245
"setup",
238246
"run",
239247
"shortcuts",
@@ -268,7 +276,8 @@ const OPTION_TYPES: CommandOptionType[] = [
268276
const ACCEPTED_FORM =
269277
'defineCommand({ name: "widget|add", run(ctx) { ... } }) — with the ' +
270278
"optional fields description, options, arguments, allowUnknownOptions, " +
271-
"setup, canExecute, shortcuts, postRun, disableAnalytics and enableHooks. " +
279+
"providers, setup, canExecute, shortcuts, postRun, disableAnalytics and " +
280+
"enableHooks. " +
272281
'Or the class form, class WidgetAdd extends Command({ name: "widget|add" }) ' +
273282
"{ run() { ... } }, which declares the same fields except the handlers and " +
274283
"implements run, and optionally canExecute, postRun and shortcuts, as methods.";
@@ -526,6 +535,23 @@ const validateDefinition = (definition: any): void => {
526535
}
527536
}
528537

538+
if (definition.providers !== undefined) {
539+
const providers = definition.providers;
540+
const wellFormed =
541+
Array.isArray(providers) &&
542+
providers.every(
543+
(provider: any) =>
544+
typeof provider === "function" ||
545+
(isPlainObject(provider) && provider.provide !== undefined),
546+
);
547+
if (!wellFormed) {
548+
invalid(
549+
definition,
550+
"'providers' must be an array of providers - classes, or objects with a 'provide' token",
551+
);
552+
}
553+
}
554+
529555
if (definition.description !== undefined) {
530556
if (typeof definition.description !== "string") {
531557
invalid(definition, "'description' must be a string");

‎lib/common/di/injector.ts‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export interface InjectOptions {
2424
skipSelf?: boolean;
2525
}
2626

27-
type ProviderKind = "value" | "class" | "factory" | "lazyClass" | "legacyClass";
27+
type ProviderKind =
28+
"value" | "class" | "factory" | "lazyClass" | "legacyClass" | "multi";
2829

2930
interface IProviderRecord {
3031
displayName: string;
@@ -43,6 +44,8 @@ interface IProviderRecord {
4344
/** Every produced instance is retained, transients included — dispose() walks them. */
4445
instances: any[];
4546
constructing: boolean;
47+
/** One record per `multi` provider, in registration order. */
48+
multiRecords?: IProviderRecord[];
4649
}
4750

4851
// Shared across the whole injector tree so cycle reports show the full path
@@ -156,7 +159,29 @@ export class Injector {
156159
constructing: false,
157160
};
158161
}
159-
this.applyProvider(record, provider);
162+
if ((<Provider>provider).multi) {
163+
if (record.kind !== undefined && record.kind !== "multi") {
164+
throw new Error(
165+
`${record.displayName} is registered as a single provider; it cannot also take multi providers`,
166+
);
167+
}
168+
const entry: IProviderRecord = {
169+
displayName: record.displayName,
170+
shared: true,
171+
instances: [],
172+
constructing: false,
173+
};
174+
this.applyProvider(entry, provider);
175+
record.kind = "multi";
176+
record.multiRecords = (record.multiRecords || []).concat(entry);
177+
} else {
178+
if (record.kind === "multi") {
179+
throw new Error(
180+
`${record.displayName} takes multi providers; a single provider cannot replace them`,
181+
);
182+
}
183+
this.applyProvider(record, provider);
184+
}
160185
for (const key of keys) {
161186
this.providers.set(key, record);
162187
}
@@ -231,6 +256,11 @@ export class Injector {
231256
disposeOne(this.instantiationOrder[i]);
232257
}
233258
for (const record of new Set(this.providers.values())) {
259+
for (const entry of record.multiRecords || []) {
260+
for (const instance of entry.instances) {
261+
disposeOne(instance);
262+
}
263+
}
234264
for (const instance of record.instances) {
235265
disposeOne(instance);
236266
}
@@ -329,6 +359,12 @@ export class Injector {
329359
record.pendingLoader = undefined;
330360
}
331361

362+
if (record.kind === "multi") {
363+
return record.multiRecords.map((entry) =>
364+
this.instantiate(entry, ctorArguments),
365+
);
366+
}
367+
332368
if (record.shared && record.instances.length) {
333369
return record.instances[0];
334370
}

‎lib/common/di/providers.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ interface IBaseProvider<T> {
1010
provide: ProviderToken<T>;
1111
/** Defaults to true. `false` constructs a fresh instance per resolution. */
1212
shared?: boolean;
13+
/**
14+
* Contributes to an array under the token instead of replacing it: every
15+
* `multi` provider for one token is resolved, in registration order, and
16+
* `get(token)` returns the array. A token is either multi or single; the
17+
* entries of a child injector shadow the parent's whole array.
18+
*/
19+
multi?: boolean;
1320
}
1421

1522
export interface IClassProvider<T> extends IBaseProvider<T> {

‎lib/common/services/command-definition-adapter.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { Injector } from "../di/injector";
66
import { IDictionary, IDashedOption, IErrors } from "../declarations";
77
import { ICommand } from "../definitions/commands";
88
import { COMMAND_CONTEXT } from "../contracts/command-context";
9+
import {
10+
COMMAND_PRECONDITIONS,
11+
CommandPrecondition,
12+
} from "../contracts/command-preconditions";
913
import { CommandsService } from "../contracts/commands-service";
1014
import {
1115
COMMAND_OWNER,
@@ -371,6 +375,16 @@ export function createCommandFromDefinition<
371375
runResult?: Awaited<TResult>;
372376
}
373377

378+
const runPreconditions = async (
379+
preconditions: CommandPrecondition[],
380+
context: CommandContext<TSchema>,
381+
injector: Injector,
382+
): Promise<void> => {
383+
for (const precondition of preconditions) {
384+
await runInInjectionContext(injector, () => precondition(context));
385+
}
386+
};
387+
374388
const startSetup = (
375389
context: CommandContext<TSchema>,
376390
injector: Injector,
@@ -403,6 +417,7 @@ export function createCommandFromDefinition<
403417
// invocation; the price is one instance per invocation.
404418
const injector = targetInjector.createChild([
405419
{ provide: COMMAND_CONTEXT, useValue: context },
420+
...(definition.providers || []),
406421
...providers,
407422
]);
408423
context.injector = injector;
@@ -412,7 +427,16 @@ export function createCommandFromDefinition<
412427
setup: undefined,
413428
hasRun: false,
414429
};
415-
invocation.setup = startSetup(context, invocation.injector);
430+
// Preconditions judge the environment and run ahead of setup and of the
431+
// arguments policy, so being outside a project is what a bad invocation
432+
// reports first. Without any, setup starts synchronously as before.
433+
const preconditions =
434+
injector.get(COMMAND_PRECONDITIONS, { optional: true }) || [];
435+
invocation.setup = preconditions.length
436+
? runPreconditions(preconditions, context, injector).then(() =>
437+
startSetup(context, injector),
438+
)
439+
: startSetup(context, invocation.injector);
416440
currentInvocation = invocation;
417441

418442
return invocation;

‎lib/contracts/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ export type {
9292
// Promoted from the internal contracts index: the class form reads it in a
9393
// field initializer, and a per-command provider is written against it.
9494
export { COMMAND_CONTEXT } from "../common/contracts/command-context";
95+
export { COMMAND_PRECONDITIONS } from "../common/contracts/command-preconditions";
96+
export type { CommandPrecondition } from "../common/contracts/command-preconditions";
9597
// The in-process dispatcher a command or plugin runs or consults other
9698
// commands through.
9799
export { CommandsService } from "../common/contracts/commands-service";

0 commit comments

Comments
 (0)