diff --git a/src/commands/auth/auth.ts b/src/commands/auth/auth.ts index bb5e596..e9f042f 100644 --- a/src/commands/auth/auth.ts +++ b/src/commands/auth/auth.ts @@ -1,10 +1,10 @@ -import inquirer from 'inquirer'; import createCommand from '../createCommand.js'; import pluginConfigFile from './plugins/config.json'; import allPlugins from './plugins/index.js'; import { ICommand } from 'types/ICommand.js'; -import { log } from 'utils/logger.js'; +import { log, logFailure } from 'utils/logger.js'; +import { selectOption } from 'utils/inquirer/prompts.js'; import colors from 'picocolors'; /** @@ -21,18 +21,13 @@ const auth: ICommand = createCommand({ { name: 'Other - I will set up auth myself', value: 'other' }, ]; try { - const answers = await inquirer.prompt([ - { - type: 'list', - name: 'auth', - message: 'Choose an authentication option for your project:', - choices: authOptions.map((auth) => auth.name), - }, - ]); - - const selectedOption = authOptions.find((auth) => auth.name === answers.auth); + const selectedOption = await selectOption({ + message: 'Choose an authentication option for your project:', + options: authOptions, + name: 'auth', + invalidMessage: 'Invalid auth selection.', + }); if (!selectedOption) { - console.error('Invalid auth selection.'); return; } const { cyan } = colors; @@ -45,7 +40,7 @@ const auth: ICommand = createCommand({ break; } } catch (error) { - console.error('Failed to add auth:', error); + logFailure('Failed to add auth', error); } }, }); diff --git a/src/commands/auth/plugins/kinde.ts b/src/commands/auth/plugins/kinde.ts index 04e82b4..72a06e4 100644 --- a/src/commands/auth/plugins/kinde.ts +++ b/src/commands/auth/plugins/kinde.ts @@ -1,10 +1,10 @@ -import inquirer from 'inquirer'; import colors from 'picocolors'; import { Plugin } from 'types/plugin.js'; import { NPM } from 'utils/package-manager.js'; import templateCopyTransfer from 'utils/template-copy-transfer.js'; import { log, LogLevel, LogColor } from 'utils/logger.js'; import { askOpenPage } from 'utils/inquirer/ask-open-page.js'; +import { confirmPrompt } from 'utils/inquirer/prompts.js'; import checkEnvVars from 'utils/env/check-env-vars.js'; /** @@ -32,14 +32,7 @@ const kinde: Plugin = { steps.forEach((step, index) => { log(`${index + 1}. ${step}`); }); - await inquirer.prompt([ - { - type: 'confirm', - name: 'continue', - message: 'Continue to next steps?', - default: true, - }, - ]); + await confirmPrompt('Continue to next steps?'); log( `Under ${bold('Quick start')}, a few steps have already been completed for you on behalf of this CLI:`, undefined, @@ -62,14 +55,7 @@ const kinde: Plugin = { 'KINDE_POST_LOGIN_REDIRECT_URL', ]; while (!finishedEnv) { - await inquirer.prompt([ - { - type: 'confirm', - name: 'done', - message: 'Done?', - default: true, - }, - ]); + await confirmPrompt('Done?'); if (checkEnvVars(requiredEnvVars)) { log('Auth is now set up. It was that easy! 🎉', LogLevel.success, undefined, true); diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 1d3ff9d..fe12705 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -1,9 +1,10 @@ -import inquirer from 'inquirer'; import createCommand from '../createCommand.js'; import pluginConfigFile from './plugins/config.json'; import allPlugins from './plugins/index.js'; import { PluginRegistry } from 'types/plugin.js'; import { ICommand } from 'types/ICommand.js'; +import { selectOption } from 'utils/inquirer/prompts.js'; +import { logFailure } from 'utils/logger.js'; /** * Adds a database to the project by prompting the user to select a database option. @@ -22,24 +23,18 @@ const addDatabase: ICommand = createCommand({ */ ]; try { - const answers = await inquirer.prompt([ - { - type: 'list', - name: 'database', - message: 'Choose a database for your project:', - choices: dbOptions.map((db) => db.name), - }, - ]); - - const selectedOption = dbOptions.find((db) => db.name === answers.database); + const selectedOption = await selectOption({ + message: 'Choose a database for your project:', + options: dbOptions, + name: 'database', + invalidMessage: 'Invalid database selection.', + }); if (!selectedOption) { - console.error('Invalid database selection.'); return; } - // console.log(`Setting up ${selectedOption.name} for your project...`); await setupDatabase(selectedOption.value, context.plugins); } catch (error) { - console.error('Failed to add database:', error); + logFailure('Failed to add database', error); } }, }); @@ -62,15 +57,7 @@ async function setupDatabase(database: string, plugins: PluginRegistry) { // break; */ } - if (pluginsToInstall.length > 0) { - // try { - // console.log(`Installing ${packageToInstall} in ${projectPath}...`); - // // NPM.install(packageToInstall); - // console.log(`${packageToInstall} installed successfully.`); - // } catch (error) { - // console.error(`Error installing ${packageToInstall}:`, error); - // } - } else { + if (pluginsToInstall.length === 0) { console.log('No database package specified for installation.'); } } diff --git a/src/commands/database/plugins/mongoose.ts b/src/commands/database/plugins/mongoose.ts index 28eedaf..9e4d467 100644 --- a/src/commands/database/plugins/mongoose.ts +++ b/src/commands/database/plugins/mongoose.ts @@ -1,10 +1,10 @@ -import inquirer from 'inquirer'; import { Plugin } from 'types/plugin.js'; import { NPM } from 'utils/package-manager.js'; import writeToEnv from 'utils/env/write-to-env.js'; -import { log, LogColor, LogLevel } from 'utils/logger.js'; +import { log, logFailure, LogColor, LogLevel } from 'utils/logger.js'; import templateCopyTransfer from 'utils/template-copy-transfer.js'; import { askOpenPage } from 'utils/inquirer/ask-open-page.js'; +import { promptRequiredInput } from 'utils/inquirer/prompts.js'; // MongoDB with Mongoose ORM const mongoose: Plugin = { @@ -24,20 +24,11 @@ const mongoose: Plugin = { LogColor.cyan, true ); - const resUrl = await inquirer.prompt([ - { - type: 'input', - name: 'url', - message: 'Enter your MongoDB connection URL:', - validate: (input) => { - if (input.length === 0) { - return 'MongoDB connection URL cannot be empty.'; - } - return true; - }, - }, - ]); - let url = resUrl.url; + let url = await promptRequiredInput({ + message: 'Enter your MongoDB connection URL:', + emptyMessage: 'MongoDB connection URL cannot be empty.', + name: 'url', + }); // If "" is in the URL, replace it with the actual password by prompting the user if (url.includes('')) { log( @@ -46,20 +37,13 @@ const mongoose: Plugin = { LogColor.cyan, true ); - const resPw = await inquirer.prompt([ - { - type: 'password', - name: 'password', - message: 'Enter your database user password:', - validate: (input) => { - if (input.length === 0) { - return 'Database user password cannot be empty.'; - } - return true; - }, - }, - ]); - url = url.replace('', resPw.password); + const password = await promptRequiredInput({ + message: 'Enter your database user password:', + emptyMessage: 'Database user password cannot be empty.', + name: 'password', + password: true, + }); + url = url.replace('', password); } // Add the MONDODB_URL to the user's .env.local file writeToEnv('MONGODB_URL', url); @@ -73,7 +57,7 @@ const mongoose: Plugin = { true ); } catch (error) { - console.error('Failed to set up database for project:', error); + logFailure('Failed to set up database for project', error); } }, }; diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index c7eff08..4167342 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -1,10 +1,11 @@ -import inquirer from 'inquirer'; import { validateGitHubStatus } from 'utils/git/check-git.js'; import { ICommand } from 'types/ICommand.js'; import createCommand from '../createCommand.js'; import allPlugins from './plugins/index.js'; import pluginConfigFile from './plugins/config.json'; import { askOpenPage } from 'utils/inquirer/ask-open-page.js'; +import { selectOption } from 'utils/inquirer/prompts.js'; +import { logFailure } from 'utils/logger.js'; const deploymentOptions = [ { @@ -25,31 +26,21 @@ const deploy: ICommand = createCommand({ plugins: allPlugins, pluginConfigFile, action: async () => { - inquirer - .prompt([ - { - type: 'list', - name: 'target', - message: 'Choose your deployment target:', - choices: deploymentOptions.map((option) => option.name), - }, - ]) - .then(async (answers) => { - try { - await validateGitHubStatus(); - const selectedOption = deploymentOptions.find((option) => option.name === answers.target); - if (selectedOption) - await askOpenPage( - `Log in to ${selectedOption.name} to deploy your project`, - selectedOption.url - ); - } catch (error) { - console.error('Failed to deploy project:', error); - } - }) - .catch((error) => { - console.error('Failed to prompt:', error); + try { + const selectedOption = await selectOption({ + message: 'Choose your deployment target:', + options: deploymentOptions, + name: 'target', + invalidMessage: 'Invalid deployment target selection.', }); + if (!selectedOption) { + return; + } + await validateGitHubStatus(); + await askOpenPage(`Log in to ${selectedOption.name} to deploy your project`, selectedOption.url); + } catch (error) { + logFailure('Failed to deploy project', error); + } }, }); diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index 42680cf..97082a3 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -1,7 +1,6 @@ import fs from 'fs'; import path from 'path'; import inquirer from 'inquirer'; -import { execSync } from 'child_process'; import { chdir } from 'process'; import installPlugins from 'utils/install-plugins.js'; import colors from 'picocolors'; @@ -10,7 +9,10 @@ import createCommand from '../createCommand.js'; import { ICommand } from 'types/ICommand.js'; import allPlugins from './plugins/index.js'; import pluginConfigFile from './plugins/config.json'; -import { log, LogLevel, LogColor, command } from 'utils/logger.js'; +import { log, logFailure, LogLevel, LogColor, command } from 'utils/logger.js'; +import runCommand from 'utils/exec.js'; +import { writeJsonFile } from 'utils/fs/json-file.js'; +import writeProjectFile from 'utils/fs/write-project-file.js'; /** * Initializes a new project by prompting the user for a project name and a GitHub template. @@ -22,8 +24,8 @@ const initProject: ICommand = createCommand({ pluginConfigFile, action: async (context) => { const { cyan } = colors; - inquirer - .prompt([ + try { + const answers = await inquirer.prompt([ { type: 'input', name: 'projectName', @@ -40,56 +42,43 @@ const initProject: ICommand = createCommand({ return 'Project name must be all lowercase and can only include letters, numbers, underscores, and hyphens.'; }, }, - ]) - .then(async (answers) => { - try { - const projectName = answers.projectName; - const projectPath = path.join(process.cwd(), projectName); - const configPath = path.join(projectPath, '.nextquickrc'); - // const defaultConfig = { projectName, projectPath }; - const defaultConfig = { projectName }; + ]); - if (!fs.existsSync(projectPath)) { - fs.mkdirSync(projectPath, { recursive: true }); - } + const projectName = answers.projectName; + const projectPath = path.join(process.cwd(), projectName); + const configPath = path.join(projectPath, '.nextquickrc'); + const defaultConfig = { projectName }; - createNextApp(projectPath); + if (!fs.existsSync(projectPath)) { + fs.mkdirSync(projectPath, { recursive: true }); + } - chdir(projectPath); + createNextApp(projectPath); - setupGitRepo(projectPath); + chdir(projectPath); - // Create an .env.local file - const envLocalPath = path.join(projectPath, '.env.local'); - fs.writeFileSync(envLocalPath, '', 'utf-8'); + setupGitRepo(projectPath); - installPlugins(context.plugins); + // Create an .env.local file + writeProjectFile('.env.local', '', { projectPath }); - execSync('npm run prettier', { stdio: 'inherit' }); + installPlugins(context.plugins); - fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf-8'); - log( - `Config file .nextquickrc created in ${configPath}`, - LogLevel.checkmark, - LogColor.cyan, - true, - true - ); - log(`Project ${cyan(projectName)} initialized.`, LogLevel.checkmark); - log(`Run ${command(`cd ${projectName}`)} then run ${command('next-quick')} to view new commands.`); - } catch (error) { - console.error('Failed to initialize project:', error); - } - }) - .catch((error) => { - console.error('Failed to prompt:', error); - }); + runCommand('npm run prettier'); + + writeJsonFile(configPath, defaultConfig); + log(`Config file .nextquickrc created in ${configPath}`, LogLevel.checkmark, LogColor.cyan, true, true); + log(`Project ${cyan(projectName)} initialized.`, LogLevel.checkmark); + log(`Run ${command(`cd ${projectName}`)} then run ${command('next-quick')} to view new commands.`); + } catch (error) { + logFailure('Failed to initialize project', error); + } }, }); function createNextApp(projectPath: string) { const FLAGS = '--use-npm --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"'; - execSync(`npx create-next-app@latest . ${FLAGS}`, { cwd: projectPath, stdio: 'inherit' }); + runCommand(`npx create-next-app@latest . ${FLAGS}`, { cwd: projectPath }); } export default initProject; diff --git a/src/commands/init/plugins/eslint.ts b/src/commands/init/plugins/eslint.ts index b6df3fc..7dee22e 100644 --- a/src/commands/init/plugins/eslint.ts +++ b/src/commands/init/plugins/eslint.ts @@ -1,6 +1,5 @@ import { Plugin } from 'types/plugin.js'; -import path from 'path'; -import fs from 'fs'; +import writeProjectFile from 'utils/fs/write-project-file.js'; /** * EsLint should already be installed in create-next-app, @@ -16,11 +15,10 @@ const eslint: Plugin = { }; function createEslintIgnore() { - fs.writeFileSync( - path.join(process.cwd(), '.eslintignore'), + writeProjectFile( + '.eslintignore', `*.json -`, - { encoding: 'utf8' } +` ); } diff --git a/src/commands/init/plugins/husky.ts b/src/commands/init/plugins/husky.ts index 2a70e2c..f22c5fd 100644 --- a/src/commands/init/plugins/husky.ts +++ b/src/commands/init/plugins/husky.ts @@ -1,9 +1,8 @@ import { Plugin } from 'types/plugin.js'; import { NPM } from 'utils/package-manager.js'; import config from './config.json'; -import fs from 'fs'; -import path from 'path'; -import { execSync } from 'child_process'; +import writeProjectFile from 'utils/fs/write-project-file.js'; +import runCommand from 'utils/exec.js'; /** * Set up husky pre-commit hooks @@ -15,8 +14,8 @@ const husky: Plugin = { NPM.installDev('husky lint-staged'); createLintStagedRc(); createHuskyPreCommitHook(); - execSync('git init'); - execSync('npx husky'); + runCommand('git init'); + runCommand('npx husky'); updatePackageJson(packageJsonAdditions); }, }; @@ -54,25 +53,17 @@ module.exports = { }; `; - fs.writeFileSync(path.join(process.cwd(), '.lintstagedrc.js'), content, { encoding: 'utf8' }); + writeProjectFile('.lintstagedrc.js', content); } function createHuskyPreCommitHook() { - const huskyDirPath = path.join(process.cwd(), '.husky'); - const preCommitFilePath = path.join(huskyDirPath, 'pre-commit'); - - // Ensure the .husky directory exists - if (!fs.existsSync(huskyDirPath)) { - fs.mkdirSync(huskyDirPath); - } - // Write the pre-commit hook script, set to executable - fs.writeFileSync( - preCommitFilePath, + writeProjectFile( + '.husky/pre-commit', `#!/bin/sh npx lint-staged `, - { mode: 0o755, encoding: 'utf8' } + { mode: 0o755 } ); } diff --git a/src/commands/init/plugins/jest.ts b/src/commands/init/plugins/jest.ts index c84f6b8..ad222ea 100644 --- a/src/commands/init/plugins/jest.ts +++ b/src/commands/init/plugins/jest.ts @@ -1,7 +1,6 @@ import { Plugin } from 'types/plugin.js'; import { NPM } from 'utils/package-manager.js'; -import path from 'path'; -import fs from 'fs'; +import writeProjectFile from 'utils/fs/write-project-file.js'; const jest: Plugin = { install: () => { @@ -13,8 +12,7 @@ const jest: Plugin = { console.log(cyan('Creating Jest config file. Please follow steps below:')); execSync('npm init jest@latest', { stdio: 'inherit' }); */ - const targetPath = path.join(process.cwd(), 'jest.config.ts'); - fs.writeFileSync(targetPath, jestConfigTemplate); + writeProjectFile('jest.config.ts', jestConfigTemplate); }, }; diff --git a/src/commands/init/plugins/prettier.ts b/src/commands/init/plugins/prettier.ts index 43bbe9d..d9d8a27 100644 --- a/src/commands/init/plugins/prettier.ts +++ b/src/commands/init/plugins/prettier.ts @@ -1,7 +1,9 @@ import { Plugin } from 'types/plugin.js'; -import fs from 'fs'; import path from 'path'; import { NPM } from 'utils/package-manager.js'; +import { readJsonFile, writeJsonFile } from 'utils/fs/json-file.js'; +import writeProjectFile from 'utils/fs/write-project-file.js'; +import { StringIndexableObject } from 'types/shared.js'; const VERBOSE = false; @@ -24,11 +26,8 @@ const prettier: Plugin = { function updateEslintConfig(projectPath: string) { const eslintConfigPath = path.join(projectPath, '.eslintrc.json'); // Adjust based on actual ESLint config file name - let eslintConfig; - try { - eslintConfig = JSON.parse(fs.readFileSync(eslintConfigPath, 'utf8')); - } catch (error) { - console.error('Failed to read ESLint config:', error); + const eslintConfig = readJsonFile(eslintConfigPath); + if (!eslintConfig) { return; } @@ -37,7 +36,7 @@ function updateEslintConfig(projectPath: string) { eslintConfig.extends = [...extendsArray, 'plugin:prettier/recommended']; // Write the updated configuration back to the file - fs.writeFileSync(eslintConfigPath, JSON.stringify(eslintConfig, null, 2), 'utf8'); + writeJsonFile(eslintConfigPath, eslintConfig); VERBOSE && console.log('ESLint configuration updated to include Prettier.'); } @@ -56,18 +55,17 @@ function createPrettierConfig(projectPath: string) { jsxSingleQuote: true, }; - const configPath = path.join(projectPath, '.prettierrc'); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); + writeJsonFile(path.join(projectPath, '.prettierrc'), config); VERBOSE && console.log('Prettier configuration created.'); } function createPrettierIgnore(projectPath: string) { - fs.writeFileSync( - path.join(projectPath, '.prettierignore'), + writeProjectFile( + '.prettierignore', ` `, - { encoding: 'utf8' } + { projectPath } ); } diff --git a/src/commands/init/selectGitHubTemplate.ts b/src/commands/init/selectGitHubTemplate.ts index 0f23b0b..b534e01 100644 --- a/src/commands/init/selectGitHubTemplate.ts +++ b/src/commands/init/selectGitHubTemplate.ts @@ -1,10 +1,8 @@ import fs from 'fs'; -import path from 'path'; import execAsync from 'utils/exec-async.js'; +import writeProjectFile from 'utils/fs/write-project-file.js'; -const templates = [ - { name: 'None', url: 'None' }, -]; +const templates = [{ name: 'None', url: 'None' }]; const prompt = { type: 'list', @@ -37,11 +35,8 @@ async function cloneTemplate(githubUrl: string, projectName: string): Promise(filePath: string): T | undefined { + let content = ''; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + logFailure(`Failed to read ${filePath}`, error); + return undefined; + } + try { + return JSON.parse(content) as T; + } catch (error) { + logFailure(`Failed to parse ${filePath}`, error); + return undefined; + } +} + +/** + * Serializes data as formatted JSON and writes it to a file. + * @returns Whether the write succeeded. + */ +export function writeJsonFile(filePath: string, data: unknown): boolean { + try { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); + return true; + } catch (error) { + logFailure(`Failed to write ${filePath}`, error); + return false; + } +} diff --git a/src/utils/fs/write-project-file.ts b/src/utils/fs/write-project-file.ts new file mode 100644 index 0000000..beb10fd --- /dev/null +++ b/src/utils/fs/write-project-file.ts @@ -0,0 +1,18 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Writes a file relative to the project directory (defaults to the current working directory). + * @returns The absolute path of the written file. + */ +export default function writeProjectFile( + relativePath: string, + content: string, + options: { projectPath?: string; mode?: number } = {} +): string { + const { projectPath = process.cwd(), mode } = options; + const filePath = path.join(projectPath, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, { encoding: 'utf8', ...(mode !== undefined ? { mode } : {}) }); + return filePath; +} diff --git a/src/utils/git/check-git.ts b/src/utils/git/check-git.ts index 4236b6c..f33dfb3 100644 --- a/src/utils/git/check-git.ts +++ b/src/utils/git/check-git.ts @@ -1,7 +1,7 @@ import { execSync } from 'child_process'; -import inquirer from 'inquirer'; import colors from 'picocolors'; import { log, LogLevel } from 'utils/logger.js'; +import { confirmPrompt } from 'utils/inquirer/prompts.js'; /** * Validates the current Git repository status to ensure that the project can be deployed. @@ -23,16 +23,11 @@ async function validateGitHubStatus() { process.exit(1); } if (hasUnpushedChanges(true)) { - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'continue', - message: "You have uncommitted changes that won't be reflected in the deployment. Continue?", - default: false, - prefix: yellow('⚠️'), - }, - ]); - if (!answers.continue) { + const confirmed = await confirmPrompt( + "You have uncommitted changes that won't be reflected in the deployment. Continue?", + { default: false, prefix: yellow('⚠️') } + ); + if (!confirmed) { process.exit(1); } } diff --git a/src/utils/git/setup-git-repo.ts b/src/utils/git/setup-git-repo.ts index 73f0618..88ecd2b 100644 --- a/src/utils/git/setup-git-repo.ts +++ b/src/utils/git/setup-git-repo.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { execSync } from 'child_process'; +import runCommand from 'utils/exec.js'; import { log, LogLevel, LogColor } from 'utils/logger.js'; function isGitRepositorySimpleCheck(directory: string) { @@ -9,7 +9,7 @@ function isGitRepositorySimpleCheck(directory: string) { export default function setupGitRepo(directory: string) { if (!isGitRepositorySimpleCheck(directory)) { - execSync('git init', { cwd: directory, stdio: 'inherit' }); + runCommand('git init', { cwd: directory }); log('Initialized a new Git repository.', LogLevel.checkmark, LogColor.cyan, undefined, true); } else { log('Existing Git repository found.', LogLevel.checkmark, LogColor.cyan, undefined, true); diff --git a/src/utils/inquirer/ask-open-page.ts b/src/utils/inquirer/ask-open-page.ts index 289ef2c..06ae0b6 100644 --- a/src/utils/inquirer/ask-open-page.ts +++ b/src/utils/inquirer/ask-open-page.ts @@ -1,18 +1,11 @@ -import inquirer from 'inquirer'; import open from 'open'; import colors from 'picocolors'; +import { confirmPrompt } from 'utils/inquirer/prompts.js'; export async function askOpenPage(action: string, url: string) { const { cyan } = colors; - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'continue', - message: `\n${action}: ${cyan(url)}\nOpen page now?`, - default: true, - }, - ]); - if (!answers.continue) { + const confirmed = await confirmPrompt(`\n${action}: ${cyan(url)}\nOpen page now?`); + if (!confirmed) { process.exit(1); } open(url); diff --git a/src/utils/inquirer/prompts.ts b/src/utils/inquirer/prompts.ts new file mode 100644 index 0000000..cbf41fa --- /dev/null +++ b/src/utils/inquirer/prompts.ts @@ -0,0 +1,73 @@ +import inquirer from 'inquirer'; +import { log, LogLevel } from 'utils/logger.js'; + +interface NamedOption { + name: string; +} + +/** + * Asks a yes/no question. + */ +export async function confirmPrompt( + message: string, + options: { default?: boolean; prefix?: string } = {} +): Promise { + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message, + default: options.default ?? true, + ...(options.prefix ? { prefix: options.prefix } : {}), + }, + ]); + return answers.confirmed; +} + +/** + * Prompts the user to pick one of `options` by its display name. + * @returns The selected option, or undefined if the answer matches no option. + */ +export async function selectOption(config: { + message: string; + options: T[]; + name?: string; + invalidMessage?: string; +}): Promise { + const { message, options, name = 'selection', invalidMessage = 'Invalid selection.' } = config; + const answers = await inquirer.prompt([ + { + type: 'list', + name, + message, + choices: options.map((option) => option.name), + }, + ]); + const selectedOption = options.find((option) => option.name === answers[name]); + if (!selectedOption) { + log(invalidMessage, LogLevel.error); + return undefined; + } + return selectedOption; +} + +/** + * Prompts for a value that cannot be left empty. + */ +export async function promptRequiredInput(config: { + message: string; + emptyMessage: string; + name?: string; + password?: boolean; +}): Promise { + const { message, emptyMessage, name = 'value', password = false } = config; + const answers = await inquirer.prompt([ + { + type: password ? 'password' : 'input', + name, + message, + validate: (input: string) => (input.length === 0 ? emptyMessage : true), + }, + ]); + return answers[name]; +} diff --git a/src/utils/install-plugins.ts b/src/utils/install-plugins.ts index 46e6b9f..dd4d793 100644 --- a/src/utils/install-plugins.ts +++ b/src/utils/install-plugins.ts @@ -1,9 +1,9 @@ import { PluginRegistry } from 'types/plugin.js'; import { StringIndexableObject } from 'types/shared.js'; import colors from 'picocolors'; -import fs from 'fs'; import path from 'path'; -import { log, LogLevel, LogColor } from 'utils/logger.js'; +import { log, logFailure, LogLevel, LogColor } from 'utils/logger.js'; +import { readJsonFile, writeJsonFile } from 'utils/fs/json-file.js'; export default function installPlugins(plugins: PluginRegistry) { const cyan = colors.cyan; @@ -16,7 +16,7 @@ export default function installPlugins(plugins: PluginRegistry) { const plugin = plugins[pluginName]; plugin.install(packageJsonAdditions); } catch (error) { - console.error(`Failed to initialize plugin ${pluginName}:`, error); + logFailure(`Failed to initialize plugin ${pluginName}`, error); } }); // Add new JSON to package.json all at once @@ -25,14 +25,10 @@ export default function installPlugins(plugins: PluginRegistry) { function applyPackageJsonAdditions(updates: StringIndexableObject) { const packageJsonPath = path.join(process.cwd(), 'package.json'); - let content = ''; - try { - content = fs.readFileSync(packageJsonPath, 'utf8'); - } catch (e) { - console.error('Failed to read package.json:', e); + const packageJson = readJsonFile(packageJsonPath); + if (!packageJson) { return; } - const packageJson = JSON.parse(content); // Iterate over the updates object and merge each section into packageJson Object.keys(updates).forEach((key) => { @@ -46,10 +42,8 @@ function applyPackageJsonAdditions(updates: StringIndexableObject) { }); // Write the updated package.json back - try { - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2), 'utf8'); - } catch (e) { - console.error('Failed to write package.json:', e); + if (!writeJsonFile(packageJsonPath, packageJson)) { + return; } log(`package.json has been updated.`, LogLevel.checkmark, LogColor.cyan, true); } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index c4ee697..bf26645 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -96,3 +96,10 @@ export function log( console.log(''); } } + +/** + * Logs an error alongside its cause. + */ +export function logFailure(text: string, error: unknown) { + log(`${text}: ${error instanceof Error ? error.message : error}`, LogLevel.error); +} diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index 874163a..6fc8b27 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -1,14 +1,10 @@ -import { execSync } from 'child_process'; - -function runCmd(cmd: string) { - execSync(cmd, { stdio: 'inherit' }); -} +import runCommand from 'utils/exec.js'; export const NPM = { install: (pkg: string) => { - runCmd(`npm install ${pkg}`); + runCommand(`npm install ${pkg}`); }, installDev: (pkg: string) => { - runCmd(`npm install ${pkg} -D`); + runCommand(`npm install ${pkg} -D`); }, }; diff --git a/src/utils/read-config-file.ts b/src/utils/read-config-file.ts index 8534a07..38f1a28 100644 --- a/src/utils/read-config-file.ts +++ b/src/utils/read-config-file.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { readJsonFile } from 'utils/fs/json-file.js'; // Check if the .nextquickrc file exists in the current directory export function nextquickRcExists(): string | false { @@ -14,14 +15,11 @@ export function nextquickRcExists(): string | false { export function readConfig(): { projectPath?: string } | undefined { const configPath = nextquickRcExists(); if (configPath) { - const configFile = fs.readFileSync(configPath, 'utf-8'); - try { - const config = JSON.parse(configFile); - return config; - } catch (error) { - console.error('Failed to parse .nextquickrc:', error); + const config = readJsonFile<{ projectPath?: string }>(configPath); + if (!config) { process.exit(1); } + return config; } console.error( '.nextquickrc does not exist in the current directory. Run `nextquick init` to create a new project.'