Skip to content

Commit e44a046

Browse files
WIP: Adds prompting on invalid option sets in Zod-enabled commands
1 parent b2b02ae commit e44a046

4 files changed

Lines changed: 79 additions & 28 deletions

File tree

src/cli/cli.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import os from 'os';
55
import path from 'path';
66
import { fileURLToPath, pathToFileURL } from 'url';
77
import yargs from 'yargs-parser';
8-
import { ZodError } from 'zod';
8+
import { ZodCustomIssue, ZodError, ZodIssue } from 'zod';
99
import Command, { CommandArgs, CommandError } from '../Command.js';
1010
import GlobalOptions from '../GlobalOptions.js';
1111
import config from '../config.js';
@@ -186,14 +186,34 @@ async function execute(rawArgs: string[]): Promise<void> {
186186
break;
187187
}
188188
else {
189-
const hasNonRequiredErrors = result.error.errors.some(e => e.code !== 'invalid_type' || e.received !== 'undefined');
190189
const shouldPrompt = cli.getSettingWithDefaultValue<boolean>(settingsNames.prompt, true);
191190

192-
if (hasNonRequiredErrors === false &&
193-
shouldPrompt) {
191+
if (!shouldPrompt) {
192+
result.error.errors.forEach(e => {
193+
if (e.code === 'invalid_type' &&
194+
e.received === 'undefined') {
195+
e.message = `Required option not specified`;
196+
}
197+
});
198+
return cli.closeWithError(result.error, cli.optionsFromArgs, true);
199+
}
200+
201+
const missingRequiredValuesErrors: ZodIssue[] = result.error.errors
202+
.filter(e => (e.code === 'invalid_type' && e.received === 'undefined') ||
203+
(e.code === 'custom' && e.params?.customCode === 'required'));
204+
const optionSetErrors: ZodCustomIssue[] = result.error.errors
205+
.filter(e => e.code === 'custom' && e.params?.customCode === 'optionSet') as ZodCustomIssue[];
206+
const otherErrors: ZodIssue[] = result.error.errors
207+
.filter(e => !missingRequiredValuesErrors.includes(e) && !optionSetErrors.includes(e as ZodCustomIssue));
208+
209+
if (otherErrors.some(e => e)) {
210+
return cli.closeWithError(result.error, cli.optionsFromArgs, true);
211+
}
212+
213+
if (missingRequiredValuesErrors.some(e => e)) {
194214
await cli.error('🌶️ Provide values for the following parameters:');
195215

196-
for (const error of result.error.errors) {
216+
for (const error of missingRequiredValuesErrors) {
197217
const optionName = error.path.join('.');
198218
const optionInfo = cli.commandToExecute.options.find(o => o.name === optionName);
199219
const answer = await cli.promptForValue(optionInfo!);
@@ -206,15 +226,14 @@ async function execute(rawArgs: string[]): Promise<void> {
206226
return cli.closeWithError(e.message, cli.optionsFromArgs, true);
207227
}
208228
}
229+
230+
continue;
209231
}
210-
else {
211-
result.error.errors.forEach(e => {
212-
if (e.code === 'invalid_type' &&
213-
e.received === 'undefined') {
214-
e.message = `Required option not specified`;
215-
}
216-
});
217-
return cli.closeWithError(result.error, cli.optionsFromArgs, true);
232+
233+
if (optionSetErrors.some(e => e)) {
234+
for (const error of optionSetErrors) {
235+
await promptForOptionSetNameAndValue(cli.optionsFromArgs, error.params?.options);
236+
}
218237
}
219238
}
220239
}
@@ -1057,6 +1076,16 @@ function shouldTrimOutput(output: string | undefined): boolean {
10571076
return output === 'text';
10581077
}
10591078

1079+
async function promptForOptionSetNameAndValue(args: CommandArgs, options: string[]): Promise<void> {
1080+
await cli.error(`🌶️ Please specify one of the following options:`);
1081+
1082+
const selectedOptionName = await prompt.forSelection<string>({ message: `Option to use:`, choices: options.map((choice: any) => { return { name: choice, value: choice }; }) });
1083+
const optionValue = await prompt.forInput({ message: `${selectedOptionName}:` });
1084+
1085+
args.options[selectedOptionName] = optionValue;
1086+
await cli.error('');
1087+
}
1088+
10601089
export const cli = {
10611090
closeWithError,
10621091
commands,

src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,14 @@ await (async () => {
1717
updateNotifier.default({ pkg: app.packageJson() as any }).notify({ defer: false });
1818
}
1919

20-
await cli.execute(process.argv.slice(2));
20+
try {
21+
await cli.execute(process.argv.slice(2));
22+
}
23+
catch (err) {
24+
if (err instanceof Error && err.name === 'ExitPromptError') {
25+
process.exit(1);
26+
}
27+
28+
cli.closeWithError(err, cli.optionsFromArgs || { options: {} });
29+
}
2130
})();

src/m365/commands/login.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,33 +52,53 @@ class LoginCommand extends Command {
5252
return schema
5353
.refine(options => typeof options.appId !== 'undefined' || cli.getClientId() || options.authType === 'identity' || options.authType === 'federatedIdentity', {
5454
message: `appId is required. TIP: use the "m365 setup" command to configure the default appId.`,
55-
path: ['appId']
55+
path: ['appId'],
56+
params: {
57+
customCode: 'required'
58+
}
5659
})
5760
.refine(options => options.authType !== 'password' || options.userName, {
5861
message: 'Username is required when using password authentication.',
59-
path: ['userName']
62+
path: ['userName'],
63+
params: {
64+
customCode: 'required'
65+
}
6066
})
6167
.refine(options => options.authType !== 'password' || options.password, {
6268
message: 'Password is required when using password authentication.',
63-
path: ['password']
69+
path: ['password'],
70+
params: {
71+
customCode: 'required'
72+
}
6473
})
6574
.refine(options => options.authType !== 'certificate' || !(options.certificateFile && options.certificateBase64Encoded), {
6675
message: 'Specify either certificateFile or certificateBase64Encoded, but not both.',
67-
path: ['certificateBase64Encoded']
76+
path: ['certificateBase64Encoded'],
77+
params: {
78+
customCode: 'optionSet',
79+
options: ['certificateFile', 'certificateBase64Encoded']
80+
}
6881
})
6982
.refine(options => options.authType !== 'certificate' ||
7083
options.certificateFile ||
7184
options.certificateBase64Encoded ||
7285
cli.getConfig().get(settingsNames.clientCertificateFile) ||
7386
cli.getConfig().get(settingsNames.clientCertificateBase64Encoded), {
7487
message: 'Specify either certificateFile or certificateBase64Encoded.',
75-
path: ['certificateFile']
88+
path: ['certificateFile'],
89+
params: {
90+
customCode: 'optionSet',
91+
options: ['certificateFile', 'certificateBase64Encoded']
92+
}
7693
})
7794
.refine(options => options.authType !== 'secret' ||
7895
options.secret ||
7996
cli.getConfig().get(settingsNames.clientSecret), {
8097
message: 'Secret is required when using secret authentication.',
81-
path: ['secret']
98+
path: ['secret'],
99+
params: {
100+
customCode: 'required'
101+
}
82102
});
83103
}
84104

src/utils/prompt.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,7 @@ export const prompt = {
8383
const errorOutput: string = cli.getSettingWithDefaultValue(settingsNames.errorOutput, 'stderr');
8484

8585
return inquirerInput
86-
.default(config, { output: errorOutput === 'stderr' ? process.stderr : process.stdout })
87-
.catch(error => {
88-
if (error instanceof Error && error.name === 'ExitPromptError') {
89-
return ''; // noop; handle Ctrl + C
90-
}
91-
92-
throw error;
93-
});
86+
.default(config, { output: errorOutput === 'stderr' ? process.stderr : process.stdout });
9487
},
9588

9689
/* c8 ignore next 9 */

0 commit comments

Comments
 (0)