From 62b7e39749b1d0d9586a85a0ad26b089a28ea782 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Tue, 10 Dec 2024 13:44:24 +0100 Subject: [PATCH] feat: add non-interactive mode this adds `--interactive` and `--non-interactive` arguments. it is automatically disabled on non-interactive terminals and CI --- .../create-react-native-library/src/index.ts | 126 ++++-------- .../create-react-native-library/src/inform.ts | 15 +- .../create-react-native-library/src/input.ts | 111 +++++++---- .../src/template.ts | 1 + .../src/utils/assert.ts | 61 ------ .../src/utils/local.ts | 24 --- .../src/utils/prompt.ts | 181 ++++++++++++++++-- 7 files changed, 283 insertions(+), 236 deletions(-) diff --git a/packages/create-react-native-library/src/index.ts b/packages/create-react-native-library/src/index.ts index 130842de6..eaec87450 100644 --- a/packages/create-react-native-library/src/index.ts +++ b/packages/create-react-native-library/src/index.ts @@ -16,17 +16,15 @@ import { createMetadata, createQuestions, type Answers, - type Args, } from './input'; import { applyTemplates, generateTemplateConfiguration } from './template'; -import { assertNpxExists, assertUserInput } from './utils/assert'; +import { assertNpxExists } from './utils/assert'; import { createInitialGitCommit } from './utils/initialCommit'; import { prompt } from './utils/prompt'; import { resolveNpmPackageVersion } from './utils/resolveNpmPackageVersion'; import { addNitroDependencyToLocalLibrary, linkLocalLibrary, - promptLocalLibrary, } from './utils/local'; import { determinePackageManager } from './utils/packageManager'; @@ -34,15 +32,15 @@ const FALLBACK_BOB_VERSION = '0.40.5'; const FALLBACK_NITRO_MODULES_VERSION = '0.22.1'; const SUPPORTED_REACT_NATIVE_VERSION = '0.78.2'; +type Args = Partial & { + name?: string; + $0: string; + [key: string]: unknown; +}; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions yargs - .command( - '$0 [name]', - 'create a react native library', - acceptedArgs, - // @ts-expect-error Some types are still incompatible - create - ) + .command('$0 [name]', 'create a react native library', acceptedArgs, create) .demandCommand() .recommendCommands() .fail(printErrorHelp) @@ -52,7 +50,7 @@ yargs }) .strict().argv; -async function create(_argv: yargs.Arguments) { +async function create(_argv: Args) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _, $0, ...argv } = _argv; @@ -66,27 +64,20 @@ async function create(_argv: yargs.Arguments) { FALLBACK_NITRO_MODULES_VERSION ); - const local = await promptLocalLibrary(argv); - const folder = await promptPath(argv, local); - await assertNpxExists(); - const basename = path.basename(folder); + const questions = await createQuestions(argv); - const questions = await createQuestions({ basename, local }); - - assertUserInput(questions, argv); + const promptAnswers = await prompt(questions, argv, { + interactive: argv.interactive, + }); - const promptAnswers = await prompt(questions, argv); - const answers: Answers = { + const answers = { ...promptAnswers, reactNativeVersion: promptAnswers.reactNativeVersion ?? SUPPORTED_REACT_NATIVE_VERSION, - local, }; - assertUserInput(questions, answers); - const bobVersion = await bobVersionPromise; const nitroModulesVersion = @@ -101,10 +92,12 @@ async function create(_argv: yargs.Arguments) { // Nitro codegen's version is always the same as nitro modules version. nitroCodegen: nitroModulesVersion, }, - basename, + basename: path.basename(answers.name ?? answers.directory), answers, }); + const folder = path.resolve(process.cwd(), answers.directory); + await fs.mkdirp(folder); if (answers.reactNativeVersion !== SUPPORTED_REACT_NATIVE_VERSION) { @@ -148,76 +141,35 @@ async function create(_argv: yargs.Arguments) { )}!\n` ); - if (!local) { - await createInitialGitCommit(folder); - - printSuccessMessage(); - - printNonLocalLibNextSteps(config); - return; - } - - const packageManager = await determinePackageManager(); - - let addedNitro = false; - if (config.project.moduleConfig === 'nitro-modules') { - addedNitro = await addNitroDependencyToLocalLibrary(config); - } + if (answers.local) { + const packageManager = await determinePackageManager(); - const linkedLocalLibrary = await linkLocalLibrary( - config, - folder, - packageManager - ); + let addedNitro = false; - printSuccessMessage(); + if (config.project.moduleConfig === 'nitro-modules') { + addedNitro = await addNitroDependencyToLocalLibrary(config); + } - printLocalLibNextSteps({ - config, - packageManager, - linkedLocalLibrary, - addedNitro, - folder, - }); -} + const linkedLocalLibrary = await linkLocalLibrary( + config, + folder, + packageManager + ); -async function promptPath(argv: Args, local: boolean) { - let folder: string; + printSuccessMessage(); - if (argv.name && !local) { - folder = path.join(process.cwd(), argv.name); - } else { - const answers = await prompt({ - type: 'text', - name: 'folder', - message: `Where do you want to create the library?`, - initial: - local && argv.name && !argv.name.includes('/') - ? `modules/${argv.name}` - : argv.name, - validate: (input) => { - if (!input) { - return 'Cannot be empty'; - } - - if (fs.pathExistsSync(path.join(process.cwd(), input))) { - return 'Folder already exists'; - } - - return true; - }, + printLocalLibNextSteps({ + config, + packageManager, + linkedLocalLibrary, + addedNitro, + folder, }); + } else { + await createInitialGitCommit(folder); - folder = path.join(process.cwd(), answers.folder); - } + printSuccessMessage(); - if (await fs.pathExists(folder)) { - throw new Error( - `A folder already exists at ${kleur.blue( - folder - )}! Please specify another folder name or delete the existing one.` - ); + printNonLocalLibNextSteps(config); } - - return folder; } diff --git a/packages/create-react-native-library/src/inform.ts b/packages/create-react-native-library/src/inform.ts index 47a8a99fd..e161f33bc 100644 --- a/packages/create-react-native-library/src/inform.ts +++ b/packages/create-react-native-library/src/inform.ts @@ -88,20 +88,19 @@ export function printLocalLibNextSteps({ export function printErrorHelp(message: string, error: Error) { console.log('\n'); - if (error) { - console.log(kleur.red(error.message)); - throw error; - } - if (message) { - console.log(kleur.red(message)); + console.log(message); } else { console.log( - kleur.red(`An unknown error occurred. See '--help' for usage guide.`) + `An unknown error occurred. See ${kleur.blue('--help')} for usage guide.` ); } - process.exit(1); + if (error) { + console.log('\n'); + + throw error; + } } export function printUsedRNVersion( diff --git a/packages/create-react-native-library/src/input.ts b/packages/create-react-native-library/src/input.ts index f14fa18d8..39fc00bcc 100644 --- a/packages/create-react-native-library/src/input.ts +++ b/packages/create-react-native-library/src/input.ts @@ -4,19 +4,8 @@ import type yargs from 'yargs'; import { version } from '../package.json'; import type { Question } from './utils/prompt'; import { spawn } from './utils/spawn'; - -export type ArgName = - | 'slug' - | 'description' - | 'authorName' - | 'authorEmail' - | 'authorUrl' - | 'repoUrl' - | 'languages' - | 'type' - | 'local' - | 'example' - | 'reactNativeVersion'; +import fs from 'fs-extra'; +import path from 'path'; export type ProjectLanguages = 'kotlin-objc' | 'kotlin-swift' | 'js'; @@ -101,7 +90,7 @@ const TYPE_CHOICES: { }, ]; -export const acceptedArgs: Record = { +export const acceptedArgs = { slug: { description: 'Name of the npm package', type: 'string', @@ -147,13 +136,19 @@ export const acceptedArgs: Record = { type: 'string', choices: EXAMPLE_CHOICES.map(({ value }) => value), }, -} as const; + interactive: { + description: 'Whether to run in interactive mode', + type: 'boolean', + }, +} as const satisfies Record< + Exclude, + yargs.Options +>; -export type Args = Record; export type ExampleApp = 'none' | 'test-app' | 'expo' | 'vanilla'; export type Answers = { - name: string; + directory: string; slug: string; description: string; authorName: string; @@ -165,34 +160,78 @@ export type Answers = { example: ExampleApp; reactNativeVersion: string; local?: boolean; + interactive?: boolean; }; export async function createQuestions({ - basename, + name, local, }: { - basename: string; - local: boolean; + name?: string; + local?: boolean; }) { - let name, email; + let fullname, email; try { - name = await spawn('git', ['config', '--get', 'user.name']); + fullname = await spawn('git', ['config', '--get', 'user.name']); email = await spawn('git', ['config', '--get', 'user.email']); } catch (e) { // Ignore error } const questions: Question[] = [ + { + type: + local == null && + (await fs.pathExists(path.join(process.cwd(), 'package.json'))) + ? 'confirm' + : null, + name: 'local', + message: `Looks like you're under a project folder. Do you want to create a local library?`, + initial: local, + default: false, + }, + { + type: (_, answers) => (name && !(answers.local ?? local) ? null : 'text'), + name: 'directory', + message: `Where do you want to create the library?`, + initial: (_: string, answers: Answers) => { + if ((answers.local ?? local) && name && !name?.includes('/')) { + return `modules/${name}`; + } + + return name ?? ''; + }, + validate: (input) => { + if (!input) { + return 'Cannot be empty'; + } + + if (fs.pathExistsSync(path.join(process.cwd(), input))) { + return 'Folder already exists'; + } + + return true; + }, + default: name, + }, { type: 'text', name: 'slug', message: 'What is the name of the npm package?', - initial: validateNpmPackage(basename).validForNewPackages - ? /^(@|react-native)/.test(basename) - ? basename - : `react-native-${basename}` - : undefined, + initial: (_: string, answers: Answers) => { + const basename = path.basename(answers.directory ?? name ?? ''); + + if (validateNpmPackage(basename).validForNewPackages) { + if (/^(@|react-native)/.test(basename)) { + return basename; + } + + return `react-native-${basename}`; + } + + return ''; + }, validate: (input) => validateNpmPackage(input).validForNewPackages || 'Must be a valid npm package name', @@ -204,14 +243,14 @@ export async function createQuestions({ validate: (input) => Boolean(input) || 'Cannot be empty', }, { - type: local ? null : 'text', + type: (_, answers) => (answers.local ?? local ? null : 'text'), name: 'authorName', message: 'What is the name of package author?', - initial: name, + initial: fullname, validate: (input) => Boolean(input) || 'Cannot be empty', }, { - type: local ? null : 'text', + type: (_, answers) => (answers.local ?? local ? null : 'text'), name: 'authorEmail', message: 'What is the email address for the package author?', initial: email, @@ -219,13 +258,13 @@ export async function createQuestions({ /^\S+@\S+$/.test(input) || 'Must be a valid email address', }, { - type: local ? null : 'text', + type: (_, answers) => (answers.local ?? local ? null : 'text'), name: 'authorUrl', message: 'What is the URL for the package author?', // @ts-expect-error this is supported, but types are wrong - initial: async (previous: string) => { + initial: async (_: string, answers: Answers) => { try { - const username = await githubUsername(previous); + const username = await githubUsername(answers.authorEmail); return `https://github.com/${username}`; } catch (e) { @@ -237,7 +276,7 @@ export async function createQuestions({ validate: (input) => /^https?:\/\//.test(input) || 'Must be a valid URL', }, { - type: local ? null : 'text', + type: (_, answers) => (answers.local ?? local ? null : 'text'), name: 'repoUrl', message: 'What is the URL for the repository?', initial: (_: string, answers: Answers) => { @@ -303,8 +342,9 @@ export async function createQuestions({ export function createMetadata(answers: Answers) { // Some of the passed args can already be derived from the generated package.json file. - const ignoredAnswers: (keyof Answers)[] = [ + const ignoredAnswers: (keyof Answers | 'name')[] = [ 'name', + 'directory', 'slug', 'description', 'authorName', @@ -314,6 +354,7 @@ export function createMetadata(answers: Answers) { 'example', 'reactNativeVersion', 'local', + 'interactive', ]; type AnswerEntries = [ diff --git a/packages/create-react-native-library/src/template.ts b/packages/create-react-native-library/src/template.ts index 901ee136f..f13d86187 100644 --- a/packages/create-react-native-library/src/template.ts +++ b/packages/create-react-native-library/src/template.ts @@ -174,6 +174,7 @@ export async function applyTemplates( folder: string ) { const { local } = answers; + if (local) { await applyTemplate(config, COMMON_LOCAL_FILES, folder); } else { diff --git a/packages/create-react-native-library/src/utils/assert.ts b/packages/create-react-native-library/src/utils/assert.ts index 8bc7fe3c1..3c3b2d538 100644 --- a/packages/create-react-native-library/src/utils/assert.ts +++ b/packages/create-react-native-library/src/utils/assert.ts @@ -1,7 +1,5 @@ import kleur from 'kleur'; import { spawn } from './spawn'; -import type { Answers, Args } from '../input'; -import type { Question } from './prompt'; export async function assertNpxExists() { try { @@ -19,62 +17,3 @@ 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: Partial -) { - for (const [key, value] of Object.entries(answers)) { - if (value == null) { - continue; - } - - const question = questions.find((q) => q.name === key); - - if (question == null) { - continue; - } - - 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 ('choices' in question) { - const choices = - typeof question.choices === 'function' - ? question.choices(undefined, answers) - : question.choices; - - 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 (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 validation === 'string') { - message += `: ${validation}`; - } - - throw new Error(message); - } - } -} diff --git a/packages/create-react-native-library/src/utils/local.ts b/packages/create-react-native-library/src/utils/local.ts index a3de726f7..e9b92b7c0 100644 --- a/packages/create-react-native-library/src/utils/local.ts +++ b/packages/create-react-native-library/src/utils/local.ts @@ -1,35 +1,11 @@ import fs from 'fs-extra'; import path from 'path'; -import { prompt } from './prompt'; import type { TemplateConfiguration } from '../template'; -import type { Args } from '../input'; type PackageJson = { dependencies?: Record; }; -export async function promptLocalLibrary(argv: Args): Promise { - if (typeof argv.local === 'boolean') { - return argv.local; - } - - const packageJsonPath = await findAppPackageJsonPath(); - - if (packageJsonPath === null) { - return false; - } - - // If we're under a project with package.json, ask the user if they want to create a local library - 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?`, - initial: true, - }); - - return answers.local; -} - /** @returns `true` if successfull */ export async function addNitroDependencyToLocalLibrary( config: TemplateConfiguration diff --git a/packages/create-react-native-library/src/utils/prompt.ts b/packages/create-react-native-library/src/utils/prompt.ts index 22e6f0a98..3ca8914f7 100644 --- a/packages/create-react-native-library/src/utils/prompt.ts +++ b/packages/create-react-native-library/src/utils/prompt.ts @@ -1,4 +1,5 @@ -import prompts from 'prompts'; +import prompts, { type Answers, type InitialReturnValue } from 'prompts'; +import kleur from 'kleur'; type Choice = { title: string; @@ -14,7 +15,8 @@ export type Question = Omit< validate?: (value: string) => boolean | string; choices?: | Choice[] - | ((prev: unknown, values: Partial>) => Choice[]); + | ((prev: unknown, values: Partial>) => Choice[]); + default?: InitialReturnValue; }; /** @@ -23,15 +25,44 @@ export type Question = Omit< * - Improved type-safety * - Read answers from passed arguments * - Skip questions with a single choice + * - Validate answers * - Exit on canceling the prompt + * - Handle non-interactive mode */ -export async function prompt( - questions: Question[] | Question, - argv?: Record, - options?: prompts.Options -) { - const singleChoiceAnswers = {}; - const promptQuestions: Question[] = []; +export async function prompt< + PromptAnswers extends Record, + Argv extends Record | undefined, +>( + questions: + | Question>[] + | Question>, + argv: Argv, + options: prompts.Options & { + interactive: boolean | undefined; + } +): Promise { + const interactive = + options?.interactive ?? + Boolean( + process.stdout.isTTY && process.env.TERM !== 'dumb' && !process.env.CI + ); + + const onCancel = () => { + // Exit the CLI on Ctrl+C + process.exit(1); + }; + + const onError = (message: string) => { + console.log(message); + process.exit(1); + }; + + if (argv) { + validate(argv, questions, onError); + } + + const defaultAnswers = {}; + const promptQuestions: Question>[] = []; if (Array.isArray(questions)) { for (const question of questions) { @@ -40,12 +71,23 @@ export async function prompt( // Skip questions which are passed as parameter and pass validation const argValue = argv?.[question.name]; - if (argValue && question.validate?.(argValue) !== false) { + if (argValue && question.validate?.(String(argValue)) !== false) { continue; } const { type, choices } = question; + // Track default value from questions + if (type && question.default != null) { + // @ts-expect-error assume the passed value is correct + defaultAnswers[question.name] = question.default; + + // Don't prompt questions with a default value when not interactive + if (!interactive) { + continue; + } + } + // Don't prompt questions with a single choice if ( type === 'select' && @@ -56,7 +98,7 @@ export async function prompt( if (onlyChoice?.value) { // @ts-expect-error assume the passed value is correct - singleChoiceAnswers[question.name] = onlyChoice.value; + defaultAnswers[question.name] = onlyChoice.value; } continue; @@ -74,7 +116,7 @@ export async function prompt( if (onlyChoice?.value) { // @ts-expect-error assume the passed value is correct - singleChoiceAnswers[question.name] = onlyChoice.value; + defaultAnswers[question.name] = onlyChoice.value; } return null; @@ -91,17 +133,114 @@ export async function prompt( promptQuestions.push(questions); } - const promptAnswers = await prompts(promptQuestions, { - onCancel() { - // Exit the CLI on cancel - process.exit(1); - }, - ...options, - }); + let promptAnswers; + + if (interactive) { + promptAnswers = await prompts(promptQuestions, { + ...options, + onCancel, + }); + } else { + const missingQuestions = promptQuestions.reduce( + (acc, question) => { + let type = question.type; + + if (typeof question.type === 'function') { + // @ts-expect-error assume the passed value is correct + type = question.type(null, argv, null); + } + + if (type != null) { + acc.push( + question.name + .replace(/([A-Z]+)/g, '-$1') + .replace(/^-/, '') + .toLowerCase() + ); + } + + return acc; + }, + [] + ); + + if (missingQuestions.length) { + onError( + `Missing values for options: ${missingQuestions + .map(kleur.blue) + .join(', ')}` + ); + } + } - return { + const result = { ...argv, - ...singleChoiceAnswers, + ...defaultAnswers, ...promptAnswers, }; + + validate(result, questions, onError); + + return result as PromptAnswers & Argv; +} + +function validate( + argv: Partial>, + questions: Question[] | Question, + onError: (message: string) => void +) { + for (const [key, value] of Object.entries(argv)) { + if (value == null) { + continue; + } + + const question = Array.isArray(questions) + ? questions.find((q) => q.name === key) + : questions.name === key + ? questions + : null; + + if (question == null) { + continue; + } + + 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 ('choices' in question) { + const choices = + typeof question.choices === 'function' + ? question.choices(undefined, argv) + : question.choices; + + 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 (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 validation === 'string') { + message += `: ${validation}`; + } + + onError(message); + } + } }