Skip to content

Commit 07f8cb4

Browse files
committed
Align project config failures with validate json
1 parent b82d4c6 commit 07f8cb4

4 files changed

Lines changed: 156 additions & 23 deletions

File tree

packages/app/src/cli/models/project/active-config.test.ts

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {selectActiveConfig} from './active-config.js'
22
import {Project} from './project.js'
3+
import {AppConfigurationAbortError} from '../app/error-parsing.js'
34
import {describe, expect, test, vi, beforeEach} from 'vitest'
45
import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs'
56
import {joinPath, basename} from '@shopify/cli-kit/node/path'
@@ -155,34 +156,103 @@ describe('selectActiveConfig', () => {
155156
})
156157
})
157158

158-
test('throws when requested config does not exist', async () => {
159+
test('throws a structured configuration abort when requested config does not exist', async () => {
159160
await inTemporaryDirectory(async (dir) => {
160161
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
161162
const project = await Project.load(dir)
162163

163-
await expect(selectActiveConfig(project, 'nonexistent')).rejects.toThrow()
164+
try {
165+
await selectActiveConfig(project, 'nonexistent')
166+
expect.unreachable('Expected selectActiveConfig to throw')
167+
} catch (error) {
168+
if (!(error instanceof AppConfigurationAbortError)) throw error
169+
170+
expect(error).toMatchObject({
171+
issues: [
172+
{
173+
filePath: joinPath(dir, 'shopify.app.nonexistent.toml'),
174+
path: [],
175+
pathString: 'root',
176+
message: `Couldn't find shopify.app.nonexistent.toml in ${dir}.`,
177+
},
178+
],
179+
})
180+
}
164181
})
165182
})
166183

167-
test('throws when the only app config is malformed (no valid configs to fall back to)', async () => {
184+
test('throws a structured configuration abort when the only app config is malformed', async () => {
168185
await inTemporaryDirectory(async (dir) => {
169-
// The only config is broken TOML — Project.load skips it and finds 0 valid configs
170186
await writeFile(joinPath(dir, 'shopify.app.toml'), '{{invalid toml')
171187

172-
await expect(Project.load(dir)).rejects.toThrow(/Could not find/)
188+
try {
189+
await Project.load(dir)
190+
expect.unreachable('Expected Project.load to throw')
191+
} catch (error) {
192+
if (!(error instanceof AppConfigurationAbortError)) throw error
193+
194+
expect(error).toMatchObject({
195+
issues: [
196+
{
197+
filePath: joinPath(dir, 'shopify.app.toml'),
198+
path: [],
199+
pathString: 'root',
200+
},
201+
],
202+
})
203+
}
173204
})
174205
})
175206

176207
test('surfaces parse error when selecting a broken config while a valid one exists', async () => {
177208
await inTemporaryDirectory(async (dir) => {
178-
// Two configs: one good, one broken. Selecting the broken one by name should
179-
// surface the real parse error via the fallback re-read, not a generic "not found".
180209
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "good"')
181210
await writeFile(joinPath(dir, 'shopify.app.broken.toml'), '{{invalid toml')
182211

183212
const project = await Project.load(dir)
184213

185-
await expect(selectActiveConfig(project, 'shopify.app.broken.toml')).rejects.toThrow()
214+
try {
215+
await selectActiveConfig(project, 'shopify.app.broken.toml')
216+
expect.unreachable('Expected selectActiveConfig to throw')
217+
} catch (error) {
218+
if (!(error instanceof AppConfigurationAbortError)) throw error
219+
220+
expect(error).toMatchObject({
221+
issues: [
222+
{
223+
filePath: joinPath(dir, 'shopify.app.broken.toml'),
224+
path: [],
225+
pathString: 'root',
226+
},
227+
],
228+
})
229+
}
230+
})
231+
})
232+
233+
test('surfaces parse error when the default config is malformed and a named config exists', async () => {
234+
await inTemporaryDirectory(async (dir) => {
235+
await writeFile(joinPath(dir, 'shopify.app.toml'), '{{invalid toml')
236+
await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"')
237+
238+
const project = await Project.load(dir)
239+
240+
try {
241+
await selectActiveConfig(project)
242+
expect.unreachable('Expected selectActiveConfig to throw')
243+
} catch (error) {
244+
if (!(error instanceof AppConfigurationAbortError)) throw error
245+
246+
expect(error).toMatchObject({
247+
issues: [
248+
{
249+
filePath: joinPath(dir, 'shopify.app.toml'),
250+
path: [],
251+
pathString: 'root',
252+
},
253+
],
254+
})
255+
}
186256
})
187257
})
188258

packages/app/src/cli/models/project/active-config.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import {Project} from './project.js'
22
import {resolveDotEnv, resolveHiddenConfig} from './config-selection.js'
33
import {AppHiddenConfig} from '../app/app.js'
4+
import {AppConfigurationAbortError} from '../app/error-parsing.js'
45
import {getAppConfigurationFileName} from '../app/config-file-naming.js'
56
import {getCachedAppInfo} from '../../services/local-storage.js'
67
import use from '../../services/app/config/use.js'
78
import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file'
89
import {DotEnvFile} from '@shopify/cli-kit/node/dot-env'
910
import {fileExistsSync} from '@shopify/cli-kit/node/fs'
1011
import {joinPath} from '@shopify/cli-kit/node/path'
11-
import {AbortError} from '@shopify/cli-kit/node/error'
1212
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
1313

1414
/** @public */
@@ -87,8 +87,14 @@ export async function selectActiveConfig(project: Project, userProvidedConfigNam
8787
const configurationFileName = getAppConfigurationFileName(configName)
8888
const file = project.appConfigByName(configurationFileName)
8989
if (!file) {
90-
throw new AbortError(
90+
const malformedFile = project.malformedAppConfigByName(configurationFileName)
91+
if (malformedFile) {
92+
throw new AppConfigurationAbortError(malformedFile.message, malformedFile.path)
93+
}
94+
95+
throw new AppConfigurationAbortError(
9196
outputContent`Couldn't find ${configurationFileName} in ${outputToken.path(project.directory)}.`,
97+
joinPath(project.directory, configurationFileName),
9298
)
9399
}
94100

packages/app/src/cli/models/project/project.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {Project} from './project.js'
2+
import {AppConfigurationAbortError} from '../app/error-parsing.js'
23
import {describe, expect, test} from 'vitest'
34
import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs'
45
import {joinPath, normalizePath} from '@shopify/cli-kit/node/path'
@@ -116,9 +117,16 @@ describe('Project', () => {
116117
})
117118
})
118119

119-
test('throws when no app config files found', async () => {
120+
test('throws a structured configuration abort when no app config files are found', async () => {
120121
await inTemporaryDirectory(async (dir) => {
121-
await expect(Project.load(dir)).rejects.toThrow()
122+
try {
123+
await Project.load(dir)
124+
expect.unreachable('Expected Project.load to throw')
125+
} catch (error) {
126+
if (!(error instanceof AppConfigurationAbortError)) throw error
127+
128+
expect(error.issues).toHaveLength(1)
129+
}
122130
})
123131
})
124132

packages/app/src/cli/models/project/project.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {configurationFileNames} from '../../constants.js'
2+
import {AppConfigurationAbortError} from '../app/error-parsing.js'
23
import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file'
34
import {readAndParseDotEnv, DotEnvFile} from '@shopify/cli-kit/node/dot-env'
45
import {fileExists, glob, findPathUp, readFile} from '@shopify/cli-kit/node/fs'
@@ -9,7 +10,6 @@ import {
910
usesWorkspaces as detectUsesWorkspaces,
1011
} from '@shopify/cli-kit/node/node-package-manager'
1112
import {joinPath, basename} from '@shopify/cli-kit/node/path'
12-
import {AbortError} from '@shopify/cli-kit/node/error'
1313
import {outputDebug} from '@shopify/cli-kit/node/output'
1414
import {JsonMapType} from '@shopify/cli-kit/node/toml'
1515

@@ -21,6 +21,11 @@ const DEFAULT_EXTENSION_DIR = 'extensions/*'
2121
const NODE_MODULES_EXCLUDE = '**/node_modules/**'
2222
const DOTENV_GLOB = '.env*'
2323

24+
interface MalformedTomlFile {
25+
path: string
26+
message: string
27+
}
28+
2429
/**
2530
* A Project is the Shopify app as it exists on the filesystem.
2631
*
@@ -45,9 +50,14 @@ export class Project {
4550
const directory = await findProjectRoot(startDirectory)
4651

4752
// Discover all app config files
48-
const appConfigFiles = await discoverAppConfigFiles(directory)
53+
const {appConfigFiles, malformedAppConfigFiles} = await discoverAppConfigFiles(directory)
4954
if (appConfigFiles.length === 0) {
50-
throw new AbortError(`Could not find a Shopify app TOML file in ${directory}`)
55+
const preferredMalformedConfig = getPreferredMalformedAppConfig(malformedAppConfigFiles)
56+
if (preferredMalformedConfig) {
57+
throw new AppConfigurationAbortError(preferredMalformedConfig.message, preferredMalformedConfig.path)
58+
}
59+
60+
throw new AppConfigurationAbortError(`Could not find a Shopify app TOML file in ${directory}`, directory)
5161
}
5262

5363
// Discover extension files from all app configs' extension_directories (union).
@@ -92,6 +102,7 @@ export class Project {
92102
nodeDependencies,
93103
usesWorkspaces,
94104
appConfigFiles,
105+
malformedAppConfigFiles,
95106
extensionConfigFiles,
96107
webConfigFiles,
97108
dotenvFiles,
@@ -104,6 +115,7 @@ export class Project {
104115
readonly nodeDependencies: Record<string, string>
105116
readonly usesWorkspaces: boolean
106117
readonly appConfigFiles: TomlFile[]
118+
readonly malformedAppConfigFiles: MalformedTomlFile[]
107119
readonly extensionConfigFiles: TomlFile[]
108120
readonly webConfigFiles: TomlFile[]
109121

@@ -119,6 +131,7 @@ export class Project {
119131
nodeDependencies: Record<string, string>
120132
usesWorkspaces: boolean
121133
appConfigFiles: TomlFile[]
134+
malformedAppConfigFiles: MalformedTomlFile[]
122135
extensionConfigFiles: TomlFile[]
123136
webConfigFiles: TomlFile[]
124137
dotenvFiles: Map<string, DotEnvFile>
@@ -129,6 +142,7 @@ export class Project {
129142
this.nodeDependencies = options.nodeDependencies
130143
this.usesWorkspaces = options.usesWorkspaces
131144
this.appConfigFiles = options.appConfigFiles
145+
this.malformedAppConfigFiles = options.malformedAppConfigFiles
132146
this.extensionConfigFiles = options.extensionConfigFiles
133147
this.webConfigFiles = options.webConfigFiles
134148
this.dotenvFiles = options.dotenvFiles
@@ -147,6 +161,11 @@ export class Project {
147161
return this.appConfigFiles.find((file) => file.content.client_id === clientId)
148162
}
149163

164+
/** Find a malformed app config file by filename. */
165+
malformedAppConfigByName(fileName: string): MalformedTomlFile | undefined {
166+
return this.malformedAppConfigFiles.find((file) => basename(file.path) === fileName)
167+
}
168+
150169
/** The default app config (shopify.app.toml), if it exists */
151170
get defaultAppConfig(): TomlFile | undefined {
152171
return this.appConfigByName(configurationFileNames.app)
@@ -167,18 +186,22 @@ async function findProjectRoot(startDirectory: string): Promise<string> {
167186
},
168187
)
169188
if (!found) {
170-
throw new AbortError(
189+
throw new AppConfigurationAbortError(
171190
`Could not find a Shopify app configuration file. Looked in ${startDirectory} and parent directories.`,
191+
startDirectory,
172192
)
173193
}
174194
return found
175195
}
176196

177-
async function discoverAppConfigFiles(directory: string): Promise<TomlFile[]> {
197+
async function discoverAppConfigFiles(
198+
directory: string,
199+
): Promise<{appConfigFiles: TomlFile[]; malformedAppConfigFiles: MalformedTomlFile[]}> {
178200
const pattern = joinPath(directory, APP_CONFIG_GLOB)
179201
const paths = await glob(pattern)
180202
const validPaths = paths.filter((filePath) => APP_CONFIG_REGEX.test(basename(filePath)))
181-
return readTomlFilesSafe(validPaths)
203+
const {files, malformedFiles} = await readTomlFiles(validPaths)
204+
return {appConfigFiles: files, malformedAppConfigFiles: malformedFiles}
182205
}
183206

184207
async function discoverExtensionFiles(directory: string, extensionDirectories?: string[]): Promise<TomlFile[]> {
@@ -202,19 +225,45 @@ async function discoverWebFiles(directory: string, webDirectories?: string[]): P
202225
* This prevents a malformed inactive config or extension TOML
203226
* from blocking the active config from loading.
204227
*/
205-
async function readTomlFilesSafe(paths: string[]): Promise<TomlFile[]> {
228+
async function readTomlFiles(paths: string[]): Promise<{files: TomlFile[]; malformedFiles: MalformedTomlFile[]}> {
206229
const results = await Promise.all(
207230
paths.map(async (filePath) => {
208231
try {
209-
return await TomlFile.read(filePath)
232+
return {file: await TomlFile.read(filePath)}
210233
// eslint-disable-next-line no-catch-all/no-catch-all
211-
} catch {
234+
} catch (error) {
212235
outputDebug(`Skipping malformed TOML file: ${filePath}`)
213-
return undefined
236+
return {
237+
malformedFile: {
238+
path: filePath,
239+
message: error instanceof Error ? error.message : `Failed to parse ${filePath}`,
240+
},
241+
}
214242
}
215243
}),
216244
)
217-
return results.filter((file): file is TomlFile => file !== undefined)
245+
246+
const files: TomlFile[] = []
247+
const malformedFiles: MalformedTomlFile[] = []
248+
249+
for (const result of results) {
250+
if ('file' in result && result.file) {
251+
files.push(result.file)
252+
} else {
253+
malformedFiles.push(result.malformedFile)
254+
}
255+
}
256+
257+
return {files, malformedFiles}
258+
}
259+
260+
async function readTomlFilesSafe(paths: string[]): Promise<TomlFile[]> {
261+
const {files} = await readTomlFiles(paths)
262+
return files
263+
}
264+
265+
function getPreferredMalformedAppConfig(malformedFiles: MalformedTomlFile[]): MalformedTomlFile | undefined {
266+
return malformedFiles.find((file) => basename(file.path) === configurationFileNames.app) ?? malformedFiles[0]
218267
}
219268

220269
/** Discover all .env* files in the project root */

0 commit comments

Comments
 (0)