diff --git a/.changeset/typed-app-config-use.md b/.changeset/typed-app-config-use.md new file mode 100644 index 00000000000..2d6dc9372f8 --- /dev/null +++ b/.changeset/typed-app-config-use.md @@ -0,0 +1,4 @@ +--- +'@shopify/cli': minor +--- +Add typed JSON output to app config commands diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 5c27070eec6..b06a21a421e 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -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 '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: 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 '?: 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 '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: 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 '?: 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": { diff --git a/packages/app/src/cli/commands/app/config/use.test.ts b/packages/app/src/cli/commands/app/config/use.test.ts new file mode 100644 index 00000000000..a36ff11ac82 --- /dev/null +++ b/packages/app/src/cli/commands/app/config/use.test.ts @@ -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>) + 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() +}) diff --git a/packages/app/src/cli/commands/app/config/use.ts b/packages/app/src/cli/commands/app/config/use.ts index 5ab6dd8200a..1e180a63f04 100644 --- a/packages/app/src/cli/commands/app/config/use.ts +++ b/packages/app/src/cli/commands/app/config/use.ts @@ -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. @@ -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, } @@ -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} } diff --git a/packages/app/src/cli/services/app/config/use.test.ts b/packages/app/src/cli/services/app/config/use.test.ts index 428e0c0a41b..b0b42b8d010 100644 --- a/packages/app/src/cli/services/app/config/use.test.ts +++ b/packages/app/src/cli/services/app/config/use.test.ts @@ -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' @@ -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() + }) +}) diff --git a/packages/app/src/cli/services/app/config/use.ts b/packages/app/src/cli/services/app/config/use.ts index 41747049098..11fb5f1cd56 100644 --- a/packages/app/src/cli/services/app/config/use.ts +++ b/packages/app/src/cli/services/app/config/use.ts @@ -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 @@ -26,36 +26,26 @@ export default async function use({ shouldRenderSuccess = true, reset = false, }: UseOptions): Promise { + // 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): Promise { 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} } /** diff --git a/packages/app/src/cli/services/app/config/use/result.ts b/packages/app/src/cli/services/app/config/use/result.ts new file mode 100644 index 00000000000..a4aa2845932 --- /dev/null +++ b/packages/app/src/cli/services/app/config/use/result.ts @@ -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 { + 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)}`}) + } +} diff --git a/packages/app/src/cli/services/app/config/use/types.ts b/packages/app/src/cli/services/app/config/use/types.ts new file mode 100644 index 00000000000..97baa2000c6 --- /dev/null +++ b/packages/app/src/cli/services/app/config/use/types.ts @@ -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 diff --git a/packages/cli/README.md b/packages/cli/README.md index f4a4197ffb4..479a134a738 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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= Alias of the Shopify account to use for authentication. [env: SHOPIFY_FLAG_AUTH_ALIAS] @@ -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` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d13a990aa98..ef0150d4eca 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -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": { @@ -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.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index ce883a83ba2..3c90f3e7847 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -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',