diff --git a/.changeset/typed-app-config-use.md b/.changeset/typed-app-config-use.md new file mode 100644 index 00000000000..13a25ac4b1b --- /dev/null +++ b/.changeset/typed-app-config-use.md @@ -0,0 +1,4 @@ +--- +'@shopify/cli': minor +--- +Add typed JSON output to app config use. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index aa8fe960e6f..7cfa2d1294b 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -817,9 +817,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 * 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 '?: 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 * 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 '?: 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..df6d7cbe0ec --- /dev/null +++ b/packages/app/src/cli/commands/app/config/use.test.ts @@ -0,0 +1,71 @@ +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: {status: 'success' as const, changed: true, path: '/app/shopify.app.staging.toml', clientId: 'key'}, + }, + {args: ['--reset', '--json'], result: {status: 'success' as const, changed: false, path: 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.path ? 'staging' : undefined, + reset: result.path === 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([ + {path: 1, clientId: 'key'}, + {path: '/app/shopify.app.toml', clientId: false}, +])('rejects invalid results %j', (result) => { + expect(() => appConfigUseJsonOutputSchema.validate(result)).toThrow() +}) + +test.each([{path: 'relative.toml'}, {extra: true}, {clientId: ''}, {status: 'failed'}])( + 'rejects malformed public fields %j', + (fields) => { + expect(() => + appConfigUseJsonOutputSchema.validate({ + status: 'success', + changed: true, + path: '/app/shopify.app.toml', + clientId: 'key', + ...fields, + }), + ).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..21cc6c84c57 100644 --- a/packages/app/src/cli/services/app/config/use.test.ts +++ b/packages/app/src/cli/services/app/config/use.test.ts @@ -1,13 +1,16 @@ -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' +import {clearCurrentConfigFile, getCachedAppInfo, setCachedAppInfo} from '../../local-storage.js' import {selectConfigFile} from '../../../prompts/config.js' import {describe, expect, test, vi} from 'vitest' import {inTemporaryDirectory, writeFileSync} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {err, ok} from '@shopify/cli-kit/node/result' +// Match the platform-native public artifact path. +// eslint-disable-next-line no-restricted-imports +import {resolve} from 'node:path' vi.mock('../../../prompts/config.js') vi.mock('../../local-storage.js') @@ -23,7 +26,7 @@ function mockContext(directory: string, configuration: Record) project: {} as any, activeConfig: { file: { - path: joinPath(directory, 'shopify.app.toml'), + path: resolve(directory, 'shopify.app.toml'), content: configuration, }, source: 'flag', @@ -297,3 +300,61 @@ 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({ + status: 'success', + changed: true, + path: resolve(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({ + status: 'success', + changed: false, + path: null, + clientId: null, + }) + expect(clearCurrentConfigFile).toHaveBeenCalledWith(directory) + expect(renderSuccess).not.toHaveBeenCalled() + }) +}) + +test.each([ + {previous: undefined, changed: true}, + {previous: 'shopify.app.toml', changed: false}, + {previous: 'shopify.app.staging.toml', changed: true}, +])('reports whether selection changes the preferred configuration: %j', async ({previous, changed}) => { + await inTemporaryDirectory(async (directory) => { + createConfigFile(directory, 'shopify.app.toml') + vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') + vi.mocked(getCachedAppInfo).mockReturnValue({directory, configFile: previous}) + mockContext(directory, {client_id: 'key'}) + await expect(useAppConfiguration({directory, configName: 'shopify.app.toml'})).resolves.toMatchObject({ + status: 'success', + changed, + }) + }) +}) + +test('reports a change when resetting an existing preferred configuration', async () => { + await inTemporaryDirectory(async (directory) => { + vi.mocked(getCachedAppInfo).mockReturnValue({directory, configFile: 'shopify.app.toml'}) + await expect(useAppConfiguration({directory, reset: true})).resolves.toEqual({ + status: 'success', + changed: true, + path: null, + clientId: null, + }) + }) +}) diff --git a/packages/app/src/cli/services/app/config/use.ts b/packages/app/src/cli/services/app/config/use.ts index 41747049098..b54bd3b3430 100644 --- a/packages/app/src/cli/services/app/config/use.ts +++ b/packages/app/src/cli/services/app/config/use.ts @@ -1,14 +1,17 @@ +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 {clearCurrentConfigFile, getCachedAppInfo, 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' +// CLI Kit normalizes separators; public JSON paths must use the native filesystem format. +// eslint-disable-next-line no-restricted-imports +import {resolve} from 'node:path' export interface UseOptions { directory: string @@ -26,36 +29,32 @@ 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.path === null ? undefined : basename(result.path) +} + +export async function useAppConfiguration({ + directory, + configName, + reset = false, +}: Pick): Promise { + const previousConfigFile = getCachedAppInfo(directory)?.configFile 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 + return {status: 'success', changed: previousConfigFile !== undefined, path: null, clientId: null} } - - if (warningContent) { - renderWarning(warningContent) - } - 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 { + status: 'success', + changed: previousConfigFile !== configFileName, + path: resolve(directory, configFileName), + clientId: activeConfig.file.content.client_id as string, } - - return configFileName } /** 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..c74b479320e --- /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.path === 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.path)}`}) + } +} 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..a227482ab4d --- /dev/null +++ b/packages/app/src/cli/services/app/config/use/types.ts @@ -0,0 +1,25 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' +import {isAbsolutePath} from '@shopify/cli-kit/node/path' + +export const appConfigUseJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppConfigUseResult', + schema: zod + .object({ + status: zod.literal('success'), + changed: zod.boolean().describe('Whether the preferred configuration changed.'), + path: zod + .string() + .refine(isAbsolutePath, 'Expected an absolute filesystem path.') + .nullable() + .describe('The preferred configuration file, or null after clearing the preference.'), + clientId: zod + .string() + .min(1) + .nullable() + .describe('The public OAuth client identifier, or null after clearing the preference.'), + }) + .strict(), +}) + +export type AppConfigUseResult = InferJsonOutputSchema diff --git a/packages/cli/README.md b/packages/cli/README.md index 4892251fb37..327367d17e1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1650,6 +1650,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] @@ -1687,6 +1691,58 @@ 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. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AppConfigUseResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "changed": { + "type": "boolean", + "description": "Whether the preferred configuration changed." + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The preferred configuration file, or null after clearing the preference." + }, + "clientId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "The public OAuth client identifier, or null after clearing the preference." + } + }, + "required": [ + "status", + "changed", + "path", + "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 330053a7d27..2aea7febcd0 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -814,7 +814,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\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppConfigUseResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"description\": \"Whether the preferred configuration changed.\"\n },\n \"path\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The preferred configuration file, or null after clearing the preference.\"\n },\n \"clientId\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"minLength\": 1\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The public OAuth client identifier, or null after clearing the preference.\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"path\",\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": { @@ -837,6 +837,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 47f4adc6a2f..e092a8d1f23 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -2,7 +2,6 @@ const commandExceptions = [ // Existing finite commands awaiting migration. Remove entries as they adopt typed JSON output. // Do not add new finite commands to this section. - 'packages/app/src/cli/commands/app/config/use.ts', 'packages/app/src/cli/commands/app/deploy.ts', 'packages/app/src/cli/commands/app/env/pull.ts', 'packages/app/src/cli/commands/app/env/show.ts',