Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/json-app-deploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
---

Add typed JSON output to `app deploy`.
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 @@ -1055,9 +1055,18 @@
"description": "The name of the app configuration.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-deploy.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 appdeploy {\n /**\n * Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates. In non-interactive environments, provide this flag, --allow-updates, or --no-release.\n * @environment SHOPIFY_FLAG_ALLOW_DELETES\n */\n '--allow-deletes'?: ''\n\n /**\n * Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments. In non-interactive environments, provide this flag, --allow-deletes, or --no-release.\n * @environment SHOPIFY_FLAG_ALLOW_UPDATES\n */\n '--allow-updates'?: ''\n\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. Use with --config to deploy that configuration to a different app than the one it is linked to.\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 * Optional message that will be associated with this version. This is for internal use only and won't be available externally.\n * @environment SHOPIFY_FLAG_MESSAGE\n */\n '--message <value>'?: string\n\n /**\n * Use with caution: Skips building any elements of the app that require building. You should ensure your app has been prepared in advance, such as by running `shopify app build` or by caching build artifacts.\n * @environment SHOPIFY_FLAG_NO_BUILD\n */\n '--no-build'?: ''\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 * Creates a version but doesn't release it - it's not made available to merchants. With this flag, a user confirmation is not required. In non-interactive environments, provide this flag, --allow-updates, or --allow-deletes.\n * @environment SHOPIFY_FLAG_NO_RELEASE\n */\n '--no-release'?: ''\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 * URL associated with the new app version.\n * @environment SHOPIFY_FLAG_SOURCE_CONTROL_URL\n */\n '--source-control-url <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * Optional version tag that will be associated with this app version. If not provided, an auto-generated identifier will be generated for this app version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version <value>'?: string\n}"
"value": "export interface appdeploy {\n /**\n * Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates. In non-interactive environments, provide this flag, --allow-updates, or --no-release.\n * @environment SHOPIFY_FLAG_ALLOW_DELETES\n */\n '--allow-deletes'?: ''\n\n /**\n * Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments. In non-interactive environments, provide this flag, --allow-deletes, or --no-release.\n * @environment SHOPIFY_FLAG_ALLOW_UPDATES\n */\n '--allow-updates'?: ''\n\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. Use with --config to deploy that configuration to a different app than the one it is linked to.\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 * Optional message that will be associated with this version. This is for internal use only and won't be available externally.\n * @environment SHOPIFY_FLAG_MESSAGE\n */\n '--message <value>'?: string\n\n /**\n * Use with caution: Skips building any elements of the app that require building. You should ensure your app has been prepared in advance, such as by running `shopify app build` or by caching build artifacts.\n * @environment SHOPIFY_FLAG_NO_BUILD\n */\n '--no-build'?: ''\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 * Creates a version but doesn't release it - it's not made available to merchants. With this flag, a user confirmation is not required. In non-interactive environments, provide this flag, --allow-updates, or --allow-deletes.\n * @environment SHOPIFY_FLAG_NO_RELEASE\n */\n '--no-release'?: ''\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 * URL associated with the new app version.\n * @environment SHOPIFY_FLAG_SOURCE_CONTROL_URL\n */\n '--source-control-url <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * Optional version tag that will be associated with this app version. If not provided, an auto-generated identifier will be generated for this app version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version <value>'?: string\n}"
}
},
"appdevclean": {
Expand Down
85 changes: 82 additions & 3 deletions packages/app/src/cli/commands/app/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
import Deploy from './deploy.js'
import {deploy} from '../../services/deploy.js'
import {linkedAppContext} from '../../services/app-context.js'
import {testAppLinked, testOrganizationApp} from '../../models/app/app.test-data.js'
import {testAppLinked, testOrganizationApp, testProject} from '../../models/app/app.test-data.js'
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
import {beforeEach, describe, expect, test, vi} from 'vitest'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {outputInfo} from '@shopify/cli-kit/node/output'
import {Config} from '@oclif/core'
import {dirname, joinPath} from '@shopify/cli-kit/node/path'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import {fileURLToPath} from 'node:url'
import type {DeployResult} from '../../services/deploy/types.js'

vi.mock('../../services/deploy.js')
vi.mock('../../services/app-context.js')

const originalExitCode = process.exitCode
afterEach(() => {
process.exitCode = originalExitCode
vi.unstubAllEnvs()
})

describe('app deploy command', () => {
beforeEach(() => {
process.exitCode = undefined
vi.mocked(linkedAppContext).mockReset()
const app = testAppLinked()
vi.mocked(linkedAppContext).mockResolvedValue({
app,
project: testProject(),
remoteApp: testOrganizationApp(),
} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(deploy).mockResolvedValue({app} as Awaited<ReturnType<typeof deploy>>)
vi.mocked(deploy).mockResolvedValue(completedDeployResult(app))
})

test('accepts --config together with --client-id to deploy a configuration to a different app', async () => {
Expand Down Expand Up @@ -44,4 +58,69 @@ describe('app deploy command', () => {
expect(linkedAppContext).not.toHaveBeenCalled()
})
})

test('writes one JSON result and sends diagnostics to stderr', async () => {
vi.mocked(deploy).mockImplementationOnce(async () => {
outputInfo('Releasing an app version')
return completedDeployResult(testAppLinked())
})
await inTemporaryDirectory(async (directory) => {
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runDeploy(['--path', directory, '--json', '--allow-updates'])
expect(JSON.parse(stdout())).toEqual({
status: 'success',
app: {name: 'app1', clientId: 'api-key'},
deployment: {
released: true,
version: {
gid: 'gid://shopify/Version/1',
name: 'v1',
message: null,
url: 'https://dev.shopify.com/dashboard/1/apps/1/versions/1',
},
},
})
expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Releasing an app version'})
})
})
})

test('cancelled JSON exits zero through the silent error path', async () => {
const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never)
vi.mocked(deploy).mockResolvedValue({status: 'cancelled', app: testAppLinked()})
try {
await inTemporaryDirectory(async (directory) => {
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runDeploy(['--path', directory, '--json', '--allow-updates'])
expect(exit).toHaveBeenCalledExactlyOnceWith(0)
expect(JSON.parse(stdout())).toEqual({status: 'cancelled'})
expect(stderr()).toBe('')
})
})
} finally {
exit.mockRestore()
}
})
})

