Skip to content
Merged
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/cli-json-app-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
---

Add JSON status output to app build.
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 @@ -95,9 +95,18 @@
"description": "The name of the app configuration.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-build.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 appbuild {\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 * 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 <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Skips the installation of dependencies. Deprecated, use workspaces instead.\n * @environment SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION\n */\n '--skip-dependencies-installation'?: ''\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 appbuild {\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 * 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 <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Skips the installation of dependencies. Deprecated, use workspaces instead.\n * @environment SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION\n */\n '--skip-dependencies-installation'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"appbulkcancel": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Build from './build.js'
import build from '../../services/build.js'
import * as localStorage from '../../services/local-storage.js'
import {LocalStorage} from '@shopify/cli-kit/node/local-storage'
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs'
import {dirname, joinPath} from '@shopify/cli-kit/node/path'
import {Config} from '@oclif/core'
import {expect, test, vi} from 'vitest'
import {fileURLToPath} from 'node:url'

vi.mock('../../services/build.js')
vi.mock('@shopify/cli-kit/node/multiple-installation-warning')

test.each([false, true])('JSON stale-config recovery respects no-input: multiple replacements=%s', async (multiple) => {
await inTemporaryDirectory(async (directory) => {
const storage = new LocalStorage<localStorage.AppLocalStorageSchema>({cwd: joinPath(directory, 'cache')})
await writeFile(joinPath(directory, 'package.json'), '{"name":"recovery-fixture"}')
await writeFile(
joinPath(directory, 'shopify.app.toml'),
`name = "Recovery fixture"
client_id = "public-fixture-id"
application_url = "https://example.com"
embedded = true
[auth]
redirect_urls = []
[webhooks]
api_version = "2023-04"
`,
)
if (multiple) {
await writeFile(
joinPath(directory, 'shopify.app.other.toml'),
await readFile(joinPath(directory, 'shopify.app.toml')),
)
}
const readCache = localStorage.getCachedAppInfo
const writeCache = localStorage.setCachedAppInfo
const readCacheSpy = vi
.spyOn(localStorage, 'getCachedAppInfo')
.mockImplementation((directory) => readCache(directory, storage))
const writeCacheSpy = vi
.spyOn(localStorage, 'setCachedAppInfo')
.mockImplementation((options) => writeCache(options, storage))
vi.mocked(build).mockImplementation(async ({app}) => ({status: 'success', appName: app.name}))
vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '1')
try {
localStorage.setCachedAppInfo({directory, configFile: 'shopify.app.deleted.toml'})
const config = await Config.load({root: joinPath(dirname(fileURLToPath(import.meta.url)), '../../../..')})
const argv = ['--path', directory, '--json', '--no-input']
const command = new Build(argv, config)
await withCapturedStandardStreams(async ({stdout, stderr}) => {
const run = runWithCommandEventsForCommand(argv, () => command.run())
if (multiple) {
await expect(run).rejects.toThrow('Failed to prompt')
expect(stdout()).toBe('')
expect(build).not.toHaveBeenCalled()
expect(localStorage.getCachedAppInfo(directory)?.configFile).toBe('shopify.app.deleted.toml')
return
}
await run
expect(JSON.parse(stdout())).toStrictEqual({status: 'success'})
expect(
stderr()
.trim()
.split('\n')
.map((line) => JSON.parse(line)),
).toContainEqual(
expect.objectContaining({
type: 'diagnostic',
level: 'warning',
message: expect.stringContaining("Couldn't find shopify.app.deleted.toml"),
}),
)
})
if (!multiple) {
expect(vi.mocked(build).mock.calls[0]![0].app.configPath).toBe(joinPath(directory, 'shopify.app.toml'))
expect(localStorage.getCachedAppInfo(directory)?.configFile).toBe('shopify.app.toml')
}
} finally {
readCacheSpy.mockRestore()
writeCacheSpy.mockRestore()
vi.unstubAllEnvs()
}
})
})
67 changes: 67 additions & 0 deletions packages/app/src/cli/commands/app/build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Build from './build.js'
import build from '../../services/build.js'
import {localAppContext} from '../../services/app-context.js'
import {appBuildJsonOutputSchema} from '../../services/build/types.js'
import {testApp, testProject} from '../../models/app/app.test-data.js'
import {appFlags} from '../../flags.js'
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {AbortSilentError} from '@shopify/cli-kit/node/error'
import {expect, test, vi} from 'vitest'
import {unstyled} from '@shopify/cli-kit/node/output'

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

