diff --git a/.changeset/cli-json-app-build.md b/.changeset/cli-json-app-build.md new file mode 100644 index 00000000000..02e6fce6e2b --- /dev/null +++ b/.changeset/cli-json-app-build.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add JSON status output to app build. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 79b22e26edf..58f99304f22 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -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 '?: 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 * 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 '?: 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 * 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": { diff --git a/packages/app/src/cli/commands/app/build.context-recovery.test.ts b/packages/app/src/cli/commands/app/build.context-recovery.test.ts new file mode 100644 index 00000000000..148e373afb2 --- /dev/null +++ b/packages/app/src/cli/commands/app/build.context-recovery.test.ts @@ -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({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() + } + }) +}) diff --git a/packages/app/src/cli/commands/app/build.test.ts b/packages/app/src/cli/commands/app/build.test.ts new file mode 100644 index 00000000000..0659d18694b --- /dev/null +++ b/packages/app/src/cli/commands/app/build.test.ts @@ -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() + } + }) +}) diff --git a/packages/app/src/cli/commands/app/build.ts b/packages/app/src/cli/commands/app/build.ts index d08a30034b0..71cd8839359 100644 --- a/packages/app/src/cli/commands/app/build.ts +++ b/packages/app/src/cli/commands/app/build.ts @@ -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 { @@ -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, 'skip-dependencies-installation': Flags.boolean({ hidden: false, description: 'Skips the installation of dependencies. Deprecated, use workspaces instead.', @@ -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.') + } + throw error + } return {app} } diff --git a/packages/app/src/cli/services/build.test.ts b/packages/app/src/cli/services/build.test.ts new file mode 100644 index 00000000000..072d4bbae52 --- /dev/null +++ b/packages/app/src/cli/services/build.test.ts @@ -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() +}) diff --git a/packages/app/src/cli/services/build.ts b/packages/app/src/cli/services/build.ts index e1b0e597f7a..365c42e3f31 100644 --- a/packages/app/src/cli/services/build.ts +++ b/packages/app/src/cli/services/build.ts @@ -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 @@ -14,7 +15,7 @@ interface BuildOptions { apiKey?: string } -async function build(options: BuildOptions) { +async function build(options: BuildOptions): Promise { if (!options.skipDependenciesInstallation && !options.project.usesWorkspaces) { await installAppDependencies(options.project) } @@ -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 diff --git a/packages/app/src/cli/services/build/presenter.ts b/packages/app/src/cli/services/build/presenter.ts new file mode 100644 index 00000000000..9dcc80a7c9d --- /dev/null +++ b/packages/app/src/cli/services/build/presenter.ts @@ -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!']}) + } +} diff --git a/packages/app/src/cli/services/build/types.ts b/packages/app/src/cli/services/build/types.ts new file mode 100644 index 00000000000..1dbadbfd1de --- /dev/null +++ b/packages/app/src/cli/services/build/types.ts @@ -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 & {appName: string} diff --git a/packages/app/src/cli/services/extensions/bundle.test.ts b/packages/app/src/cli/services/extensions/bundle.test.ts index 712f07f5df6..eb3fa626c3c 100644 --- a/packages/app/src/cli/services/extensions/bundle.test.ts +++ b/packages/app/src/cli/services/extensions/bundle.test.ts @@ -4,8 +4,17 @@ import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-sp import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {describe, expect, test, vi} from 'vitest' import {context as esContext} from 'esbuild' -import {glob, inTemporaryDirectory, mkdir, touchFileSync, readFile, fileExistsSync} from '@shopify/cli-kit/node/fs' +import { + glob, + inTemporaryDirectory, + mkdir, + touchFileSync, + readFile, + fileExistsSync, + writeFile, +} from '@shopify/cli-kit/node/fs' import {basename, joinPath} from '@shopify/cli-kit/node/path' +import {runWithCommandEvents} from '@shopify/cli-kit/node/command-events' vi.mock('esbuild', async () => { const esbuild: any = await vi.importActual('esbuild') @@ -24,6 +33,33 @@ vi.mock('@luckycatfactory/esbuild-graphql-loader', () => ({ })) describe('bundleExtension()', () => { + test('JSON compiler failures use the supplied stream with native logging disabled', async () => { + await inTemporaryDirectory(async (directory) => { + await writeFile(joinPath(directory, 'invalid.tsx'), 'export const broken = ;') + const actualEsbuild = await vi.importActual('esbuild') + vi.mocked(esContext).mockImplementation((options) => { + expect(options.logLevel).toBe('silent') + return actualEsbuild.context(options) + }) + const stdout = {write: vi.fn()} + const stderr = {write: vi.fn()} + await expect( + runWithCommandEvents({outputMode: 'json'}, () => + bundleExtension({ + env: {}, + outputPath: joinPath(directory, 'dist/extension.js'), + minify: true, + environment: 'production', + stdin: {contents: "import './invalid.tsx';", resolveDir: directory, loader: 'tsx'}, + stdout: stdout as unknown as NodeJS.WriteStream, + stderr: stderr as unknown as NodeJS.WriteStream, + }), + ), + ).rejects.toThrow('Unexpected ";"') + expect(stderr.write).toHaveBeenCalledWith(expect.stringContaining('Unexpected ";"')) + }) + }) + test('invokes ESBuild with the right options and forwards the logs', async () => { // Given const extension = await testUIExtension() diff --git a/packages/app/src/cli/services/extensions/bundle.ts b/packages/app/src/cli/services/extensions/bundle.ts index b41ab0c9e28..8de5eebd4ea 100644 --- a/packages/app/src/cli/services/extensions/bundle.ts +++ b/packages/app/src/cli/services/extensions/bundle.ts @@ -8,6 +8,7 @@ import {copyFile, glob, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath, parsePath, relativePath} from '@shopify/cli-kit/node/path' import {outputDebug, outputWarn} from '@shopify/cli-kit/node/output' import {isTruthy} from '@shopify/cli-kit/node/context/utilities' +import {commandEventOutputMode} from '@shopify/cli-kit/node/command-events' import {pickBy} from '@shopify/cli-kit/common/object' import graphqlLoaderPlugin from '@luckycatfactory/esbuild-graphql-loader' import {Writable} from 'stream' @@ -58,12 +59,18 @@ interface BundleOptions { export async function bundleExtension(options: BundleOptions, processEnv = process.env) { const esbuildOptions = getESBuildOptions(options, processEnv) const context = await esContext(esbuildOptions) - const result = await context.rebuild() - onResult(result, options) - - await writeMetafile(result, options.outputPath) - - await context.dispose() + try { + const result = await context.rebuild() + onResult(result, options) + await writeMetafile(result, options.outputPath) + } catch (error) { + if (commandEventOutputMode() === 'json' && error instanceof Error) { + options.stderr.write(`${error.message}\n`) + } + throw error + } finally { + await context.dispose() + } } export async function bundleThemeExtension( @@ -157,7 +164,7 @@ function getESBuildOptions(options: BundleOptions, processEnv = process.env): Pa bundle: true, define, jsx: 'automatic', - logLevel: options.logLevel ?? 'error', + logLevel: commandEventOutputMode() === 'json' ? 'silent' : (options.logLevel ?? 'error'), loader: { '.esnext': 'ts', '.js': 'jsx', diff --git a/packages/app/src/cli/services/function/build.test.ts b/packages/app/src/cli/services/function/build.test.ts index bdb378388cc..043ab427ec5 100644 --- a/packages/app/src/cli/services/function/build.test.ts +++ b/packages/app/src/cli/services/function/build.test.ts @@ -26,6 +26,7 @@ import {packageManagerBinaryCommandForDirectory} from '@shopify/cli-kit/node/nod import {dirname, joinPath} from '@shopify/cli-kit/node/path' import {inTemporaryDirectory, mkdir, readFile, writeFile, removeFile} from '@shopify/cli-kit/node/fs' import {build as esBuild} from 'esbuild' +import {runWithCommandEvents} from '@shopify/cli-kit/node/command-events' vi.mock('@shopify/cli-kit/node/system') vi.mock('@shopify/cli-kit/node/node-package-manager', async () => { @@ -477,6 +478,20 @@ describe('bundleExtension', () => { }) describe('runJavy', () => { + test('JSON context honors the supplied compiler sinks and signal', async () => { + await inTemporaryDirectory(async (directory) => { + const fun = await testFunctionExtension({dir: directory}) + await runWithCommandEvents({outputMode: 'json', sink: () => {}}, () => + runJavy(fun, {stdout, stderr, signal, app}, derivedDeps), + ) + expect(exec).toHaveBeenCalledWith(javyBinary(derivedDeps.javy).path, expect.any(Array), { + cwd: fun.directory, + stdout, + stderr, + signal, + }) + }) + }) test('runs javy to compile JS into Wasm', {timeout: 20000}, async () => { await inTemporaryDirectory(async (tmpDir) => { // Given diff --git a/packages/app/src/cli/services/function/build.ts b/packages/app/src/cli/services/function/build.ts index 5a881ecaa67..e1d6e792356 100644 --- a/packages/app/src/cli/services/function/build.ts +++ b/packages/app/src/cli/services/function/build.ts @@ -29,6 +29,7 @@ import { packageManagerBinaryCommandForDirectory, readAndParsePackageJson, } from '@shopify/cli-kit/node/node-package-manager' +import {commandEventOutputMode} from '@shopify/cli-kit/node/command-events' import {Writable} from 'stream' export const PREFERRED_FUNCTION_NPM_PACKAGE_MAJOR_VERSION = '2' @@ -405,8 +406,8 @@ export async function runJavy( return exec(javy.path, args, { cwd: fun.directory, - stdout: 'inherit', - stderr: 'inherit', + stdout: commandEventOutputMode() === 'json' ? options.stdout : 'inherit', + stderr: commandEventOutputMode() === 'json' ? options.stderr : 'inherit', signal: options.signal, }) } diff --git a/packages/cli/README.md b/packages/cli/README.md index 95b19a8e8b7..0d7bde4038f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -130,14 +130,18 @@ Build the app, including extensions. ``` USAGE - $ shopify app build [--auth-alias ] [--client-id | -c ] [--json-schema] [--no-color] - [--no-input] [--path ] [--reset | ] [--skip-dependencies-installation] [--verbose] + $ shopify app build [--auth-alias ] [--client-id | -c ] [-j] [--json-schema] + [--no-color] [--no-input] [--path ] [--reset | ] [--skip-dependencies-installation] [--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] @@ -184,6 +188,28 @@ DESCRIPTION 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. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AppBuildResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + } + }, + "required": [ + "status" + ], + "additionalProperties": false, + "title": "AppBuildResult", + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app bulk cancel` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 31e82b9ad49..9f37300660b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6,7 +6,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "This command executes the build script specified in the element's TOML file. You can specify a custom script in the file. To learn about configuration files in Shopify apps, refer to \"App configuration\" (https://shopify.dev/docs/apps/tools/cli/configuration).\n\n 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.", + "description": "This command executes the build script specified in the element's TOML file. You can specify a custom script in the file. To learn about configuration files in Shopify apps, refer to \"App configuration\" (https://shopify.dev/docs/apps/tools/cli/configuration).\n\n 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.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppBuildResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppBuildResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "This command executes the build script specified in the element's TOML file. You can specify a custom script in the file. To learn about configuration files in Shopify apps, refer to [App configuration](https://shopify.dev/docs/apps/tools/cli/configuration).\n\n 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.", "flags": { "auth-alias": { @@ -39,6 +39,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/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js index 5eaa3d46144..87c3fa901bd 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.test.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -50,14 +50,14 @@ ruleTester.run('command-json-output', rule, { }, { name: 'legacy command baseline', - filename: '/repo/packages/app/src/cli/commands/app/build.ts', - code: 'export default class Build extends Command {}', + filename: '/repo/packages/app/src/cli/commands/app/deploy.ts', + code: 'export default class Deploy extends Command {}', }, { name: 'omitted exceptions preserve the default baseline', - filename: '/repo/packages/app/src/cli/commands/app/build.ts', + filename: '/repo/packages/app/src/cli/commands/app/deploy.ts', options: [{}], - code: 'export default class Build extends Command {}', + code: 'export default class Deploy extends Command {}', }, { name: 'custom exception in another repository', @@ -99,16 +99,16 @@ ruleTester.run('command-json-output', rule, { }, { name: 'custom exceptions replace the default baseline', - filename: '/repo/packages/app/src/cli/commands/app/build.ts', + filename: '/repo/packages/app/src/cli/commands/app/deploy.ts', options: [{exceptions: ['packages/cli/src/commands/hydrogen/dev.ts']}], - code: 'export default class Build extends Command {}', + code: 'export default class Deploy extends Command {}', errors: [{messageId: 'missingJsonOutputSchema'}, {messageId: 'missingJsonFlag'}], }, { name: 'empty exceptions enforce the rule for legacy commands', - filename: '/repo/packages/app/src/cli/commands/app/build.ts', + filename: '/repo/packages/app/src/cli/commands/app/deploy.ts', options: [{exceptions: []}], - code: 'export default class Build extends Command {}', + code: 'export default class Deploy extends Command {}', errors: [{messageId: 'missingJsonOutputSchema'}, {messageId: 'missingJsonFlag'}], }, { 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 0fcd20c5ff1..445c3a7ca6c 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/build.ts', 'packages/app/src/cli/commands/app/config/link.ts', 'packages/app/src/cli/commands/app/config/pull.ts', 'packages/app/src/cli/commands/app/config/use.ts',