Skip to content

Commit bd2d8cc

Browse files
Merge pull request #8605 from Shopify/gonzalo/json-app-config-use
Add typed JSON output to app config use
2 parents 7a5c974 + 5d1a9b5 commit bd2d8cc

14 files changed

Lines changed: 374 additions & 45 deletions

File tree

‎.changeset/typed-app-config-link.md‎

Lines changed: 0 additions & 4 deletions
This file was deleted.

‎.changeset/typed-app-config-pull.md‎

Lines changed: 0 additions & 4 deletions
This file was deleted.

‎.changeset/typed-app-config-use.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
'@shopify/cli': minor
3+
---
4+
Add typed JSON output to app config use, pull, and link.

‎docs-shopify.dev/generated/generated_docs_data_v2.json‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -817,9 +817,18 @@
817817
"description": "Increase the verbosity of the output. May include sensitive data.",
818818
"isOptional": true,
819819
"environmentValue": "SHOPIFY_FLAG_VERBOSE"
820+
},
821+
{
822+
"filePath": "docs-shopify.dev/commands/interfaces/app-config-use.interface.ts",
823+
"syntaxKind": "PropertySignature",
824+
"name": "-j, --json",
825+
"value": "''",
826+
"description": "Output the result as JSON. Automatically disables color output.",
827+
"isOptional": true,
828+
"environmentValue": "SHOPIFY_FLAG_JSON"
820829
}
821830
],
822-
"value": "export interface appconfiguse {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
831+
"value": "export interface appconfiguse {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
823832
}
824833
},
825834
"appconfigvalidate": {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import ConfigUse from './use.js'
2+
import * as localStorage from '../../../services/local-storage.js'
3+
import {Config} from '@oclif/core'
4+
import {expect, test, vi} from 'vitest'
5+
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
6+
import {joinPath} from '@shopify/cli-kit/node/path'
7+
import {LocalStorage} from '@shopify/cli-kit/node/local-storage'
8+
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
9+
// Match the platform-native public artifact path.
10+
// eslint-disable-next-line no-restricted-imports
11+
import {resolve} from 'node:path'
12+
13+
const {getCachedAppInfo, setCachedAppInfo, clearCurrentConfigFile} = localStorage
14+
15+
test.each([
16+
{previousConfigFile: 'shopify.app.deleted.toml', reset: false, changed: true},
17+
{previousConfigFile: 'shopify.app.toml', reset: false, changed: false},
18+
{previousConfigFile: undefined, reset: false, changed: true},
19+
{previousConfigFile: 'shopify.app.deleted.toml', reset: true, changed: true},
20+
{previousConfigFile: undefined, reset: true, changed: false},
21+
])('compares the final preference with command entry: %j', async ({previousConfigFile, reset, changed}) => {
22+
await inTemporaryDirectory(async (directory) => {
23+
const storage = new LocalStorage<localStorage.AppLocalStorageSchema>({cwd: joinPath(directory, 'storage')})
24+
vi.spyOn(localStorage, 'getCachedAppInfo').mockImplementation((path) => getCachedAppInfo(path, storage))
25+
vi.spyOn(localStorage, 'setCachedAppInfo').mockImplementation((options) => setCachedAppInfo(options, storage))
26+
vi.spyOn(localStorage, 'clearCurrentConfigFile').mockImplementation((path) => clearCurrentConfigFile(path, storage))
27+
setCachedAppInfo({directory, configFile: previousConfigFile}, storage)
28+
await writeFile(joinPath(directory, 'package.json'), '{}')
29+
await writeFile(
30+
joinPath(directory, 'shopify.app.toml'),
31+
`name = "test-app"
32+
client_id = "test-client-id"
33+
application_url = "https://example.com"
34+
embedded = true
35+
[auth]
36+
redirect_urls = ["https://example.com/callback"]
37+
[webhooks]
38+
api_version = "2026-01"
39+
`,
40+
)
41+
const args = ['--path', directory, '--json', '--no-input', ...(reset ? ['--reset'] : [])]
42+
const command = new ConfigUse(args, await Config.load())
43+
44+
await withCapturedStandardStreams(async (streams) => {
45+
await command.run()
46+
47+
expect(JSON.parse(streams.stdout())).toEqual({
48+
status: 'success',
49+
changed,
50+
path: reset ? null : resolve(directory, 'shopify.app.toml'),
51+
clientId: reset ? null : 'test-client-id',
52+
})
53+
})
54+
expect(getCachedAppInfo(directory, storage)?.configFile).toBe(reset ? undefined : 'shopify.app.toml')
55+
})
56+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import ConfigUse from './use.js'
2+
import {useAppConfiguration} from '../../../services/app/config/use.js'
3+
import {appConfigUseJsonOutputSchema} from '../../../services/app/config/use/types.js'
4+
import {localAppContext} from '../../../services/app-context.js'
5+
import {testApp} from '../../../models/app/app.test-data.js'
6+
import {checkFolderIsValidApp} from '../../../models/app/loader.js'
7+
import {Config} from '@oclif/core'
8+
import {expect, test, vi} from 'vitest'
9+
import * as context from '@shopify/cli-kit/node/context/local'
10+
import {mockAndCaptureStandardStreams} from '@shopify/cli-kit/node/testing/output'
11+
12+
vi.mock('../../../services/app/config/use.js')
13+
vi.mock('../../../services/app-context.js')
14+
vi.mock('../../../models/app/loader.js')
15+
vi.mock('../../../services/local-storage.js')
16+
17+
test.each([
18+
{
19+
args: ['staging', '--json'],
20+
result: {status: 'success' as const, changed: true, path: '/app/shopify.app.staging.toml', clientId: 'key'},
21+
},
22+
{args: ['--reset', '--json'], result: {status: 'success' as const, changed: false, path: null, clientId: null}},
23+
])('writes exactly one result for $args', async ({args, result}) => {
24+
const app = testApp()
25+
vi.mocked(localAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof localAppContext>>)
26+
vi.mocked(useAppConfiguration).mockResolvedValue(result)
27+
const command = new ConfigUse(args, await Config.load())
28+
vi.spyOn(context, 'isUnitTest').mockReturnValue(false)
29+
const streams = mockAndCaptureStandardStreams()
30+
try {
31+
await expect(command.run()).resolves.toEqual({app})
32+
expect(streams.stdout()).toBe(`${JSON.stringify(result, null, 2)}\n`)
33+
expect(streams.stderr()).toBe('')
34+
expect(checkFolderIsValidApp).toHaveBeenCalled()
35+
expect(useAppConfiguration).toHaveBeenCalledWith({
36+
directory: expect.any(String),
37+
configName: result.path ? 'staging' : undefined,
38+
reset: result.path === null,
39+
previousConfigFile: null,
40+
})
41+
} finally {
42+
streams.restore()
43+
}
44+
})
45+
46+
test('exposes the schema without reintroducing the config flag', () => {
47+
expect(ConfigUse.jsonOutputSchema).toBe(appConfigUseJsonOutputSchema)
48+
expect(ConfigUse.description).toContain('AppConfigUseResult')
49+
expect(ConfigUse.flags.json).toBeDefined()
50+
expect(ConfigUse.flags).not.toHaveProperty('config')
51+
})
52+
53+
test.each([
54+
{path: 1, clientId: 'key'},
55+
{path: '/app/shopify.app.toml', clientId: false},
56+
])('rejects invalid results %j', (result) => {
57+
expect(() => appConfigUseJsonOutputSchema.validate(result)).toThrow()
58+
})
59+
60+
test.each([{path: 'relative.toml'}, {extra: true}, {clientId: ''}, {status: 'failed'}])(
61+
'rejects malformed public fields %j',
62+
(fields) => {
63+
expect(() =>
64+
appConfigUseJsonOutputSchema.validate({
65+
status: 'success',
66+
changed: true,
67+
path: '/app/shopify.app.toml',
68+
clientId: 'key',
69+
...fields,
70+
}),
71+
).toThrow()
72+
},
73+
)

‎packages/app/src/cli/commands/app/config/use.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import {appFlags} from '../../../flags.js'
22
import {checkFolderIsValidApp} from '../../../models/app/loader.js'
33
import {localAppContext} from '../../../services/app-context.js'
4-
import use from '../../../services/app/config/use.js'
4+
import {getCachedAppInfo} from '../../../services/local-storage.js'
5+
import {useAppConfiguration} from '../../../services/app/config/use.js'
6+
import {appConfigUseJsonOutputSchema} from '../../../services/app/config/use/types.js'
7+
import {renderAppConfigUseResult} from '../../../services/app/config/use/result.js'
58
import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../../utilities/app-unlinked-command.js'
69
import {Args} from '@oclif/core'
7-
import {globalFlags} from '@shopify/cli-kit/node/cli'
10+
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
811

912
// This is one of the few commands where we don't need a
1013
// `--config` flag, because we're passing it as an argument.
@@ -15,12 +18,17 @@ export default class ConfigUse extends AppUnlinkedCommand {
1518

1619
static descriptionWithMarkdown = `Sets default configuration when you run app-related CLI commands. If you omit the \`config-name\` parameter, then you'll be prompted to choose from the configuration files in your project.`
1720

21+
static get jsonOutputSchema() {
22+
return appConfigUseJsonOutputSchema
23+
}
24+
1825
static description = this.descriptionForHelp()
1926

2027
static usage = `app config use [config] [flags]`
2128

2229
static flags = {
2330
...globalFlags,
31+
...jsonFlag,
2432
...appFlagsWithoutConfig,
2533
}
2634

@@ -34,14 +42,21 @@ export default class ConfigUse extends AppUnlinkedCommand {
3442

3543
public async run(): Promise<AppUnlinkedCommandOutput> {
3644
const {flags, args} = await this.parse(ConfigUse)
45+
const previousConfigFile = getCachedAppInfo(flags.path)?.configFile ?? null
3746

3847
const {app} = await localAppContext({
3948
directory: flags.path,
4049
userProvidedConfigName: args.config,
4150
})
4251

4352
await checkFolderIsValidApp(flags.path)
44-
await use({directory: flags.path, configName: args.config, reset: flags.reset})
53+
const result = await useAppConfiguration({
54+
directory: flags.path,
55+
configName: args.config,
56+
reset: flags.reset,
57+
previousConfigFile,
58+
})
59+
await renderAppConfigUseResult(result, flags.path, flags.json ? 'json' : 'text')
4560

4661
return {app}
4762
}

‎packages/app/src/cli/services/app/config/use.test.ts‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import use, {UseOptions} from './use.js'
1+
import use, {UseOptions, useAppConfiguration} from './use.js'
22
import {testApp, testAppWithConfig, testDeveloperPlatformClient} from '../../../models/app/app.test-data.js'
33
import {getAppConfigurationFileName, getAppConfigurationContext} from '../../../models/app/loader.js'
4-
import {clearCurrentConfigFile, setCachedAppInfo} from '../../local-storage.js'
4+
import {clearCurrentConfigFile, getCachedAppInfo, setCachedAppInfo} from '../../local-storage.js'
55
import {selectConfigFile} from '../../../prompts/config.js'
66
import {describe, expect, test, vi} from 'vitest'
77
import {inTemporaryDirectory, writeFileSync} from '@shopify/cli-kit/node/fs'
88
import {joinPath} from '@shopify/cli-kit/node/path'
99
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
1010
import {err, ok} from '@shopify/cli-kit/node/result'
11+
// Match the platform-native public artifact path.
12+
// eslint-disable-next-line no-restricted-imports
13+
import {resolve} from 'node:path'
1114

1215
vi.mock('../../../prompts/config.js')
1316
vi.mock('../../local-storage.js')
@@ -23,7 +26,7 @@ function mockContext(directory: string, configuration: Record<string, unknown>)
2326
project: {} as any,
2427
activeConfig: {
2528
file: {
26-
path: joinPath(directory, 'shopify.app.toml'),
29+
path: resolve(directory, 'shopify.app.toml'),
2730
content: configuration,
2831
},
2932
source: 'flag',
@@ -297,3 +300,61 @@ function createConfigFile(tmp: string, fileName: string) {
297300
const filePath = joinPath(tmp, fileName)
298301
writeFileSync(filePath, '')
299302
}
303+
304+
test('returns selected configuration facts without presentation', async () => {
305+
await inTemporaryDirectory(async (directory) => {
306+
createConfigFile(directory, 'shopify.app.toml')
307+
vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml')
308+
mockContext(directory, {client_id: 'key'})
309+
await expect(useAppConfiguration({directory, configName: 'shopify.app.toml'})).resolves.toEqual({
310+
status: 'success',
311+
changed: true,
312+
path: resolve(directory, 'shopify.app.toml'),
313+
clientId: 'key',
314+
})
315+
expect(renderSuccess).not.toHaveBeenCalled()
316+
expect(setCachedAppInfo).toHaveBeenCalledWith({directory, configFile: 'shopify.app.toml'})
317+
})
318+
})
319+
320+
test('returns explicit nulls after resetting the preference without presentation', async () => {
321+
await inTemporaryDirectory(async (directory) => {
322+
await expect(useAppConfiguration({directory, reset: true})).resolves.toEqual({
323+
status: 'success',
324+
changed: false,
325+
path: null,
326+
clientId: null,
327+
})
328+
expect(clearCurrentConfigFile).toHaveBeenCalledWith(directory)
329+
expect(renderSuccess).not.toHaveBeenCalled()
330+
})
331+
})
332+
333+
test.each([
334+
{previous: undefined, changed: true},
335+
{previous: 'shopify.app.toml', changed: false},
336+
{previous: 'shopify.app.staging.toml', changed: true},
337+
])('reports whether selection changes the preferred configuration: %j', async ({previous, changed}) => {
338+
await inTemporaryDirectory(async (directory) => {
339+
createConfigFile(directory, 'shopify.app.toml')
340+
vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml')
341+
vi.mocked(getCachedAppInfo).mockReturnValue({directory, configFile: previous})
342+
mockContext(directory, {client_id: 'key'})
343+
await expect(useAppConfiguration({directory, configName: 'shopify.app.toml'})).resolves.toMatchObject({
344+
status: 'success',
345+
changed,
346+
})
347+
})
348+
})
349+
350+
test('reports a change when resetting an existing preferred configuration', async () => {
351+
await inTemporaryDirectory(async (directory) => {
352+
vi.mocked(getCachedAppInfo).mockReturnValue({directory, configFile: 'shopify.app.toml'})
353+
await expect(useAppConfiguration({directory, reset: true})).resolves.toEqual({
354+
status: 'success',
355+
changed: true,
356+
path: null,
357+
clientId: null,
358+
})
359+
})
360+
})

‎packages/app/src/cli/services/app/config/use.ts‎

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
import {type AppConfigUseResult} from './use/types.js'
2+
import {renderAppConfigUseResult} from './use/result.js'
13
import {getAppConfigurationFileName, getAppConfigurationContext} from '../../../models/app/loader.js'
2-
import {clearCurrentConfigFile, setCachedAppInfo} from '../../local-storage.js'
4+
import {clearCurrentConfigFile, getCachedAppInfo, setCachedAppInfo} from '../../local-storage.js'
35
import {selectConfigFile} from '../../../prompts/config.js'
46
import {DeveloperPlatformClient} from '../../../utilities/developer-platform-client.js'
57
import {AbortError} from '@shopify/cli-kit/node/error'
68
import {fileExists} from '@shopify/cli-kit/node/fs'
7-
import {joinPath} from '@shopify/cli-kit/node/path'
8-
import {RenderAlertOptions, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
9+
import {basename, joinPath} from '@shopify/cli-kit/node/path'
10+
import {RenderAlertOptions, renderWarning} from '@shopify/cli-kit/node/ui'
911
import {Result, err, ok} from '@shopify/cli-kit/node/result'
10-
import {getPackageManager} from '@shopify/cli-kit/node/node-package-manager'
11-
import {formatPackageManagerCommand} from '@shopify/cli-kit/node/output'
12+
// CLI Kit normalizes separators; public JSON paths must use the native filesystem format.
13+
// eslint-disable-next-line no-restricted-imports
14+
import {resolve} from 'node:path'
1215

1316
export interface UseOptions {
1417
directory: string
@@ -26,36 +29,34 @@ export default async function use({
2629
shouldRenderSuccess = true,
2730
reset = false,
2831
}: UseOptions): Promise<string | undefined> {
32+
// Compatibility adapter for configuration selection during other commands.
33+
if (warningContent && !reset) renderWarning(warningContent)
34+
const result = await useAppConfiguration({directory, configName, reset})
35+
if (reset || shouldRenderSuccess) await renderAppConfigUseResult(result, directory, 'text')
36+
return result.path === null ? undefined : basename(result.path)
37+
}
38+
39+
export async function useAppConfiguration({
40+
directory,
41+
configName,
42+
reset = false,
43+
previousConfigFile = getCachedAppInfo(directory)?.configFile ?? null,
44+
}: Pick<UseOptions, 'directory' | 'configName' | 'reset'> & {
45+
previousConfigFile?: string | null
46+
}): Promise<AppConfigUseResult> {
2947
if (reset) {
3048
clearCurrentConfigFile(directory)
31-
const packageManager = await getPackageManager(directory)
32-
renderSuccess({
33-
headline: 'Cleared current configuration.',
34-
body: [
35-
'In order to set a new current configuration, please run',
36-
{command: formatPackageManagerCommand(packageManager, 'shopify app config use CONFIG_NAME')},
37-
{char: '.'},
38-
],
39-
})
40-
return
49+
return {status: 'success', changed: previousConfigFile !== null, path: null, clientId: null}
4150
}
42-
43-
if (warningContent) {
44-
renderWarning(warningContent)
45-
}
46-
4751
const configFileName = (await getConfigFileName(directory, configName)).valueOrAbort()
48-
4952
const {activeConfig} = await getAppConfigurationContext(directory, configFileName)
5053
setCurrentConfigPreference(activeConfig.file.content, {configFileName, directory})
51-
52-
if (shouldRenderSuccess) {
53-
renderSuccess({
54-
headline: `Using configuration file ${configFileName}`,
55-
})
54+
return {
55+
status: 'success',
56+
changed: previousConfigFile !== configFileName,
57+
path: resolve(directory, configFileName),
58+
clientId: activeConfig.file.content.client_id as string,
5659
}
57-
58-
return configFileName
5960
}
6061

6162
/**

0 commit comments

Comments
 (0)