Skip to content

Commit 60d2885

Browse files
committed
feat(commands): ask another command whether it can execute
canExecuteCommand(name, args) resolves a registered command and primes its options exactly as runCommand does, then returns its own canExecute verdict. The child builds its setup from its own services, so a command can reuse another's precondition without importing its handlers.
1 parent 92002a2 commit 60d2885

5 files changed

Lines changed: 191 additions & 0 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ interface ICommandsService {
2323
commandName: string,
2424
commandArguments?: string[],
2525
): Promise<void>;
26+
/**
27+
* Asks a command whether it could run, without running it. The command
28+
* builds its own setup from its own services.
29+
*/
30+
canExecuteCommandInProcess(
31+
commandName: string,
32+
commandArguments?: string[],
33+
): Promise<boolean>;
2634
}
2735

2836
/**

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,26 @@ export async function runCommand(
559559
await commandsService.executeCommandInProcess(name, args);
560560
}
561561

562+
/**
563+
* Asks a registered command whether it could run on `args`, without running it.
564+
* The named command is resolved and its options primed exactly as `runCommand`
565+
* does, and its own `canExecute` returns the verdict.
566+
*
567+
* This is how one command reuses another's precondition — `embed` asking
568+
* whether `prepare` would run. The child resolves its own services, so nothing
569+
* crosses between the two but the name and the arguments; pass only the
570+
* arguments the child's own `arguments` policy accepts.
571+
*/
572+
export async function canExecuteCommand(
573+
name: string,
574+
args: string[] = [],
575+
): Promise<boolean> {
576+
const commandsService =
577+
contextInjector().get<ICommandsService>("commandsService");
578+
579+
return commandsService.canExecuteCommandInProcess(name, args);
580+
}
581+
562582
/**
563583
* Registers a command with the CLI. Takes a Command() class, the result of
564584
* defineCommand(), or a bare definition, which it defines on the caller's

lib/common/services/commands-service.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,39 @@ export class CommandsService implements ICommandsService {
283283
}
284284
}
285285

286+
/**
287+
* The `canExecute` half of {@link executeCommandInProcess}: the named command
288+
* is resolved and its options are primed the same way, and its own
289+
* `canExecute` returns the verdict. The child builds its own setup from its
290+
* own services — nothing is threaded in from the caller — which is what lets
291+
* one command reuse another's precondition without importing its handlers.
292+
*/
293+
public async canExecuteCommandInProcess(
294+
commandName: string,
295+
commandArguments: string[] = [],
296+
): Promise<boolean> {
297+
this.inProcessDepth++;
298+
try {
299+
const command = this.$injector.resolveCommand(commandName);
300+
if (!command) {
301+
this.$errors.failWithHelp(
302+
`Unknown command '${helpers.stringReplaceAll(commandName, "|", " ")}'.`,
303+
);
304+
}
305+
306+
this.commands.push({ commandName, commandArguments });
307+
const restoreOptions = this.primeOptions(command);
308+
try {
309+
return await this.canExecuteCommand(commandName, commandArguments);
310+
} finally {
311+
restoreOptions();
312+
this.commands.pop();
313+
}
314+
} finally {
315+
this.inProcessDepth--;
316+
}
317+
}
318+
286319
/**
287320
* Merging a command's options into the parser rewrites the values the host
288321
* process is still running on: a declared default replaces the CLI-wide one

test/define-command.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
stringOption,
3030
} from "../lib/common/define-command";
3131
import {
32+
canExecuteCommand,
3233
createCommandFromDefinition,
3334
registerBuiltInCommand,
3435
registerCommand,
@@ -1344,6 +1345,128 @@ describe("defineCommand", () => {
13441345
});
13451346
});
13461347

1348+
describe("canExecuteCommand", () => {
1349+
const createInProcessInjector = (): IInjector => {
1350+
const testInjector = new Yok();
1351+
testInjector.register("errors", {
1352+
beginCommand: async (action: () => Promise<boolean>) => action(),
1353+
failWithHelp: (message: string) => {
1354+
throw new Error(message);
1355+
},
1356+
fail: (message: string) => {
1357+
throw new Error(message);
1358+
},
1359+
reportCommandError: async (ex: Error) => {
1360+
throw ex;
1361+
},
1362+
});
1363+
testInjector.register("hooksService", HooksServiceStub);
1364+
testInjector.register("logger", LoggerStub);
1365+
testInjector.register("staticConfig", {
1366+
disableAnalytics: true,
1367+
disableCommandHooks: true,
1368+
});
1369+
testInjector.register("extensibilityService", {});
1370+
testInjector.register("optionsTracker", {});
1371+
testInjector.register("options", {
1372+
validateOptions: (): void => undefined,
1373+
});
1374+
testInjector.register("commandsService", CommandsService);
1375+
return testInjector;
1376+
};
1377+
1378+
it("returns the named command's own verdict without running it", async () => {
1379+
const testInjector = createInProcessInjector();
1380+
let ran = false;
1381+
1382+
runInInjectionContext(testInjector, () => {
1383+
registerCommand(
1384+
defineCommand({
1385+
name: "dctest-can-yes",
1386+
arguments: "any",
1387+
canExecute: (context) => context.args[0] === "ok",
1388+
run: () => {
1389+
ran = true;
1390+
},
1391+
}),
1392+
);
1393+
});
1394+
1395+
const verdicts = [
1396+
await runInInjectionContext(testInjector, () =>
1397+
canExecuteCommand("dctest-can-yes", ["ok"]),
1398+
),
1399+
await runInInjectionContext(testInjector, () =>
1400+
canExecuteCommand("dctest-can-yes", ["nope"]),
1401+
),
1402+
];
1403+
1404+
assert.deepEqual(verdicts, [true, false]);
1405+
assert.isFalse(ran);
1406+
});
1407+
1408+
it("enforces the child's arguments policy before its canExecute", async () => {
1409+
const testInjector = createInProcessInjector();
1410+
let consulted = false;
1411+
1412+
runInInjectionContext(testInjector, () =>
1413+
registerCommand(
1414+
defineCommand({
1415+
name: "dctest-can-none",
1416+
canExecute: () => {
1417+
consulted = true;
1418+
return true;
1419+
},
1420+
run: (): void => undefined,
1421+
}),
1422+
),
1423+
);
1424+
1425+
await assert.isRejected(
1426+
runInInjectionContext(testInjector, () =>
1427+
canExecuteCommand("dctest-can-none", ["stray"]),
1428+
),
1429+
/doesn't accept parameters/,
1430+
);
1431+
assert.isFalse(consulted);
1432+
});
1433+
1434+
it("builds the child's setup from the child's own services", async () => {
1435+
const testInjector = createInProcessInjector();
1436+
testInjector.register("gadgetService", { ready: true });
1437+
1438+
runInInjectionContext(testInjector, () =>
1439+
registerCommand(
1440+
defineCommand({
1441+
name: "dctest-can-setup",
1442+
setup: () => ({
1443+
$gadgetService: inject<any>("gadgetService"),
1444+
}),
1445+
canExecute: (context, services) => services.$gadgetService.ready,
1446+
run: (): void => undefined,
1447+
}),
1448+
),
1449+
);
1450+
1451+
assert.isTrue(
1452+
await runInInjectionContext(testInjector, () =>
1453+
canExecuteCommand("dctest-can-setup"),
1454+
),
1455+
);
1456+
});
1457+
1458+
it("fails by name for a command that is not registered", async () => {
1459+
const testInjector = createInProcessInjector();
1460+
1461+
await assert.isRejected(
1462+
runInInjectionContext(testInjector, () =>
1463+
canExecuteCommand("dctest-can-missing"),
1464+
),
1465+
/Unknown command 'dctest-can-missing'/,
1466+
);
1467+
});
1468+
});
1469+
13471470
describe("positional argument specs", () => {
13481471
const platformCommand = (extra: any = {}) =>
13491472
createCommandFromDefinition(

test/stubs.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,13 @@ export class CommandsService implements ICommandsService {
13351335
return Promise.resolve();
13361336
}
13371337

1338+
public canExecuteCommandInProcess(
1339+
commandName: string,
1340+
commandArguments?: string[],
1341+
): Promise<boolean> {
1342+
return Promise.resolve(true);
1343+
}
1344+
13381345
public completeCommand(): Promise<boolean> {
13391346
return Promise.resolve(true);
13401347
}

0 commit comments

Comments
 (0)