diff --git a/.changeset/bright-dev-stream.md b/.changeset/bright-dev-stream.md new file mode 100644 index 00000000000..4fbdb05bb25 --- /dev/null +++ b/.changeset/bright-dev-stream.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add `app dev --json` streaming and expose session-file schemas with `--json-schema`. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index f1a53fae7a4..30839954c0a 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -1402,6 +1402,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", "syntaxKind": "PropertySignature", @@ -1421,7 +1430,7 @@ "environmentValue": "SHOPIFY_FLAG_THEME" } ], - "value": "export interface appdev {\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 * Resource URL for checkout UI extension. Format: \"/cart/{productVariantID}:{productQuantity}\"\n * @environment SHOPIFY_FLAG_CHECKOUT_CART_URL\n */\n '--checkout-cart-url '?: string\n\n /**\n * The Client ID of your app. Use with --config to run dev with that configuration for a different app than the one it is linked to.\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 * Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.\n * @environment SHOPIFY_FLAG_INSTALL_MKCERT\n */\n '--install-mkcert'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Port to use for localhost. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_LOCALHOST_PORT\n */\n '--localhost-port '?: string\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 * Uses the app URL from the toml file instead an autogenerated URL for dev.\n * @environment SHOPIFY_FLAG_NO_UPDATE\n */\n '--no-update'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\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 * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Resource URL for subscription UI extension. Format: \"/products/{productId}\"\n * @environment SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL\n */\n '--subscription-product-url '?: string\n\n /**\n * Theme ID or name of the theme app extension host theme.\n * @environment SHOPIFY_FLAG_THEME\n */\n '-t, --theme '?: string\n\n /**\n * Local port of the theme app extension development server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT\n */\n '--theme-app-extension-port '?: string\n\n /**\n * Use a custom tunnel, it must be running before executing dev. Format: \"https://my-tunnel-url:port\".\n * @environment SHOPIFY_FLAG_TUNNEL_URL\n */\n '--tunnel-url '?: string\n\n /**\n * Allow app dev to continue with warnings for eligible app version validation failures.\n * @environment SHOPIFY_FLAG_UNSAFE_VALIDATION\n */\n '--unsafe-validation'?: ''\n\n /**\n * Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks)\n * @environment SHOPIFY_FLAG_USE_LOCALHOST\n */\n '--use-localhost'?: ''\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 appdev {\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 * Resource URL for checkout UI extension. Format: \"/cart/{productVariantID}:{productQuantity}\"\n * @environment SHOPIFY_FLAG_CHECKOUT_CART_URL\n */\n '--checkout-cart-url '?: string\n\n /**\n * The Client ID of your app. Use with --config to run dev with that configuration for a different app than the one it is linked to.\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 * Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.\n * @environment SHOPIFY_FLAG_INSTALL_MKCERT\n */\n '--install-mkcert'?: ''\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 * Port to use for localhost. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_LOCALHOST_PORT\n */\n '--localhost-port '?: string\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 * Uses the app URL from the toml file instead an autogenerated URL for dev.\n * @environment SHOPIFY_FLAG_NO_UPDATE\n */\n '--no-update'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\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 * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Resource URL for subscription UI extension. Format: \"/products/{productId}\"\n * @environment SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL\n */\n '--subscription-product-url '?: string\n\n /**\n * Theme ID or name of the theme app extension host theme.\n * @environment SHOPIFY_FLAG_THEME\n */\n '-t, --theme '?: string\n\n /**\n * Local port of the theme app extension development server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT\n */\n '--theme-app-extension-port '?: string\n\n /**\n * Use a custom tunnel, it must be running before executing dev. Format: \"https://my-tunnel-url:port\".\n * @environment SHOPIFY_FLAG_TUNNEL_URL\n */\n '--tunnel-url '?: string\n\n /**\n * Allow app dev to continue with warnings for eligible app version validation failures.\n * @environment SHOPIFY_FLAG_UNSAFE_VALIDATION\n */\n '--unsafe-validation'?: ''\n\n /**\n * Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks)\n * @environment SHOPIFY_FLAG_USE_LOCALHOST\n */\n '--use-localhost'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appenvpull": { diff --git a/docs/cli/dev-instance-agent-workflow.md b/docs/cli/dev-instance-agent-workflow.md index 295035e7738..b088f608d45 100644 --- a/docs/cli/dev-instance-agent-workflow.md +++ b/docs/cli/dev-instance-agent-workflow.md @@ -53,12 +53,18 @@ of commits and external reports; output and local URLs can contain secrets. ## App dev `shopify app dev` owns one session per app configuration. The instance details -include the app configuration, app identity, store, URLs, and `preview`: +include the app configuration, app identity, store, URLs, and `preview`. -- `state`: `starting`, `updating`, `ready`, or `error`. -- `message`: the current CLI status message, or `null` before one is available. -- `errors`: the current reported errors. Errors remain during retries and clear - when the CLI reports success. +Use `shopify app dev --json-schema` to inspect the contract. `definitions.DevInstance` +describes the saved instance JSON, and `definitions.DevPreview` describes +`details.preview`. `definitions.Result` describes each JSONL stream record. +The instance metadata is stored in the file; it is not added to the JSONL stream. +Preview errors remain during retries and clear when the CLI reports success. Process messages keep their terminal source, including extension handles. Changes to the status message also produce a log record with source `app dev`. + +With `--json`, dev stream records use identical JSONL bytes in stdout and the session +log. The log contains process output, preview URLs, and session status messages. +CLI setup messages and other diagnostics use stderr. Fatal CLI errors use the +standard JSON error output on stdout and are not added to the session log. diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index aea695beaf9..9f2aaf4fd3e 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -240,6 +240,23 @@ events from stderr; missing required input then produces a fatal error instead o ## Exempt only streaming commands +`shopify app dev --json` writes dev stream records as identical JSONL bytes to stdout and the session log. Each record +has `timestamp`, `source`, and `message`, with `level` when available. The log contains process output, preview URLs, +and session status messages. There is no final success result. Preview errors remain log records so that the +developer can fix the app while dev keeps running. + +CLI setup messages and other diagnostics use stderr. Fatal CLI failures use the standard `{error: {...}}` envelope +on stdout and exit nonzero. These failures are not added to the session log. A second invocation that cannot acquire +the session never changes the running process's log. + +```jsonl +{"timestamp":"2026-10-20T09:00:00Z","source":"web","message":"Server listening on 3000"} +{"timestamp":"2026-10-20T09:00:01Z","source":"app dev","level":"info","message":"Ready, watching for changes in your app"} +``` + +Setup prompts still use stderr. Use `--json --no-input` for unattended runs. `app dev --json-schema` describes the +stream's event and fatal error formats. This streaming contract does not change the single-document rule for finite commands. + Long-lived commands that produce an open-ended event stream don't have one finite result. Track these exemptions in `packages/eslint-plugin-cli/rules/json-output-command-exceptions.js`, in its streaming section. Add the command's repository-relative path: diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index a20efa5e5bd..2bf7d8544a3 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -1,7 +1,8 @@ import Dev from './dev.js' import DevClean from './dev/clean.js' import {devClean} from '../../services/dev-clean.js' -import {dev} from '../../services/dev.js' +import {dev, withDevSession} from '../../services/dev.js' +import {appDevOutputSchema} from '../../services/dev/schema.js' import {linkedAppContext} from '../../services/app-context.js' import {storeContext} from '../../services/store-context.js' import {getTunnelMode} from '../../services/dev/tunnel-mode.js' @@ -14,9 +15,18 @@ import { testOrganizationStore, testProject, } from '../../models/app/app.test-data.js' -import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {launchCLI} from '@shopify/cli-kit/node/cli-launcher' +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' +import {AbortError, AbortSilentError, CancelExecution} from '@shopify/cli-kit/node/error' +import {outputInfo} from '@shopify/cli-kit/node/output' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import {isInputDisabled} from '@shopify/cli-kit/node/no-input' import { acquireDevInstance, + devInstanceLogOutputSchema, + devInstancePaths, DEV_INSTANCE_ALREADY_RUNNING_CODE, readDevInstance, } from '@shopify/cli-kit/node/dev-instance' @@ -67,6 +77,234 @@ describe('app dev command', () => { return {store} } + test('declares its streaming schema and retains inherited flags', () => { + expect(Dev.jsonOutputSchema).toBe(appDevOutputSchema) + expect(Dev.jsonOutputSchema.schema).toBe(devInstanceLogOutputSchema.schema) + expect(Dev.flags.json.char).toBe('j') + expect(Dev.flags.json.env).toBe('SHOPIFY_FLAG_JSON') + expect(Dev.baseFlags).toHaveProperty('json-schema') + expect(Dev.baseFlags).toHaveProperty('auth-alias') + expect(Dev.descriptionForHelp()).toContain('DevInstanceLogEntry') + }) + + test('prints instance and preview schemas before loading an app or starting dev', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await launchCLI({ + moduleURL: new URL('../../../../../cli/package.json', import.meta.url).href, + argv: ['app', 'dev', '--json-schema'], + lazyCommandLoader: async (id) => (id === 'app:dev' ? Dev : undefined), + }) + const {definitions, anyOf} = JSON.parse(stdout()) + expect(definitions.DevInstance.properties.details.properties.preview.$ref).toBe('#/definitions/DevPreview') + expect(definitions.DevPreview.properties.state.enum).toEqual(['starting', 'updating', 'ready', 'error']) + expect(definitions.DevPreview.required).toEqual(['state', 'message', 'errors']) + expect(anyOf).not.toContainEqual({$ref: '#/definitions/DevInstance'}) + expect(stderr()).toBe('') + expect(linkedAppContext).not.toHaveBeenCalled() + expect(dev).not.toHaveBeenCalled() + }) + }) + + test('the instance schema accepts saved startup and preview metadata', async () => { + await inTemporaryDirectory(async (directory) => { + await withDevSession({app: testAppLinked({directory}), remoteApp: testOrganizationApp()}, async (instance) => { + const schema = appDevOutputSchema.definitions.DevInstance! + const startup = JSON.parse(await readFile(instance.paths.infoFile)) + expect(schema.parse(startup)).toEqual(startup) + await instance.update({ + status: 'running', + details: { + preview: {state: 'error', message: 'Build failed', errors: ['Invalid extension']}, + }, + }) + const running = JSON.parse(await readFile(instance.paths.infoFile)) + expect(schema.parse(running)).toEqual(running) + expect( + schema.safeParse({ + ...running, + details: { + ...running.details, + preview: { + state: 'unknown', + message: null, + errors: [], + }, + }, + }).success, + ).toBe(false) + }) + }) + }) + + test.each([ + {timestamp: '2026-10-20T09:00:00Z', source: 'app dev', level: 'info', message: 'Starting dev'}, + {timestamp: '2026-10-20T09:00:00Z', source: 'web', message: 'Server ready'}, + ] as const)( + 'the stream schema accepts records without type or stream and rejects these extra fields: %j', + (entry) => { + expect(JSON.parse(devInstanceLogOutputSchema.encode(entry))).toEqual(entry) + expect(() => devInstanceLogOutputSchema.validate({...entry, type: 'diagnostic'})).toThrow() + expect(() => devInstanceLogOutputSchema.validate({...entry, stream: 'stdout'})).toThrow() + }, + ) + + test.each(['--json', '-j', 'env'])('%s mirrors only dev stream records to stdout and the file', async (source) => { + await inTemporaryDirectory(async (tmp) => { + const {store} = mockAppAndStore(tmp) + vi.mocked(getTunnelMode).mockImplementation(async () => { + outputInfo('Resolving app setup') + return {mode: 'auto'} + }) + if (source === 'env') vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + let logFile: string | undefined + vi.mocked(dev).mockImplementation(async ({devInstance}) => { + logFile = devInstance?.paths.logFile + outputInfo('CLI diagnostic') + emitCommandEvent({type: 'progress', operation: 'setup', status: 'updated', message: 'Setup complete'}) + await devInstance?.log({source: 'app dev', level: 'info', message: 'Ready, watching for changes in your app'}) + await devInstance?.log({source: 'web', message: 'Server ready'}) + await devInstance?.log({source: 'app dev', level: 'error', message: 'Build error'}) + }) + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await Dev.run( + ['--path', tmp, '--store', store.shopDomain, ...(source.startsWith('-') ? [source] : [])], + import.meta.url, + ) + const events = stdout() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + events.forEach((event) => { + expect(() => Dev.jsonOutputSchema.validate(event)).not.toThrow() + expect(event).not.toHaveProperty('type') + expect(event).not.toHaveProperty('stream') + }) + expect(events).toEqual([ + expect.objectContaining({ + source: 'app dev', + level: 'info', + message: 'Ready, watching for changes in your app', + }), + expect.objectContaining({source: 'web', message: 'Server ready'}), + expect.objectContaining({level: 'error', message: 'Build error'}), + ]) + expect(logFile).toBeDefined() + expect(stdout()).toBe(await readFile(logFile!)) + expect( + stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line).message), + ).toEqual(expect.arrayContaining(['Resolving app setup', 'CLI diagnostic', 'Setup complete'])) + }) + } finally { + vi.unstubAllEnvs() + } + }) + }) + + test.each([false, true])('JSON preserves the input policy with no-input=%s', async (noInput) => { + await inTemporaryDirectory(async (tmp) => { + const {store} = mockAppAndStore(tmp) + vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) + vi.stubEnv('CI', '') + vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', noInput ? '1' : '0') + const stdinIsTTY = process.stdin.isTTY + const stderrIsTTY = process.stderr.isTTY + Object.defineProperty(process.stdin, 'isTTY', {value: true, configurable: true}) + Object.defineProperty(process.stderr, 'isTTY', {value: true, configurable: true}) + vi.mocked(linkedAppContext).mockImplementation(async () => { + expect(isInputDisabled()).toBe(noInput) + expect(terminalSupportsPrompting()).toBe(!noInput) + throw new AbortError('Input policy checked') + }) + try { + await withCapturedStandardStreams(async ({stdout}) => { + await expect(Dev.run(['--path', tmp, '--json'], import.meta.url)).rejects.toThrow() + expect(JSON.parse(stdout().trim().split('\n').at(-1)!)).toMatchObject({ + error: {type: 'abort', message: 'Input policy checked'}, + }) + }) + } finally { + Object.defineProperty(process.stdin, 'isTTY', {value: stdinIsTTY, configurable: true}) + Object.defineProperty(process.stderr, 'isTTY', {value: stderrIsTTY, configurable: true}) + vi.unstubAllEnvs() + } + }) + }) + + test.each([false, true])('a failure during store selection=%s stays out of the session log', async (duringSetup) => { + await inTemporaryDirectory(async (tmp) => { + const {store} = mockAppAndStore(tmp) + vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) + const logFile = devInstancePaths({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}).logFile + const fail = async () => { + outputInfo('CLI diagnostic') + throw new AbortError('Dev failed') + } + vi.mocked(dev).mockImplementation(async ({devInstance}) => { + await devInstance?.log({source: 'web', message: 'Server ready'}) + return fail() + }) + if (duringSetup) vi.mocked(storeContext).mockImplementation(fail) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(Dev.run(['--path', tmp, '--store', store.shopDomain, '--json'], import.meta.url)).rejects.toThrow() + const records = stdout() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(records.at(-1)).toEqual({error: {type: 'abort', message: 'Dev failed'}}) + const log = await readFile(logFile) + expect(log).toBe(duringSetup ? '' : `${JSON.stringify(records[0])}\n`) + expect(records).toHaveLength(duringSetup ? 1 : 2) + expect(log).not.toContain('Dev failed') + expect(log).not.toContain('CLI diagnostic') + expect( + stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toContainEqual(expect.objectContaining({type: 'diagnostic', message: 'CLI diagnostic'})) + }) + await expect( + readDevInstance({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}), + ).resolves.toBeUndefined() + }) + }) + + test.each(['silent', 'cancel'])( + 'releases the instance after a %s error without adding it to the log', + async (kind) => { + await inTemporaryDirectory(async (tmp) => { + const {store} = mockAppAndStore(tmp) + vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) + const logFile = devInstancePaths({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}).logFile + vi.mocked(dev).mockImplementation(async ({devInstance}) => { + await devInstance?.log({source: 'web', message: 'Server ready'}) + throw kind === 'silent' ? new AbortSilentError() : new CancelExecution('Stopped') + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect( + Dev.run(['--path', tmp, '--store', store.shopDomain, '--json'], import.meta.url), + ).rejects.toThrow() + expect(stdout()).toBe(await readFile(logFile)) + expect(JSON.parse(stdout().trim())).toMatchObject({source: 'web', message: 'Server ready'}) + const diagnostics = stderr() + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + if (kind === 'cancel') expect(diagnostics).toContainEqual(expect.objectContaining({message: '✨ Stopped'})) + else expect(diagnostics.every((entry) => entry.level === 'warning')).toBe(true) + }) + await expect( + readDevInstance({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}), + ).resolves.toBeUndefined() + }) + }, + ) + test.each([ ['app dev', Dev, dev], ['app dev clean', DevClean, devClean], @@ -160,9 +398,14 @@ describe('app dev command', () => { }) }) - test('requires --use-localhost when --install-mkcert is passed', async () => { + test.each([false, true])('requires --use-localhost when --install-mkcert is passed, json=%s', async (json) => { await inTemporaryDirectory(async (tmp) => { - await expect(Dev.run(['--path', tmp, '--install-mkcert'], import.meta.url)).rejects.toThrow() + await withCapturedStandardStreams(async ({stdout}) => { + await expect( + Dev.run(['--path', tmp, '--install-mkcert', ...(json ? ['--json'] : [])], import.meta.url), + ).rejects.toThrow() + if (json) expect(JSON.parse(stdout().trim().split('\n').at(-1)!)).toHaveProperty('error.type', 'abort') + }) expect(dev).not.toHaveBeenCalled() expect(devClean).not.toHaveBeenCalled() @@ -186,21 +429,40 @@ describe('app dev command', () => { }) }) - test('refuses to start when app dev is already running for the same configuration', async () => { - await inTemporaryDirectory(async (tmp) => { - const {store} = mockAppAndStore(tmp) - vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) - const running = await acquireDevInstance({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}) + test.each([false, true])( + 'refuses to start when app dev is already running for the same configuration, json=%s', + async (json) => { + await inTemporaryDirectory(async (tmp) => { + const {store} = mockAppAndStore(tmp) + vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) + const running = await acquireDevInstance({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}) - // The command's error handler renders the AbortError and exits. - await expect(Dev.run(['--path', tmp, '--store', store.shopDomain], import.meta.url)).rejects.toThrow() - await expect( - acquireDevInstance({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}), - ).rejects.toMatchObject({code: DEV_INSTANCE_ALREADY_RUNNING_CODE, details: {instance: running.info}}) + await running.log({source: 'app dev', message: 'Existing session'}) + const existingLog = await readFile(running.paths.logFile) - expect(storeContext).not.toHaveBeenCalled() - expect(dev).not.toHaveBeenCalled() - await running.release() - }) - }) + // The command's error handler renders the AbortError and exits. + await withCapturedStandardStreams(async ({stdout}) => { + await expect( + Dev.run(['--path', tmp, '--store', store.shopDomain, ...(json ? ['--json'] : [])], import.meta.url), + ).rejects.toThrow() + if (json) + expect(JSON.parse(stdout().trim().split('\n').at(-1)!)).toMatchObject({ + error: { + type: 'abort', + code: DEV_INSTANCE_ALREADY_RUNNING_CODE, + details: {instance: running.info}, + }, + }) + }) + await expect( + acquireDevInstance({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}), + ).rejects.toMatchObject({code: DEV_INSTANCE_ALREADY_RUNNING_CODE, details: {instance: running.info}}) + + await expect(readFile(running.paths.logFile)).resolves.toBe(existingLog) + expect(storeContext).not.toHaveBeenCalled() + expect(dev).not.toHaveBeenCalled() + await running.release() + }) + }, + ) }) diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index a32290c0cff..eaea555b1c8 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -1,5 +1,6 @@ import {appFlags} from '../../flags.js' import {dev, DevOptions, withDevSession} from '../../services/dev.js' +import {appDevOutputSchema} from '../../services/dev/schema.js' import {checkFolderIsValidApp} from '../../models/app/loader.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' import {linkedAppContext} from '../../services/app-context.js' @@ -7,7 +8,7 @@ import {storeContext} from '../../services/store-context.js' import {getTunnelMode} from '../../services/dev/tunnel-mode.js' import {Flags} from '@oclif/core' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {globalFlags, portFlag} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag, portFlag} from '@shopify/cli-kit/node/cli' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' export default class Dev extends AppLinkedCommand { @@ -19,11 +20,16 @@ export default class Dev extends AppLinkedCommand { static descriptionWithMarkdown = `Builds and previews your app on a dev store, and watches for changes. [Read more about testing apps locally](https://shopify.dev/docs/apps/build/cli-for-apps/test-apps-locally).` + static get jsonOutputSchema() { + return appDevOutputSchema + } + static description = this.descriptionForHelp() static flags = { ...globalFlags, ...appFlags, + ...jsonFlag, // The selected configuration is used with the app identified by --client-id. 'client-id': Flags.string({ hidden: false, @@ -143,7 +149,7 @@ export default class Dev extends AppLinkedCommand { userProvidedConfigName: flags.config, }) - return withDevSession(appContextResult, async (devInstance) => { + return withDevSession({...appContextResult, json: flags.json}, async (devInstance) => { const store = await storeContext({ appContextResult, storeFqdn: flags.store, diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index ef292d7dc30..edde4c778f2 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -71,7 +71,7 @@ export interface DevOptions { /** Runs app development while the configuration's dev instance is owned. */ export function withDevSession( - {app, remoteApp}: Pick, + {app, remoteApp, json = false}: Pick & {json?: boolean}, execute: (instance: DevInstance) => T | Promise, ): Promise { return withDevInstance( @@ -80,6 +80,7 @@ export function withDevSession( directory: app.directory, key: basename(app.configPath), details: {configPath: app.configPath, appName: remoteApp.title, clientId: remoteApp.apiKey}, + logOutput: json ? process.stdout : undefined, }, execute, ) diff --git a/packages/app/src/cli/services/dev/schema.ts b/packages/app/src/cli/services/dev/schema.ts new file mode 100644 index 00000000000..0641a122ddd --- /dev/null +++ b/packages/app/src/cli/services/dev/schema.ts @@ -0,0 +1,49 @@ +import {devInstanceLogOutputSchema, type DevInstanceInfo} from '@shopify/cli-kit/node/dev-instance' +import {defineJsonOutputSchema, jsonOutputTimestampSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const previewSchema = zod + .object({ + state: zod.enum(['starting', 'updating', 'ready', 'error']), + message: zod.string().nullable(), + errors: zod.array(zod.string()), + }) + .strict() + .describe('The CLI-reported preview state in the instance file; this is not a health check.') + +const instanceSchema = zod + .object({ + schemaVersion: zod.literal(1), + sessionId: zod.string(), + command: zod.literal('app dev'), + key: zod.string().optional(), + pid: zod.number().int().positive(), + status: zod.enum(['starting', 'running']), + startedAt: jsonOutputTimestampSchema, + updatedAt: jsonOutputTimestampSchema, + directory: zod.string(), + logFile: zod.string(), + details: zod + .object({ + configPath: zod.string(), + appName: zod.string(), + clientId: zod.string(), + storeFqdn: zod.string().optional(), + previewUrl: zod.string().optional(), + graphiqlUrl: zod.string().optional(), + appUrl: zod.string().optional(), + localUrl: zod.string().optional(), + preview: previewSchema.optional(), + }) + .strict(), + }) + .strict() + .describe( + 'The instance JSON file under .shopify/dev/. This object is not emitted to stdout.', + ) satisfies zod.ZodType + +export const appDevOutputSchema = defineJsonOutputSchema({ + name: devInstanceLogOutputSchema.name, + schema: devInstanceLogOutputSchema.schema, + definitions: {DevInstance: instanceSchema, DevPreview: previewSchema}, +}) diff --git a/packages/app/src/cli/services/dev/ui.integration.test.ts b/packages/app/src/cli/services/dev/ui.integration.test.ts index 8879ed3ffd7..afba1a32950 100644 --- a/packages/app/src/cli/services/dev/ui.integration.test.ts +++ b/packages/app/src/cli/services/dev/ui.integration.test.ts @@ -39,11 +39,11 @@ test.each(shutdownCases)( }, ) -test.each(shutdownCases)( - '$delivery $signal flushes queued output and exits with background work after the postrun hook', +test.each(shutdownCases.flatMap((entry) => ['text', 'json'].map((mode) => ({...entry, mode}))))( + '$delivery $signal in $mode mode flushes queued output and exits with background work after the postrun hook', // Source loading has its own allowance; the child separately bounds shutdown after the signal. {timeout: 60000}, - async ({signal, delivery}) => { + async ({signal, delivery, mode}) => { await inTemporaryDirectory(async (directory) => { const script = ` await import(${JSON.stringify(bootstrapUrl)}) @@ -52,7 +52,9 @@ test.each(shutdownCases)( const {DevSessionStatusManager} = await import(${JSON.stringify(statusUrl)}) const {hook} = await import(${JSON.stringify(postrunUrl)}) const {spawn} = await import('node:child_process') - await withDevSession({ + const {runWithCommandEvents} = await import('@shopify/cli-kit/node/command-events') + await runWithCommandEvents({outputMode: ${JSON.stringify(mode)}, sink: () => {}}, () => withDevSession({ + json: ${mode === 'json'}, app: {directory: ${JSON.stringify(directory)}, configPath: 'shopify.app.toml'}, remoteApp: {title: 'Local fixture', apiKey: 'fixture'}, }, async (instance) => { @@ -81,10 +83,10 @@ test.each(shutdownCases)( shopFqdn: 'fixture.myshopify.com', devSessionStatusManager: new DevSessionStatusManager(), }) - }) - process.stdout.write('Session cleaned up\\n') + })) + process.stderr.write('Session cleaned up\\n') await hook.call({}, {config: {}, Command: {id: 'app:dev'}}) - process.stdout.write('Postrun completed\\n') + process.stderr.write('Postrun completed\\n') setTimeout(() => { process.stderr.write('Postrun shutdown timed out\\n') process.exit(2) @@ -94,6 +96,7 @@ test.each(shutdownCases)( process.execPath, ['--loader', 'ts-node/esm', '--loader', sourceLoaderUrl, '--input-type=module', '--eval', script], { + stripFinalNewline: false, env: { ...process.env, NODE_NO_WARNINGS: '1', @@ -109,10 +112,12 @@ test.each(shutdownCases)( expect(childPid).toBeGreaterThan(0) await vi.waitFor(() => expect(isProcessAlive(childPid)).toBe(false)) expect(result.exitCode, result.stderr).toBe(0) - expect(result.stdout).toContain('Session cleaned up') - expect(result.stdout).toContain('Postrun completed') + expect(result.stderr).toContain('Session cleaned up') + expect(result.stderr).toContain('Postrun completed') const paths = devInstancePaths({command: 'app dev', directory, key: 'shopify.app.toml'}) - const records = (await readFile(paths.logFile)) + const log = await readFile(paths.logFile) + if (mode === 'json') expect(result.stdout).toBe(log) + const records = log .trim() .split('\n') .map((line) => JSON.parse(line)) diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index 0d35081b638..151e1c21bd7 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -7,6 +7,10 @@ import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-ki import {AbortController} from '@shopify/cli-kit/node/abort' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' import {outputWarn, unstyled} from '@shopify/cli-kit/node/output' +import {runWithCommandEvents} from '@shopify/cli-kit/node/command-events' +import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' +import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' +import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' import {Writable} from 'stream' vi.mock('@shopify/cli-kit/node/system') @@ -61,45 +65,208 @@ describe('ui', () => { }) }) - test('a process failure aborts and waits for the other processes to stop', async () => { - vi.mocked(terminalSupportsPrompting).mockReturnValue(false) - const abortController = new AbortController() - const failure = new Error('Process failed') - let stopped = false - const signalListeners = process.listenerCount('SIGTERM') - const shutdownListeners = process.listenerCount('shopify:signal') - await withCapturedStandardStreams(async () => { - await expect( - renderDev({ - processes: [ - { - prefix: 'failed', - action: async () => { - throw failure + test('streams many JSON processes without exceeding target listener limits and removes listeners on release', async () => { + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: () => {}}, async () => { + const instance = await acquireDevInstance({command: 'app dev', directory, logOutput: process.stdout}) + const abortController = new AbortController() + const targets = new Set() + const sources = Array.from({length: 20}, (_, index) => `process-${index}`) + const running = renderDev({ + processes: sources.map((source) => ({ + prefix: source, + action: async (targetStdout, targetStderr) => { + targets.add(targetStdout) + targets.add(targetStderr) + instance.tee(targetStdout, {source}).write('stdout message\n') + instance.tee(targetStderr, {source}).write('stderr message\n') }, - }, - { - prefix: 'watcher', - action: async (_stdout, _stderr, signal) => { - await new Promise((resolve) => signal.addEventListener('abort', () => resolve(), {once: true})) - await new Promise((resolve) => setTimeout(resolve, 5)) - stopped = true - }, - }, - ], - previewUrl: 'https://example.com', - app: {id: '123', developerPlatformClient}, - abortController, - shopFqdn: 'dev-store.myshopify.com', - devSessionStatusManager: new DevSessionStatusManager(), - }), - ).rejects.toBe(failure) + })), + previewUrl: 'https://example.com', + app: {id: '123', developerPlatformClient}, + abortController, + shopFqdn: 'dev-store.myshopify.com', + devSessionStatusManager: new DevSessionStatusManager(), + }) + try { + for (const target of targets) { + expect(target.listenerCount('error')).toBeLessThanOrEqual(target.getMaxListeners()) + } + } finally { + abortController.abort() + await running + await instance.release() + } + for (const target of targets) expect(target.listenerCount('error')).toBe(0) + expect(stdout()).toBe(await readFile(instance.paths.logFile)) + const events = stdout() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toHaveLength(sources.length * 2) + for (const source of sources) { + expect(events.filter((event) => event.source === source).map((event) => event.message)).toEqual([ + 'stdout message', + 'stderr message', + ]) + } + expect(stderr()).toBe('') + }) + }) + }) + }) + + test('streams session output after setup, under load, and through shutdown with a TTY', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) + vi.mocked(DevSessionUI).mockClear() + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents( + { + outputMode: 'json', + sink: () => {}, + }, + async () => { + const instance = await acquireDevInstance({ + command: 'app dev', + directory, + logOutput: process.stdout, + }) + const abortController = new AbortController() + const completed = vi.fn() + const burstSize = 1000 + let lateOutput: Writable | undefined + const running = renderDev({ + processes: [ + { + prefix: 'web', + action: async (targetStdout, targetStderr) => { + const childStdout = instance.tee(targetStdout, { + source: 'web', + stringWritesAreMessages: true, + }) + const childStderr = instance.tee(targetStderr, { + source: 'web', + stringWritesAreMessages: true, + }) + childStdout.write('Starting without a newline') + useConcurrentOutputContext({outputPrefix: 'discount'}, () => { + childStdout.write('\u001b[32mBuilding function\u001b[0m') + childStdout.write('Building types') + }) + const unicode = Buffer.from('éclair\r\n') + childStderr.write(unicode.subarray(0, 1)) + childStderr.write(unicode.subarray(1)) + for (let index = 0; index < burstSize; index++) childStdout.write(Buffer.from(`line ${index}\n`)) + lateOutput = childStdout + }, + }, + ], + previewUrl: 'https://example.com', + app: {id: '123', developerPlatformClient}, + abortController, + shopFqdn: 'dev-store.myshopify.com', + devSessionStatusManager: new DevSessionStatusManager(), + }).then(completed) + try { + await vi.waitFor(() => expect(stdout()).toContain(`line ${burstSize - 1}`)) + expect(completed).not.toHaveBeenCalled() + lateOutput?.write('After setup') + await vi.waitFor(() => expect(stdout()).toContain('After setup')) + lateOutput?.write(Buffer.from('Last partial line')) + abortController.abort() + await running + } finally { + abortController.abort() + await running + await instance.release() + } + expect(stdout()).toBe(await readFile(instance.paths.logFile)) + const events = stdout() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + events.forEach((event) => { + expect(event).not.toHaveProperty('type') + expect(event).not.toHaveProperty('stream') + }) + expect(events).toHaveLength(burstSize + 6) + const messages = events.map((event) => event.message) + expect(messages).toEqual( + expect.arrayContaining([ + 'Starting without a newline', + 'Building function', + 'Building types', + 'éclair', + 'After setup', + 'Last partial line', + ]), + ) + expect(messages.filter((message) => message.startsWith('line '))).toEqual( + Array.from({length: burstSize}, (_, index) => `line ${index}`), + ) + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({source: 'discount', message: 'Building function'}), + expect.objectContaining({source: 'web', message: 'éclair'}), + ]), + ) + expect(vi.mocked(DevSessionUI)).not.toHaveBeenCalled() + expect(stdout()).not.toContain('\u001b') + expect(stderr()).toBe('') + }, + ) + }) }) - expect(stopped).toBe(true) - expect(process.listenerCount('SIGTERM')).toBe(signalListeners) - expect(process.listenerCount('shopify:signal')).toBe(shutdownListeners) }) + test.each(['text', 'json'] as const)( + 'a %s process failure aborts and waits for the other processes to stop', + async (mode) => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(mode === 'json') + const abortController = new AbortController() + const failure = new Error('Process failed') + let stopped = false + const signalListeners = process.listenerCount('SIGTERM') + const shutdownListeners = process.listenerCount('shopify:signal') + await withCapturedStandardStreams(async () => { + await expect( + runWithCommandEvents({outputMode: mode, sink: () => {}}, () => + renderDev({ + processes: [ + { + prefix: 'failed', + action: async () => { + throw failure + }, + }, + { + prefix: 'watcher', + action: async (_stdout, _stderr, signal) => { + await new Promise((resolve) => + signal.addEventListener('abort', () => resolve(), {once: true}), + ) + await new Promise((resolve) => setTimeout(resolve, 5)) + stopped = true + }, + }, + ], + previewUrl: 'https://example.com', + app: {id: '123', developerPlatformClient}, + abortController, + shopFqdn: 'dev-store.myshopify.com', + devSessionStatusManager: new DevSessionStatusManager(), + }), + ), + ).rejects.toBe(failure) + }) + expect(stopped).toBe(true) + expect(process.listenerCount('SIGTERM')).toBe(signalListeners) + expect(process.listenerCount('shopify:signal')).toBe(shutdownListeners) + }, + ) + test("doesn't use ink when terminal doesn't support TTY", async () => { vi.mocked(terminalSupportsPrompting).mockReturnValue(false) const concurrentProcess = { diff --git a/packages/app/src/cli/services/dev/ui.tsx b/packages/app/src/cli/services/dev/ui.tsx index fb6c709ff24..2d0924e63e1 100644 --- a/packages/app/src/cli/services/dev/ui.tsx +++ b/packages/app/src/cli/services/dev/ui.tsx @@ -7,6 +7,7 @@ import React from 'react' import {render} from '@shopify/cli-kit/node/ui' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' import {waitForPostRunHookAndExit} from '@shopify/cli-kit/node/hooks/postrun' +import {commandEventOutputMode} from '@shopify/cli-kit/node/command-events' import {Writable} from 'stream' interface DevProps { @@ -45,7 +46,7 @@ export async function renderDev({ const stop = () => abortController.abort() process.on('shopify:signal', stop) try { - if (terminalSupportsPrompting()) { + if (commandEventOutputMode() !== 'json' && terminalSupportsPrompting()) { return await render( ) { - process.stdout.write(`\nPreview URL: ${previewUrl}\n`) - if (graphiqlUrl) { - process.stdout.write(`GraphiQL URL (Admin API): ${graphiqlUrl}\n`) + const json = commandEventOutputMode() === 'json' + if (!json) { + process.stdout.write(`\nPreview URL: ${previewUrl}\n`) + if (graphiqlUrl) process.stdout.write(`GraphiQL URL (Admin API): ${graphiqlUrl}\n`) } const {signal} = abortController @@ -100,9 +102,16 @@ async function renderDevNonInteractive({ signal.addEventListener('abort', onAbort, {once: true}) if (signal.aborted) resolve() }) - const running = processes.map(async (concurrentProcess) => - concurrentProcess.action(createTextOutputStream(process.stdout), createTextOutputStream(process.stderr), signal), - ) + // JSON events come from the session log, including output written after a watcher finishes setup. + const running = processes.map(async (concurrentProcess) => { + // Tees attach error listeners, so each process needs its own discard stream. + const terminalOutput = new Writable({write: (_chunk, _encoding, next) => next()}) + return concurrentProcess.action( + json ? terminalOutput : createTextOutputStream(process.stdout), + json ? terminalOutput : createTextOutputStream(process.stderr), + signal, + ) + }) try { // A watcher can finish setup while it continues serving. Keep the session registered until abort. await Promise.all([...running, stopped]) diff --git a/packages/cli-kit/src/private/node/command-json-schema.ts b/packages/cli-kit/src/private/node/command-json-schema.ts index 6a09a8a8884..e508581e1df 100644 --- a/packages/cli-kit/src/private/node/command-json-schema.ts +++ b/packages/cli-kit/src/private/node/command-json-schema.ts @@ -34,6 +34,7 @@ export async function printCommandJsonSchema( name: 'CommandOutput', schema: zod.union([outputSchema.schema, jsonErrorOutputSchema.schema, commandEventOutputSchema.schema]), definitions: { + ...outputSchema.definitions, Result: outputSchema.schema, Error: jsonErrorOutputSchema.schema, Event: commandEventOutputSchema.schema, diff --git a/packages/cli/README.md b/packages/cli/README.md index 876b1cee62e..c59bf75e11d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1885,7 +1885,7 @@ Run the app. ``` USAGE $ shopify app dev [--auth-alias ] [--checkout-cart-url ] [--client-id ] - [--install-mkcert --use-localhost] [--json-schema] [--localhost-port ] [--no-color] [--no-input] + [--install-mkcert --use-localhost] [-j] [--json-schema] [--localhost-port ] [--no-color] [--no-input] [--no-update] [--notify ] [--path ] [--reset | -c ] [--skip-dependencies-installation] [-s ] [--store-password ] [--subscription-product-url ] [-t ] [--theme-app-extension-port ] [--tunnel-url | ] [--unsafe-validation] [--verbose] @@ -1895,6 +1895,10 @@ FLAGS 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] + -s, --store= Store URL. Must be an existing development or Shopify Plus sandbox store. [env: SHOPIFY_FLAG_STORE] @@ -1991,6 +1995,178 @@ DESCRIPTION Builds and previews your app on a dev store, and watches for changes. "Read more about testing apps locally" (https://shopify.dev/docs/apps/build/cli-for-apps/test-apps-locally). + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `DevInstanceLogEntry` schema. + + ```json + { + "type": "object", + "properties": { + "timestamp": { + "$ref": "#/definitions/DevInstance/properties/startedAt" + }, + "source": { + "type": "string", + "description": "What produced the line, for example a process prefix like web or extensions." + }, + "level": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "timestamp", + "source", + "message" + ], + "additionalProperties": false, + "title": "DevInstanceLogEntry", + "definitions": { + "DevInstance": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "number", + "const": 1 + }, + "sessionId": { + "type": "string" + }, + "command": { + "type": "string", + "const": "app dev" + }, + "key": { + "type": "string" + }, + "pid": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "status": { + "type": "string", + "enum": [ + "starting", + "running" + ] + }, + "startedAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "updatedAt": { + "$ref": "#/definitions/DevInstance/properties/startedAt" + }, + "directory": { + "type": "string" + }, + "logFile": { + "type": "string" + }, + "details": { + "type": "object", + "properties": { + "configPath": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "storeFqdn": { + "type": "string" + }, + "previewUrl": { + "type": "string" + }, + "graphiqlUrl": { + "type": "string" + }, + "appUrl": { + "type": "string" + }, + "localUrl": { + "type": "string" + }, + "preview": { + "$ref": "#/definitions/DevPreview", + "description": "The CLI-reported preview state in the instance file; this is not a health check." + } + }, + "required": [ + "configPath", + "appName", + "clientId" + ], + "additionalProperties": false + } + }, + "required": [ + "schemaVersion", + "sessionId", + "command", + "pid", + "status", + "startedAt", + "updatedAt", + "directory", + "logFile", + "details" + ], + "additionalProperties": false, + "description": "The instance JSON file under .shopify/dev/. This object is not emitted to stdout." + }, + "DevPreview": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "starting", + "updating", + "ready", + "error" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "state", + "message", + "errors" + ], + "additionalProperties": false, + "description": "The CLI-reported preview state in the instance file; this is not a health check." + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app dev clean` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 7e272930ac8..d9833c0a556 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1191,7 +1191,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Builds and previews your app on a dev store, and watches for changes. \"Read more about testing apps locally\" (https://shopify.dev/docs/apps/build/cli-for-apps/test-apps-locally).", + "description": "Builds and previews your app on a dev store, and watches for changes. \"Read more about testing apps locally\" (https://shopify.dev/docs/apps/build/cli-for-apps/test-apps-locally).\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `DevInstanceLogEntry` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"timestamp\": {\n \"$ref\": \"#/definitions/DevInstance/properties/startedAt\"\n },\n \"source\": {\n \"type\": \"string\",\n \"description\": \"What produced the line, for example a process prefix like web or extensions.\"\n },\n \"level\": {\n \"type\": \"string\",\n \"enum\": [\n \"debug\",\n \"info\",\n \"warning\",\n \"error\"\n ]\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"source\",\n \"message\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DevInstanceLogEntry\",\n \"definitions\": {\n \"DevInstance\": {\n \"type\": \"object\",\n \"properties\": {\n \"schemaVersion\": {\n \"type\": \"number\",\n \"const\": 1\n },\n \"sessionId\": {\n \"type\": \"string\"\n },\n \"command\": {\n \"type\": \"string\",\n \"const\": \"app dev\"\n },\n \"key\": {\n \"type\": \"string\"\n },\n \"pid\": {\n \"type\": \"integer\",\n \"exclusiveMinimum\": 0\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"starting\",\n \"running\"\n ]\n },\n \"startedAt\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z$\",\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"updatedAt\": {\n \"$ref\": \"#/definitions/DevInstance/properties/startedAt\"\n },\n \"directory\": {\n \"type\": \"string\"\n },\n \"logFile\": {\n \"type\": \"string\"\n },\n \"details\": {\n \"type\": \"object\",\n \"properties\": {\n \"configPath\": {\n \"type\": \"string\"\n },\n \"appName\": {\n \"type\": \"string\"\n },\n \"clientId\": {\n \"type\": \"string\"\n },\n \"storeFqdn\": {\n \"type\": \"string\"\n },\n \"previewUrl\": {\n \"type\": \"string\"\n },\n \"graphiqlUrl\": {\n \"type\": \"string\"\n },\n \"appUrl\": {\n \"type\": \"string\"\n },\n \"localUrl\": {\n \"type\": \"string\"\n },\n \"preview\": {\n \"$ref\": \"#/definitions/DevPreview\",\n \"description\": \"The CLI-reported preview state in the instance file; this is not a health check.\"\n }\n },\n \"required\": [\n \"configPath\",\n \"appName\",\n \"clientId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"schemaVersion\",\n \"sessionId\",\n \"command\",\n \"pid\",\n \"status\",\n \"startedAt\",\n \"updatedAt\",\n \"directory\",\n \"logFile\",\n \"details\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The instance JSON file under .shopify/dev/. This object is not emitted to stdout.\"\n },\n \"DevPreview\": {\n \"type\": \"object\",\n \"properties\": {\n \"state\": {\n \"type\": \"string\",\n \"enum\": [\n \"starting\",\n \"updating\",\n \"ready\",\n \"error\"\n ]\n },\n \"message\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"errors\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"state\",\n \"message\",\n \"errors\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The CLI-reported preview state in the instance file; this is not a health check.\"\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Builds and previews your app on a dev store, and watches for changes. [Read more about testing apps locally](https://shopify.dev/docs/apps/build/cli-for-apps/test-apps-locally).", "flags": { "auth-alias": { @@ -1257,6 +1257,15 @@ "name": "install-mkcert", "type": "boolean" }, + "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/cli/src/cli/commands/doc/fetch.test.ts b/packages/cli/src/cli/commands/doc/fetch.test.ts index 9af4eab987d..3f4fa17bd24 100644 --- a/packages/cli/src/cli/commands/doc/fetch.test.ts +++ b/packages/cli/src/cli/commands/doc/fetch.test.ts @@ -156,8 +156,10 @@ describe('doc fetch command', () => { }) const schema = JSON.parse(stdout()) expect(schema.definitions.Result.anyOf).toHaveLength(2) - expect(schema.definitions.Result.anyOf[0].properties.document.properties.content.type).toBe('string') - expect(schema.definitions.Result.anyOf[1].properties.format.const).toBe('markdown') + expect(schema.definitions.Result.anyOf[0].properties.document).toEqual({$ref: '#/definitions/Document'}) + expect(schema.definitions.Document.properties.content.type).toBe('string') + expect(schema.definitions.Result.anyOf[1]).toEqual({$ref: '#/definitions/DocumentFile'}) + expect(schema.definitions.DocumentFile.properties.format.const).toBe('markdown') expect(stderr()).toBe('') expect(fetch).not.toHaveBeenCalled() }) diff --git a/packages/cli/src/cli/commands/doc/search.test.ts b/packages/cli/src/cli/commands/doc/search.test.ts index 3916c514059..07f53d2a4fa 100644 --- a/packages/cli/src/cli/commands/doc/search.test.ts +++ b/packages/cli/src/cli/commands/doc/search.test.ts @@ -177,7 +177,10 @@ describe('doc search command', () => { }) const schema = JSON.parse(stdout()) expect(schema.definitions.Result.required).toEqual(['results', 'pageInfo']) - expect(schema.definitions.Result.properties.results.items.properties.domain.type).toEqual(['string', 'null']) + expect(schema.definitions.Result.properties.results.items).toEqual({ + $ref: '#/definitions/DocumentationSearchEntry', + }) + expect(schema.definitions.DocumentationSearchEntry.properties.domain.type).toEqual(['string', 'null']) expect(schema.definitions.Result.additionalProperties).toBe(false) expect(stderr()).toBe('') expect(shopifyFetch).not.toHaveBeenCalled()