From 3457b7ca13c18166c2bbfc6a0c1806a0ba538b87 Mon Sep 17 00:00:00 2001 From: John Leider Date: Thu, 15 Jan 2026 12:26:12 -0600 Subject: [PATCH] refactor(shared): improve error handling with structured error types Add VuetifyCliError base class with actionable suggestions to help developers resolve issues quickly. Each error type provides context-aware messages based on the failure mode (network, permissions, syntax, etc). Changes: - Add errors.ts with structured error types (TemplateDownloadError, TemplateCopyError, FeatureApplyError, DependencyInstallError, etc) - Update scaffold.ts to throw typed errors with suggestions - Update create.ts to format errors with suggestions for users - Update analyze.ts to track and report file parse failures - Update console reporter to display skipped files with reasons - Add parseErrors field to AnalyzeReport for programmatic access --- packages/shared/src/errors.ts | 147 ++++++++++++++++++++++ packages/shared/src/functions/analyze.ts | 23 +++- packages/shared/src/functions/create.ts | 7 +- packages/shared/src/functions/scaffold.ts | 50 +++++--- packages/shared/src/index.ts | 1 + packages/shared/src/reporters/console.ts | 23 +++- packages/shared/src/reporters/types.ts | 6 + 7 files changed, 236 insertions(+), 21 deletions(-) create mode 100644 packages/shared/src/errors.ts diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts new file mode 100644 index 0000000..23db918 --- /dev/null +++ b/packages/shared/src/errors.ts @@ -0,0 +1,147 @@ +/** + * Structured error types for better DX. + * Each error includes an actionable message to help developers resolve issues. + */ + +export class VuetifyCliError extends Error { + constructor ( + message: string, + public readonly code: string, + public readonly suggestion?: string, + ) { + super(message) + this.name = 'VuetifyCliError' + } + + toString (): string { + let result = `${this.message}` + if (this.suggestion) { + result += `\n Suggestion: ${this.suggestion}` + } + return result + } +} + +export class TemplateDownloadError extends VuetifyCliError { + constructor (templateName: string, cause?: Error) { + const isNetworkError = cause?.message?.includes('fetch') || cause?.message?.includes('ENOTFOUND') + const suggestion = isNetworkError + ? 'Check your network connection and try again.' + : 'Verify the template exists at gh:vuetifyjs/templates and try again.' + + super( + `Failed to download template "${templateName}"${cause ? `: ${cause.message}` : ''}`, + 'TEMPLATE_DOWNLOAD_FAILED', + suggestion, + ) + this.name = 'TemplateDownloadError' + if (cause) { + this.cause = cause + } + } +} + +export class TemplateCopyError extends VuetifyCliError { + constructor (templatePath: string, cause?: Error) { + const isPermissionError = cause?.message?.includes('EACCES') || cause?.message?.includes('EPERM') + const suggestion = isPermissionError + ? 'Check that you have read permissions for the template directory.' + : 'Verify the template path exists and is accessible.' + + super( + `Failed to copy template from "${templatePath}"${cause ? `: ${cause.message}` : ''}`, + 'TEMPLATE_COPY_FAILED', + suggestion, + ) + this.name = 'TemplateCopyError' + if (cause) { + this.cause = cause + } + } +} + +export class ProjectExistsError extends VuetifyCliError { + constructor (projectPath: string) { + super( + `Directory "${projectPath}" already exists`, + 'PROJECT_EXISTS', + 'Use --force to overwrite the existing directory, or choose a different project name.', + ) + this.name = 'ProjectExistsError' + } +} + +export class DependencyInstallError extends VuetifyCliError { + constructor (packageManager: string, cause?: Error) { + super( + `Failed to install dependencies with ${packageManager}${cause ? `: ${cause.message}` : ''}`, + 'DEPENDENCY_INSTALL_FAILED', + `Try running "${packageManager} install" manually in the project directory.`, + ) + this.name = 'DependencyInstallError' + if (cause) { + this.cause = cause + } + } +} + +export class FeatureApplyError extends VuetifyCliError { + constructor (featureName: string, cause?: Error) { + super( + `Failed to apply feature "${featureName}"${cause ? `: ${cause.message}` : ''}`, + 'FEATURE_APPLY_FAILED', + 'Try creating the project without this feature and adding it manually.', + ) + this.name = 'FeatureApplyError' + if (cause) { + this.cause = cause + } + } +} + +export class FileParseError extends VuetifyCliError { + constructor ( + public readonly filePath: string, + cause?: Error, + ) { + const isSyntaxError = cause?.message?.includes('Unexpected token') || cause?.message?.includes('SyntaxError') + const suggestion = isSyntaxError + ? 'The file may contain syntax errors. Fix them and try again.' + : 'The file could not be parsed. It may use unsupported syntax.' + + super( + `Failed to parse "${filePath}"${cause ? `: ${cause.message}` : ''}`, + 'FILE_PARSE_FAILED', + suggestion, + ) + this.name = 'FileParseError' + if (cause) { + this.cause = cause + } + } +} + +export class DirectoryNotFoundError extends VuetifyCliError { + constructor (dirPath: string) { + super( + `Directory "${dirPath}" does not exist`, + 'DIRECTORY_NOT_FOUND', + 'Verify the path is correct and the directory exists.', + ) + this.name = 'DirectoryNotFoundError' + } +} + +/** + * Format an error for display in the console. + * Handles both VuetifyCliError and standard Error types. + */ +export function formatError (error: unknown): string { + if (error instanceof VuetifyCliError) { + return error.toString() + } + if (error instanceof Error) { + return error.message + } + return String(error) +} diff --git a/packages/shared/src/functions/analyze.ts b/packages/shared/src/functions/analyze.ts index e4ec3d9..deb5823 100644 --- a/packages/shared/src/functions/analyze.ts +++ b/packages/shared/src/functions/analyze.ts @@ -1,11 +1,12 @@ -import type { AnalyzeReport, FeatureType } from '../reporters/types' +import type { AnalyzeReport, FeatureType, ParseError } from '../reporters/types' import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { createRequire } from 'node:module' -import { dirname, join } from 'pathe' +import { dirname, join, relative } from 'pathe' import { readPackageJSON, resolvePackageJSON } from 'pkg-types' import { glob } from 'tinyglobby' import { parse } from 'vue-eslint-parser' +import { DirectoryNotFoundError, FileParseError } from '../errors' const require = createRequire(import.meta.url) @@ -193,7 +194,7 @@ export function analyzeCode (code: string, targetPackages: string[] = ['@vuetify export async function analyzeProject (cwd: string = process.cwd(), targetPackages: string[] = ['@vuetify/v0']): Promise { if (!existsSync(cwd)) { - throw new Error(`Directory ${cwd} does not exist`) + throw new DirectoryNotFoundError(cwd) } const [files, importMaps, pkgs] = await Promise.all([ @@ -211,6 +212,8 @@ export async function analyzeProject (cwd: string = process.cwd(), targetPackage features.set(pkg, new Map()) } + const parseErrors: ParseError[] = [] + for (const file of files) { try { const code = await readFile(file, 'utf8') @@ -228,8 +231,17 @@ export async function analyzeProject (cwd: string = process.cwd(), targetPackage } } } - } catch { - // console.warn(`Failed to analyze ${file}:`, error) + } catch (error_) { + const error = error_ instanceof Error ? error_ : new Error(String(error_)) + const parseError = new FileParseError(file, error) + parseErrors.push({ + file: relative(cwd, file), + error: error.message, + }) + // Log in debug mode if needed, but don't swallow silently + if (process.env.DEBUG) { + console.warn(parseError.toString()) + } } } @@ -247,6 +259,7 @@ export async function analyzeProject (cwd: string = process.cwd(), targetPackage name, type: getFeatureType(name, pkgFeatures.get(name)?.isType, importMap), })), + parseErrors: parseErrors.length > 0 ? parseErrors : undefined, } }) } diff --git a/packages/shared/src/functions/create.ts b/packages/shared/src/functions/create.ts index 6806e9b..71d61b3 100644 --- a/packages/shared/src/functions/create.ts +++ b/packages/shared/src/functions/create.ts @@ -3,6 +3,7 @@ import { box, intro, outro, spinner } from '@clack/prompts' import { ansi256, link } from 'kolorist' import { getUserAgent } from 'package-manager-detector' import { join, relative } from 'pathe' +import { formatError, VuetifyCliError } from '../errors' import { i18n } from '../i18n' import { type ProjectOptions, prompt } from '../prompts' import { createBanner } from '../utils/banner' @@ -101,7 +102,11 @@ export async function createVuetify (options: CreateVuetifyOptions) { }) } catch (error) { s.stop(i18n.t('spinners.template.failed')) - console.error(`Failed to create project: ${error}`) + console.error() + console.error(formatError(error)) + if (error instanceof VuetifyCliError && error.suggestion) { + console.error() + } throw error } diff --git a/packages/shared/src/functions/scaffold.ts b/packages/shared/src/functions/scaffold.ts index 8177c0d..a73d641 100644 --- a/packages/shared/src/functions/scaffold.ts +++ b/packages/shared/src/functions/scaffold.ts @@ -2,6 +2,7 @@ import fs, { existsSync, rmSync } from 'node:fs' import { downloadTemplate } from 'giget' import { join } from 'pathe' import { readPackageJSON, writePackageJSON } from 'pkg-types' +import { DependencyInstallError, FeatureApplyError, TemplateCopyError, TemplateDownloadError } from '../errors' import { applyFeatures, vuetifyNuxtManual } from '../features' import { convertProjectToJS } from '../utils/convertProjectToJS' import { installDependencies } from '../utils/installDependencies' @@ -32,6 +33,7 @@ export interface ScaffoldCallbacks { onInstallEnd?: () => void } +// eslint-disable-next-line complexity -- error handling adds necessary complexity for DX export async function scaffold (options: ScaffoldOptions, callbacks: ScaffoldCallbacks = {}) { const { cwd, @@ -76,15 +78,20 @@ export async function scaffold (options: ScaffoldOptions, callbacks: ScaffoldCal debug(`Copying template from ${templatePath}...`) if (existsSync(templatePath)) { debug(`templatePath exists. Copying to ${projectRoot}`) - fs.cpSync(templatePath, projectRoot, { - recursive: true, - filter: src => { - return !src.includes('node_modules') && !src.includes('.git') && !src.includes('.DS_Store') - }, - }) - debug(`Copy complete.`) + try { + fs.cpSync(templatePath, projectRoot, { + recursive: true, + filter: src => { + return !src.includes('node_modules') && !src.includes('.git') && !src.includes('.DS_Store') + }, + }) + debug(`Copy complete.`) + } catch (error_) { + const error = error_ instanceof Error ? error_ : new Error(String(error_)) + throw new TemplateCopyError(templatePath, error) + } } else { - debug(`templatePath does not exist: ${templatePath}`) + throw new TemplateCopyError(templatePath, new Error('Template path does not exist')) } } else { const templateSource = `gh:vuetifyjs/templates/${templateName}` @@ -94,9 +101,9 @@ export async function scaffold (options: ScaffoldOptions, callbacks: ScaffoldCal dir: projectRoot, force, }) - } catch (error) { - console.error(`Failed to download template: ${error}`) - throw error + } catch (error_) { + const error = error_ instanceof Error ? error_ : new Error(String(error_)) + throw new TemplateDownloadError(templateName, error) } } callbacks.onDownloadEnd?.() @@ -106,11 +113,21 @@ export async function scaffold (options: ScaffoldOptions, callbacks: ScaffoldCal callbacks.onConfigStart?.() if (features && features.length > 0) { - await applyFeatures(projectRoot, features, pkg, !!typescript, platform === 'nuxt', clientHints, type) + try { + await applyFeatures(projectRoot, features, pkg, !!typescript, platform === 'nuxt', clientHints, type) + } catch (error_) { + const error = error_ instanceof Error ? error_ : new Error(String(error_)) + throw new FeatureApplyError(features.join(', '), error) + } } if (platform === 'nuxt' && type !== 'vuetify0' && (!features || !features.includes('vuetify-nuxt-module'))) { - await vuetifyNuxtManual.apply({ cwd: projectRoot, pkg, isTypescript: !!typescript, isNuxt: true }) + try { + await vuetifyNuxtManual.apply({ cwd: projectRoot, pkg, isTypescript: !!typescript, isNuxt: true }) + } catch (error_) { + const error = error_ instanceof Error ? error_ : new Error(String(error_)) + throw new FeatureApplyError('vuetify-nuxt-manual', error) + } } callbacks.onConfigEnd?.() @@ -132,7 +149,12 @@ export async function scaffold (options: ScaffoldOptions, callbacks: ScaffoldCal if (install && packageManager) { callbacks.onInstallStart?.(packageManager) - await installDependencies(projectRoot, packageManager as any) + try { + await installDependencies(projectRoot, packageManager as any) + } catch (error_) { + const error = error_ instanceof Error ? error_ : new Error(String(error_)) + throw new DependencyInstallError(packageManager, error) + } callbacks.onInstallEnd?.() } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 05f44b0..7410c2d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,7 @@ export { projectArgs, type ProjectArgs } from './args' export * from './commands' export { registerProjectArgsCompletion } from './completion' +export * from './errors' export * from './functions' export * from './reporters' export * from './utils/banner' diff --git a/packages/shared/src/reporters/console.ts b/packages/shared/src/reporters/console.ts index f0b11d6..d056364 100644 --- a/packages/shared/src/reporters/console.ts +++ b/packages/shared/src/reporters/console.ts @@ -1,5 +1,5 @@ import type { AnalyzeReport, Reporter } from './types' -import { ansi256, bold, green, yellow } from 'kolorist' +import { ansi256, bold, dim, green, red, yellow } from 'kolorist' const blue = ansi256(33) @@ -10,6 +10,9 @@ export const ConsoleReporter: Reporter = { console.log(blue('=======================')) console.log() + // Collect all parse errors across reports (they're duplicated since they apply to all packages) + const allParseErrors = reports[0]?.parseErrors ?? [] + for (const data of reports) { console.log(`Package: ${green(data.meta.packageName)}`) console.log(`Version: ${green(data.meta.version)}`) @@ -63,5 +66,23 @@ export const ConsoleReporter: Reporter = { console.log(` ${blue('→')} ${url}`) console.log() } + + // Report parse errors if any files were skipped + if (allParseErrors.length > 0) { + console.log(yellow(bold('Warnings'))) + console.log(yellow(`${allParseErrors.length} file(s) could not be parsed and were skipped:`)) + console.log() + for (const parseError of allParseErrors.slice(0, 10)) { + console.log(` ${red('✗')} ${parseError.file}`) + console.log(` ${dim(parseError.error.split('\n')[0])}`) + } + if (allParseErrors.length > 10) { + console.log(` ${dim(`... and ${allParseErrors.length - 10} more`)}`) + } + console.log() + console.log(dim(' These files may contain syntax errors or unsupported syntax.')) + console.log(dim(' Set DEBUG=1 for detailed error messages.')) + console.log() + } }, } diff --git a/packages/shared/src/reporters/types.ts b/packages/shared/src/reporters/types.ts index 7e449ea..226b1f5 100644 --- a/packages/shared/src/reporters/types.ts +++ b/packages/shared/src/reporters/types.ts @@ -5,12 +5,18 @@ export interface AnalyzedFeature { type: FeatureType } +export interface ParseError { + file: string + error: string +} + export interface AnalyzeReport { meta: { packageName: string version: string } features: AnalyzedFeature[] + parseErrors?: ParseError[] } export interface ReporterOptions {