diff --git a/.changeset/typed-app-config-link.md b/.changeset/typed-app-config-link.md new file mode 100644 index 00000000000..cb215e8afa5 --- /dev/null +++ b/.changeset/typed-app-config-link.md @@ -0,0 +1,4 @@ +--- +'@shopify/cli': minor +--- +Add typed JSON output to app config link. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 2191cb75c30..6fe1b56a3dd 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -624,9 +624,18 @@ "description": "The name of the local app configuration to read and overwrite. Use --file-name instead when specifying --client-id.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-config-link.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 appconfiglink {\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 the remote app to link. Use --file-name to specify the destination configuration file. Required if non interactive.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the local app configuration to read and overwrite. Use --file-name instead when specifying --client-id.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * The name of the app configuration file to create or overwrite. Requires --force to overwrite an existing file.\n * @environment SHOPIFY_FLAG_APP_CONFIG_FILE_NAME\n */\n '--file-name '?: string\n\n /**\n * Overwrite an existing configuration file without prompting.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '--force'?: ''\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 appconfiglink {\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 the remote app to link. Use --file-name to specify the destination configuration file. Required if non interactive.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the local app configuration to read and overwrite. Use --file-name instead when specifying --client-id.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * The name of the app configuration file to create or overwrite. Requires --force to overwrite an existing file.\n * @environment SHOPIFY_FLAG_APP_CONFIG_FILE_NAME\n */\n '--file-name '?: string\n\n /**\n * Overwrite an existing configuration file without prompting.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '--force'?: ''\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}" } }, "appconfigpull": { diff --git a/packages/app/src/cli/commands/app/config/link.test.ts b/packages/app/src/cli/commands/app/config/link.test.ts index 8c53f9095a1..7640eeb5e5c 100644 --- a/packages/app/src/cli/commands/app/config/link.test.ts +++ b/packages/app/src/cli/commands/app/config/link.test.ts @@ -1,7 +1,10 @@ import ConfigLink from './link.js' -import link from '../../../services/app/config/link.js' +import {linkAppConfiguration as link} from '../../../services/app/config/link.js' +import {projectAppConfigResult} from '../../../services/app/config/link/types.js' import {linkedAppContext} from '../../../services/app-context.js' import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' +import {mockAndCaptureStandardStreams} from '@shopify/cli-kit/node/testing/output' +import * as context from '@shopify/cli-kit/node/context/local' import {Config} from '@oclif/core' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' @@ -47,36 +50,45 @@ describe('app config link command', () => { }) }) - test('accepts --client-id with --file-name to link a specific app to a specific config file', async () => { - await inTemporaryDirectory(async (tmp) => { - const app = testAppLinked() - vi.mocked(link).mockResolvedValue({ - remoteApp: testOrganizationApp(), - configFileName: 'shopify.app.staging.toml', - configuration: app.configuration, - }) - vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) + test.each([{outputFlags: []}, {outputFlags: ['--json']}])( + 'accepts explicit app and file selection with output flags %j', + async ({outputFlags}) => { + await inTemporaryDirectory(async (tmp) => { + const app = testAppLinked() + vi.mocked(link).mockResolvedValue({ + remoteApp: testOrganizationApp(), + configFileName: 'shopify.app.staging.toml', + configuration: app.configuration, + packageManager: 'npm', + result: projectAppConfigResult({ + path: '/app/shopify.app.toml', + configuration: app.configuration, + app: testOrganizationApp(), + }), + }) + vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) - await ConfigLink.run( - ['--path', tmp, '--client-id', 'api-key', '--file-name', 'staging', '--force'], - import.meta.url, - ) + await ConfigLink.run( + ['--path', tmp, '--client-id', 'api-key', '--file-name', 'staging', '--force', ...outputFlags], + import.meta.url, + ) - expect(link).toHaveBeenCalledWith({ - directory: tmp, - apiKey: 'api-key', - configName: undefined, - fileName: 'staging', - force: true, + expect(link).toHaveBeenCalledWith({ + directory: tmp, + apiKey: 'api-key', + configName: undefined, + fileName: 'staging', + force: true, + }) + expect(linkedAppContext).toHaveBeenCalledWith({ + directory: tmp, + clientId: undefined, + forceRelink: false, + userProvidedConfigName: 'shopify.app.staging.toml', + }) }) - expect(linkedAppContext).toHaveBeenCalledWith({ - directory: tmp, - clientId: undefined, - forceRelink: false, - userProvidedConfigName: 'shopify.app.staging.toml', - }) - }) - }) + }, + ) test('accepts --config without requiring --file-name when --force is not passed', async () => { await inTemporaryDirectory(async (tmp) => { @@ -85,6 +97,12 @@ describe('app config link command', () => { remoteApp: testOrganizationApp(), configFileName: 'shopify.app.secondary.toml', configuration: app.configuration, + packageManager: 'npm', + result: projectAppConfigResult({ + path: '/app/shopify.app.toml', + configuration: app.configuration, + app: testOrganizationApp(), + }), }) vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) @@ -114,3 +132,28 @@ describe('app config link command', () => { }) }) }) + +test('does not write a JSON result if final app loading fails after linking', async () => { + const app = testAppLinked() + vi.mocked(link).mockResolvedValue({ + remoteApp: testOrganizationApp(), + configuration: app.configuration, + configFileName: 'shopify.app.toml', + packageManager: 'npm', + result: projectAppConfigResult({ + path: app.configPath, + configuration: app.configuration, + app: testOrganizationApp(), + }), + }) + vi.mocked(linkedAppContext).mockRejectedValue(new Error('App loading failed')) + const command = new ConfigLink(['--json', '--client-id', 'key'], await Config.load()) + vi.spyOn(context, 'isUnitTest').mockReturnValue(false) + const streams = mockAndCaptureStandardStreams() + try { + await expect(command.run()).rejects.toThrow('App loading failed') + expect(streams.stdout()).toBe('') + } finally { + streams.restore() + } +}) diff --git a/packages/app/src/cli/commands/app/config/link.ts b/packages/app/src/cli/commands/app/config/link.ts index 75d24f612bc..36926570a0d 100644 --- a/packages/app/src/cli/commands/app/config/link.ts +++ b/packages/app/src/cli/commands/app/config/link.ts @@ -1,9 +1,11 @@ import {appFlags} from '../../../flags.js' import {linkedAppContext} from '../../../services/app-context.js' -import link, {LinkOptions} from '../../../services/app/config/link.js' +import {linkAppConfiguration, LinkOptions} from '../../../services/app/config/link.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {appConfigLinkJsonOutputSchema} from '../../../services/app/config/link/types.js' +import {renderAppConfigLinkResult} from '../../../services/app/config/link/result.js' import {Flags} from '@oclif/core' -import {globalFlags, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' import {AbortError} from '@shopify/cli-kit/node/error' export default class ConfigLink extends AppLinkedCommand { @@ -14,10 +16,15 @@ export default class ConfigLink extends AppLinkedCommand { For more information on the format of the created TOML configuration file, refer to the [App configuration](https://shopify.dev/docs/apps/tools/cli/configuration) page. ` + static get jsonOutputSchema() { + return appConfigLinkJsonOutputSchema + } + static description = this.descriptionForHelp() static flags = { ...globalFlags, + ...jsonFlag, ...appFlags, config: Flags.string({ hidden: false, @@ -74,7 +81,8 @@ export default class ConfigLink extends AppLinkedCommand { force: flags.force ?? false, } - const result = await link(options) + const result = await linkAppConfiguration(options) + if (!flags.json) renderAppConfigLinkResult(result.result, result.packageManager, 'text') const {app} = await linkedAppContext({ directory: flags.path, @@ -83,6 +91,9 @@ export default class ConfigLink extends AppLinkedCommand { userProvidedConfigName: result.configFileName, }) + // JSON must wait until all command work succeeds, so a later failure cannot emit a second document. + if (flags.json) renderAppConfigLinkResult(result.result, result.packageManager, 'json') + return {app} } } diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index 31083764e01..b946e7929c6 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -1,3 +1,5 @@ +import {projectAppConfigResult, type AppConfigLinkResult} from './link/types.js' +import {renderAppConfigLinkResult} from './link/result.js' import {setCurrentConfigPreference} from './use.js' import {AppConfiguration, CurrentAppConfiguration, CliBuildPreferences, getAppScopes} from '../../../models/app/app.js' import {OrganizationApp} from '../../../models/organization.js' @@ -22,8 +24,6 @@ import {fetchSpecifications} from '../../generate/fetch-extension-specifications import {AppConfigurationUsedByCli} from '../../../models/extensions/specifications/types/app_config.js' import {getTomls} from '../../../utilities/app/config/getTomls.js' import {loadLocalExtensionsSpecifications} from '../../../models/extensions/load-specifications.js' -import {renderSuccess} from '@shopify/cli-kit/node/ui' -import {formatPackageManagerCommand} from '@shopify/cli-kit/node/output' import {deepMergeObjects, isEmpty} from '@shopify/cli-kit/common/object' import {fileExists} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' @@ -60,6 +60,14 @@ interface LinkOutput { * @returns The final app configuration object that was written to the filesystem */ export default async function link(options: LinkOptions, shouldRenderSuccess = true): Promise { + const output = await linkAppConfiguration(options) + if (shouldRenderSuccess) renderAppConfigLinkResult(output.result, output.packageManager, 'text') + return output +} + +export async function linkAppConfiguration( + options: LinkOptions, +): Promise { // First, select (or create, if the user chooses to) a remote app to link to const {remoteApp, appDirectory, developerPlatformClient} = await selectOrCreateRemoteAppToLinkTo(options) @@ -87,11 +95,17 @@ export default async function link(options: LinkOptions, shouldRenderSuccess = t localAppOptions, }) - if (shouldRenderSuccess) { - renderSuccessMessage(configFileName, mergedAppConfiguration.name, localAppOptions.packageManager) + return { + remoteApp, + configFileName, + configuration: mergedAppConfiguration, + packageManager: localAppOptions.packageManager, + result: projectAppConfigResult({ + path: joinPath(appDirectory, configFileName), + configuration: mergedAppConfiguration, + app: remoteApp, + }), } - - return {remoteApp, configFileName, configuration: mergedAppConfiguration} } function abortIfLinkPromptCannotRun(missingFlags: string[]) { @@ -434,30 +448,6 @@ function buildOptionsForGeneratedConfigFile(options: { } } -function renderSuccessMessage(configFileName: string, appName: string, packageManager: PackageManager) { - renderSuccess({ - headline: `${configFileName} is now linked to "${appName}" on Shopify`, - body: `Using ${configFileName} as your default config.`, - nextSteps: [ - [`Make updates to ${configFileName} in your local project`], - [ - 'To upload your config, run', - { - command: formatPackageManagerCommand(packageManager, 'shopify app deploy'), - }, - ], - ], - reference: [ - { - link: { - label: 'App configuration', - url: 'https://shopify.dev/docs/apps/tools/cli/configuration', - }, - }, - ], - }) -} - /** * Given a remote app, and some local app configuration, build a top-level app configuration object to use locally. * diff --git a/packages/app/src/cli/services/app/config/link/result.test.ts b/packages/app/src/cli/services/app/config/link/result.test.ts new file mode 100644 index 00000000000..4e06c422061 --- /dev/null +++ b/packages/app/src/cli/services/app/config/link/result.test.ts @@ -0,0 +1,85 @@ +import {appConfigLinkJsonOutputSchema, projectAppConfigResult} from './types.js' +import {renderAppConfigLinkResult} from './result.js' +import ConfigLink from '../../../../commands/app/config/link.js' +import {testOrganizationApp} from '../../../../models/app/app.test-data.js' +import {describe, expect, test, vi} from 'vitest' +import {mockAndCaptureStandardStreams} from '@shopify/cli-kit/node/testing/output' +import * as context from '@shopify/cli-kit/node/context/local' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {outputInfo} from '@shopify/cli-kit/node/output' + +function result() { + return projectAppConfigResult({ + path: '/app/shopify.app.toml', + configuration: {client_id: 'key', name: 'Example', embedded: false, custom_module: {enabled: true}}, + app: testOrganizationApp({apiKey: 'key', developmentStorePreviewEnabled: false}), + }) +} + +describe('config link result', () => { + test('retains public configuration and app fields while excluding credentials and runtime state', () => { + const value = result() + const encoded = JSON.parse(appConfigLinkJsonOutputSchema.encode(value)) + expect(encoded.configuration).toEqual({ + client_id: 'key', + name: 'Example', + embedded: false, + custom_module: {enabled: true}, + }) + expect(encoded.app).toMatchObject({clientId: 'key', developmentStorePreviewEnabled: false, grantedScopes: []}) + expect(encoded.app).not.toHaveProperty('apiSecretKeys') + expect(encoded.app).not.toHaveProperty('developerPlatformClient') + expect(encoded.app).not.toHaveProperty('flags') + expect(encoded.app.appType).toBeNull() + }) + + test('separates actual GIDs from other app and organization identifiers', () => { + const value = projectAppConfigResult({ + path: '/app/shopify.app.toml', + configuration: {client_id: 'key'}, + app: testOrganizationApp({id: 'gid://shopify/App/123', organizationId: 'gid://shopify/Organization/456'}), + }) + expect(value.app).toMatchObject({ + id: null, + gid: 'gid://shopify/App/123', + organizationId: null, + organizationGid: 'gid://shopify/Organization/456', + }) + expect(result().app).toMatchObject({id: '1', gid: null, organizationId: '1', organizationGid: null}) + }) + + test.each([ + {path: 'relative.toml'}, + {extra: true}, + {app: {...result().app, extra: true}}, + {app: {...result().app, applicationUrl: 'invalid-url'}}, + {app: {...result().app, gid: 'gid://shopify/Shop/123'}}, + ])('rejects malformed public projections %j', (fields) => { + expect(() => appConfigLinkJsonOutputSchema.validate({...result(), ...fields})).toThrow() + }) + + test('rejects a missing configuration client ID', () => { + expect(() => appConfigLinkJsonOutputSchema.validate({...result(), configuration: {name: 'Example'}})).toThrow() + }) + + test('exposes the schema and JSON flag', () => { + expect(ConfigLink.jsonOutputSchema).toBe(appConfigLinkJsonOutputSchema) + expect(ConfigLink.flags.json).toBeDefined() + expect(ConfigLink.description).toContain('AppConfigLinkResult') + }) + + test('writes one encoded result to stdout and diagnostics to stderr', () => { + vi.spyOn(context, 'isUnitTest').mockReturnValue(false) + const streams = mockAndCaptureStandardStreams() + try { + runWithCommandEventsForCommand(['--json'], () => { + outputInfo('Configuration fetched') + renderAppConfigLinkResult(result(), 'npm', 'json') + }) + expect(streams.stdout()).toBe(`${appConfigLinkJsonOutputSchema.encode(result())}\n`) + expect(JSON.parse(streams.stderr())).toMatchObject({type: 'diagnostic', message: 'Configuration fetched'}) + } finally { + streams.restore() + } + }) +}) diff --git a/packages/app/src/cli/services/app/config/link/result.ts b/packages/app/src/cli/services/app/config/link/result.ts new file mode 100644 index 00000000000..14cba8ef65f --- /dev/null +++ b/packages/app/src/cli/services/app/config/link/result.ts @@ -0,0 +1,41 @@ +import {appConfigLinkJsonOutputSchema, type AppConfigLinkResult} from './types.js' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {formatPackageManagerCommand, outputResult} from '@shopify/cli-kit/node/output' +import {basename} from '@shopify/cli-kit/node/path' +import {PackageManager} from '@shopify/cli-kit/node/node-package-manager' + +export function renderAppConfigLinkResult( + result: AppConfigLinkResult, + packageManager: PackageManager, + format: 'json' | 'text', +): void { + if (format === 'json') { + outputResult(appConfigLinkJsonOutputSchema.encode(result)) + return + } + renderSuccessMessage(basename(result.path), String(result.configuration.name), packageManager) +} + +function renderSuccessMessage(configFileName: string, appName: string, packageManager: PackageManager) { + renderSuccess({ + headline: `${configFileName} is now linked to "${appName}" on Shopify`, + body: `Using ${configFileName} as your default config.`, + nextSteps: [ + [`Make updates to ${configFileName} in your local project`], + [ + 'To upload your config, run', + { + command: formatPackageManagerCommand(packageManager, 'shopify app deploy'), + }, + ], + ], + reference: [ + { + link: { + label: 'App configuration', + url: 'https://shopify.dev/docs/apps/tools/cli/configuration', + }, + }, + ], + }) +} diff --git a/packages/app/src/cli/services/app/config/link/types.ts b/packages/app/src/cli/services/app/config/link/types.ts new file mode 100644 index 00000000000..01ccd3b7fa3 --- /dev/null +++ b/packages/app/src/cli/services/app/config/link/types.ts @@ -0,0 +1,127 @@ +import {OrganizationApp} from '../../../../models/organization.js' +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' +// 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' + +const urlSchema = zod.string().url().nullable() +const appProxySchema = zod.object({subPath: zod.string(), subPathPrefix: zod.string(), url: urlSchema}).strict() +const privacyWebhooksSchema = zod + .object({customerDeletionUrl: urlSchema, customerDataRequestUrl: urlSchema, shopDeletionUrl: urlSchema}) + .strict() +const remoteAppSchema = zod + .object({ + id: zod + .string() + .min(1) + .refine((value) => !value.startsWith('gid://')) + .nullable() + .describe('The upstream non-GID app identifier, or null when the platform supplies a GID.'), + gid: zod + .string() + .regex(/^gid:\/\/shopify\/App\/[^\s/]+$/) + .nullable() + .describe('The Shopify App GID, or null when the platform supplies another identifier.'), + name: zod.string(), + clientId: zod.string().min(1).describe('The public OAuth client identifier.'), + organizationId: zod + .string() + .min(1) + .refine((value) => !value.startsWith('gid://')) + .nullable() + .describe('The upstream non-GID organization identifier, or null when unavailable.'), + organizationGid: zod + .string() + .regex(/^gid:\/\/shopify\/Organization\/[^\s/]+$/) + .nullable() + .describe('The Shopify Organization GID, or null when unavailable.'), + appType: zod.string().nullable().describe('The upstream app type; known values vary by platform.'), + newApp: zod.boolean().nullable(), + grantedScopes: zod.array(zod.string()), + developmentStorePreviewEnabled: zod.boolean().nullable(), + applicationUrl: urlSchema, + redirectUrls: zod.array(zod.string().url()).nullable(), + requestedAccessScopes: zod.array(zod.string()).nullable(), + webhookApiVersion: zod.string().nullable(), + embedded: zod.boolean().nullable(), + posEmbedded: zod.boolean().nullable(), + preferencesUrl: urlSchema, + privacyWebhooks: privacyWebhooksSchema.nullable(), + appProxy: appProxySchema.nullable(), + }) + .strict() + .describe('The linked app projection. Unavailable selected fields are null; secrets and runtime state are excluded.') + +// App configuration is native TOML content; platform module keys remain unchanged. +const configurationSchema = zod.object({client_id: zod.string().min(1)}).passthrough() + +export const appConfigLinkJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppConfigLinkResult', + schema: zod + .object({ + path: zod.string().refine(isAbsolutePath, 'Expected an absolute filesystem path.'), + configuration: configurationSchema, + app: remoteAppSchema, + }) + .strict(), + definitions: { + AppConfiguration: configurationSchema, + LinkedApp: remoteAppSchema, + PrivacyWebhooks: privacyWebhooksSchema, + AppProxy: appProxySchema, + }, +}) + +export type AppConfigLinkResult = InferJsonOutputSchema + +export function projectAppConfigResult(input: { + path: string + configuration: {client_id: string; [key: string]: unknown} + app: OrganizationApp +}): AppConfigLinkResult { + const {app} = input + const webhooks = app.gdprWebhooks + return appConfigLinkJsonOutputSchema.validate({ + path: resolve(input.path), + configuration: input.configuration, + app: { + id: app.id.startsWith('gid://') ? null : app.id, + gid: app.id.startsWith('gid://') ? app.id : null, + name: app.title, + clientId: app.apiKey, + organizationId: app.organizationId.startsWith('gid://') ? null : app.organizationId, + organizationGid: app.organizationId.startsWith('gid://') ? app.organizationId : null, + appType: nullableString(app.appType), + newApp: app.newApp ?? null, + grantedScopes: app.grantedScopes, + developmentStorePreviewEnabled: app.developmentStorePreviewEnabled ?? null, + applicationUrl: nullableString(app.applicationUrl), + redirectUrls: app.redirectUrlWhitelist ?? null, + requestedAccessScopes: app.requestedAccessScopes ?? null, + webhookApiVersion: nullableString(app.webhookApiVersion), + embedded: app.embedded ?? null, + posEmbedded: app.posEmbedded ?? null, + preferencesUrl: nullableString(app.preferencesUrl), + privacyWebhooks: webhooks + ? { + customerDeletionUrl: nullableString(webhooks.customerDeletionUrl), + customerDataRequestUrl: nullableString(webhooks.customerDataRequestUrl), + shopDeletionUrl: nullableString(webhooks.shopDeletionUrl), + } + : null, + appProxy: app.appProxy + ? { + subPath: app.appProxy.subPath, + subPathPrefix: app.appProxy.subPathPrefix, + url: nullableString(app.appProxy.url), + } + : null, + }, + }) +} + +function nullableString(value: string | undefined): string | null { + return value === '' ? null : (value ?? null) +} diff --git a/packages/cli/README.md b/packages/cli/README.md index daed386b1f9..9c31812262d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -972,13 +972,17 @@ Fetch your app configuration from the Developer Dashboard. ``` USAGE $ shopify app config link [--auth-alias ] [--client-id ] [--force [--file-name | -c ]] - [--json-schema] [--no-color] [--no-input] [--path ] [--reset | ] [--verbose] + [-j] [--json-schema] [--no-color] [--no-input] [--path ] [--reset | ] [--verbose] FLAGS -c, --config= The name of the local app configuration to read and overwrite. Use --file-name instead when specifying --client-id. [env: SHOPIFY_FLAG_APP_CONFIG] + -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] @@ -1028,6 +1032,279 @@ DESCRIPTION For more information on the format of the created TOML configuration file, refer to the "App configuration" (https://shopify.dev/docs/apps/tools/cli/configuration) page. + + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AppConfigLinkResult` schema. + + ```json + { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "configuration": { + "$ref": "#/definitions/AppConfiguration" + }, + "app": { + "$ref": "#/definitions/LinkedApp" + } + }, + "required": [ + "path", + "configuration", + "app" + ], + "additionalProperties": false, + "title": "AppConfigLinkResult", + "definitions": { + "AppConfiguration": { + "type": "object", + "properties": { + "client_id": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "client_id" + ], + "additionalProperties": true + }, + "LinkedApp": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "The upstream non-GID app identifier, or null when the platform supplies a GID." + }, + "gid": { + "anyOf": [ + { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/App\\/[^\\s/]+$" + }, + { + "type": "null" + } + ], + "description": "The Shopify App GID, or null when the platform supplies another identifier." + }, + "name": { + "type": "string" + }, + "clientId": { + "type": "string", + "minLength": 1, + "description": "The public OAuth client identifier." + }, + "organizationId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "The upstream non-GID organization identifier, or null when unavailable." + }, + "organizationGid": { + "anyOf": [ + { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/Organization\\/[^\\s/]+$" + }, + { + "type": "null" + } + ], + "description": "The Shopify Organization GID, or null when unavailable." + }, + "appType": { + "type": [ + "string", + "null" + ], + "description": "The upstream app type; known values vary by platform." + }, + "newApp": { + "type": [ + "boolean", + "null" + ] + }, + "grantedScopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "developmentStorePreviewEnabled": { + "type": [ + "boolean", + "null" + ] + }, + "applicationUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "redirectUrls": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + { + "type": "null" + } + ] + }, + "requestedAccessScopes": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "webhookApiVersion": { + "type": [ + "string", + "null" + ] + }, + "embedded": { + "type": [ + "boolean", + "null" + ] + }, + "posEmbedded": { + "type": [ + "boolean", + "null" + ] + }, + "preferencesUrl": { + "$ref": "#/definitions/LinkedApp/properties/applicationUrl" + }, + "privacyWebhooks": { + "anyOf": [ + { + "$ref": "#/definitions/PrivacyWebhooks" + }, + { + "type": "null" + } + ] + }, + "appProxy": { + "anyOf": [ + { + "$ref": "#/definitions/AppProxy" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "gid", + "name", + "clientId", + "organizationId", + "organizationGid", + "appType", + "newApp", + "grantedScopes", + "developmentStorePreviewEnabled", + "applicationUrl", + "redirectUrls", + "requestedAccessScopes", + "webhookApiVersion", + "embedded", + "posEmbedded", + "preferencesUrl", + "privacyWebhooks", + "appProxy" + ], + "additionalProperties": false, + "description": "The linked app projection. Unavailable selected fields are null; secrets and runtime state are excluded." + }, + "PrivacyWebhooks": { + "type": "object", + "properties": { + "customerDeletionUrl": { + "$ref": "#/definitions/LinkedApp/properties/applicationUrl" + }, + "customerDataRequestUrl": { + "$ref": "#/definitions/LinkedApp/properties/applicationUrl" + }, + "shopDeletionUrl": { + "$ref": "#/definitions/LinkedApp/properties/applicationUrl" + } + }, + "required": [ + "customerDeletionUrl", + "customerDataRequestUrl", + "shopDeletionUrl" + ], + "additionalProperties": false + }, + "AppProxy": { + "type": "object", + "properties": { + "subPath": { + "type": "string" + }, + "subPathPrefix": { + "type": "string" + }, + "url": { + "$ref": "#/definitions/LinkedApp/properties/applicationUrl" + } + }, + "required": [ + "subPath", + "subPathPrefix", + "url" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app config pull` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 8d61f5ca123..6887db94a6b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -561,7 +561,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Pulls app configuration from the Developer Dashboard and creates or overwrites a configuration file. You can create a new app with this command to start with a default configuration file.\n\n For more information on the format of the created TOML configuration file, refer to the \"App configuration\" (https://shopify.dev/docs/apps/tools/cli/configuration) page.\n ", + "description": "Pulls app configuration from the Developer Dashboard and creates or overwrites a configuration file. You can create a new app with this command to start with a default configuration file.\n\n For more information on the format of the created TOML configuration file, refer to the \"App configuration\" (https://shopify.dev/docs/apps/tools/cli/configuration) page.\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppConfigLinkResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"configuration\": {\n \"$ref\": \"#/definitions/AppConfiguration\"\n },\n \"app\": {\n \"$ref\": \"#/definitions/LinkedApp\"\n }\n },\n \"required\": [\n \"path\",\n \"configuration\",\n \"app\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppConfigLinkResult\",\n \"definitions\": {\n \"AppConfiguration\": {\n \"type\": \"object\",\n \"properties\": {\n \"client_id\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n },\n \"required\": [\n \"client_id\"\n ],\n \"additionalProperties\": true\n },\n \"LinkedApp\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"minLength\": 1\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The upstream non-GID app identifier, or null when the platform supplies a GID.\"\n },\n \"gid\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/App\\\\/[^\\\\s/]+$\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The Shopify App GID, or null when the platform supplies another identifier.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The public OAuth client identifier.\"\n },\n \"organizationId\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"minLength\": 1\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The upstream non-GID organization identifier, or null when unavailable.\"\n },\n \"organizationGid\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/Organization\\\\/[^\\\\s/]+$\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The Shopify Organization GID, or null when unavailable.\"\n },\n \"appType\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"The upstream app type; known values vary by platform.\"\n },\n \"newApp\": {\n \"type\": [\n \"boolean\",\n \"null\"\n ]\n },\n \"grantedScopes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"developmentStorePreviewEnabled\": {\n \"type\": [\n \"boolean\",\n \"null\"\n ]\n },\n \"applicationUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"redirectUrls\": {\n \"anyOf\": [\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"format\": \"uri\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"requestedAccessScopes\": {\n \"anyOf\": [\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"webhookApiVersion\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"embedded\": {\n \"type\": [\n \"boolean\",\n \"null\"\n ]\n },\n \"posEmbedded\": {\n \"type\": [\n \"boolean\",\n \"null\"\n ]\n },\n \"preferencesUrl\": {\n \"$ref\": \"#/definitions/LinkedApp/properties/applicationUrl\"\n },\n \"privacyWebhooks\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/PrivacyWebhooks\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"appProxy\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/AppProxy\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"gid\",\n \"name\",\n \"clientId\",\n \"organizationId\",\n \"organizationGid\",\n \"appType\",\n \"newApp\",\n \"grantedScopes\",\n \"developmentStorePreviewEnabled\",\n \"applicationUrl\",\n \"redirectUrls\",\n \"requestedAccessScopes\",\n \"webhookApiVersion\",\n \"embedded\",\n \"posEmbedded\",\n \"preferencesUrl\",\n \"privacyWebhooks\",\n \"appProxy\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The linked app projection. Unavailable selected fields are null; secrets and runtime state are excluded.\"\n },\n \"PrivacyWebhooks\": {\n \"type\": \"object\",\n \"properties\": {\n \"customerDeletionUrl\": {\n \"$ref\": \"#/definitions/LinkedApp/properties/applicationUrl\"\n },\n \"customerDataRequestUrl\": {\n \"$ref\": \"#/definitions/LinkedApp/properties/applicationUrl\"\n },\n \"shopDeletionUrl\": {\n \"$ref\": \"#/definitions/LinkedApp/properties/applicationUrl\"\n }\n },\n \"required\": [\n \"customerDeletionUrl\",\n \"customerDataRequestUrl\",\n \"shopDeletionUrl\"\n ],\n \"additionalProperties\": false\n },\n \"AppProxy\": {\n \"type\": \"object\",\n \"properties\": {\n \"subPath\": {\n \"type\": \"string\"\n },\n \"subPathPrefix\": {\n \"type\": \"string\"\n },\n \"url\": {\n \"$ref\": \"#/definitions/LinkedApp/properties/applicationUrl\"\n }\n },\n \"required\": [\n \"subPath\",\n \"subPathPrefix\",\n \"url\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Pulls app configuration from the Developer Dashboard and creates or overwrites a configuration file. You can create a new app with this command to start with a default configuration file.\n\n For more information on the format of the created TOML configuration file, refer to the [App configuration](https://shopify.dev/docs/apps/tools/cli/configuration) page.\n ", "flags": { "auth-alias": { @@ -614,6 +614,15 @@ "name": "force", "type": "boolean" }, + "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 669d6e604f9..445831622fa 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/link.ts', 'packages/app/src/cli/commands/app/config/pull.ts', 'packages/app/src/cli/commands/app/config/use.ts', 'packages/app/src/cli/commands/app/deploy.ts',