Skip to content

Commit a34d80f

Browse files
authored
fix: validate arguments correctly (#724)
currently the arguments are not validated correctly (#722) since they are validated against filtered questions. this fixes it to validate against the full list of questions. this also refactors the prompts module to move argument handling to the prompt function.
1 parent a403f14 commit a34d80f

File tree

7 files changed

+140
-110
lines changed

7 files changed

+140
-110
lines changed

.github/workflows/build-templates.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
language: kotlin-swift
4646
- type: fabric-view
4747
language: cpp
48-
- type: legacy-module
48+
- type: legacy-view
4949
language: cpp
5050
include:
5151
- os: ubuntu-latest

packages/create-react-native-library/src/index.ts

+8-15
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from 'fs-extra';
33
import kleur from 'kleur';
44
import yargs from 'yargs';
55
import ora from 'ora';
6-
import prompts from './utils/prompts';
6+
import { prompt } from './utils/prompt';
77
import generateExampleApp from './exampleApp/generateExampleApp';
88
import { addCodegenBuildScript } from './exampleApp/addCodegenBuildScript';
99
import { createInitialGitCommit } from './utils/initialCommit';
@@ -56,22 +56,15 @@ async function create(_argv: yargs.Arguments<Args>) {
5656

5757
const basename = path.basename(folder);
5858

59-
const { questions, singleChoiceAnswers } = await createQuestions({
60-
basename,
61-
local,
62-
argv,
63-
});
59+
const questions = await createQuestions({ basename, local });
6460

6561
assertUserInput(questions, argv);
6662

67-
const promptAnswers = await prompts(questions);
68-
69-
const answers = {
70-
...argv,
71-
local,
72-
...singleChoiceAnswers,
63+
const promptAnswers = await prompt(questions, argv);
64+
const answers: Answers = {
7365
...promptAnswers,
74-
} as Required<Answers>;
66+
local,
67+
};
7568

7669
assertUserInput(questions, answers);
7770

@@ -161,7 +154,7 @@ async function promptLocalLibrary(argv: Args) {
161154

162155
if (hasPackageJson) {
163156
// If we're under a project with package.json, ask the user if they want to create a local library
164-
const answers = await prompts({
157+
const answers = await prompt({
165158
type: 'confirm',
166159
name: 'local',
167160
message: `Looks like you're under a project folder. Do you want to create a local library?`,
@@ -181,7 +174,7 @@ async function promptPath(argv: Args, local: boolean) {
181174
if (argv.name && !local) {
182175
folder = path.join(process.cwd(), argv.name);
183176
} else {
184-
const answers = await prompts({
177+
const answers = await prompt({
185178
type: 'text',
186179
name: 'folder',
187180
message: `Where do you want to create the library?`,

packages/create-react-native-library/src/input.ts

+8-59
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { version } from '../package.json';
2-
import validateNpmPackage from 'validate-npm-package-name';
31
import githubUsername from 'github-username';
2+
import validateNpmPackage from 'validate-npm-package-name';
43
import type yargs from 'yargs';
5-
import type { PromptObject } from './utils/prompts';
4+
import { version } from '../package.json';
5+
import type { Question } from './utils/prompt';
66
import { spawn } from './utils/spawn';
77

88
export type ArgName =
@@ -111,14 +111,6 @@ const TYPE_CHOICES: {
111111
},
112112
];
113113

114-
export type Question = Omit<
115-
PromptObject<keyof Answers>,
116-
'validate' | 'name'
117-
> & {
118-
validate?: (value: string) => boolean | string;
119-
name: keyof Answers;
120-
};
121-
122114
export const acceptedArgs: Record<ArgName, yargs.Options> = {
123115
slug: {
124116
description: 'Name of the npm package',
@@ -180,20 +172,18 @@ export type Answers = {
180172
authorUrl: string;
181173
repoUrl: string;
182174
languages: ProjectLanguages;
183-
type?: ProjectType;
184-
example?: ExampleApp;
175+
type: ProjectType;
176+
example: ExampleApp;
185177
reactNativeVersion?: string;
186178
local?: boolean;
187179
};
188180

189181
export async function createQuestions({
190182
basename,
191183
local,
192-
argv,
193184
}: {
194185
basename: string;
195186
local: boolean;
196-
argv: Args;
197187
}) {
198188
let name, email;
199189

@@ -204,7 +194,7 @@ export async function createQuestions({
204194
// Ignore error
205195
}
206196

207-
const initialQuestions: Question[] = [
197+
const questions: Question<keyof Answers>[] = [
208198
{
209199
type: 'text',
210200
name: 'slug',
@@ -295,7 +285,7 @@ export async function createQuestions({
295285
];
296286

297287
if (!local) {
298-
initialQuestions.push({
288+
questions.push({
299289
type: 'select',
300290
name: 'example',
301291
message: 'What type of example app do you want to create?',
@@ -313,48 +303,7 @@ export async function createQuestions({
313303
});
314304
}
315305

316-
const singleChoiceAnswers: Partial<Answers> = {};
317-
const finalQuestions: Question[] = [];
318-
319-
for (const question of initialQuestions) {
320-
// Skip questions which are passed as parameter and pass validation
321-
const argValue = argv[question.name];
322-
if (argValue && question.validate?.(argValue) !== false) {
323-
continue;
324-
}
325-
326-
// Don't prompt questions with a single choice
327-
if (Array.isArray(question.choices) && question.choices.length === 1) {
328-
const onlyChoice = question.choices[0]!;
329-
singleChoiceAnswers[question.name] = onlyChoice.value;
330-
331-
continue;
332-
}
333-
334-
const { type, choices } = question;
335-
336-
// Don't prompt dynamic questions with a single choice
337-
if (type === 'select' && typeof choices === 'function') {
338-
question.type = (prev, values, prompt) => {
339-
const dynamicChoices = choices(prev, { ...argv, ...values }, prompt);
340-
341-
if (dynamicChoices && dynamicChoices.length === 1) {
342-
const onlyChoice = dynamicChoices[0]!;
343-
singleChoiceAnswers[question.name] = onlyChoice.value;
344-
return null;
345-
}
346-
347-
return type;
348-
};
349-
}
350-
351-
finalQuestions.push(question);
352-
}
353-
354-
return {
355-
questions: finalQuestions,
356-
singleChoiceAnswers,
357-
};
306+
return questions;
358307
}
359308

360309
export function createMetadata(answers: Answers) {

packages/create-react-native-library/src/template.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export function generateTemplateConfiguration({
9999
}: {
100100
bobVersion: string;
101101
basename: string;
102-
answers: Required<Answers>;
102+
answers: Answers;
103103
}): TemplateConfiguration {
104104
const { slug, languages, type } = answers;
105105

packages/create-react-native-library/src/utils/assert.ts

+24-18
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import kleur from 'kleur';
22
import { spawn } from './spawn';
3-
import type { Answers, Args, Question } from '../input';
3+
import type { Answers, Args } from '../input';
4+
import type { Question } from './prompt';
45

56
export async function assertNpxExists() {
67
try {
@@ -25,8 +26,8 @@ export async function assertNpxExists() {
2526
* Makes sure the answers are in expected form and ends the process with error if they are not
2627
*/
2728
export function assertUserInput(
28-
questions: Question[],
29-
answers: Answers | Args
29+
questions: Question<keyof Answers>[],
30+
answers: Partial<Answers | Args>
3031
) {
3132
for (const [key, value] of Object.entries(answers)) {
3233
if (value == null) {
@@ -39,35 +40,40 @@ export function assertUserInput(
3940
continue;
4041
}
4142

42-
let valid = question.validate ? question.validate(String(value)) : true;
43+
let validation;
4344

4445
// We also need to guard against invalid choices
4546
// If we don't already have a validation message to provide a better error
46-
if (typeof valid !== 'string' && 'choices' in question) {
47+
if ('choices' in question) {
4748
const choices =
4849
typeof question.choices === 'function'
49-
? question.choices(
50-
undefined,
51-
// @ts-expect-error: it complains about optional values, but it should be fine
52-
answers,
53-
question
54-
)
50+
? question.choices(undefined, answers)
5551
: question.choices;
5652

57-
if (choices && !choices.some((choice) => choice.value === value)) {
58-
valid = `Supported values are - ${choices.map((c) =>
59-
kleur.green(c.value)
60-
)}`;
53+
if (choices && choices.every((choice) => choice.value !== value)) {
54+
if (choices.length > 1) {
55+
validation = `Must be one of ${choices
56+
.map((choice) => kleur.green(choice.value))
57+
.join(', ')}`;
58+
} else if (choices[0]) {
59+
validation = `Must be '${kleur.green(choices[0].value)}'`;
60+
} else {
61+
validation = false;
62+
}
6163
}
6264
}
6365

64-
if (valid !== true) {
66+
if (validation == null && question.validate) {
67+
validation = question.validate(String(value));
68+
}
69+
70+
if (validation != null && validation !== true) {
6571
let message = `Invalid value ${kleur.red(
6672
String(value)
6773
)} passed for ${kleur.blue(key)}`;
6874

69-
if (typeof valid === 'string') {
70-
message += `: ${valid}`;
75+
if (typeof validation === 'string') {
76+
message += `: ${validation}`;
7177
}
7278

7379
console.log(message);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import prompts from 'prompts';
2+
3+
type Choice = {
4+
title: string;
5+
value: string;
6+
description?: string;
7+
};
8+
9+
export type Question<T extends string> = Omit<
10+
prompts.PromptObject<T>,
11+
'validate' | 'name' | 'choices'
12+
> & {
13+
name: T;
14+
validate?: (value: string) => boolean | string;
15+
choices?:
16+
| Choice[]
17+
| ((prev: unknown, values: Partial<prompts.Answers<T>>) => Choice[]);
18+
};
19+
20+
/**
21+
* Wrapper around `prompts` with additional features:
22+
*
23+
* - Improved type-safety
24+
* - Read answers from passed arguments
25+
* - Skip questions with a single choice
26+
* - Exit on canceling the prompt
27+
*/
28+
export async function prompt<T extends string>(
29+
questions: Question<T>[] | Question<T>,
30+
argv?: Record<T, string>,
31+
options?: prompts.Options
32+
) {
33+
const singleChoiceAnswers = {};
34+
const promptQuestions = [];
35+
36+
if (Array.isArray(questions)) {
37+
for (const question of questions) {
38+
// Skip questions which are passed as parameter and pass validation
39+
const argValue = argv?.[question.name];
40+
41+
if (argValue && question.validate?.(argValue) !== false) {
42+
continue;
43+
}
44+
45+
// Don't prompt questions with a single choice
46+
if (Array.isArray(question.choices) && question.choices.length === 1) {
47+
const onlyChoice = question.choices[0];
48+
49+
if (onlyChoice?.value) {
50+
// @ts-expect-error assume the passed value is correct
51+
singleChoiceAnswers[question.name] = onlyChoice.value;
52+
}
53+
54+
continue;
55+
}
56+
57+
const { type, choices } = question;
58+
59+
// Don't prompt dynamic questions with a single choice
60+
if (type === 'select' && typeof choices === 'function') {
61+
question.type = (prev, values) => {
62+
const dynamicChoices = choices(prev, { ...argv, ...values });
63+
64+
if (dynamicChoices && dynamicChoices.length === 1) {
65+
const onlyChoice = dynamicChoices[0];
66+
67+
if (onlyChoice?.value) {
68+
// @ts-expect-error assume the passed value is correct
69+
singleChoiceAnswers[question.name] = onlyChoice.value;
70+
}
71+
72+
return null;
73+
}
74+
75+
return type;
76+
};
77+
}
78+
79+
promptQuestions.push(question);
80+
}
81+
} else {
82+
promptQuestions.push(questions);
83+
}
84+
85+
const promptAnswers = await prompts(promptQuestions, {
86+
onCancel() {
87+
// Exit the CLI on cancel
88+
process.exit(1);
89+
},
90+
...options,
91+
});
92+
93+
return {
94+
...argv,
95+
...singleChoiceAnswers,
96+
...promptAnswers,
97+
};
98+
}

packages/create-react-native-library/src/utils/prompts.ts

-16
This file was deleted.

0 commit comments

Comments
 (0)