async function runDeploy(argv: string[]) {
const config = await Config.load({root: joinPath(dirname(fileURLToPath(import.meta.url)), '../../../..')})
// The source package is bundled in the installed CLI, not a custom plugin.
config.plugins.delete('@shopify/app')
return Deploy.run(argv, config)
}

function completedDeployResult(app: ReturnType<typeof testAppLinked>): Exclude<DeployResult, {status: 'cancelled'}> {
return {
status: 'success',
app,
release: true,
didMigrateExtensionsToDevDash: false,
uploadExtensionsBundleResult: {
validationErrors: [],
versionGid: 'gid://shopify/Version/1',
versionTag: 'v1',
location: 'https://dev.shopify.com/dashboard/1/apps/1/versions/1',
},
}
}
11 changes: 9 additions & 2 deletions packages/app/src/cli/commands/app/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {appFlags} from '../../flags.js'
import {deploy} from '../../services/deploy.js'
import {appDeployJsonOutputSchema} from '../../services/deploy/types.js'
import {renderAppDeployResult} from '../../services/deploy/result.js'
import {validateVersion} from '../../validations/version-name.js'
import {validateMessage} from '../../validations/message.js'
import metadata from '../../metadata.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js'
import {linkedAppContext} from '../../services/app-context.js'
import {Flags} from '@oclif/core'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {addPublicMetadata} from '@shopify/cli-kit/node/metadata'
import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command'

