Skip to content
Draft
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
7 changes: 5 additions & 2 deletions packages/app/src/cli/commands/app/config/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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()
Expand Down
110 changes: 54 additions & 56 deletions packages/app/src/cli/commands/app/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand All @@ -26,71 +28,67 @@ export default class Validate extends AppLinkedCommand {
public async run(): Promise<AppLinkedCommandOutput> {
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))
}
40 changes: 28 additions & 12 deletions packages/app/src/cli/models/app/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
22 changes: 19 additions & 3 deletions packages/app/src/cli/models/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -73,6 +73,20 @@ export interface ConfigurationError {
code?: string
}

export type AppConfigValidationErrorCode = 'schema-validation'

export class AppConfigValidationError
extends AbortError
implements DomainError<AppConfigValidationErrorCode, {configPath: string; errors: ConfigurationError[]}>
{
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}`
Expand Down Expand Up @@ -294,8 +308,10 @@ export async function loadAppFromContext<TModuleSpec extends ExtensionSpecificat

const configResult = await parseConfigurationFile(configSchema, configurationPath, rawConfig)
if (configResult.errors) {
const formatted = configResult.errors.map(formatConfigurationError).join('\n')
throw new AbortError(`Validation errors in ${configurationPath}:\n\n${formatted}`)
throw new AppConfigValidationError('schema-validation', {
configPath: configurationPath,
errors: configResult.errors,
})
}
const configuration = configResult.data

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ describe('selectActiveConfig', () => {
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')
})
})

Expand Down
21 changes: 16 additions & 5 deletions packages/app/src/cli/models/project/active-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActiveConfigErrorCode, {configName: string; directory: string}>
{
constructor(
public readonly code: ActiveConfigErrorCode,
public readonly details: {configName: string; directory: string},
) {
super(`ActiveConfigError: ${code}`)
}
}

/** @public */
export type ConfigSource = 'flag' | 'cached' | 'default'
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/cli/models/project/config-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
}
Loading
Loading