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
4 changes: 4 additions & 0 deletions .changeset/typed-app-config-use.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
'@shopify/cli': minor
---
Add typed JSON output to app config commands
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -718,9 +718,18 @@
"description": "Increase the verbosity of the output. May include sensitive data.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_VERBOSE"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-config-use.interface.ts",
"syntaxKind": "PropertySignature",
"name": "-j, --json",
"value": "''",
"description": "Output the result as JSON. Automatically disables color output.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_JSON"
}
],
"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 * 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}"
"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 * 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}"
}
},
"appconfigvalidate": {
Expand Down
53 changes: 53 additions & 0 deletions packages/app/src/cli/commands/app/config/use.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import ConfigUse from './use.js'
import {useAppConfiguration} from '../../../services/app/config/use.js'
import {appConfigUseJsonOutputSchema} from '../../../services/app/config/use/types.js'
import {localAppContext} from '../../../services/app-context.js'
import {testApp} from '../../../models/app/app.test-data.js'
import {checkFolderIsValidApp} from '../../../models/app/loader.js'
import {Config} from '@oclif/core'
import {expect, test, vi} from 'vitest'
import * as context from '@shopify/cli-kit/node/context/local'
import {mockAndCaptureStandardStreams} from '@shopify/cli-kit/node/testing/output'

vi.mock('../../../services/app/config/use.js')
vi.mock('../../../services/app-context.js')
vi.mock('../../../models/app/loader.js')

test.each([
{args: ['staging', '--json'], result: {configFile: '/app/shopify.app.staging.toml', clientId: 'key'}},
{args: ['--reset', '--json'], result: {configFile: null, clientId: null}},
])('writes exactly one result for $args', async ({args, result}) => {
const app = testApp()
vi.mocked(localAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof localAppContext>>)
vi.mocked(useAppConfiguration).mockResolvedValue(result)
const command = new ConfigUse(args, await Config.load())
vi.spyOn(context, 'isUnitTest').mockReturnValue(false)
const streams = mockAndCaptureStandardStreams()
try {
await expect(command.run()).resolves.toEqual({app})
expect(streams.stdout()).toBe(`${JSON.stringify(result, null, 2)}\n`)
expect(streams.stderr()).toBe('')
expect(checkFolderIsValidApp).toHaveBeenCalled()
expect(useAppConfiguration).toHaveBeenCalledWith({
directory: expect.any(String),
configName: result.configFile ? 'staging' : undefined,
reset: result.configFile === null,
})
} finally {
streams.restore()
}
})

test('exposes the schema without reintroducing the config flag', () => {
expect(ConfigUse.jsonOutputSchema).toBe(appConfigUseJsonOutputSchema)
expect(ConfigUse.description).toContain('AppConfigUseResult')
expect(ConfigUse.flags.json).toBeDefined()
expect(ConfigUse.flags).not.toHaveProperty('config')
})

test.each([
{configFile: 1, clientId: 'key'},
{configFile: '/app/shopify.app.toml', clientId: false},
])('rejects invalid results %j', (result) => {
expect(() => appConfigUseJsonOutputSchema.validate(result)).toThrow()
})
14 changes: 11 additions & 3 deletions packages/app/src/cli/commands/app/config/use.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {appFlags} from '../../../flags.js'
import {checkFolderIsValidApp} from '../../../models/app/loader.js'
import {localAppContext} from '../../../services/app-context.js'
import use from '../../../services/app/config/use.js'
import {useAppConfiguration} from '../../../services/app/config/use.js'
import {appConfigUseJsonOutputSchema} from '../../../services/app/config/use/types.js'
import {renderAppConfigUseResult} from '../../../services/app/config/use/result.js'
import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../../utilities/app-unlinked-command.js'
import {Args} from '@oclif/core'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'

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

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.`

static get jsonOutputSchema() {
return appConfigUseJsonOutputSchema
}

static description = this.descriptionForHelp()

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

static flags = {
...globalFlags,
...jsonFlag,
...appFlagsWithoutConfig,
}

Expand All @@ -41,7 +48,8 @@ export default class ConfigUse extends AppUnlinkedCommand {
})

await checkFolderIsValidApp(flags.path)
await use({directory: flags.path, configName: args.config, reset: flags.reset})
const result = await useAppConfiguration({directory: flags.path, configName: args.config, reset: flags.reset})
await renderAppConfigUseResult(result, flags.path, flags.json ? 'json' : 'text')

return {app}
}
Expand Down
24 changes: 23 additions & 1 deletion packages/app/src/cli/services/app/config/use.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import use, {UseOptions} from './use.js'
import use, {UseOptions, useAppConfiguration} from './use.js'
import {testApp, testAppWithConfig, testDeveloperPlatformClient} from '../../../models/app/app.test-data.js'
import {getAppConfigurationFileName, getAppConfigurationContext} from '../../../models/app/loader.js'
import {clearCurrentConfigFile, setCachedAppInfo} from '../../local-storage.js'
Expand Down Expand Up @@ -297,3 +297,25 @@ function createConfigFile(tmp: string, fileName: string) {
const filePath = joinPath(tmp, fileName)
writeFileSync(filePath, '')
}

test('returns selected configuration facts without presentation', async () => {
await inTemporaryDirectory(async (directory) => {
createConfigFile(directory, 'shopify.app.toml')
vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml')
mockContext(directory, {client_id: 'key'})
await expect(useAppConfiguration({directory, configName: 'shopify.app.toml'})).resolves.toEqual({
configFile: joinPath(directory, 'shopify.app.toml'),
clientId: 'key',
})
expect(renderSuccess).not.toHaveBeenCalled()
expect(setCachedAppInfo).toHaveBeenCalledWith({directory, configFile: 'shopify.app.toml'})
})
})

test('returns explicit nulls after resetting the preference without presentation', async () => {
await inTemporaryDirectory(async (directory) => {
await expect(useAppConfiguration({directory, reset: true})).resolves.toEqual({configFile: null, clientId: null})
expect(clearCurrentConfigFile).toHaveBeenCalledWith(directory)
expect(renderSuccess).not.toHaveBeenCalled()
})
})
46 changes: 18 additions & 28 deletions packages/app/src/cli/services/app/config/use.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {type AppConfigUseResult} from './use/types.js'
import {renderAppConfigUseResult} from './use/result.js'
import {getAppConfigurationFileName, getAppConfigurationContext} from '../../../models/app/loader.js'
import {clearCurrentConfigFile, setCachedAppInfo} from '../../local-storage.js'
import {selectConfigFile} from '../../../prompts/config.js'
import {DeveloperPlatformClient} from '../../../utilities/developer-platform-client.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {fileExists} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {RenderAlertOptions, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
import {basename, joinPath} from '@shopify/cli-kit/node/path'
import {RenderAlertOptions, renderWarning} from '@shopify/cli-kit/node/ui'
import {Result, err, ok} from '@shopify/cli-kit/node/result'
import {getPackageManager} from '@shopify/cli-kit/node/node-package-manager'
import {formatPackageManagerCommand} from '@shopify/cli-kit/node/output'

export interface UseOptions {
directory: string
Expand All @@ -26,36 +26,26 @@ export default async function use({
shouldRenderSuccess = true,
reset = false,
}: UseOptions): Promise<string | undefined> {
// Compatibility adapter for configuration selection during other commands.
if (warningContent && !reset) renderWarning(warningContent)
const result = await useAppConfiguration({directory, configName, reset})
if (reset || shouldRenderSuccess) await renderAppConfigUseResult(result, directory, 'text')
return result.configFile === null ? undefined : basename(result.configFile)
}

export async function useAppConfiguration({
directory,
configName,
reset = false,
}: Pick<UseOptions, 'directory' | 'configName' | 'reset'>): Promise<AppConfigUseResult> {
if (reset) {
clearCurrentConfigFile(directory)
const packageManager = await getPackageManager(directory)
renderSuccess({
headline: 'Cleared current configuration.',
body: [
'In order to set a new current configuration, please run',
{command: formatPackageManagerCommand(packageManager, 'shopify app config use CONFIG_NAME')},
{char: '.'},
],
})
return
}

if (warningContent) {
renderWarning(warningContent)
return {configFile: null, clientId: null}
}

const configFileName = (await getConfigFileName(directory, configName)).valueOrAbort()

const {activeConfig} = await getAppConfigurationContext(directory, configFileName)
setCurrentConfigPreference(activeConfig.file.content, {configFileName, directory})

if (shouldRenderSuccess) {
renderSuccess({
headline: `Using configuration file ${configFileName}`,
})
}

return configFileName
return {configFile: joinPath(directory, configFileName), clientId: activeConfig.file.content.client_id as string}
}

/**
Expand Down
29 changes: 29 additions & 0 deletions packages/app/src/cli/services/app/config/use/result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {appConfigUseJsonOutputSchema, type AppConfigUseResult} from './types.js'
import {renderSuccess} from '@shopify/cli-kit/node/ui'
import {formatPackageManagerCommand, outputResult} from '@shopify/cli-kit/node/output'
import {getPackageManager} from '@shopify/cli-kit/node/node-package-manager'
import {basename} from '@shopify/cli-kit/node/path'

export async function renderAppConfigUseResult(
result: AppConfigUseResult,
directory: string,
format: 'json' | 'text',
): Promise<void> {
if (format === 'json') {
outputResult(appConfigUseJsonOutputSchema.encode(result))
return
}
if (result.configFile === null) {
const packageManager = await getPackageManager(directory)
renderSuccess({
headline: 'Cleared current configuration.',
body: [
'In order to set a new current configuration, please run',
{command: formatPackageManagerCommand(packageManager, 'shopify app config use CONFIG_NAME')},
{char: '.'},
],
})
} else {
renderSuccess({headline: `Using configuration file ${basename(result.configFile)}`})
}
}
9 changes: 9 additions & 0 deletions packages/app/src/cli/services/app/config/use/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
import {zod} from '@shopify/cli-kit/node/schema'

export const appConfigUseJsonOutputSchema = defineJsonOutputSchema({
name: 'AppConfigUseResult',
schema: zod.object({configFile: zod.string().nullable(), clientId: zod.string().nullable()}),
})

export type AppConfigUseResult = InferJsonOutputSchema<typeof appConfigUseJsonOutputSchema>
35 changes: 35 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,10 @@ ARGUMENTS
[CONFIG] The name of the app configuration. Can be 'shopify.app.staging.toml' or simply 'staging'.

FLAGS
-j, --json
Output the result as JSON. Automatically disables color output.
[env: SHOPIFY_FLAG_JSON]

--auth-alias=<value>
Alias of the Shopify account to use for authentication.
[env: SHOPIFY_FLAG_AUTH_ALIAS]
Expand Down Expand Up @@ -858,6 +862,37 @@ DESCRIPTION

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.

Output from `--json` conforms to the `AppConfigUseResult` schema.

Use `--json-schema` to print the result, error, and event schemas.

```json
{
"type": "object",
"properties": {
"configFile": {
"type": [
"string",
"null"
]
},
"clientId": {
"type": [
"string",
"null"
]
}
},
"required": [
"configFile",
"clientId"
],
"additionalProperties": false,
"title": "AppConfigUseResult",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
```

## `shopify app config validate`
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,7 @@
}
},
"customPluginName": "@shopify/app",
"description": "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.",
"description": "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.\n\nOutput from `--json` conforms to the `AppConfigUseResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"configFile\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"clientId\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n }\n },\n \"required\": [\n \"configFile\",\n \"clientId\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppConfigUseResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"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.",
"flags": {
"auth-alias": {
Expand All @@ -761,6 +761,15 @@
"name": "client-id",
"type": "option"
},
"json": {
"allowNo": false,
"char": "j",
"description": "Output the result as JSON. Automatically disables color output.",
"env": "SHOPIFY_FLAG_JSON",
"hidden": false,
"name": "json",
"type": "boolean"
},
"json-schema": {
"allowNo": false,
"description": "Print the command's JSON schemas.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ const commandExceptions = [
'packages/app/src/cli/commands/app/bulk/cancel.ts',
'packages/app/src/cli/commands/app/bulk/execute.ts',
'packages/app/src/cli/commands/app/bulk/status.ts',
'packages/app/src/cli/commands/app/config/use.ts',
'packages/app/src/cli/commands/app/deploy.ts',
'packages/app/src/cli/commands/app/dev/clean.ts',
'packages/app/src/cli/commands/app/security/check.ts',
Expand Down
Loading