Skip to content

Commit

Permalink
fix: validate arguments correctly (#724)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
satya164 authored Dec 8, 2024
1 parent a403f14 commit a34d80f
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 110 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build-templates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
language: kotlin-swift
- type: fabric-view
language: cpp
- type: legacy-module
- type: legacy-view
language: cpp
include:
- os: ubuntu-latest
Expand Down
23 changes: 8 additions & 15 deletions packages/create-react-native-library/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from 'fs-extra';
import kleur from 'kleur';
import yargs from 'yargs';
import ora from 'ora';
import prompts from './utils/prompts';
import { prompt } from './utils/prompt';
import generateExampleApp from './exampleApp/generateExampleApp';
import { addCodegenBuildScript } from './exampleApp/addCodegenBuildScript';
import { createInitialGitCommit } from './utils/initialCommit';
Expand Down Expand Up @@ -56,22 +56,15 @@ async function create(_argv: yargs.Arguments<Args>) {

const basename = path.basename(folder);

const { questions, singleChoiceAnswers } = await createQuestions({
basename,
local,
argv,
});
const questions = await createQuestions({ basename, local });

assertUserInput(questions, argv);

const promptAnswers = await prompts(questions);

const answers = {
...argv,
local,
...singleChoiceAnswers,
const promptAnswers = await prompt(questions, argv);
const answers: Answers = {
...promptAnswers,
} as Required<Answers>;
local,
};

assertUserInput(questions, answers);

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

if (hasPackageJson) {
// If we're under a project with package.json, ask the user if they want to create a local library
const answers = await prompts({
const answers = await prompt({
type: 'confirm',
name: 'local',
message: `Looks like you're under a project folder. Do you want to create a local library?`,
Expand All @@ -181,7 +174,7 @@ async function promptPath(argv: Args, local: boolean) {
if (argv.name && !local) {
folder = path.join(process.cwd(), argv.name);
} else {
const answers = await prompts({
const answers = await prompt({
type: 'text',
name: 'folder',
message: `Where do you want to create the library?`,
Expand Down
67 changes: 8 additions & 59 deletions packages/create-react-native-library/src/input.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { version } from '../package.json';
import validateNpmPackage from 'validate-npm-package-name';
import githubUsername from 'github-username';
import validateNpmPackage from 'validate-npm-package-name';
import type yargs from 'yargs';
import type { PromptObject } from './utils/prompts';
import { version } from '../package.json';
import type { Question } from './utils/prompt';
import { spawn } from './utils/spawn';

export type ArgName =
Expand Down Expand Up @@ -111,14 +111,6 @@ const TYPE_CHOICES: {
},
];

export type Question = Omit<
PromptObject<keyof Answers>,
'validate' | 'name'
> & {
validate?: (value: string) => boolean | string;
name: keyof Answers;
};

export const acceptedArgs: Record<ArgName, yargs.Options> = {
slug: {
description: 'Name of the npm package',
Expand Down Expand Up @@ -180,20 +172,18 @@ export type Answers = {
authorUrl: string;
repoUrl: string;
languages: ProjectLanguages;
type?: ProjectType;
example?: ExampleApp;
type: ProjectType;
example: ExampleApp;
reactNativeVersion?: string;
local?: boolean;
};

export async function createQuestions({
basename,
local,
argv,
}: {
basename: string;
local: boolean;
argv: Args;
}) {
let name, email;

Expand All @@ -204,7 +194,7 @@ export async function createQuestions({
// Ignore error
}

const initialQuestions: Question[] = [
const questions: Question<keyof Answers>[] = [
{
type: 'text',
name: 'slug',
Expand Down Expand Up @@ -295,7 +285,7 @@ export async function createQuestions({
];

if (!local) {
initialQuestions.push({
questions.push({
type: 'select',
name: 'example',
message: 'What type of example app do you want to create?',
Expand All @@ -313,48 +303,7 @@ export async function createQuestions({
});
}

const singleChoiceAnswers: Partial<Answers> = {};
const finalQuestions: Question[] = [];

for (const question of initialQuestions) {
// Skip questions which are passed as parameter and pass validation
const argValue = argv[question.name];
if (argValue && question.validate?.(argValue) !== false) {
continue;
}

// Don't prompt questions with a single choice
if (Array.isArray(question.choices) && question.choices.length === 1) {
const onlyChoice = question.choices[0]!;
singleChoiceAnswers[question.name] = onlyChoice.value;

continue;
}

const { type, choices } = question;

// Don't prompt dynamic questions with a single choice
if (type === 'select' && typeof choices === 'function') {
question.type = (prev, values, prompt) => {
const dynamicChoices = choices(prev, { ...argv, ...values }, prompt);

if (dynamicChoices && dynamicChoices.length === 1) {
const onlyChoice = dynamicChoices[0]!;
singleChoiceAnswers[question.name] = onlyChoice.value;
return null;
}

return type;
};
}

finalQuestions.push(question);
}

return {
questions: finalQuestions,
singleChoiceAnswers,
};
return questions;
}

export function createMetadata(answers: Answers) {
Expand Down
2 changes: 1 addition & 1 deletion packages/create-react-native-library/src/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function generateTemplateConfiguration({
}: {
bobVersion: string;
basename: string;
answers: Required<Answers>;
answers: Answers;
}): TemplateConfiguration {
const { slug, languages, type } = answers;

Expand Down
42 changes: 24 additions & 18 deletions packages/create-react-native-library/src/utils/assert.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import kleur from 'kleur';
import { spawn } from './spawn';
import type { Answers, Args, Question } from '../input';
import type { Answers, Args } from '../input';
import type { Question } from './prompt';

export async function assertNpxExists() {
try {
Expand All @@ -25,8 +26,8 @@ export async function assertNpxExists() {
* Makes sure the answers are in expected form and ends the process with error if they are not
*/
export function assertUserInput(
questions: Question[],
answers: Answers | Args
questions: Question<keyof Answers>[],
answers: Partial<Answers | Args>
) {
for (const [key, value] of Object.entries(answers)) {
if (value == null) {
Expand All @@ -39,35 +40,40 @@ export function assertUserInput(
continue;
}

let valid = question.validate ? question.validate(String(value)) : true;
let validation;

// We also need to guard against invalid choices
// If we don't already have a validation message to provide a better error
if (typeof valid !== 'string' && 'choices' in question) {
if ('choices' in question) {
const choices =
typeof question.choices === 'function'
? question.choices(
undefined,
// @ts-expect-error: it complains about optional values, but it should be fine
answers,
question
)
? question.choices(undefined, answers)
: question.choices;

if (choices && !choices.some((choice) => choice.value === value)) {
valid = `Supported values are - ${choices.map((c) =>
kleur.green(c.value)
)}`;
if (choices && choices.every((choice) => choice.value !== value)) {
if (choices.length > 1) {
validation = `Must be one of ${choices
.map((choice) => kleur.green(choice.value))
.join(', ')}`;
} else if (choices[0]) {
validation = `Must be '${kleur.green(choices[0].value)}'`;
} else {
validation = false;
}
}
}

if (valid !== true) {
if (validation == null && question.validate) {
validation = question.validate(String(value));
}

if (validation != null && validation !== true) {
let message = `Invalid value ${kleur.red(
String(value)
)} passed for ${kleur.blue(key)}`;

if (typeof valid === 'string') {
message += `: ${valid}`;
if (typeof validation === 'string') {
message += `: ${validation}`;
}

console.log(message);
Expand Down
98 changes: 98 additions & 0 deletions packages/create-react-native-library/src/utils/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import prompts from 'prompts';

type Choice = {
title: string;
value: string;
description?: string;
};

export type Question<T extends string> = Omit<
prompts.PromptObject<T>,
'validate' | 'name' | 'choices'
> & {
name: T;
validate?: (value: string) => boolean | string;
choices?:
| Choice[]
| ((prev: unknown, values: Partial<prompts.Answers<T>>) => Choice[]);
};

/**
* Wrapper around `prompts` with additional features:
*
* - Improved type-safety
* - Read answers from passed arguments
* - Skip questions with a single choice
* - Exit on canceling the prompt
*/
export async function prompt<T extends string>(
questions: Question<T>[] | Question<T>,
argv?: Record<T, string>,
options?: prompts.Options
) {
const singleChoiceAnswers = {};
const promptQuestions = [];

if (Array.isArray(questions)) {
for (const question of questions) {
// Skip questions which are passed as parameter and pass validation
const argValue = argv?.[question.name];

if (argValue && question.validate?.(argValue) !== false) {
continue;
}

// Don't prompt questions with a single choice
if (Array.isArray(question.choices) && question.choices.length === 1) {
const onlyChoice = question.choices[0];

if (onlyChoice?.value) {
// @ts-expect-error assume the passed value is correct
singleChoiceAnswers[question.name] = onlyChoice.value;
}

continue;
}

const { type, choices } = question;

// Don't prompt dynamic questions with a single choice
if (type === 'select' && typeof choices === 'function') {
question.type = (prev, values) => {
const dynamicChoices = choices(prev, { ...argv, ...values });

if (dynamicChoices && dynamicChoices.length === 1) {
const onlyChoice = dynamicChoices[0];

if (onlyChoice?.value) {
// @ts-expect-error assume the passed value is correct
singleChoiceAnswers[question.name] = onlyChoice.value;
}

return null;
}

return type;
};
}

promptQuestions.push(question);
}
} else {
promptQuestions.push(questions);
}

const promptAnswers = await prompts(promptQuestions, {
onCancel() {
// Exit the CLI on cancel
process.exit(1);
},
...options,
});

return {
...argv,
...singleChoiceAnswers,
...promptAnswers,
};
}
16 changes: 0 additions & 16 deletions packages/create-react-native-library/src/utils/prompts.ts

This file was deleted.

0 comments on commit a34d80f

Please sign in to comment.