function setup(directory: string) {
const app = testApp({name: 'Example app', directory, webs: []})
vi.mocked(localAppContext).mockResolvedValue({app, project: testProject(), activeConfig: {} as never})
vi.mocked(build).mockResolvedValue({status: 'success', appName: app.name})
return app
}

test('declares JSON/schema/help and retains app and inherited flags', () => {
expect(Build.flags.json).toBeDefined()
expect(Build.flags.path).toBe(appFlags.path)
expect(Build.baseFlags).toHaveProperty('json-schema')
expect(Build.baseFlags['auth-alias']).toBeDefined()
expect(Build.jsonOutputSchema).toBe(appBuildJsonOutputSchema)
expect(Build.descriptionForHelp()).toContain('AppBuildResult')
})

test.each([['--json'], ['--json', '--no-input'], ['--no-input']])(
'JSON and no-input remain independent: %j',
async (...flags) => {
await inTemporaryDirectory(async (directory) => {
setup(directory)
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await Build.run(['--path', directory, '--skip-dependencies-installation', ...flags], import.meta.url)
if (flags.includes('--json')) {
expect(JSON.parse(stdout())).toStrictEqual({status: 'success'})
} else {
expect(stdout()).toBe('')
expect(unstyled(stderr())).toContain('Example app built!')
}
})
expect(build).toHaveBeenCalledWith(expect.objectContaining({skipDependenciesInstallation: true}))
})
},
)

test('silent build failure becomes one fatal JSON document, not an empty stdout', async () => {
await inTemporaryDirectory(async (directory) => {
setup(directory)
vi.mocked(build).mockRejectedValueOnce(new AbortSilentError())
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
try {
await withCapturedStandardStreams(async ({stdout}) => {
await expect(Build.run(['--path', directory, '--json'], import.meta.url)).rejects.toThrow()
expect(JSON.parse(stdout())).toMatchObject({
error: {type: 'abort', message: 'The app build did not complete. See the build diagnostics for details.'},
})
})
} finally {
vi.unstubAllEnvs()
}
})
})
26 changes: 24 additions & 2 deletions packages/app/src/cli/commands/app/build.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import {appFlags} from '../../flags.js'
import build from '../../services/build.js'
import {appBuildJsonOutputSchema} from '../../services/build/types.js'
import {presentAppBuildResult} from '../../services/build/presenter.js'
import {localAppContext} from '../../services/app-context.js'
import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../utilities/app-unlinked-command.js'
import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error'
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'