Expand All @@ -20,11 +22,16 @@ export default class Deploy extends AppLinkedCommand {
This command doesn't deploy your [web app](https://shopify.dev/docs/apps/tools/cli/structure#web-components). You need to [deploy your web app](https://shopify.dev/docs/apps/deployment/web) to your own hosting solution.
`

static get jsonOutputSchema() {
return appDeployJsonOutputSchema
}

static description = this.descriptionForHelp()

static flags = {
...globalFlags,
...appFlags,
...jsonFlag,
// Unlike the shared app flag, deploy accepts --client-id together with --config:
// the configuration selected by --config is deployed to the app identified by --client-id.
'client-id': Flags.string({
Expand Down Expand Up @@ -125,7 +132,7 @@ export default class Deploy extends AppLinkedCommand {
commitReference: flags['source-control-url'],
skipBuild: flags['no-build'],
})

await renderAppDeployResult(result, remoteApp, project, flags.json ? 'json' : 'text')
return {app: result.app}
}
}
34 changes: 33 additions & 1 deletion packages/app/src/cli/services/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {ensureDeployContext} from './context.js'
import {deploy, importExtensionsIfNeeded} from './deploy.js'
import {renderAppDeployResult} from './deploy/result.js'
import {uploadExtensionsBundle} from './deploy/upload.js'
import {bundleAndBuildExtensions} from './deploy/bundle.js'
import {importAllExtensions, allExtensionTypes, filterOutImportedExtensions} from './import-extensions.js'
Expand Down Expand Up @@ -34,6 +35,7 @@ import {
} from '@shopify/cli-kit/node/ui'
import {formatPackageManagerCommand} from '@shopify/cli-kit/node/output'
import {randomUUID} from '@shopify/cli-kit/node/crypto'
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
import {AbortSilentError} from '@shopify/cli-kit/node/error'

const versionTag = 'unique-version-tag'
Expand Down Expand Up @@ -511,6 +513,31 @@ describe('deploy', () => {
expect(bundleAndBuildExtensions).toHaveBeenCalledOnce()
})

test('returns deployment data without a completion banner', async () => {
await inTemporaryDirectory(async (directory) => {
const app = testAppLinked({directory, allExtensions: []})
const result = await testDeployBundle({app, remoteApp, developerPlatformClient, renderResult: false})
expect(result).toMatchObject({
status: 'success',
app,
release: true,
uploadExtensionsBundleResult: {versionGid: 'gid://shopify/Version/1', versionTag},
didMigrateExtensionsToDevDash: false,
})
expect(renderSuccess).not.toHaveBeenCalled()
expect(renderInfo).not.toHaveBeenCalled()
})
})

test('returns cancelled when deployment confirmation is declined', async () => {
const app = testAppLinked()
vi.mocked(ensureDeployContext).mockRejectedValueOnce(new AbortSilentError())
const result = await testDeployBundle({app, remoteApp, developerPlatformClient, renderResult: false})
expect(result).toEqual({status: 'cancelled', app})
expect(bundleAndBuildExtensions).not.toHaveBeenCalled()
expect(uploadExtensionsBundle).not.toHaveBeenCalled()
})

test('shows a success message', async () => {
// Given
const uiExtension = await testUIExtension({type: 'web_pixel_extension'})
Expand Down Expand Up @@ -690,6 +717,7 @@ interface TestDeployBundleInput {
appToDeploy?: AppInterface
developerPlatformClient: DeveloperPlatformClient
didMigrateExtensionsToDevDash?: boolean
renderResult?: boolean
}

async function testDeployBundle({
Expand All @@ -701,6 +729,7 @@ async function testDeployBundle({
appToDeploy,
developerPlatformClient,
didMigrateExtensionsToDevDash = false,
renderResult = true,
}: TestDeployBundleInput) {
// Given
const appModuleUuids: {[key: string]: string} = {}
Expand All @@ -713,13 +742,14 @@ async function testDeployBundle({

vi.mocked(uploadExtensionsBundle).mockResolvedValue({
validationErrors: [],
versionGid: 'gid://shopify/Version/1',
versionTag,
message: options?.message,
...(!released && {deployError: 'no release error'}),
location: 'https://partners.shopify.com/0/apps/0/versions/1',
})

await deploy({
const result = await deploy({
app,
project: testProject(),
remoteApp,
Expand All @@ -732,6 +762,8 @@ async function testDeployBundle({
developerPlatformClient,
skipBuild: false,
})
if (renderResult) await renderAppDeployResult(result, remoteApp, testProject(), 'text')
return result
}

describe('ImportExtensionsIfNeeded', () => {
Expand Down
Loading
Loading