Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/typed-app-config-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
'@shopify/cli': minor
---
Add typed JSON output to app config pull.
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -635,9 +635,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 <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
"value": "export interface appconfigpull {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"appconfiguse": {
Expand Down
109 changes: 109 additions & 0 deletions packages/app/src/cli/commands/app/config/pull.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import ConfigPull from './pull.js'
import pull from '../../../services/app/config/pull.js'
import {appConfigPullJsonOutputSchema} from '../../../services/app/config/pull/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 = appConfigPullJsonOutputSchema.validate({
configFile: app.configPath,
configuration: app.configuration,
app: remoteApp,
})
vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited<ReturnType<typeof linkedAppContext>>)
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, configFile: 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 publicApp = Object.fromEntries(
Object.entries(remoteApp).filter(
([key]) => !['apiSecretKeys', 'flags', 'disabledFlags', 'developerPlatformClient'].includes(key),
),
)
const result = appConfigPullJsonOutputSchema.validate({...setup().result, app: remoteApp})
const encoded = JSON.parse(appConfigPullJsonOutputSchema.encode(result))
expect(encoded.app).toEqual(publicApp)
})
18 changes: 10 additions & 8 deletions packages/app/src/cli/commands/app/config/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand All @@ -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,
}

Expand All @@ -30,18 +35,15 @@ 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,
configuration: app.configuration,
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}
}
Expand Down
50 changes: 50 additions & 0 deletions packages/app/src/cli/services/app/config/pull.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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'

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.configFile).toBe(app.configPath)
expect(result.configuration).toEqual(app.configuration)
expect(result.app.apiKey).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()
})
17 changes: 7 additions & 10 deletions packages/app/src/cli/services/app/config/pull.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// packages/app/src/cli/services/app/config/pull.ts

import {appConfigPullJsonOutputSchema, type AppConfigPullResult} from './pull/types.js'
import {LinkOptions, loadLocalAppOptions, overwriteLocalConfigFileWithRemoteAppConfiguration} from './link.js'
import {CurrentAppConfiguration} from '../../../models/app/app.js'
import {OrganizationApp} from '../../../models/organization.js'
Expand All @@ -18,16 +17,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<PullOutput> {
export default async function pull(options: PullOptions): Promise<AppConfigPullResult> {
const {directory, configName, configPath, configuration, remoteApp} = options

if (!configuration.client_id) {
Expand Down Expand Up @@ -68,5 +61,9 @@ export default async function pull(options: PullOptions): Promise<PullOutput> {
localAppOptions,
})

return {configPath, configuration: mergedConfiguration, remoteApp}
return appConfigPullJsonOutputSchema.validate({
configFile: configPath,
configuration: mergedConfiguration,
app: remoteApp,
})
}
15 changes: 15 additions & 0 deletions packages/app/src/cli/services/app/config/pull/result.ts
Original file line number Diff line number Diff line change
@@ -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.configFile)} with the remote data.`,
})
}
10 changes: 10 additions & 0 deletions packages/app/src/cli/services/app/config/pull/types.ts
Original file line number Diff line number Diff line change
@@ -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<typeof appConfigPullJsonOutputSchema>
Loading
Loading