diff --git a/packages/app/src/cli/commands/app/config/validate.test.ts b/packages/app/src/cli/commands/app/config/validate.test.ts index 784c89a1031..08e1f7562e8 100644 --- a/packages/app/src/cli/commands/app/config/validate.test.ts +++ b/packages/app/src/cli/commands/app/config/validate.test.ts @@ -6,7 +6,7 @@ import {Project} from '../../../models/project/project.js' import {selectActiveConfig} from '../../../models/project/active-config.js' import {errorsForConfig} from '../../../models/project/config-selection.js' import {outputResult} from '@shopify/cli-kit/node/output' -import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' +import {TomlFile, TomlFileError} from '@shopify/cli-kit/node/toml/toml-file' import {describe, expect, test, vi} from 'vitest' vi.mock('../../../services/app-context.js') @@ -64,7 +64,10 @@ describe('app config validate command', () => { vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project) vi.mocked(selectActiveConfig).mockResolvedValue({file: new TomlFile('shopify.app.toml', {})} as any) vi.mocked(errorsForConfig).mockReturnValue([ - {path: '/app/shopify.app.toml', message: 'Unexpected character at row 1, col 5'} as any, + new TomlFileError('toml-parse-error', { + path: '/app/shopify.app.toml', + message: 'Unexpected character at row 1, col 5', + }), ]) await expect(Validate.run(['--json'], import.meta.url)).rejects.toThrow() diff --git a/packages/app/src/cli/commands/app/config/validate.ts b/packages/app/src/cli/commands/app/config/validate.ts index dd9efc4958a..b2b996d7c1a 100644 --- a/packages/app/src/cli/commands/app/config/validate.ts +++ b/packages/app/src/cli/commands/app/config/validate.ts @@ -2,13 +2,15 @@ import {appFlags} from '../../../flags.js' import {validateApp} from '../../../services/validate.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {linkedAppContext} from '../../../services/app-context.js' -import {selectActiveConfig} from '../../../models/project/active-config.js' +import {selectActiveConfig, ActiveConfigError} from '../../../models/project/active-config.js' import {errorsForConfig} from '../../../models/project/config-selection.js' -import {Project} from '../../../models/project/project.js' +import {Project, ProjectError} from '../../../models/project/project.js' +import {AppConfigValidationError, formatConfigurationError} from '../../../models/app/loader.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' -import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' -import {outputResult, stringifyMessage, unstyled} from '@shopify/cli-kit/node/output' +import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' import {renderError} from '@shopify/cli-kit/node/ui' +import {TomlFileError} from '@shopify/cli-kit/node/toml/toml-file' export default class Validate extends AppLinkedCommand { static summary = 'Validate your app configuration and extensions.' @@ -26,71 +28,67 @@ export default class Validate extends AppLinkedCommand { public async run(): Promise { const {flags} = await this.parse(Validate) - // Stage 1: Load project - let project: Project try { - project = await Project.load(flags.path) - } catch (err) { - if (err instanceof AbortError && flags.json) { - const message = unstyled(stringifyMessage(err.message)).trim() - outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2)) - throw new AbortSilentError() - } - throw err - } + const project = await Project.load(flags.path) + const activeConfig = await selectActiveConfig(project, flags.config) - // Stage 2: Select active config and check for TOML parse errors scoped to it - let activeConfig - try { - activeConfig = await selectActiveConfig(project, flags.config) - } catch (err) { - if (err instanceof AbortError && flags.json) { - const message = unstyled(stringifyMessage(err.message)).trim() - outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2)) + const configErrors = errorsForConfig(project, activeConfig.file) + if (configErrors.length > 0) { + const issues = configErrors.map((err) => ({file: err.details.path, message: err.details.message})) + if (flags.json) { + outputValidationJson({valid: false, issues}) + } else { + renderError({ + headline: 'Validation errors found.', + body: issues.map((issue) => `• ${issue.message}`).join('\n'), + }) + } throw new AbortSilentError() } - throw err - } - - const configErrors = errorsForConfig(project, activeConfig.file) - if (configErrors.length > 0) { - const issues = configErrors.map((err) => ({file: err.path, message: err.message})) - if (flags.json) { - outputResult(JSON.stringify({valid: false, issues}, null, 2)) - throw new AbortSilentError() - } - renderError({ - headline: 'Validation errors found.', - body: issues.map((issue) => `• ${issue.message}`).join('\n'), - }) - throw new AbortSilentError() - } - // Stage 3: Load app (link + remote fetch + schema validation) - let app - try { - const context = await linkedAppContext({ + const {app} = await linkedAppContext({ directory: flags.path, clientId: flags['client-id'], forceRelink: flags.reset, userProvidedConfigName: flags.config, unsafeTolerateErrors: true, }) - app = context.app + + await validateApp(app, {json: flags.json}) + return {app} } catch (err) { - // Only catch config validation errors for JSON output. Auth/linking/remote - // failures should propagate normally — they aren't validation results. - const message = err instanceof AbortError ? unstyled(stringifyMessage(err.message)).trim() : '' - const isValidationError = message.startsWith('Validation errors in ') - if (isValidationError && flags.json) { - outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2)) - throw new AbortSilentError() + if (!flags.json) throw err + + if (err instanceof TomlFileError) { + outputValidationJson({valid: false, issues: [{file: err.details.path, message: err.details.message}]}) + } else if (err instanceof ProjectError) { + outputValidationJson({ + valid: false, + issues: [{message: `No app configuration found in ${err.details.directory}`}], + }) + } else if (err instanceof ActiveConfigError) { + outputValidationJson({ + valid: false, + issues: [{message: `Config ${err.details.configName} not found in ${err.details.directory}`}], + }) + } else if (err instanceof AppConfigValidationError) { + outputValidationJson({ + valid: false, + issues: err.details.errors.map((ce) => ({ + file: ce.file, + message: formatConfigurationError(ce), + path: ce.path, + code: ce.code, + })), + }) + } else { + throw err } - throw err + throw new AbortSilentError() } - - await validateApp(app, {json: flags.json}) - - return {app} } } + +function outputValidationJson(result: {valid: boolean; issues: object[]}) { + outputResult(JSON.stringify(result, null, 2)) +} diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 4f35ca534b0..40df03312b0 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -8,9 +8,11 @@ import { getAppConfigurationContext, loadConfigForAppCreation, reloadApp, + AppConfigValidationError, } from './loader.js' import {App, AppInterface, AppLinkedInterface, AppSchema, WebConfigurationSchema} from './app.js' import {DEFAULT_CONFIG, buildVersionedAppSchema, getWebhookConfig} from './app.test-data.js' +import {ProjectError} from '../project/project.js' import {ExtensionInstance} from '../extensions/extension-instance.js' import {configurationFileNames, blocks} from '../../constants.js' import metadata from '../../metadata.js' @@ -235,26 +237,31 @@ describe('load', () => { cmd_app_linked_config_used: false, }) - test("throws an error if the directory doesn't exist", async () => { + test("throws ProjectError if the directory doesn't exist", async () => { await inTemporaryDirectory(async (tmp) => { // Given await rmdir(tmp, {force: true}) // When/Then - await expect(loadApp({directory: tmp, specifications, userProvidedConfigName: undefined})).rejects.toThrow( - /Could not find a Shopify app configuration file/, + const error = await loadApp({directory: tmp, specifications, userProvidedConfigName: undefined}).catch( + (err) => err, ) + expect(error).toBeInstanceOf(ProjectError) + expect(error.code).toBe('no-project-root') + expect(error.details.directory).toBe(tmp) }) }) - test("throws an error if the configuration file doesn't exist", async () => { + test("throws ProjectError if the configuration file doesn't exist", async () => { // Given const currentDir = cwd() // When/Then - await expect(loadApp({directory: currentDir, specifications, userProvidedConfigName: undefined})).rejects.toThrow( - /Could not find a Shopify app configuration file/, + const error = await loadApp({directory: currentDir, specifications, userProvidedConfigName: undefined}).catch( + (err) => err, ) + expect(error).toBeInstanceOf(ProjectError) + expect(error.code).toBe('no-project-root') }) test('throws an error when the configuration file is invalid', async () => { @@ -268,13 +275,18 @@ describe('load', () => { await expect(loadTestingApp()).rejects.toThrow() }) - test('throws an error when the application_url is invalid', async () => { + test('throws AppConfigValidationError when the application_url is invalid', async () => { // Given const config = buildAppConfiguration({applicationUrl: 'wrong'}) await writeConfig(config) // When/Then - await expect(loadTestingApp()).rejects.toThrow(/\[application_url\]: Invalid URL/) + const error = await loadTestingApp().catch((err) => err) + expect(error).toBeInstanceOf(AppConfigValidationError) + expect(error.code).toBe('schema-validation') + expect( + error.details.errors.some((err: {path?: string[]; message: string}) => err.path?.includes('application_url')), + ).toBe(true) }) test('loads the app when the configuration is valid and has no blocks', async () => { @@ -389,12 +401,14 @@ describe('load', () => { expect(app.webs.length).toBe(1) }, 30000) - test("throws an error if the extension configuration file doesn't exist", async () => { + test("throws ProjectError if the extension configuration file doesn't exist", async () => { // Given await makeBlockDir({name: 'my-extension'}) // When - await expect(loadTestingApp()).rejects.toThrow(/Could not find a Shopify app configuration file/) + const error = await loadTestingApp().catch((err) => err) + expect(error).toBeInstanceOf(ProjectError) + expect(error.code).toBe('no-project-root') }) test('throws an error if the extension configuration file is invalid', async () => { @@ -960,12 +974,14 @@ describe('load', () => { await expect(() => loadTestingApp()).rejects.toThrowError() }) - test("throws an error if the configuration file doesn't exist", async () => { + test("throws ProjectError if the configuration file doesn't exist", async () => { // Given await makeBlockDir({name: 'my-functions'}) // When - await expect(loadTestingApp()).rejects.toThrow(/Could not find a Shopify app configuration file/) + const error = await loadTestingApp().catch((err) => err) + expect(error).toBeInstanceOf(ProjectError) + expect(error.code).toBe('no-project-root') }) test('throws an error if the function configuration file is invalid', async () => { diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 2e5aad94508..1619bcfe4ca 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -45,7 +45,7 @@ import {resolveFramework} from '@shopify/cli-kit/node/framework' import {hashString} from '@shopify/cli-kit/node/crypto' import {JsonMapType} from '@shopify/cli-kit/node/toml' import {joinPath, dirname, basename, relativePath, relativizePath} from '@shopify/cli-kit/node/path' -import {AbortError} from '@shopify/cli-kit/node/error' +import {AbortError, type DomainError} from '@shopify/cli-kit/node/error' import {outputContent, outputDebug, outputToken, stringifyMessage} from '@shopify/cli-kit/node/output' import {joinWithAnd} from '@shopify/cli-kit/common/string' import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array' @@ -73,6 +73,20 @@ export interface ConfigurationError { code?: string } +export type AppConfigValidationErrorCode = 'schema-validation' + +export class AppConfigValidationError + extends AbortError + implements DomainError +{ + constructor( + public readonly code: AppConfigValidationErrorCode, + public readonly details: {configPath: string; errors: ConfigurationError[]}, + ) { + super(`AppConfigValidationError: ${code}`) + } +} + export function formatConfigurationError(error: ConfigurationError): string { if (error.path?.length) { return `[${error.path.join('.')}]: ${error.message}` @@ -294,8 +308,10 @@ export async function loadAppFromContext { expect(project.appConfigFiles).toHaveLength(1) expect(project.appConfigFiles[0]!.errors).toHaveLength(1) expect(project.errors).toHaveLength(1) - expect(project.errors[0]!.path).toContain('shopify.app.toml') + expect(project.errors[0]!.details.path).toContain('shopify.app.toml') }) }) diff --git a/packages/app/src/cli/models/project/active-config.ts b/packages/app/src/cli/models/project/active-config.ts index dbf8dd86eeb..d12a59b15f1 100644 --- a/packages/app/src/cli/models/project/active-config.ts +++ b/packages/app/src/cli/models/project/active-config.ts @@ -8,8 +8,21 @@ import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' import {fileExistsSync} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' -import {AbortError} from '@shopify/cli-kit/node/error' -import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {AbortError, type DomainError} from '@shopify/cli-kit/node/error' + +export type ActiveConfigErrorCode = 'config-not-found' + +export class ActiveConfigError + extends AbortError + implements DomainError +{ + constructor( + public readonly code: ActiveConfigErrorCode, + public readonly details: {configName: string; directory: string}, + ) { + super(`ActiveConfigError: ${code}`) + } +} /** @public */ export type ConfigSource = 'flag' | 'cached' | 'default' @@ -87,9 +100,7 @@ export async function selectActiveConfig(project: Project, userProvidedConfigNam const configurationFileName = getAppConfigurationFileName(configName) const file = project.appConfigByName(configurationFileName) if (!file) { - throw new AbortError( - outputContent`Couldn't find ${configurationFileName} in ${outputToken.path(project.directory)}.`, - ) + throw new ActiveConfigError('config-not-found', {configName: configurationFileName, directory: project.directory}) } return buildActiveConfig(project, file, source) diff --git a/packages/app/src/cli/models/project/config-selection.ts b/packages/app/src/cli/models/project/config-selection.ts index ae3f4684ffb..9dfd5297a09 100644 --- a/packages/app/src/cli/models/project/config-selection.ts +++ b/packages/app/src/cli/models/project/config-selection.ts @@ -133,8 +133,8 @@ export function errorsForConfig(project: Project, activeConfig: TomlFile): TomlF const allPatterns = [...extPatterns, ...webPatterns] return project.errors.filter((err) => { - if (err.path === activeConfig.path) return true - const relPath = relativePath(project.directory, err.path).replace(/\\/g, '/') + if (err.details.path === activeConfig.path) return true + const relPath = relativePath(project.directory, err.details.path).replace(/\\/g, '/') return allPatterns.some((pattern) => matchGlob(relPath, pattern)) }) } diff --git a/packages/app/src/cli/models/project/project.ts b/packages/app/src/cli/models/project/project.ts index f34ac61bedb..61b0716c2aa 100644 --- a/packages/app/src/cli/models/project/project.ts +++ b/packages/app/src/cli/models/project/project.ts @@ -9,9 +9,20 @@ import { usesWorkspaces as detectUsesWorkspaces, } from '@shopify/cli-kit/node/node-package-manager' import {joinPath, basename} from '@shopify/cli-kit/node/path' -import {AbortError} from '@shopify/cli-kit/node/error' +import {AbortError, type DomainError} from '@shopify/cli-kit/node/error' import {JsonMapType} from '@shopify/cli-kit/node/toml' +export type ProjectErrorCode = 'no-project-root' | 'no-app-configs' + +export class ProjectError extends AbortError implements DomainError { + constructor( + public readonly code: ProjectErrorCode, + public readonly details: {directory: string}, + ) { + super(`ProjectError: ${code}`) + } +} + const APP_CONFIG_GLOB = 'shopify.app*.toml' const APP_CONFIG_REGEX = /^shopify\.app(\.[-\w]+)?\.toml$/ const EXTENSION_TOML = '*.extension.toml' @@ -47,7 +58,7 @@ export class Project { // Discover all app config files const appConfigFiles = await discoverAppConfigFiles(directory, errors) if (appConfigFiles.length === 0) { - throw new AbortError(`Could not find a Shopify app TOML file in ${directory}`) + throw new ProjectError('no-app-configs', {directory}) } // Discover extension files from all app configs' extension_directories (union). @@ -173,9 +184,7 @@ async function findProjectRoot(startDirectory: string): Promise { }, ) if (!found) { - throw new AbortError( - `Could not find a Shopify app configuration file. Looked in ${startDirectory} and parent directories.`, - ) + throw new ProjectError('no-project-root', {directory: startDirectory}) } return found } @@ -219,7 +228,10 @@ async function readTomlFilesCollectingErrors(paths: string[], errors: TomlFileEr files.push(await TomlFile.read(filePath)) // eslint-disable-next-line no-catch-all/no-catch-all } catch (err) { - const tomlError = err instanceof TomlFileError ? err : new TomlFileError(filePath, `Failed to read ${filePath}`) + const tomlError = + err instanceof TomlFileError + ? err + : new TomlFileError('toml-parse-error', {path: filePath, message: `Failed to read ${filePath}`}) const file = new TomlFile(filePath, {}) file.errors.push(tomlError) files.push(file) diff --git a/packages/app/src/cli/services/app-context.ts b/packages/app/src/cli/services/app-context.ts index 8ba969e20f2..44944958205 100644 --- a/packages/app/src/cli/services/app-context.ts +++ b/packages/app/src/cli/services/app-context.ts @@ -85,8 +85,9 @@ export async function linkedAppContext({ let {project, activeConfig} = await getAppConfigurationContext(directory, userProvidedConfigName) let remoteApp: OrganizationApp | undefined - if (activeConfig.file.errors.length > 0) { - throw new AbortError(activeConfig.file.errors.map((err) => err.message).join('\n')) + const firstTomlError = activeConfig.file.errors[0] + if (firstTomlError) { + throw firstTomlError } if (!activeConfig.isLinked || forceRelink) { @@ -178,8 +179,9 @@ export async function localAppContext({ }: LocalAppContextOptions): Promise { const {project, activeConfig} = await getAppConfigurationContext(directory, userProvidedConfigName) - if (activeConfig.file.errors.length > 0) { - throw new AbortError(activeConfig.file.errors.map((err) => err.message).join('\n')) + const firstTomlError = activeConfig.file.errors[0] + if (firstTomlError) { + throw firstTomlError } const specifications = await loadLocalExtensionsSpecifications() diff --git a/packages/cli-kit/src/private/node/ui/components/FatalError.tsx b/packages/cli-kit/src/private/node/ui/components/FatalError.tsx index f8876fab6ea..86a877c807b 100644 --- a/packages/cli-kit/src/private/node/ui/components/FatalError.tsx +++ b/packages/cli-kit/src/private/node/ui/components/FatalError.tsx @@ -3,16 +3,30 @@ import {TokenizedText} from './TokenizedText.js' import {Command} from './Command.js' import {List} from './List.js' import {TabularData} from './TabularData.js' -import {BugError, cleanSingleStackTracePath, ExternalError, FatalError as Fatal} from '../../../../public/node/error.js' +import { + BugError, + cleanSingleStackTracePath, + ExternalError, + FatalError as Fatal, + type DomainError, +} from '../../../../public/node/error.js' import {Box, Text} from 'ink' import React, {FunctionComponent} from 'react' import StackTracey from 'stacktracey' +function isDomainError(error: unknown): error is Fatal & DomainError { + return error instanceof Fatal && 'code' in error && 'details' in error +} + interface FatalErrorProps { error: Fatal } const FatalError: FunctionComponent = ({error}) => { + if (isDomainError(error)) { + return + } + let stack let tool @@ -89,4 +103,63 @@ const FatalError: FunctionComponent = ({error}) => { ) } +const DomainErrorBanner: FunctionComponent<{error: Fatal & DomainError}> = ({error}) => { + const {details} = error + + switch (error.code) { + case 'no-project-root': + return ( + + + Could not find a Shopify app configuration file. Looked in {String(details.directory)} and parent + directories. + + + ) + case 'no-app-configs': + return ( + + Could not find a Shopify app TOML file in {String(details.directory)} + + ) + case 'config-not-found': + return ( + + + Couldn't find {String(details.configName)} in {String(details.directory)}. + + + ) + case 'toml-not-found': + case 'toml-parse-error': + return ( + + + TOML error in {String(details.path)}: + {String(details.message)} + + + ) + case 'schema-validation': { + const errors = (details.errors ?? []) as {file?: string; path?: (string | number)[]; message: string}[] + return ( + + + Validation errors in {String(details.configPath)}: + {errors.map((ce, idx) => ( + • {ce.path?.length ? `[${ce.path.join('.')}]: ${ce.message}` : ce.message} + ))} + + + ) + } + default: + return ( + + {error.message} + + ) + } +} + export {FatalError} diff --git a/packages/cli-kit/src/public/node/error.ts b/packages/cli-kit/src/public/node/error.ts index 2de5c3e3e4e..349b5e04f1f 100644 --- a/packages/cli-kit/src/public/node/error.ts +++ b/packages/cli-kit/src/public/node/error.ts @@ -68,6 +68,19 @@ export abstract class FatalError extends Error { } } +/** + * Shared interface for domain-scoped errors. Each domain model defines its own + * error class that extends AbortError and implements this interface. + * `code` is the discriminant, `details` carries domain-specific structured data. + */ +export interface DomainError< + TCode extends string = string, + TDetails extends Record = Record, +> { + readonly code: TCode + readonly details: TDetails +} + /** * An abort error is a fatal error that shouldn't be reported as a bug. * Those usually represent unexpected scenarios that we can't handle and that usually require some action from the developer. diff --git a/packages/cli-kit/src/public/node/toml/toml-file.test.ts b/packages/cli-kit/src/public/node/toml/toml-file.test.ts index 2edcf00fb11..4a560269436 100644 --- a/packages/cli-kit/src/public/node/toml/toml-file.test.ts +++ b/packages/cli-kit/src/public/node/toml/toml-file.test.ts @@ -28,18 +28,23 @@ describe('TomlFile', () => { }) }) - test('throws TomlFileError with file path on invalid TOML', async () => { + test('throws TomlFileError with parse details on invalid TOML', async () => { await inTemporaryDirectory(async (dir) => { const path = joinPath(dir, 'bad.toml') await writeFile(path, 'name = [invalid') - await expect(TomlFile.read(path)).rejects.toThrow(TomlFileError) - await expect(TomlFile.read(path)).rejects.toThrow(/row.*col/) + const error = await TomlFile.read(path).catch((err) => err) + expect(error).toBeInstanceOf(TomlFileError) + expect(error.code).toBe('toml-parse-error') + expect(error.details.path).toBe(path) + expect(error.details.message).toMatch(/row.*col/) }) }) test('throws TomlFileError if file does not exist', async () => { - await expect(TomlFile.read('/nonexistent/path/test.toml')).rejects.toThrow(TomlFileError) + const error = await TomlFile.read('/nonexistent/path/test.toml').catch((err) => err) + expect(error).toBeInstanceOf(TomlFileError) + expect(error.code).toBe('toml-not-found') }) }) diff --git a/packages/cli-kit/src/public/node/toml/toml-file.ts b/packages/cli-kit/src/public/node/toml/toml-file.ts index 2100fd4ea45..d2b1cca4855 100644 --- a/packages/cli-kit/src/public/node/toml/toml-file.ts +++ b/packages/cli-kit/src/public/node/toml/toml-file.ts @@ -1,20 +1,25 @@ import {JsonMapType, decodeToml, encodeToml} from './codec.js' import {fileExists, readFile, writeFile} from '../fs.js' +import {AbortError, type DomainError} from '../error.js' import {updateTomlValues} from '@shopify/toml-patch' type TomlPatchValue = string | number | boolean | undefined | (string | number | boolean)[] +export type TomlFileErrorCode = 'toml-not-found' | 'toml-parse-error' + /** * An error on a TOML file — missing or malformed. - * Extends Error so it can be thrown. Carries path and a clean message suitable for JSON output. + * Carries structured data; rendering happens at the display boundary. */ -export class TomlFileError extends Error { - readonly path: string - - constructor(path: string, message: string) { - super(message) - this.name = 'TomlFileError' - this.path = path +export class TomlFileError + extends AbortError + implements DomainError +{ + constructor( + public readonly code: TomlFileErrorCode, + public readonly details: {path: string; message: string}, + ) { + super(`TomlFileError: ${code}`) } } @@ -40,7 +45,7 @@ export class TomlFile { */ static async read(path: string): Promise { if (!(await fileExists(path))) { - throw new TomlFileError(path, `TOML file not found: ${path}`) + throw new TomlFileError('toml-not-found', {path, message: `TOML file not found: ${path}`}) } const raw = await readFile(path) const file = new TomlFile(path, {}) @@ -143,7 +148,10 @@ export class TomlFile { } catch (err: any) { if (err.line !== undefined && err.col !== undefined) { const description = String(err.message).split('\n')[0] ?? 'Invalid TOML' - throw new TomlFileError(this.path, `${description} at row ${err.line}, col ${err.col}`) + throw new TomlFileError('toml-parse-error', { + path: this.path, + message: `${description} at row ${err.line}, col ${err.col}`, + }) } throw err }