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-link.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 link.
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 @@ -543,9 +543,18 @@
"description": "The name of the app configuration.",
"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 <value>'?: string\n\n /**\n * The Client ID of your app. Required if non interactive.\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 * The name of the app configuration file to create or overwrite.\n * @environment SHOPIFY_FLAG_APP_CONFIG_FILE_NAME\n */\n '--file-name <value>'?: 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 * 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 appconfiglink {\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. Required if non interactive.\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 * The name of the app configuration file to create or overwrite.\n * @environment SHOPIFY_FLAG_APP_CONFIG_FILE_NAME\n */\n '--file-name <value>'?: 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 * 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}"
}
},
"appconfigpull": {
Expand Down
100 changes: 72 additions & 28 deletions packages/app/src/cli/commands/app/config/link.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import ConfigLink from './link.js'
import link from '../../../services/app/config/link.js'
import {linkAppConfiguration as link} from '../../../services/app/config/link.js'
import {appConfigLinkJsonOutputSchema} 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'
import {beforeEach, describe, expect, test, vi} from 'vitest'
Expand All @@ -17,36 +21,45 @@ describe('app config link command', () => {
vi.mocked(terminalSupportsPrompting).mockReturnValue(true)
})

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<ReturnType<typeof linkedAppContext>>)
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: appConfigLinkJsonOutputSchema.validate({
configFile: '/app/shopify.app.toml',
configuration: app.configuration,
app: testOrganizationApp(),
}),
})
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)

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) => {
Expand All @@ -55,6 +68,12 @@ describe('app config link command', () => {
remoteApp: testOrganizationApp(),
configFileName: 'shopify.app.secondary.toml',
configuration: app.configuration,
packageManager: 'npm',
result: appConfigLinkJsonOutputSchema.validate({
configFile: '/app/shopify.app.toml',
configuration: app.configuration,
app: testOrganizationApp(),
}),
})
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)

Expand Down Expand Up @@ -84,3 +103,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: appConfigLinkJsonOutputSchema.validate({
configFile: 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()
}
})
17 changes: 14 additions & 3 deletions packages/app/src/cli/commands/app/config/link.ts
Original file line number Diff line number Diff line change
@@ -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'

export default class ConfigLink extends AppLinkedCommand {
static summary = 'Fetch your app configuration from the Developer Dashboard.'
Expand All @@ -13,10 +15,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,
'organization-id': Flags.string({
hidden: true,
Expand Down Expand Up @@ -50,7 +57,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,
Expand All @@ -59,6 +67,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}
}
}
50 changes: 20 additions & 30 deletions packages/app/src/cli/services/app/config/link.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {appConfigLinkJsonOutputSchema, 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'
Expand All @@ -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'
Expand Down Expand Up @@ -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<LinkOutput> {
const output = await linkAppConfiguration(options)
if (shouldRenderSuccess) renderAppConfigLinkResult(output.result, output.packageManager, 'text')
return output
}

export async function linkAppConfiguration(
options: LinkOptions,
): Promise<LinkOutput & {result: AppConfigLinkResult; packageManager: PackageManager}> {
// First, select (or create, if the user chooses to) a remote app to link to
const {remoteApp, appDirectory, developerPlatformClient} = await selectOrCreateRemoteAppToLinkTo(options)

Expand Down Expand Up @@ -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: appConfigLinkJsonOutputSchema.validate({
configFile: joinPath(appDirectory, configFileName),
configuration: mergedAppConfiguration,
app: remoteApp,
}),
}

return {remoteApp, configFileName, configuration: mergedAppConfiguration}
}

function abortIfLinkPromptCannotRun(missingFlags: string[]) {
Expand Down Expand Up @@ -431,30 +445,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.
*
Expand Down
60 changes: 60 additions & 0 deletions packages/app/src/cli/services/app/config/link/result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {appConfigLinkJsonOutputSchema} 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 appConfigLinkJsonOutputSchema.validate({
configFile: '/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({apiKey: '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).not.toHaveProperty('appType')
})

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()
}
})
})
41 changes: 41 additions & 0 deletions packages/app/src/cli/services/app/config/link/result.ts
Original file line number Diff line number Diff line change
@@ -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.configFile), 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',
},
},
],
})
}
Loading
Loading