export default class Build extends AppUnlinkedCommand {
Expand All @@ -13,11 +16,16 @@ export default class Build extends AppUnlinkedCommand {

If you're building a [theme app extension](https://shopify.dev/docs/apps/online-store/theme-app-extensions), then running the \`build\` command runs [Theme Check](https://shopify.dev/docs/themes/tools/theme-check) against your extension to ensure that it's valid.`

static get jsonOutputSchema() {
return appBuildJsonOutputSchema
}

static description = this.descriptionForHelp()

static flags = {
...globalFlags,
...appFlags,
...jsonFlag,
Comment thread
isaacroldan marked this conversation as resolved.
'skip-dependencies-installation': Flags.boolean({
hidden: false,
description: 'Skips the installation of dependencies. Deprecated, use workspaces instead.',
Expand All @@ -39,7 +47,21 @@ export default class Build extends AppUnlinkedCommand {
userProvidedConfigName: flags.config,
})

await build({app, project, skipDependenciesInstallation: flags['skip-dependencies-installation'], apiKey: clientId})
try {
const result = await build({
app,
project,
skipDependenciesInstallation: flags['skip-dependencies-installation'],
apiKey: clientId,
})
presentAppBuildResult(result, flags.json)
} catch (error) {
if (flags.json && error instanceof AbortSilentError) {
// Builders can stop after reporting diagnostics; JSON still needs a fatal result document.
throw new AbortError('The app build did not complete. See the build diagnostics for details.')
Comment thread
isaacroldan marked this conversation as resolved.
}
throw error
}

return {app}
}
Expand Down
73 changes: 73 additions & 0 deletions packages/app/src/cli/services/build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import build from './build.js'
import buildWeb from './web.js'
import {installAppDependencies} from './dependencies.js'
import {installJavy} from './function/build.js'
import {appBuildJsonOutputSchema} from './build/types.js'
import {presentAppBuildResult} from './build/presenter.js'
import {testApp, testProject, testUIExtension} from '../models/app/app.test-data.js'
import {WebType} from '../models/app/app.js'
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {expect, test, vi} from 'vitest'

vi.mock('./web.js')
vi.mock('./dependencies.js')
vi.mock('./function/build.js')

test('build returns data and the real presenter reserves stdout for the JSON result', async () => {
await inTemporaryDirectory(async (directory) => {
const extension = await testUIExtension({directory: joinPath(directory, 'extension')})
vi.spyOn(extension, 'build').mockImplementation(async ({stdout}) => {
stdout.write('extension built\n')
})
vi.mocked(buildWeb).mockImplementation(async (_command, {stdout, stderr}) => {
stdout.write('web built\n')
stderr.write('build diagnostic\n')
})
const app = testApp({
name: 'Example app',
directory,
allExtensions: [extension],
webs: [{directory, configuration: {roles: [WebType.Backend], commands: {dev: '', build: 'external-build'}}}],
})
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runWithCommandEventsForCommand(['--json'], async () => {
const result = await build({app, project: testProject(), skipDependenciesInstallation: true})
expect(result).toStrictEqual({status: 'success', appName: 'Example app'})
expect(stdout()).toBe('')
presentAppBuildResult(result, true)
})
expect(JSON.parse(stdout())).toStrictEqual({status: 'success'})
const messages = stderr()
.trim()
.split('\n')
.map((line) => JSON.parse(line).message)
expect(messages).toEqual(
expect.arrayContaining(
['web built', 'build diagnostic', 'extension built'].map((message) => expect.stringContaining(message)),
),
)
expect(extension.build).toHaveBeenCalledOnce()
expect(installAppDependencies).not.toHaveBeenCalled()
})
})
})

test('empty apps still return a successful result', async () => {
const app = testApp({name: 'Empty app', webs: [], allExtensions: []})
await expect(build({app, project: testProject(), skipDependenciesInstallation: true})).resolves.toStrictEqual({
status: 'success',
appName: 'Empty app',
})
expect(installJavy).toHaveBeenCalledWith(app)
})

test.each([
['missing status', {}],
['invalid status', {status: 'failed'}],
['extra field', {status: 'success', appName: 'Example app'}],
])('rejects %s', (_name, invalid) => {
expect(() => appBuildJsonOutputSchema.encode(invalid as never)).toThrow()
})
10 changes: 7 additions & 3 deletions packages/app/src/cli/services/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import {installAppDependencies} from './dependencies.js'
import {installJavy} from './function/build.js'
import {AppInterface, Web} from '../models/app/app.js'
import {Project} from '../models/project/project.js'
import {renderConcurrent, renderSuccess} from '@shopify/cli-kit/node/ui'
import {renderConcurrent} from '@shopify/cli-kit/node/ui'
import {AbortSignal} from '@shopify/cli-kit/node/abort'
import {Writable} from 'stream'
import type {AppBuildResult} from './build/types.js'

interface BuildOptions {
app: AppInterface
Expand All @@ -14,7 +15,7 @@ interface BuildOptions {
apiKey?: string
}

async function build(options: BuildOptions) {
async function build(options: BuildOptions): Promise<AppBuildResult> {
if (!options.skipDependenciesInstallation && !options.project.usesWorkspaces) {
await installAppDependencies(options.project)
}
Expand Down Expand Up @@ -50,7 +51,10 @@ async function build(options: BuildOptions) {
showTimestamps: false,
})

renderSuccess({headline: [{userInput: options.app.name}, 'built!']})
return {
status: 'success',
appName: options.app.name,
}
}

export default build
11 changes: 11 additions & 0 deletions packages/app/src/cli/services/build/presenter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {appBuildJsonOutputSchema, type AppBuildResult} from './types.js'
import {outputResult} from '@shopify/cli-kit/node/output'
import {renderSuccess} from '@shopify/cli-kit/node/ui'

export function presentAppBuildResult(result: AppBuildResult, json: boolean): void {
if (json) {
outputResult(appBuildJsonOutputSchema.encode({status: result.status}))
} else {
renderSuccess({headline: [{userInput: result.appName}, 'built!']})
}
}
9 changes: 9 additions & 0 deletions packages/app/src/cli/services/build/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
import {zod} from '@shopify/cli-kit/node/schema'

export const appBuildJsonOutputSchema = defineJsonOutputSchema({
name: 'AppBuildResult',
schema: zod.object({status: zod.literal('success')}).strict(),
})

export type AppBuildResult = InferJsonOutputSchema<typeof appBuildJsonOutputSchema> & {appName: string}
Loading
Loading