diff --git a/.changeset/typed-app-config-pull.md b/.changeset/typed-app-config-pull.md new file mode 100644 index 00000000000..7037c4b2b4a --- /dev/null +++ b/.changeset/typed-app-config-pull.md @@ -0,0 +1,4 @@ +--- +'@shopify/cli': minor +--- +Add typed JSON output to app config pull. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 6fe1b56a3dd..aa8fe960e6f 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -725,9 +725,18 @@ "description": "The name of the app configuration.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-config-pull.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 appconfigpull {\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 * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: 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 appconfigpull {\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 * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: 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}" } }, "appconfiguse": { diff --git a/packages/app/src/cli/commands/app/config/pull.test.ts b/packages/app/src/cli/commands/app/config/pull.test.ts new file mode 100644 index 00000000000..c665d89ffc2 --- /dev/null +++ b/packages/app/src/cli/commands/app/config/pull.test.ts @@ -0,0 +1,127 @@ +import ConfigPull from './pull.js' +import pull from '../../../services/app/config/pull.js' +import {appConfigPullJsonOutputSchema} from '../../../services/app/config/pull/types.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 {Config} from '@oclif/core' +import {expect, test, vi} from 'vitest' +import * as context from '@shopify/cli-kit/node/context/local' +import * as ui from '@shopify/cli-kit/node/ui' +import {mockAndCaptureStandardStreams} from '@shopify/cli-kit/node/testing/output' + +vi.mock('../../../services/app/config/pull.js') +vi.mock('../../../services/app-context.js') + +function setup() { + const app = testAppLinked() + const remoteApp = testOrganizationApp() + const result = projectAppConfigResult({ + path: app.configPath, + configuration: app.configuration, + app: remoteApp, + }) + vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited>) + vi.mocked(pull).mockResolvedValue(result) + return {app, remoteApp, result} +} + +test('exposes the schema in help and retains app flags', () => { + expect(ConfigPull.jsonOutputSchema).toBe(appConfigPullJsonOutputSchema) + expect(ConfigPull.description).toContain('AppConfigPullResult') + expect(ConfigPull.flags.json).toBeDefined() + expect(ConfigPull.flags.config).toBeDefined() +}) + +test('writes the real encoded result to stdout', async () => { + const {app, result} = setup() + const command = new ConfigPull(['--json'], await Config.load()) + vi.spyOn(context, 'isUnitTest').mockReturnValue(false) + const streams = mockAndCaptureStandardStreams() + try { + await expect(command.run()).resolves.toEqual({app}) + expect(streams.stdout()).toBe(`${appConfigPullJsonOutputSchema.encode(result)}\n`) + expect(streams.stderr()).toBe('') + } finally { + streams.restore() + } +}) + +test('preserves text presentation and passes the selected configuration to the service', async () => { + const {app, remoteApp} = setup() + const render = vi.spyOn(ui, 'renderSuccess').mockReturnValue(undefined) + await new ConfigPull(['--config', 'staging'], await Config.load()).run() + expect(pull).toHaveBeenCalledWith({ + directory: expect.any(String), + configName: 'staging', + configPath: app.configPath, + configuration: app.configuration, + remoteApp, + }) + expect(render).toHaveBeenCalledWith({ + headline: `Pulled latest configuration for "${app.configuration.name}"`, + body: 'Updated shopify.app.toml with the remote data.', + }) +}) + +test('propagates failures without writing a success document', async () => { + setup() + vi.mocked(pull).mockRejectedValue(new Error('Remote configuration unavailable')) + const command = new ConfigPull(['--json'], await Config.load()) + vi.spyOn(context, 'isUnitTest').mockReturnValue(false) + const streams = mockAndCaptureStandardStreams() + try { + await expect(command.run()).rejects.toThrow('Remote configuration unavailable') + expect(streams.stdout()).toBe('') + } finally { + streams.restore() + } +}) + +test('rejects an invalid file path while all other fields are valid', () => { + expect(() => appConfigPullJsonOutputSchema.validate({...setup().result, path: null})).toThrow() +}) + +test('preserves all available public remote fields without exposing credentials', () => { + const remoteApp = testOrganizationApp({ + appType: 'custom', + newApp: false, + grantedScopes: ['read_products'], + developmentStorePreviewEnabled: false, + applicationUrl: 'https://example.com', + redirectUrlWhitelist: [], + requestedAccessScopes: [], + webhookApiVersion: '2026-07', + embedded: false, + posEmbedded: false, + preferencesUrl: '', + gdprWebhooks: {customerDeletionUrl: '', customerDataRequestUrl: '', shopDeletionUrl: ''}, + appProxy: {subPath: 'example', subPathPrefix: 'apps', url: 'https://example.com/proxy'}, + configuration: testAppLinked().configuration, + }) + const result = projectAppConfigResult({ + path: setup().result.path, + configuration: testAppLinked().configuration, + app: remoteApp, + }) + const encoded = JSON.parse(appConfigPullJsonOutputSchema.encode(result)) + expect(encoded.app).toMatchObject({ + name: remoteApp.title, + clientId: remoteApp.apiKey, + appType: 'custom', + newApp: false, + grantedScopes: ['read_products'], + developmentStorePreviewEnabled: false, + applicationUrl: 'https://example.com', + redirectUrls: [], + requestedAccessScopes: [], + webhookApiVersion: '2026-07', + embedded: false, + posEmbedded: false, + preferencesUrl: null, + privacyWebhooks: {customerDeletionUrl: null, customerDataRequestUrl: null, shopDeletionUrl: null}, + appProxy: {subPath: 'example', subPathPrefix: 'apps', url: 'https://example.com/proxy'}, + }) + expect(encoded.app).not.toHaveProperty('apiSecretKeys') + expect(encoded.app).not.toHaveProperty('configuration') +}) diff --git a/packages/app/src/cli/commands/app/config/pull.ts b/packages/app/src/cli/commands/app/config/pull.ts index aeb7c267601..76c0c5caf62 100644 --- a/packages/app/src/cli/commands/app/config/pull.ts +++ b/packages/app/src/cli/commands/app/config/pull.ts @@ -2,9 +2,9 @@ import {appFlags} from '../../../flags.js' import {linkedAppContext} from '../../../services/app-context.js' import pull from '../../../services/app/config/pull.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' -import {renderSuccess} from '@shopify/cli-kit/node/ui' -import {globalFlags} from '@shopify/cli-kit/node/cli' -import {basename} from '@shopify/cli-kit/node/path' +import {appConfigPullJsonOutputSchema} from '../../../services/app/config/pull/types.js' +import {renderAppConfigPullResult} from '../../../services/app/config/pull/result.js' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' export default class ConfigPull extends AppLinkedCommand { static summary = 'Refresh an already-linked app configuration without prompts.' @@ -13,10 +13,15 @@ export default class ConfigPull extends AppLinkedCommand { This command reuses the existing linked app and organization and skips all interactive prompts. Use \`--config\` to target a specific configuration file, or omit it to use the default one.` + static get jsonOutputSchema() { + return appConfigPullJsonOutputSchema + } + static description = this.descriptionForHelp() static flags = { ...globalFlags, + ...jsonFlag, ...appFlags, } @@ -30,7 +35,7 @@ This command reuses the existing linked app and organization and skips all inter userProvidedConfigName: flags.config, }) - const {configuration, configPath} = await pull({ + const result = await pull({ directory: flags.path, configName: flags.config, configPath: app.configPath, @@ -38,10 +43,7 @@ This command reuses the existing linked app and organization and skips all inter remoteApp, }) - renderSuccess({ - headline: `Pulled latest configuration for "${configuration.name}"`, - body: `Updated ${basename(configPath)} with the remote data.`, - }) + renderAppConfigPullResult(result, flags.json ? 'json' : 'text') return {app} } diff --git a/packages/app/src/cli/services/app/config/pull.test.ts b/packages/app/src/cli/services/app/config/pull.test.ts new file mode 100644 index 00000000000..ddf94a1134c --- /dev/null +++ b/packages/app/src/cli/services/app/config/pull.test.ts @@ -0,0 +1,53 @@ +import pull from './pull.js' +import {loadLocalAppOptions, overwriteLocalConfigFileWithRemoteAppConfiguration} from './link.js' +import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' +import {fetchSpecifications} from '../../generate/fetch-extension-specifications.js' +import {expect, test, vi} from 'vitest' +// Match the platform-native public artifact path. +// eslint-disable-next-line no-restricted-imports +import {resolve} from 'node:path' + +vi.mock('./link.js') +vi.mock('../../generate/fetch-extension-specifications.js') + +test('returns the written configuration and public app metadata', async () => { + const app = testAppLinked() + const remoteApp = testOrganizationApp() + vi.mocked(fetchSpecifications).mockResolvedValue([]) + vi.mocked(loadLocalAppOptions).mockResolvedValue({ + state: 'unable-to-load-config', + localAppIdMatchedRemote: false, + scopes: '', + existingBuildOptions: undefined, + existingConfig: undefined, + appDirectory: undefined, + packageManager: 'npm', + }) + vi.mocked(overwriteLocalConfigFileWithRemoteAppConfiguration).mockResolvedValue(app.configuration) + const result = await pull({ + directory: app.directory, + configPath: app.configPath, + configuration: app.configuration, + remoteApp, + }) + expect(result.path).toBe(resolve(app.configPath)) + expect(result.configuration).toEqual(app.configuration) + expect(result.app.clientId).toBe(remoteApp.apiKey) + expect(result.app).not.toHaveProperty('apiSecretKeys') + expect(overwriteLocalConfigFileWithRemoteAppConfiguration).toHaveBeenCalledWith( + expect.objectContaining({configFileName: 'shopify.app.toml', remoteApp, appDirectory: app.directory}), + ) +}) + +test('rejects an unlinked configuration before fetching remote specifications', async () => { + const app = testAppLinked() + await expect( + pull({ + directory: app.directory, + configPath: app.configPath, + configuration: {...app.configuration, client_id: ''}, + remoteApp: testOrganizationApp(), + }), + ).rejects.toThrow('The selected configuration is not linked to a remote app.') + expect(fetchSpecifications).not.toHaveBeenCalled() +}) diff --git a/packages/app/src/cli/services/app/config/pull.ts b/packages/app/src/cli/services/app/config/pull.ts index 67c7155265b..cbfd338bb4c 100644 --- a/packages/app/src/cli/services/app/config/pull.ts +++ b/packages/app/src/cli/services/app/config/pull.ts @@ -1,5 +1,5 @@ -// packages/app/src/cli/services/app/config/pull.ts - +import {type AppConfigPullResult} from './pull/types.js' +import {projectAppConfigResult} from './link/types.js' import {LinkOptions, loadLocalAppOptions, overwriteLocalConfigFileWithRemoteAppConfiguration} from './link.js' import {CurrentAppConfiguration} from '../../../models/app/app.js' import {OrganizationApp} from '../../../models/organization.js' @@ -8,7 +8,7 @@ import {fetchSpecifications} from '../../generate/fetch-extension-specifications import {RemoteAwareExtensionSpecification} from '../../../models/extensions/specification.js' import {Flag} from '../../../utilities/developer-platform-client.js' import {AbortError} from '@shopify/cli-kit/node/error' -import {basename} from '@shopify/cli-kit/node/path' +import {basename, resolvePath} from '@shopify/cli-kit/node/path' interface PullOptions { directory: string @@ -18,16 +18,10 @@ interface PullOptions { remoteApp: OrganizationApp } -interface PullOutput { - configPath: string - configuration: CurrentAppConfiguration - remoteApp: OrganizationApp -} - /** * Refresh an already-linked app configuration without prompting for org/app. */ -export default async function pull(options: PullOptions): Promise { +export default async function pull(options: PullOptions): Promise { const {directory, configName, configPath, configuration, remoteApp} = options if (!configuration.client_id) { @@ -68,5 +62,9 @@ export default async function pull(options: PullOptions): Promise { localAppOptions, }) - return {configPath, configuration: mergedConfiguration, remoteApp} + return projectAppConfigResult({ + path: resolvePath(localAppOptions.appDirectory ?? directory, configFileName), + configuration: mergedConfiguration, + app: remoteApp, + }) } diff --git a/packages/app/src/cli/services/app/config/pull/result.ts b/packages/app/src/cli/services/app/config/pull/result.ts new file mode 100644 index 00000000000..2fe218fb358 --- /dev/null +++ b/packages/app/src/cli/services/app/config/pull/result.ts @@ -0,0 +1,15 @@ +import {appConfigPullJsonOutputSchema, type AppConfigPullResult} from './types.js' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {outputResult} from '@shopify/cli-kit/node/output' +import {basename} from '@shopify/cli-kit/node/path' + +export function renderAppConfigPullResult(result: AppConfigPullResult, format: 'json' | 'text'): void { + if (format === 'json') { + outputResult(appConfigPullJsonOutputSchema.encode(result)) + return + } + renderSuccess({ + headline: `Pulled latest configuration for "${result.configuration.name}"`, + body: `Updated ${basename(result.path)} with the remote data.`, + }) +} diff --git a/packages/app/src/cli/services/app/config/pull/types.ts b/packages/app/src/cli/services/app/config/pull/types.ts new file mode 100644 index 00000000000..ce01281e2b6 --- /dev/null +++ b/packages/app/src/cli/services/app/config/pull/types.ts @@ -0,0 +1,10 @@ +import {appConfigLinkJsonOutputSchema} from '../link/types.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' + +export const appConfigPullJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppConfigPullResult', + schema: appConfigLinkJsonOutputSchema.schema, + definitions: appConfigLinkJsonOutputSchema.definitions, +}) + +export type AppConfigPullResult = InferJsonOutputSchema diff --git a/packages/cli/README.md b/packages/cli/README.md index 9c31812262d..4892251fb37 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1313,14 +1313,18 @@ Refresh an already-linked app configuration without prompts. ``` USAGE - $ shopify app config pull [--auth-alias ] [--client-id | -c ] [--json-schema] [--no-color] - [--no-input] [--path ] [--reset | ] [--verbose] + $ shopify app config pull [--auth-alias ] [--client-id | -c ] [-j] [--json-schema] + [--no-color] [--no-input] [--path ] [--reset | ] [--verbose] FLAGS -c, --config= The name of the app configuration. [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] @@ -1360,6 +1364,278 @@ DESCRIPTION This command reuses the existing linked app and organization and skips all interactive prompts. Use `--config` to target a specific configuration file, or omit it to use the default one. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AppConfigPullResult` schema. + + ```json + { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "configuration": { + "$ref": "#/definitions/AppConfiguration" + }, + "app": { + "$ref": "#/definitions/LinkedApp" + } + }, + "required": [ + "path", + "configuration", + "app" + ], + "additionalProperties": false, + "title": "AppConfigPullResult", + "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 use [config] [flags]` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 6887db94a6b..330053a7d27 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -701,7 +701,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Pulls the latest configuration from the already-linked Shopify app and updates the selected configuration file.\n\nThis command reuses the existing linked app and organization and skips all interactive prompts. Use `--config` to target a specific configuration file, or omit it to use the default one.", + "description": "Pulls the latest configuration from the already-linked Shopify app and updates the selected configuration file.\n\nThis command reuses the existing linked app and organization and skips all interactive prompts. Use `--config` to target a specific configuration file, or omit it to use the default one.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppConfigPullResult` 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\": \"AppConfigPullResult\",\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 the latest configuration from the already-linked Shopify app and updates the selected configuration file.\n\nThis command reuses the existing linked app and organization and skips all interactive prompts. Use `--config` to target a specific configuration file, or omit it to use the default one.", "flags": { "auth-alias": { @@ -734,6 +734,15 @@ "name": "config", "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 445831622fa..47f4adc6a2f 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/pull.ts', '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',