From f6e60f8b70171c93006c277b65f63d8e18c42319 Mon Sep 17 00:00:00 2001 From: 10xai <10xaidev@gmail.com> Date: Mon, 24 Aug 2026 18:06:15 +0000 Subject: [PATCH] Propagate errors instead of silently swallowing them Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/commands/COMMAND_TEMPLATE/TEMPLATE.ts | 3 +- src/commands/auth/auth.ts | 51 +++++------ src/commands/auth/plugins/kinde.ts | 8 +- src/commands/createCommand.ts | 3 +- src/commands/database/database.ts | 42 ++++----- src/commands/database/plugins/mongoose.ts | 90 +++++++++---------- src/commands/deploy/deploy.ts | 40 ++++----- src/commands/init/init.ts | 100 ++++++++++------------ src/commands/init/plugins/husky.ts | 13 ++- src/commands/init/plugins/prettier.ts | 4 +- src/commands/init/selectGitHubTemplate.ts | 16 ++-- src/index.ts | 26 +++++- src/types/plugin.ts | 4 +- src/utils/check-directory.ts | 15 +--- src/utils/env/check-env-vars.ts | 11 ++- src/utils/env/write-to-env.ts | 18 ++-- src/utils/errors.ts | 30 +++++++ src/utils/git/check-git.ts | 25 ++++-- src/utils/git/setup-git-repo.ts | 7 +- src/utils/inquirer/ask-open-page.ts | 10 ++- src/utils/install-plugins.ts | 39 +++++---- src/utils/package-manager.ts | 7 +- src/utils/read-config-file.ts | 27 +++--- 23 files changed, 332 insertions(+), 257 deletions(-) create mode 100644 src/utils/errors.ts diff --git a/src/commands/COMMAND_TEMPLATE/TEMPLATE.ts b/src/commands/COMMAND_TEMPLATE/TEMPLATE.ts index b2f7057..0c50274 100644 --- a/src/commands/COMMAND_TEMPLATE/TEMPLATE.ts +++ b/src/commands/COMMAND_TEMPLATE/TEMPLATE.ts @@ -4,6 +4,7 @@ import pluginConfigFile from './plugins/config.json'; import allPlugins from './plugins/index.js'; import { ICommand } from 'types/ICommand.js'; +import { requirePlugin } from 'utils/install-plugins.js'; /** * TEMPLATE @@ -16,7 +17,7 @@ const COMMAND_NAME: ICommand = createCommand({ const plugins = context.plugins; // Add stuff here, like inquirer prompts or other logic - plugins.prettier.install({}); + await requirePlugin(plugins, 'prettier').install({}); }, }); diff --git a/src/commands/auth/auth.ts b/src/commands/auth/auth.ts index bb5e596..1edfffc 100644 --- a/src/commands/auth/auth.ts +++ b/src/commands/auth/auth.ts @@ -5,6 +5,8 @@ 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 { withContext } from 'utils/errors.js'; +import { requirePlugin } from 'utils/install-plugins.js'; import colors from 'picocolors'; /** @@ -20,32 +22,31 @@ const auth: ICommand = createCommand({ { name: 'Kinde (Recommended) https://kinde.com/', value: 'kinde' }, { 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 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); - if (!selectedOption) { - console.error('Invalid auth selection.'); - return; - } - const { cyan } = colors; - log(`Setting up ${cyan(selectedOption.name)} for your project...`); - switch (selectedOption.value) { - case 'kinde': - plugins.kinde.install({}); - break; - case 'other': - break; - } - } catch (error) { - console.error('Failed to add auth:', error); + const selectedOption = authOptions.find((auth) => auth.name === answers.auth); + if (!selectedOption) { + throw new Error(`Invalid auth selection: ${answers.auth}`); + } + const { cyan } = colors; + log(`Setting up ${cyan(selectedOption.name)} for your project...`); + switch (selectedOption.value) { + case 'kinde': + try { + await requirePlugin(plugins, 'kinde').install({}); + } catch (error) { + throw withContext('Failed to set up Kinde auth', error); + } + break; + case 'other': + break; } }, }); diff --git a/src/commands/auth/plugins/kinde.ts b/src/commands/auth/plugins/kinde.ts index 04e82b4..e349aca 100644 --- a/src/commands/auth/plugins/kinde.ts +++ b/src/commands/auth/plugins/kinde.ts @@ -62,7 +62,7 @@ const kinde: Plugin = { 'KINDE_POST_LOGIN_REDIRECT_URL', ]; while (!finishedEnv) { - await inquirer.prompt([ + const answers = await inquirer.prompt([ { type: 'confirm', name: 'done', @@ -71,10 +71,14 @@ const kinde: Plugin = { }, ]); + if (!answers.done) { + log('Finish adding the env variables above, then run next-quick auth again.', LogLevel.warn); + return; + } + if (checkEnvVars(requiredEnvVars)) { log('Auth is now set up. It was that easy! 🎉', LogLevel.success, undefined, true); finishedEnv = true; - break; } } }, diff --git a/src/commands/createCommand.ts b/src/commands/createCommand.ts index a1b7a10..074bc8d 100644 --- a/src/commands/createCommand.ts +++ b/src/commands/createCommand.ts @@ -1,6 +1,7 @@ import { ICommand } from 'types/ICommand.js'; import { PluginRegistry, PluginConfigFile } from 'types/plugin.js'; import { readConfig } from 'utils/read-config-file.js'; +import { log, LogLevel } from 'utils/logger.js'; export default function createCommand(config: { requiresProjectInitialized: boolean; @@ -20,7 +21,7 @@ export default function createCommand(config: { const config = readConfig(); if (!config) { - console.error('Project is not initialized.'); + log('Project is not initialized. Run next-quick init first.', LogLevel.error); process.exit(1); } } diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 1d3ff9d..fcce3c8 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -4,6 +4,9 @@ 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 { withContext } from 'utils/errors.js'; +import { requirePlugin } from 'utils/install-plugins.js'; +import { log } from 'utils/logger.js'; /** * Adds a database to the project by prompting the user to select a database option. @@ -21,26 +24,21 @@ const addDatabase: ICommand = createCommand({ // { name: 'PostgreSQL', value: 'postgresql' }, */ ]; - try { - const answers = await inquirer.prompt([ - { - type: 'list', - name: 'database', - message: 'Choose a database for your project:', - choices: dbOptions.map((db) => db.name), - }, - ]); + 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); - 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); + const selectedOption = dbOptions.find((db) => db.name === answers.database); + if (!selectedOption) { + throw new Error(`Invalid database selection: ${answers.database}`); } + // console.log(`Setting up ${selectedOption.name} for your project...`); + await setupDatabase(selectedOption.value, context.plugins); }, }); @@ -49,7 +47,11 @@ async function setupDatabase(database: string, plugins: PluginRegistry) { switch (database) { case 'mongodb': pluginsToInstall.push('mongoose'); - plugins.mongoose.install({}); + try { + await requirePlugin(plugins, 'mongoose').install({}); + } catch (error) { + throw withContext('Failed to set up MongoDB', error); + } break; case 'other': break; @@ -71,7 +73,7 @@ async function setupDatabase(database: string, plugins: PluginRegistry) { // console.error(`Error installing ${packageToInstall}:`, error); // } } else { - console.log('No database package specified for installation.'); + 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..f9cb6a2 100644 --- a/src/commands/database/plugins/mongoose.ts +++ b/src/commands/database/plugins/mongoose.ts @@ -9,72 +9,60 @@ import { askOpenPage } from 'utils/inquirer/ask-open-page.js'; // MongoDB with Mongoose ORM const mongoose: Plugin = { install: async () => { - try { - NPM.install('mongoose'); + NPM.install('mongoose'); - // Prompt user to go to mongoDB website to set up project - await askOpenPage( - 'Log in to MongoDB to connect your project', - 'https://www.mongodb.com/cloud/atlas/register' - ); - // Prompt user for & add to url .env + // Prompt user to go to mongoDB website to set up project + await askOpenPage('Log in to MongoDB to connect your project', 'https://www.mongodb.com/cloud/atlas/register'); + // Prompt user for & add to url .env + log( + 'Log in to your MongoDB Atlas account and copy the connection string, which can be found under:\n“Connect” > “Connecting with MongoDB for VS Code.”', + undefined, + 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; + // If "" is in the URL, replace it with the actual password by prompting the user + if (url.includes('')) { log( - 'Log in to your MongoDB Atlas account and copy the connection string, which can be found under:\n“Connect” > “Connecting with MongoDB for VS Code.”', + 'Please enter the password for your MongoDB database user. You can find this on the left navigation bar:\n“Security” > “Database Access.”', undefined, LogColor.cyan, true ); - const resUrl = await inquirer.prompt([ + const resPw = await inquirer.prompt([ { - type: 'input', - name: 'url', - message: 'Enter your MongoDB connection URL:', + type: 'password', + name: 'password', + message: 'Enter your database user password:', validate: (input) => { if (input.length === 0) { - return 'MongoDB connection URL cannot be empty.'; + return 'Database user password cannot be empty.'; } return true; }, }, ]); - let url = resUrl.url; - // If "" is in the URL, replace it with the actual password by prompting the user - if (url.includes('')) { - log( - 'Please enter the password for your MongoDB database user. You can find this on the left navigation bar:\n“Security” > “Database Access.”', - undefined, - 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); - } - // Add the MONDODB_URL to the user's .env.local file - writeToEnv('MONGODB_URL', url); - await templateCopyTransfer('plugins/mongoose', 'src'); - NPM.installDev('swr'); - - log( - 'Test the database connection at http://localhost:3000/test-database', - LogLevel.success, - undefined, - true - ); - } catch (error) { - console.error('Failed to set up database for project:', error); + url = url.replace('', resPw.password); } + // Add the MONDODB_URL to the user's .env.local file + writeToEnv('MONGODB_URL', url); + await templateCopyTransfer('plugins/mongoose', 'src'); + NPM.installDev('swr'); + + log('Test the database connection at http://localhost:3000/test-database', LogLevel.success, undefined, true); }, }; diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index c7eff08..f6e63c3 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -25,31 +25,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); - }); + const answers = await inquirer.prompt([ + { + type: 'list', + name: 'target', + message: 'Choose your deployment target:', + choices: deploymentOptions.map((option) => option.name), + }, + ]); + + const selectedOption = deploymentOptions.find((option) => option.name === answers.target); + if (!selectedOption) { + throw new Error(`Unknown deployment target: ${answers.target}`); + } + await validateGitHubStatus(); + await askOpenPage(`Log in to ${selectedOption.name} to deploy your project`, selectedOption.url); }, }); diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index 42680cf..64cd649 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -11,6 +11,7 @@ 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 { withContext } from 'utils/errors.js'; /** * Initializes a new project by prompting the user for a project name and a GitHub template. @@ -22,74 +23,67 @@ const initProject: ICommand = createCommand({ pluginConfigFile, action: async (context) => { const { cyan } = colors; - inquirer - .prompt([ - { - type: 'input', - name: 'projectName', - message: 'Project name:', - validate: (input) => { - if (input.length === 0) { - return 'Project name cannot be empty.'; - } else if (/^[a-z0-9-_]+$/.test(input)) { - if (fs.existsSync(input)) { - return 'A directory with this name already exists.'; - } - return true; + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'projectName', + message: 'Project name:', + validate: (input) => { + if (input.length === 0) { + return 'Project name cannot be empty.'; + } else if (/^[a-z0-9-_]+$/.test(input)) { + if (fs.existsSync(input)) { + return 'A directory with this name already exists.'; } - return 'Project name must be all lowercase and can only include letters, numbers, underscores, and hyphens.'; - }, + return true; + } + 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, projectPath }; + const defaultConfig = { projectName }; + + try { + if (!fs.existsSync(projectPath)) { + fs.mkdirSync(projectPath, { recursive: true }); + } - createNextApp(projectPath); + createNextApp(projectPath); - chdir(projectPath); + chdir(projectPath); - setupGitRepo(projectPath); + setupGitRepo(projectPath); - // Create an .env.local file - const envLocalPath = path.join(projectPath, '.env.local'); - fs.writeFileSync(envLocalPath, '', 'utf-8'); + // Create an .env.local file + const envLocalPath = path.join(projectPath, '.env.local'); + fs.writeFileSync(envLocalPath, '', 'utf-8'); - installPlugins(context.plugins); + await installPlugins(context.plugins); - execSync('npm run prettier', { stdio: 'inherit' }); + execSync('npm run prettier', { stdio: 'inherit' }); - 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); - }); + fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf-8'); + } catch (error) { + throw withContext(`Failed to initialize project ${projectName}`, error); + } + 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.`); }, }); 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' }); + try { + execSync(`npx create-next-app@latest . ${FLAGS}`, { cwd: projectPath, stdio: 'inherit' }); + } catch (error) { + throw withContext('create-next-app failed', error); + } } export default initProject; diff --git a/src/commands/init/plugins/husky.ts b/src/commands/init/plugins/husky.ts index 2a70e2c..6e0be3d 100644 --- a/src/commands/init/plugins/husky.ts +++ b/src/commands/init/plugins/husky.ts @@ -4,6 +4,7 @@ import config from './config.json'; import fs from 'fs'; import path from 'path'; import { execSync } from 'child_process'; +import { withContext } from 'utils/errors.js'; /** * Set up husky pre-commit hooks @@ -15,12 +16,20 @@ const husky: Plugin = { NPM.installDev('husky lint-staged'); createLintStagedRc(); createHuskyPreCommitHook(); - execSync('git init'); - execSync('npx husky'); + run('git init'); + run('npx husky'); updatePackageJson(packageJsonAdditions); }, }; +function run(cmd: string) { + try { + execSync(cmd, { stdio: 'inherit' }); + } catch (error) { + throw withContext(`Command failed: ${cmd}`, error); + } +} + /* eslint-disable @typescript-eslint/no-explicit-any */ function updatePackageJson(packageJsonAdditions: any) { packageJsonAdditions.scripts = packageJsonAdditions.scripts || {}; diff --git a/src/commands/init/plugins/prettier.ts b/src/commands/init/plugins/prettier.ts index 43bbe9d..9bb4640 100644 --- a/src/commands/init/plugins/prettier.ts +++ b/src/commands/init/plugins/prettier.ts @@ -2,6 +2,7 @@ import { Plugin } from 'types/plugin.js'; import fs from 'fs'; import path from 'path'; import { NPM } from 'utils/package-manager.js'; +import { withContext } from 'utils/errors.js'; const VERBOSE = false; @@ -28,8 +29,7 @@ function updateEslintConfig(projectPath: string) { try { eslintConfig = JSON.parse(fs.readFileSync(eslintConfigPath, 'utf8')); } catch (error) { - console.error('Failed to read ESLint config:', error); - return; + throw withContext(`Failed to read ESLint config at ${eslintConfigPath}`, error); } // Ensure 'extends' is an array and includes Prettier configs diff --git a/src/commands/init/selectGitHubTemplate.ts b/src/commands/init/selectGitHubTemplate.ts index 0f23b0b..7efb0d2 100644 --- a/src/commands/init/selectGitHubTemplate.ts +++ b/src/commands/init/selectGitHubTemplate.ts @@ -1,6 +1,8 @@ import fs from 'fs'; import path from 'path'; import execAsync from 'utils/exec-async.js'; +import { log } from 'utils/logger.js'; +import { withContext } from 'utils/errors.js'; const templates = [ { name: 'None', url: 'None' }, @@ -22,16 +24,18 @@ const action = async (answers: { template: string }, projectName: string, projec await cloneTemplate(url, projectName); } } else { - console.error('Invalid template selected.'); + throw new Error(`Invalid template selected: ${answers.template}`); } }; async function cloneTemplate(githubUrl: string, projectName: string): Promise { - console.log(`Cloning template from ${githubUrl} into ${projectName}...`); - const { stdout, stderr } = await execAsync(`git clone ${githubUrl} ${projectName}`); - console.log(stdout); - if (stderr) { - console.error(stderr); + log(`Cloning template from ${githubUrl} into ${projectName}...`); + try { + // git writes progress to stderr on success, so only a rejection means failure + const { stdout } = await execAsync(`git clone ${githubUrl} ${projectName}`); + log(stdout); + } catch (error) { + throw withContext(`Failed to clone template from ${githubUrl}`, error); } } diff --git a/src/index.ts b/src/index.ts index 74873ad..b1c12ee 100755 --- a/src/index.ts +++ b/src/index.ts @@ -7,14 +7,32 @@ import deploy from './commands/deploy/deploy.js'; import auth from './commands/auth/auth.js'; import { nextquickRcExists } from 'utils/read-config-file.js'; import checkIfAnyDirectoryExists from 'utils/check-directory.js'; +import { runCommand, toError } from 'utils/errors.js'; import colors from 'picocolors'; +import { ICommand } from 'types/ICommand.js'; +import { log, LogLevel } from 'utils/logger.js'; + +// Safety net so unexpected failures surface with a non-zero exit code instead of being swallowed +process.on('unhandledRejection', (reason) => { + log(toError(reason).message, LogLevel.error); + process.exit(1); +}); +process.on('uncaughtException', (error) => { + log(toError(error).message, LogLevel.error); + process.exit(1); +}); program.name('next-quick').description('CLI to initialize and set up volunteer management systems'); +const runner = (name: string, cmd: ICommand) => () => runCommand(name, () => cmd.execute()); + const { green } = colors; // Only show init command if .nextquickrc does not exist if (!nextquickRcExists()) { - program.command('init').description('Initialize a new volunteer management project').action(initProject.execute); + program + .command('init') + .description('Initialize a new volunteer management project') + .action(runner('init', initProject)); // Case for if they created a project but haven't cd into it yet // If a directory exists, add help text to the end of message to remind them to cd into the project directory if (checkIfAnyDirectoryExists()) { @@ -25,9 +43,9 @@ if (!nextquickRcExists()) { } } else { // Only show rest of commands if .nextquickrc exists - program.command('deploy').description('Deploy the project').action(deploy.execute); - program.command('database').description('Add a database to the project').action(addDatabase.execute); - program.command('auth').description('Add authentication to the project').action(auth.execute); + program.command('deploy').description('Deploy the project').action(runner('deploy', deploy)); + program.command('database').description('Add a database to the project').action(runner('database', addDatabase)); + program.command('auth').description('Add authentication to the project').action(runner('auth', auth)); } program.parse(process.argv); diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 84e9851..5f06ed4 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -1,11 +1,11 @@ export interface StringIndexablePlugin { /* eslint-disable @typescript-eslint/no-explicit-any */ - [key: string]: (packageJsonAdditions: any) => void; + [key: string]: (packageJsonAdditions: any) => void | Promise; } interface BasePlugin { /* eslint-disable @typescript-eslint/no-explicit-any */ - install: (packageJsonAdditions: any) => void; + install: (packageJsonAdditions: any) => void | Promise; } export type Plugin = BasePlugin & StringIndexablePlugin; diff --git a/src/utils/check-directory.ts b/src/utils/check-directory.ts index a5b45b4..6e8c428 100644 --- a/src/utils/check-directory.ts +++ b/src/utils/check-directory.ts @@ -1,5 +1,4 @@ import fs from 'fs'; -import path from 'path'; /** * Checks if any directory exists in the current working directory. @@ -7,16 +6,6 @@ import path from 'path'; */ export default function checkIfAnyDirectoryExists() { const cwd = process.cwd(); - const filesAndDirectories = fs.readdirSync(cwd); - - for (const name of filesAndDirectories) { - const fullPath = path.join(cwd, name); - const stat = fs.statSync(fullPath); - - if (stat.isDirectory()) { - return true; // Return true at the first directory found - } - } - - return false; // Return false if no directories are found + // withFileTypes avoids a stat per entry, which would throw on broken symlinks + return fs.readdirSync(cwd, { withFileTypes: true }).some((entry) => entry.isDirectory()); } diff --git a/src/utils/env/check-env-vars.ts b/src/utils/env/check-env-vars.ts index 5a8ac95..8b4b96d 100644 --- a/src/utils/env/check-env-vars.ts +++ b/src/utils/env/check-env-vars.ts @@ -3,6 +3,7 @@ import * as dotenv from 'dotenv'; import path from 'path'; import colors from 'picocolors'; import { log, LogLevel } from 'utils/logger.js'; +import { toError } from 'utils/errors.js'; // Function to check if required environment variables are set in .env.local export default function checkEnvVars(envVars: string[]): boolean { @@ -11,12 +12,18 @@ export default function checkEnvVars(envVars: string[]): boolean { const filePath = path.resolve(cwd, '.env.local'); // Check if .env.local file exists if (!fs.existsSync(filePath)) { - console.error('.env.local file does not exist.'); + log('.env.local file does not exist.', LogLevel.error); return false; } // Read and parse the .env.local file - const envConfig = dotenv.parse(fs.readFileSync(filePath)); + let envConfig: dotenv.DotenvParseOutput; + try { + envConfig = dotenv.parse(fs.readFileSync(filePath)); + } catch (error) { + log(`Failed to read ${filePath}: ${toError(error).message}`, LogLevel.error); + return false; + } // Check each required environment variable const missingVars: string[] = []; diff --git a/src/utils/env/write-to-env.ts b/src/utils/env/write-to-env.ts index 7b07e98..69579f5 100644 --- a/src/utils/env/write-to-env.ts +++ b/src/utils/env/write-to-env.ts @@ -1,24 +1,24 @@ import { existsSync, appendFileSync } from 'fs'; import colors from 'picocolors'; import { log, LogLevel } from 'utils/logger.js'; +import { withContext } from 'utils/errors.js'; const envLocalPath = '.env.local'; /** * Writes to the .env.local file. + * @throws if the file is missing or cannot be written to. */ export default function writeToEnv(key: string, value: string) { const { cyan } = colors; + if (!existsSync(envLocalPath)) { + throw new Error(`${envLocalPath} file does not exist. Run next-quick init to create the project files.`); + } try { - // Check if the .env.local file exists - if (existsSync(envLocalPath)) { - // Append a new line to the .env.local file - appendFileSync(envLocalPath, `\n${key}=${value}\n`); - log(`Added new line for ${cyan(key)} to ${envLocalPath} file.`, LogLevel.checkmark, undefined, true); - } else { - log(`${cyan(envLocalPath)} file does not exist.`, LogLevel.error); - } + // Append a new line to the .env.local file + appendFileSync(envLocalPath, `\n${key}=${value}\n`); } catch (error) { - log(`Failed to write to ${cyan(envLocalPath)} file, appending ${cyan(key)}: ${error}`, LogLevel.error); + throw withContext(`Failed to append ${key} to ${envLocalPath}`, error); } + log(`Added new line for ${cyan(key)} to ${envLocalPath} file.`, LogLevel.checkmark, undefined, true); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..b0aeda9 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,30 @@ +import { log, LogLevel } from 'utils/logger.js'; + +/** + * Normalizes an unknown thrown value into an Error. + */ +export function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +/** + * Wraps an error with additional context, keeping the original message and stack. + */ +export function withContext(context: string, value: unknown): Error { + const error = toError(value); + const wrapped = new Error(`${context}: ${error.message}`); + wrapped.stack = error.stack; + return wrapped; +} + +/** + * Runs a command, reporting any failure to the user and exiting with a non-zero code. + */ +export async function runCommand(name: string, execute: () => Promise) { + try { + await execute(); + } catch (error) { + log(`Command ${name} failed: ${toError(error).message}`, LogLevel.error, undefined, true); + process.exitCode = 1; + } +} diff --git a/src/utils/git/check-git.ts b/src/utils/git/check-git.ts index 4236b6c..a9e004c 100644 --- a/src/utils/git/check-git.ts +++ b/src/utils/git/check-git.ts @@ -2,6 +2,7 @@ import { execSync } from 'child_process'; import inquirer from 'inquirer'; import colors from 'picocolors'; import { log, LogLevel } from 'utils/logger.js'; +import { withContext } from 'utils/errors.js'; /** * Validates the current Git repository status to ensure that the project can be deployed. @@ -49,21 +50,29 @@ function isGitRepository() { function hasRemote() { try { - const result = execSync('git remote', { encoding: 'utf8' }); + const result = execSync('git remote', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); return result.trim().length > 0; - } catch { - return false; + } catch (error) { + throw withContext('Failed to list Git remotes', error); } } function hasUnpushedChanges(silent: boolean): boolean { + let status: string; try { - execSync('git diff --quiet && git diff --staged --quiet', { stdio: 'ignore' }); - return false; - } catch { - !silent && console.warn("You have uncommitted changes that won't be reflected in the deployment."); - return true; + // --untracked-files=no: only tracked, uncommitted changes matter for a deployment + status = execSync('git status --porcelain --untracked-files=no', { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + throw withContext('Failed to read Git status', error); + } + const hasChanges = status.trim().length > 0; + if (hasChanges && !silent) { + log("You have uncommitted changes that won't be reflected in the deployment.", LogLevel.warn); } + return hasChanges; } export { validateGitHubStatus, isGitRepository, hasRemote, hasUnpushedChanges }; diff --git a/src/utils/git/setup-git-repo.ts b/src/utils/git/setup-git-repo.ts index 73f0618..e068e7d 100644 --- a/src/utils/git/setup-git-repo.ts +++ b/src/utils/git/setup-git-repo.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { execSync } from 'child_process'; import { log, LogLevel, LogColor } from 'utils/logger.js'; +import { withContext } from 'utils/errors.js'; function isGitRepositorySimpleCheck(directory: string) { return fs.existsSync(path.join(directory, '.git')); @@ -9,7 +10,11 @@ function isGitRepositorySimpleCheck(directory: string) { export default function setupGitRepo(directory: string) { if (!isGitRepositorySimpleCheck(directory)) { - execSync('git init', { cwd: directory, stdio: 'inherit' }); + try { + execSync('git init', { cwd: directory, stdio: 'inherit' }); + } catch (error) { + throw withContext(`Failed to initialize a Git repository in ${directory}`, error); + } 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..4c5de53 100644 --- a/src/utils/inquirer/ask-open-page.ts +++ b/src/utils/inquirer/ask-open-page.ts @@ -1,6 +1,8 @@ import inquirer from 'inquirer'; import open from 'open'; import colors from 'picocolors'; +import { log, LogLevel } from 'utils/logger.js'; +import { toError } from 'utils/errors.js'; export async function askOpenPage(action: string, url: string) { const { cyan } = colors; @@ -15,5 +17,11 @@ export async function askOpenPage(action: string, url: string) { if (!answers.continue) { process.exit(1); } - open(url); + try { + await open(url); + } catch (error) { + // Not being able to launch a browser shouldn't abort setup, but the user has to know + log(`Could not open ${cyan(url)}: ${toError(error).message}`, LogLevel.warn); + log(`Please open ${cyan(url)} manually to continue.`, LogLevel.warn); + } } diff --git a/src/utils/install-plugins.ts b/src/utils/install-plugins.ts index 46e6b9f..1876a4c 100644 --- a/src/utils/install-plugins.ts +++ b/src/utils/install-plugins.ts @@ -4,35 +4,44 @@ import colors from 'picocolors'; import fs from 'fs'; import path from 'path'; import { log, LogLevel, LogColor } from 'utils/logger.js'; +import { withContext } from 'utils/errors.js'; -export default function installPlugins(plugins: PluginRegistry) { +/** + * Looks up a plugin by name, failing loudly when it is disabled in the command's plugin config. + */ +export function requirePlugin(plugins: PluginRegistry, pluginName: string) { + const plugin = plugins[pluginName]; + if (!plugin) { + throw new Error(`Plugin ${pluginName} is not available. Enable it in the command's plugins/config.json.`); + } + return plugin; +} + +export default async function installPlugins(plugins: PluginRegistry) { const cyan = colors.cyan; log('Installing additional plugins...'); const packageJsonAdditions = {}; - Object.keys(plugins).forEach((pluginName) => { + for (const pluginName of Object.keys(plugins)) { + log(`- ${cyan(pluginName)}`); try { - log(`- ${cyan(pluginName)}`); - const plugin = plugins[pluginName]; - plugin.install(packageJsonAdditions); + await plugins[pluginName].install(packageJsonAdditions); } catch (error) { - console.error(`Failed to initialize plugin ${pluginName}:`, error); + throw withContext(`Failed to install plugin ${pluginName}`, error); } - }); + } // Add new JSON to package.json all at once applyPackageJsonAdditions(packageJsonAdditions); } function applyPackageJsonAdditions(updates: StringIndexableObject) { const packageJsonPath = path.join(process.cwd(), 'package.json'); - let content = ''; + let packageJson: StringIndexableObject; try { - content = fs.readFileSync(packageJsonPath, 'utf8'); - } catch (e) { - console.error('Failed to read package.json:', e); - return; + packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + } catch (error) { + throw withContext(`Failed to read ${packageJsonPath}`, error); } - const packageJson = JSON.parse(content); // Iterate over the updates object and merge each section into packageJson Object.keys(updates).forEach((key) => { @@ -48,8 +57,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); + } catch (error) { + throw withContext(`Failed to write ${packageJsonPath}`, error); } log(`package.json has been updated.`, LogLevel.checkmark, LogColor.cyan, true); } diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index 874163a..c4a3a10 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -1,7 +1,12 @@ import { execSync } from 'child_process'; +import { withContext } from 'utils/errors.js'; function runCmd(cmd: string) { - execSync(cmd, { stdio: 'inherit' }); + try { + execSync(cmd, { stdio: 'inherit' }); + } catch (error) { + throw withContext(`Command failed: ${cmd}`, error); + } } export const NPM = { diff --git a/src/utils/read-config-file.ts b/src/utils/read-config-file.ts index 8534a07..eb4aaf0 100644 --- a/src/utils/read-config-file.ts +++ b/src/utils/read-config-file.ts @@ -1,5 +1,7 @@ import fs from 'fs'; import path from 'path'; +import { log, LogLevel } from 'utils/logger.js'; +import { toError } from 'utils/errors.js'; // Check if the .nextquickrc file exists in the current directory export function nextquickRcExists(): string | false { @@ -13,18 +15,17 @@ export function nextquickRcExists(): string | false { // Utility function to read the .nextquickrc file 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); - process.exit(1); - } + if (!configPath) { + log( + '.nextquickrc does not exist in the current directory. Run `next-quick init` to create a new project.', + LogLevel.error + ); + process.exit(1); + } + try { + return JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch (error) { + log(`Failed to read ${configPath}: ${toError(error).message}`, LogLevel.error); + process.exit(1); } - console.error( - '.nextquickrc does not exist in the current directory. Run `nextquick init` to create a new project.' - ); - process.exit(1); }