Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: validate arguments correctly #724

Merged
merged 1 commit into from
Dec 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

Loading