Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions packages/shared/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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)
}
23 changes: 18 additions & 5 deletions packages/shared/src/functions/analyze.ts
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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<AnalyzeReport[]> {
if (!existsSync(cwd)) {
throw new Error(`Directory ${cwd} does not exist`)
throw new DirectoryNotFoundError(cwd)
}

const [files, importMaps, pkgs] = await Promise.all([
Expand 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')
Expand All @@ -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())
}
}
}

Expand All @@ -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,
}
})
}
7 changes: 6 additions & 1 deletion packages/shared/src/functions/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}

Expand Down
50 changes: 36 additions & 14 deletions packages/shared/src/functions/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}`
Expand All @@ -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?.()
Expand All @@ -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?.()

Expand All @@ -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?.()
}
}
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
23 changes: 22 additions & 1 deletion packages/shared/src/reporters/console.ts
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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)}`)
Expand Down Expand Up @@ -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()
}
},
}
6 changes: 6 additions & 0 deletions packages/shared/src/reporters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down