Skip to content

Commit 809fd38

Browse files
authored
Merge pull request #8762 from Shopify/app-doctor/config-prompt
App Security: ask which app configuration to scan when none is selected
2 parents a62b762 + a9c4f68 commit 809fd38

4 files changed

Lines changed: 233 additions & 10 deletions

File tree

‎packages/app/src/cli/services/app-security-selection.test.ts‎

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import {
1111
type AppSecuritySelectionDependencies,
1212
} from './app-security-selection.js'
1313
import {validAppConfiguration} from './app-security-selection.test-data.js'
14-
import {getCachedAppInfo} from './local-storage.js'
14+
import {getCachedAppInfo, setCachedAppInfo} from './local-storage.js'
1515
import {appCreationDefaults} from './app/config/link.js'
1616
import {fetchOrCreateOrganizationApp} from './context.js'
17+
import use from './app/config/use.js'
1718
import {AbortError} from '@shopify/cli-kit/node/error'
1819
import {fileRealPath, inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs'
1920
import {joinPath} from '@shopify/cli-kit/node/path'
@@ -25,7 +26,9 @@ import type {OrganizationApp} from '../models/organization.js'
2526
vi.mock('./local-storage.js', async (importOriginal) => ({
2627
...(await importOriginal<typeof import('./local-storage.js')>()),
2728
getCachedAppInfo: vi.fn(),
29+
setCachedAppInfo: vi.fn(),
2830
}))
31+
vi.mock('./app/config/use.js', () => ({default: vi.fn()}))
2932
vi.mock('./context.js', async (importOriginal) => ({
3033
...(await importOriginal<typeof import('./context.js')>()),
3134
fetchOrCreateOrganizationApp: vi.fn(),
@@ -51,6 +54,7 @@ function promptDependencies(overrides: Partial<AppSecuritySelectionDependencies>
5154
return {
5255
confirmScanWithoutAppConfig: vi.fn(async (_directory: string) => true),
5356
pickClientId: vi.fn(async (_appDirectory: string) => 'picked-client-id'),
57+
pickConfigFile: vi.fn(async (_appDirectory: string) => 'shopify.app.staging.toml'),
5458
...overrides,
5559
}
5660
}
@@ -212,6 +216,116 @@ describe('resolveAppSecuritySelection with an app configuration', () => {
212216
})
213217
})
214218

219+
describe('resolveAppSecuritySelection with several TOMLs and none selected', () => {
220+
async function writeProductionAndStaging(directory: string): Promise<void> {
221+
await writeConfiguration(directory, 'production-client-id', 'shopify.app.production.toml')
222+
await writeConfiguration(directory, 'staging-client-id', 'shopify.app.staging.toml')
223+
}
224+
225+
test('asks which TOML to scan, without saving the answer', async () => {
226+
await inTemporaryDirectory(async (directory) => {
227+
await writeProductionAndStaging(directory)
228+
const dependencies = promptDependencies()
229+
230+
const selection = await resolveAppSecuritySelection({path: directory, allowPrompts: true}, dependencies)
231+
232+
expect(selection).toMatchObject({
233+
kind: 'config',
234+
appConfigFilePath: joinPath(await fileRealPath(directory), 'shopify.app.staging.toml'),
235+
configClientId: 'staging-client-id',
236+
appConfigFilePicked: true,
237+
})
238+
expect(dependencies.pickConfigFile).toHaveBeenCalledOnce()
239+
expect(setCachedAppInfo).not.toHaveBeenCalled()
240+
})
241+
})
242+
243+
test('asks when the `app config use` choice no longer exists', async () => {
244+
await inTemporaryDirectory(async (directory) => {
245+
await writeProductionAndStaging(directory)
246+
vi.mocked(getCachedAppInfo).mockReturnValue({directory, configFile: 'shopify.app.deleted.toml'})
247+
const dependencies = promptDependencies()
248+
249+
const selection = await resolveAppSecuritySelection({path: directory, allowPrompts: true}, dependencies)
250+
251+
expect(selection).toMatchObject({configClientId: 'staging-client-id', appConfigFilePicked: true})
252+
expect(setCachedAppInfo).not.toHaveBeenCalled()
253+
})
254+
})
255+
256+
test('scans shopify.app.toml without asking or saving a choice when the `app config use` choice no longer exists', async () => {
257+
await inTemporaryDirectory(async (directory) => {
258+
await writeProductionAndStaging(directory)
259+
await writeConfiguration(directory, 'default-client-id')
260+
vi.mocked(getCachedAppInfo).mockReturnValue({directory, configFile: 'shopify.app.deleted.toml'})
261+
const dependencies = promptDependencies()
262+
263+
const selection = await resolveAppSecuritySelection({path: directory, allowPrompts: true}, dependencies)
264+
265+
expect(selection).toMatchObject({configClientId: 'default-client-id', appConfigFilePicked: false})
266+
expect(dependencies.pickConfigFile).not.toHaveBeenCalled()
267+
expect(use).not.toHaveBeenCalled()
268+
expect(setCachedAppInfo).not.toHaveBeenCalled()
269+
})
270+
})
271+
272+
test('scans shopify.app.toml without asking when it exists', async () => {
273+
await inTemporaryDirectory(async (directory) => {
274+
await writeProductionAndStaging(directory)
275+
await writeConfiguration(directory, 'default-client-id')
276+
const dependencies = promptDependencies()
277+
278+
const selection = await resolveAppSecuritySelection({path: directory, allowPrompts: true}, dependencies)
279+
280+
expect(selection).toMatchObject({configClientId: 'default-client-id', appConfigFilePicked: undefined})
281+
expect(dependencies.pickConfigFile).not.toHaveBeenCalled()
282+
})
283+
})
284+
285+
test('scans the only TOML without asking', async () => {
286+
await inTemporaryDirectory(async (directory) => {
287+
await writeConfiguration(directory, 'staging-client-id', 'shopify.app.staging.toml')
288+
const dependencies = promptDependencies()
289+
290+
const selection = await resolveAppSecuritySelection({path: directory, allowPrompts: false}, dependencies)
291+
292+
expect(selection).toMatchObject({configClientId: 'staging-client-id', appConfigFilePicked: false})
293+
expect(dependencies.pickConfigFile).not.toHaveBeenCalled()
294+
})
295+
})
296+
297+
test('aborts with the TOMLs to choose from when it cannot ask', async () => {
298+
await inTemporaryDirectory(async (directory) => {
299+
await writeProductionAndStaging(directory)
300+
const dependencies = promptDependencies()
301+
302+
const error = await selectionError(
303+
resolveAppSecuritySelection({path: directory, allowPrompts: false}, dependencies),
304+
)
305+
306+
expect(error.message).toBe(`2 app configurations found in ${directory}, and none is selected.`)
307+
expect(error.tryMessage).toBe('Pass `--config` with one of: production, staging.')
308+
expect(dependencies.pickConfigFile).not.toHaveBeenCalled()
309+
})
310+
})
311+
312+
test('aborts instead of asking with --client-id, which --config cannot repeat', async () => {
313+
await inTemporaryDirectory(async (directory) => {
314+
await writeProductionAndStaging(directory)
315+
const dependencies = promptDependencies()
316+
317+
const error = await selectionError(
318+
resolveAppSecuritySelection({path: directory, clientId: 'flag-client-id', allowPrompts: true}, dependencies),
319+
)
320+
321+
expect(error.tryMessage).toBe(
322+
"`--client-id` can't be combined with `--config`, so first select one with `shopify app config use <config>`: production, staging.",
323+
)
324+
expect(dependencies.pickConfigFile).not.toHaveBeenCalled()
325+
})
326+
})
327+
})
328+
215329
describe('resolveAppSecuritySelection with --without-app-config', () => {
216330
test('aborts without --client-id', async () => {
217331
await inTemporaryDirectory(async (directory) => {
@@ -486,6 +600,34 @@ describe('resolveAppDirectory', () => {
486600
})
487601
})
488602

603+
test('finds the app directory when there are several TOMLs and none is selected', async () => {
604+
await inTemporaryDirectory(async (directory) => {
605+
await writeConfiguration(directory, 'production-client-id', 'shopify.app.production.toml')
606+
await writeConfiguration(directory, 'staging-client-id', 'shopify.app.staging.toml')
607+
608+
await expect(resolveAppDirectory({path: directory})).resolves.toBe(await fileRealPath(directory))
609+
})
610+
})
611+
612+
test('finds the app directory without validating its TOML', async () => {
613+
await inTemporaryDirectory(async (directory) => {
614+
await writeFile(joinPath(directory, 'shopify.app.toml'), 'name = [')
615+
616+
await expect(resolveAppDirectory({path: directory})).resolves.toBe(await fileRealPath(directory))
617+
})
618+
})
619+
620+
test('aborts when --path does not exist instead of walking up to the app above it', async () => {
621+
await inTemporaryDirectory(async (directory) => {
622+
await writeConfiguration(directory, 'default-client-id')
623+
const missing = joinPath(directory, 'missing-app')
624+
625+
await expect(resolveAppDirectory({path: missing})).rejects.toMatchObject({
626+
message: `--path ${missing}: not a directory.`,
627+
})
628+
})
629+
})
630+
489631
test('aborts when no TOML is found, without prompting', async () => {
490632
await inTemporaryDirectory(async (directory) => {
491633
await expect(resolveAppDirectory({path: directory})).rejects.toMatchObject({

‎packages/app/src/cli/services/app-security-selection.ts‎

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import {localAppContext} from './app-context.js'
22
import {appCreationDefaults} from './app/config/link.js'
33
import {fetchOrCreateOrganizationApp} from './context.js'
4-
import {NoAppConfigurationFoundError} from '../models/project/project.js'
4+
import {getCachedAppInfo} from './local-storage.js'
5+
import {NoAppConfigurationFoundError, Project} from '../models/project/project.js'
6+
import {getAppConfigurationShorthand} from '../models/app/config-file-naming.js'
7+
import {findConfigFiles, selectConfigFile} from '../prompts/config.js'
8+
import {configurationFileNames} from '../constants.js'
59
import {AbortError} from '@shopify/cli-kit/node/error'
6-
import {fileRealPath, isDirectory} from '@shopify/cli-kit/node/fs'
10+
import {fileExistsSync, fileRealPath, isDirectory} from '@shopify/cli-kit/node/fs'
711
import {basename, cwd, isSubpath, joinPath, normalizePath, relativePath, resolvePath} from '@shopify/cli-kit/node/path'
812
import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui'
913

@@ -18,6 +22,8 @@ export type AppSecuritySelection =
1822
configClientId?: string
1923
/** The `--client-id` value, if passed. */
2024
clientIdOverride?: string
25+
/** True when `check` asked which TOML to scan, because nothing else selected one. */
26+
appConfigFilePicked?: boolean
2127
}
2228
| {
2329
kind: 'no-config'
@@ -45,6 +51,7 @@ interface AppSecuritySelectionOptions {
4551
export interface AppSecuritySelectionDependencies {
4652
confirmScanWithoutAppConfig(directory: string): Promise<boolean>
4753
pickClientId(appDirectory: string): Promise<string>
54+
pickConfigFile(appDirectory: string): Promise<string>
4855
}
4956

5057
const defaultDependencies: AppSecuritySelectionDependencies = {
@@ -56,6 +63,7 @@ const defaultDependencies: AppSecuritySelectionDependencies = {
5663
defaultValue: false,
5764
}),
5865
pickClientId: async (appDirectory) => (await fetchOrCreateOrganizationApp(appCreationDefaults(appDirectory))).apiKey,
66+
pickConfigFile: async (appDirectory) => (await selectConfigFile(appDirectory)).valueOrAbort(),
5967
}
6068

6169
/** The name of the selected TOML, or undefined when there is none. */
@@ -85,11 +93,23 @@ export function clientIdSource(selection: AppSecuritySelection): 'config' | 'fla
8593

8694
/**
8795
* The app directory alone, for `clean --all`, which needs no client ID and no results key. With
88-
* `--without-app-config` that's `--path` itself, so no client ID is required to find it.
96+
* `--without-app-config` that's `--path` itself, so no client ID is required to find it. Otherwise it's the directory
97+
* that holds the TOMLs, found by walking up as for the other commands. No TOML is selected or validated: they all share
98+
* that directory, so `clean --all` also works with several TOMLs and none selected, or with a TOML that is invalid.
8999
*/
90-
export async function resolveAppDirectory(options: Omit<AppSecuritySelectionOptions, 'allowPrompts'>): Promise<string> {
91-
if (options.withoutAppConfig) return realDirectory(options.path)
92-
return (await resolveAppSecuritySelection({...options, allowPrompts: false})).appDirectory
100+
export async function resolveAppDirectory(
101+
options: Pick<AppSecuritySelectionOptions, 'path' | 'withoutAppConfig'>,
102+
): Promise<string> {
103+
// Checked before walking up: walking up from a missing directory would find, and clean, the app above it.
104+
const directory = await realDirectory(options.path)
105+
if (options.withoutAppConfig) return directory
106+
107+
try {
108+
return await fileRealPath((await Project.load(options.path)).directory)
109+
} catch (error) {
110+
if (!(error instanceof NoAppConfigurationFoundError)) throw error
111+
abortNoAppConfigurationFound(options.path)
112+
}
93113
}
94114

95115
export async function resolveAppSecuritySelection(
@@ -110,9 +130,10 @@ export async function resolveAppSecuritySelection(
110130
await realDirectory(options.path)
111131

112132
try {
133+
const unselectedConfigFile = options.config ? undefined : await configFileWhenNoneIsSelected(options, dependencies)
113134
const {app} = await localAppContext({
114135
directory: options.path,
115-
userProvidedConfigName: options.config,
136+
userProvidedConfigName: options.config ?? unselectedConfigFile?.fileName,
116137
skipPrompts: !options.allowPrompts,
117138
})
118139
const appDirectory = await fileRealPath(app.directory)
@@ -123,13 +144,40 @@ export async function resolveAppSecuritySelection(
123144
appConfigFilePath: joinPath(appDirectory, basename(app.configPath)),
124145
configClientId: app.configuration.client_id || undefined,
125146
clientIdOverride: options.clientId,
147+
appConfigFilePicked: unselectedConfigFile?.picked,
126148
}
127149
} catch (error) {
128150
if (!(error instanceof NoAppConfigurationFoundError)) throw error
129151
return resolveWithoutAppConfigurationFile(options, dependencies)
130152
}
131153
}
132154

155+
/**
156+
* The TOML to scan when there's no `--config`, no `app config use` choice whose file exists and no shopify.app.toml.
157+
* Other app commands abort then. With several TOMLs, `check` asks which one to scan instead, and doesn't save the
158+
* answer: the printed commands carry it as `--config`. Undefined when the usual selection applies.
159+
*
160+
* With a stale `app config use` choice, shopify.app.toml is named explicitly: otherwise `localAppContext` would run
161+
* `app config use`, which asks for a TOML and saves the answer.
162+
*/
163+
async function configFileWhenNoneIsSelected(
164+
options: AppSecuritySelectionOptions,
165+
dependencies: AppSecuritySelectionDependencies,
166+
): Promise<{fileName: string; picked: boolean} | undefined> {
167+
const {directory} = await Project.load(options.path)
168+
const cachedFileName = getCachedAppInfo(directory)?.configFile
169+
if (cachedFileName && fileExistsSync(joinPath(directory, cachedFileName))) return undefined
170+
if (fileExistsSync(joinPath(directory, configurationFileNames.app))) {
171+
return cachedFileName ? {fileName: configurationFileNames.app, picked: false} : undefined
172+
}
173+
174+
const fileNames = (await findConfigFiles(directory)).map((path) => basename(path))
175+
if (fileNames.length === 1) return {fileName: fileNames[0]!, picked: false}
176+
// `--client-id` can't be combined with `--config`, so a picked TOML couldn't be repeated by the printed commands.
177+
if (!options.allowPrompts || options.clientId) abortNoAppConfigurationSelected(directory, fileNames, options.clientId)
178+
return {fileName: await dependencies.pickConfigFile(directory), picked: true}
179+
}
180+
133181
async function resolveWithoutAppConfigurationFile(
134182
options: AppSecuritySelectionOptions,
135183
dependencies: AppSecuritySelectionDependencies,
@@ -208,6 +256,16 @@ function abortNoAppConfigurationFound(directory: string): never {
208256
)
209257
}
210258

259+
function abortNoAppConfigurationSelected(directory: string, fileNames: string[], clientId?: string): never {
260+
const configNames = fileNames.map((fileName) => getAppConfigurationShorthand(fileName) ?? fileName).join(', ')
261+
throw new AbortError(
262+
`${fileNames.length} app configurations found in ${directory}, and none is selected.`,
263+
clientId
264+
? `\`--client-id\` can't be combined with \`--config\`, so first select one with \`shopify app config use <config>\`: ${configNames}.`
265+
: `Pass \`--config\` with one of: ${configNames}.`,
266+
)
267+
}
268+
211269
async function realDirectory(path: string): Promise<string> {
212270
const realPath = await realPathIfExists(path)
213271
if (realPath === undefined || !(await isDirectory(realPath))) throw new AbortError(`--path ${path}: not a directory.`)

‎packages/app/src/cli/services/security-check.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,27 @@ describe('securityCheck', () => {
465465
)
466466
})
467467

468+
test('shows the generated check command, with --config, after asking which TOML to scan', async () => {
469+
const selection: AppSecuritySelection = {
470+
kind: 'config',
471+
appDirectory,
472+
appConfigFilePath: `${appDirectory}/shopify.app.staging.toml`,
473+
configClientId: 'toml-client-id',
474+
appConfigFilePicked: true,
475+
}
476+
const dependencies = testDependencies(scanExecution, selection)
477+
dependencies.canPrompt.mockReturnValue(true)
478+
479+
await securityCheck({...testOptions(), skipInstructions: true}, dependencies)
480+
481+
const {scan} = commandsFor(selection)
482+
expect(scan.args).toContainEqual({flag: '--config', value: 'staging'})
483+
expect(dependencies.renderInfo).toHaveBeenCalledWith({
484+
headline: 'To skip these prompts next time, run:',
485+
body: [{command: formatAppSecurityCommand(scan)}],
486+
})
487+
})
488+
468489
test('does not show the prompt-flow command when a TOML was found', async () => {
469490
const dependencies = testDependencies()
470491

‎packages/app/src/cli/services/security-check.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,10 @@ export default async function securityCheck(
198198
}
199199
const commands = resolveAppSecurityCommands(selection, options.directory, scope)
200200
const resolution = {selection, resultsKey: resultsKey(selection), commands}
201-
// The prompt is only shown when no TOML was found and `--without-app-config` wasn't passed.
202-
if (selection.kind === 'no-config' && !options.withoutAppConfig) {
201+
// Prompts are shown when no TOML was found and `--without-app-config` wasn't passed, or when `check` asked which TOML
202+
// to scan.
203+
const prompted = selection.kind === 'no-config' ? !options.withoutAppConfig : selection.appConfigFilePicked === true
204+
if (prompted) {
203205
dependencies.renderInfo({
204206
headline: 'To skip these prompts next time, run:',
205207
body: [{command: formatAppSecurityCommand(commands.scan)}],

0 commit comments

Comments
 (0)