From 94eccfbbc88e54b6ec319d21e1f1e015ef4a22b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Mon, 19 Oct 2026 17:40:12 +0200 Subject: [PATCH 01/23] Expose app dev session files for agents --- .changeset/app-dev-instance-lock.md | 5 + docs/README.md | 1 + docs/cli/app-dev-agent-workflow.md | 46 +++++++++ packages/app/src/cli/commands/app/dev.test.ts | 40 ++++++++ packages/app/src/cli/commands/app/dev.ts | 73 +++++++++----- packages/app/src/cli/services/dev.test.ts | 99 +++++++++++++++++++ packages/app/src/cli/services/dev.ts | 77 +++++++++++---- .../dev-session/dev-session-logger.test.ts | 3 +- .../dev-session/dev-session-logger.ts | 2 +- .../dev-session-status-manager.test.ts | 9 ++ .../dev-session/dev-session-status-manager.ts | 7 +- .../dev/processes/dev-session/dev-session.ts | 19 +++- packages/app/src/cli/services/dev/ui.test.tsx | 17 +++- packages/app/src/cli/services/dev/ui.tsx | 28 +++++- 14 files changed, 367 insertions(+), 59 deletions(-) create mode 100644 .changeset/app-dev-instance-lock.md create mode 100644 docs/cli/app-dev-agent-workflow.md diff --git a/.changeset/app-dev-instance-lock.md b/.changeset/app-dev-instance-lock.md new file mode 100644 index 00000000000..e4f8610153c --- /dev/null +++ b/.changeset/app-dev-instance-lock.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Record `app dev` sessions, preview status, and JSONL logs so agents can reuse a running session. diff --git a/docs/README.md b/docs/README.md index 34222272eb1..d3dcd34d424 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ The list below contains valuable resources for people interested in contributing * [Designing for CLI](./cli/designing-for-cli.md) * [Conventions](./cli/conventions.md) * [JSON output contracts](./cli/json-output.md) +* [App dev session files](./cli/app-dev-agent-workflow.md) * [Performance](./cli/performance.md) * [Debugging](./cli/debugging.md) * [ESLint rules](./cli/eslint-rules.md) diff --git a/docs/cli/app-dev-agent-workflow.md b/docs/cli/app-dev-agent-workflow.md new file mode 100644 index 00000000000..d2fc10575a9 --- /dev/null +++ b/docs/cli/app-dev-agent-workflow.md @@ -0,0 +1,46 @@ +# App dev session files + +`shopify app dev` writes one instance JSON file and one JSONL log under +`/.shopify/dev/` for each app configuration. Agents can read these files when +dev was started in another terminal. There is no IPC endpoint or extra command. + +The instance JSON includes the PID, `sessionId`, URLs, `logFile`, and +`details.preview`. The preview contains: + +- `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. + +This is a snapshot of the CLI's reported state, not a health check or proof that a +specific edit has reached the store. Check new output and test the behaviour before +claiming success. Web-server and browser health require their own checks. + +Each complete JSONL line contains `sessionId`, `timestamp`, `source`, and `message`, +with `stream` or `level` where available. Keep a byte offset to read new lines. +Wait for an incomplete final line to finish. If the session ID changes, reset the +offset: each new run clears the log. Logs have no size limit or rotation, so a long +session can produce a large file. + +## Agent workflow + +1. Find the instance file for the intended app configuration and check its app and + store details. Check the PID and lock heartbeat; a saved file alone does not prove + the process is alive. A live PID blocks takeover even after the fixed 30-second + stale interval. Stop an unresponsive owner before starting another instance. +2. Reuse an existing session. If none is available, run `shopify app dev` through + the agent's background terminal facility. Supply the app, configuration, and + store flags and use `--no-input` when setup and authentication are available. +3. If a second start reports `dev_instance_already_running`, read the reported + instance and log. Do not stop a session started by the developer. +4. Make the change, read new output, inspect `details.preview`, and test the result + in the development store. Use a bounded wait and report errors or timeouts. + +Normal shutdown removes the instance record and retains the log until the next run. +Crashes after startup recover automatically on the next start. If the CLI is killed during +the short lock-acquisition step, a `.lock.acquire` directory can remain. The next start +reports its path. Confirm that no other startup is running before removing that +directory and trying again. + +A crash can leave stale metadata or an incomplete log. Keep these local files out +of commits and external reports; application output and local URLs can contain secrets. diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index 098e329f753..e9c6aaede13 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -15,6 +15,11 @@ import { testProject, } from '../../models/app/app.test-data.js' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import { + acquireDevInstance, + DEV_INSTANCE_ALREADY_RUNNING_CODE, + readDevInstance, +} from '@shopify/cli-kit/node/dev-instance' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' import {beforeEach, describe, expect, test, vi} from 'vitest' @@ -160,4 +165,39 @@ describe('app dev command', () => { expect(devClean).not.toHaveBeenCalled() }) }) + test('records the running instance while dev runs and releases it afterwards', async () => { + await inTemporaryDirectory(async (tmp) => { + const {store} = mockAppAndStore(tmp) + vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) + const identity = {command: 'app dev', directory: tmp, key: 'shopify.app.toml'} + let instanceWhileRunning: Awaited> + vi.mocked(dev).mockImplementation(async () => { + instanceWhileRunning = await readDevInstance(identity) + }) + + await Dev.run(['--path', tmp, '--store', store.shopDomain], import.meta.url) + + expect(instanceWhileRunning).toMatchObject({command: 'app dev', key: 'shopify.app.toml', pid: process.pid}) + expect(dev).toHaveBeenCalledWith(expect.objectContaining({devInstance: expect.any(Object)})) + await expect(readDevInstance(identity)).resolves.toBeUndefined() + }) + }) + + 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'}) + + // 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}}) + + 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 6fe12af0385..71380f3b005 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -9,6 +9,8 @@ import {Flags} from '@oclif/core' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {globalFlags, portFlag} from '@shopify/cli-kit/node/cli' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' +import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' +import {basename} from '@shopify/cli-kit/node/path' export default class Dev extends AppLinkedCommand { public static get requiresSyncAnalytics(): boolean { @@ -142,35 +144,54 @@ export default class Dev extends AppLinkedCommand { forceRelink: flags.reset, userProvidedConfigName: flags.config, }) - const store = await storeContext({ - appContextResult, - storeFqdn: flags.store, - forceReselectStore: flags.reset, - storeCreationMode: 'selection-option', + + // One `app dev` per app configuration: a second one reports the running instance and exits, + // so agents can discover a session started in another terminal and read its log. + const devInstance = await acquireDevInstance({ + command: 'app dev', + directory: appContextResult.app.directory, + key: basename(appContextResult.app.configPath), + details: { + configPath: appContextResult.app.configPath, + appName: appContextResult.remoteApp.title, + clientId: appContextResult.remoteApp.apiKey, + }, }) - const devOptions: DevOptions = { - ...appContextResult, - store, - directory: flags.path, - update: !flags['no-update'], - unsafeValidation: flags['unsafe-validation'], - skipDependenciesInstallation: flags['skip-dependencies-installation'], - commandConfig: this.config, - subscriptionProductUrl: flags['subscription-product-url'], - checkoutCartUrl: flags['checkout-cart-url'], - theme: flags.theme, - themeExtensionPort: flags['theme-app-extension-port'], - storePassword: flags['store-password'], - notify: flags.notify, - graphiqlPort: flags['graphiql-port'], - graphiqlKey: flags['graphiql-key'], - // Must stay undefined when the flag is absent: generateCertificate() only prompts when this is nullish. - installMkcert: flags['install-mkcert'], - tunnel: tunnelMode, - } + try { + const store = await storeContext({ + appContextResult, + storeFqdn: flags.store, + forceReselectStore: flags.reset, + storeCreationMode: 'selection-option', + }) - await dev(devOptions) + const devOptions: DevOptions = { + ...appContextResult, + store, + directory: flags.path, + update: !flags['no-update'], + unsafeValidation: flags['unsafe-validation'], + skipDependenciesInstallation: flags['skip-dependencies-installation'], + commandConfig: this.config, + subscriptionProductUrl: flags['subscription-product-url'], + checkoutCartUrl: flags['checkout-cart-url'], + theme: flags.theme, + themeExtensionPort: flags['theme-app-extension-port'], + storePassword: flags['store-password'], + notify: flags.notify, + graphiqlPort: flags['graphiql-port'], + graphiqlKey: flags['graphiql-key'], + // Must stay undefined when the flag is absent: generateCertificate() only prompts when this is nullish. + installMkcert: flags['install-mkcert'], + tunnel: tunnelMode, + devInstance, + } + + await dev(devOptions) + } finally { + await devInstance.release() + } return {app: appContextResult.app} } } diff --git a/packages/app/src/cli/services/dev.test.ts b/packages/app/src/cli/services/dev.test.ts index 119576f884d..dd8ceaaabb2 100644 --- a/packages/app/src/cli/services/dev.test.ts +++ b/packages/app/src/cli/services/dev.test.ts @@ -2,6 +2,7 @@ import {dev, blockIfMigrationIncomplete} from './dev.js' import {setupDevProcesses} from './dev/processes/setup-dev-processes.js' import {renderDev} from './dev/ui.js' import {fetchAppRemoteConfiguration} from './app/select-app.js' +import {DevSessionStatusManager} from './dev/processes/dev-session/dev-session-status-manager.js' import { testAppLinked, testDeveloperPlatformClient, @@ -15,7 +16,11 @@ import {describe, expect, test, vi} from 'vitest' import {hashString} from '@shopify/cli-kit/node/crypto' import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' +import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' +import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' +import {Writable} from 'stream' +vi.mock('./local-storage.js') vi.mock('./dev/fetch.js') vi.mock('./dev/processes/setup-dev-processes.js') vi.mock('./dev/ui.js') @@ -86,6 +91,100 @@ describe('dev', () => { }) }) +describe('dev with a dev instance', () => { + test.each([false, true])('captures output and session URLs, stopping on lock loss: %s', async (loseLock) => { + await inTemporaryDirectory(async (tmp) => { + const store = testOrganizationStore({shopDomain: 'dev-store.myshopify.com'}) + const app = testAppLinked({directory: tmp}) + const manager = new DevSessionStatusManager() + const devInstance = await acquireDevInstance({command: 'app dev', directory: tmp}) + const ownership = new AbortController() + const previewUrl = 'https://dev-store.myshopify.com/admin/apps/api-key' + let infoWhileRunning: unknown + + vi.mocked(fetchAppRemoteConfiguration).mockResolvedValue({ + name: 'Remote app', + application_url: '', + embedded: true, + }) + vi.mocked(getAvailableTCPPort).mockResolvedValue(3456) + vi.mocked(checkPortAvailability).mockResolvedValue(true) + vi.mocked(setupDevProcesses).mockResolvedValue({ + processes: [ + { + type: 'web', + prefix: 'web', + function: async ({stdout, stderr}: {stdout: Writable; stderr: Writable}) => { + stdout.write('Server listening on 3000\n') + stderr.write('Deprecation warning\n') + }, + options: {}, + } as any, + ], + previewUrl, + graphiqlUrl: undefined, + devSessionStatusManager: manager, + }) + const terminal = new Writable({write: (_chunk, _encoding, callback) => callback()}) + vi.mocked(renderDev).mockImplementation(async ({processes, abortController}) => { + manager.updateStatus({isReady: true}) + manager.setMessage('BUILD_ERROR', ['discount: Invalid input']) + await vi.waitFor(async () => { + infoWhileRunning = JSON.parse(await readFile(devInstance.paths.infoFile)) + expect(infoWhileRunning).toHaveProperty('details.preview.errors', ['discount: Invalid input']) + }) + await Promise.all(processes.map((process) => process.action(terminal, terminal, new AbortController().signal))) + if (loseLock) { + ownership.abort(new Error('Lock lost')) + expect(abortController.signal.aborted).toBe(true) + } + }) + + const running = dev({ + app, + project: testProject({usesWorkspaces: true}), + remoteApp: testOrganizationApp({apiKey: 'api-key'}), + organization: testOrganization(), + specifications: [], + developerPlatformClient: testDeveloperPlatformClient(), + store, + directory: app.directory, + update: false, + commandConfig: {} as any, + skipDependenciesInstallation: true, + tunnel: {mode: 'custom', url: 'https://localhost:3456'}, + devInstance: {...devInstance, abortSignal: ownership.signal}, + }) + if (loseLock) { + await expect(running).rejects.toThrow('Lock lost') + } else { + await running + } + await devInstance.release() + + expect(infoWhileRunning).toMatchObject({ + status: 'running', + details: { + storeFqdn: 'dev-store.myshopify.com', + previewUrl, + preview: {state: 'error', errors: ['discount: Invalid input']}, + }, + }) + const entries = (await readFile(devInstance.paths.logFile)) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({source: 'app dev', message: `Preview URL: ${previewUrl}`}), + expect.objectContaining({source: 'web', stream: 'stdout', message: 'Server listening on 3000'}), + expect.objectContaining({source: 'web', stream: 'stderr', message: 'Deprecation warning'}), + ]), + ) + }) + }) +}) + describe('blockIfMigrationIncomplete', () => { const baseConfig = () => ({ localApp: testAppLinked({}), diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index e5a81c4ad65..ba0e56c40e7 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -15,7 +15,7 @@ import {renderDev} from './dev/ui.js' import {DevProcessFunction} from './dev/processes/types.js' import {getCachedAppInfo, setCachedAppInfo} from './local-storage.js' import {fetchAppRemoteConfiguration} from './app/select-app.js' -import {DevSessionStatusManager} from './dev/processes/dev-session/dev-session-status-manager.js' +import {DevSessionStatus, DevSessionStatusManager} from './dev/processes/dev-session/dev-session-status-manager.js' import {TunnelMode} from './dev/tunnel-mode.js' import {PortDetail, renderPortWarnings} from './dev/port-warnings.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' @@ -36,9 +36,10 @@ import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/ import {TunnelClient} from '@shopify/cli-kit/node/plugins/tunnel' import {getBackendPort} from '@shopify/cli-kit/node/environment' import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' -import {OutputProcess} from '@shopify/cli-kit/node/output' +import {OutputProcess, outputDebug} from '@shopify/cli-kit/node/output' import {hashString} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' +import {DevInstance} from '@shopify/cli-kit/node/dev-instance' export interface DevOptions { app: AppLinkedInterface @@ -63,6 +64,8 @@ export interface DevOptions { graphiqlPort?: number graphiqlKey?: string installMkcert?: boolean + /** The running-instance record for this session: its log receives every process's output. */ + devInstance?: DevInstance } export async function dev(commandOptions: DevOptions) { @@ -328,37 +331,77 @@ async function launchDevProcesses({ devSessionStatusManager: DevSessionStatusManager }) { const abortController = new AbortController() + const devInstance = config.commandOptions.devInstance const processesForTaskRunner: OutputProcess[] = processes.map((process) => { const outputProcess: OutputProcess = { prefix: process.prefix, action: async (stdout, stderr, signal) => { const fn = process.function as DevProcessFunction - return fn({stdout, stderr, abortSignal: signal}, process.options) + const processStdout = devInstance ? devInstance.tee(stdout, {source: process.prefix, stream: 'stdout'}) : stdout + const processStderr = devInstance ? devInstance.tee(stderr, {source: process.prefix, stream: 'stderr'}) : stderr + return fn({stdout: processStdout, stderr: processStderr, abortSignal: signal}, process.options) }, } return outputProcess }) + if (devInstance) { + const urls = { + storeFqdn: config.storeFqdn, + previewUrl, + graphiqlUrl, + appUrl: config.localApp.devApplicationURLs?.applicationUrl, + localUrl: config.network.proxyUrl, + } + await devInstance.update({ + status: 'running', + details: {...urls, preview: previewStatus(devSessionStatusManager.status)}, + }) + await devInstance.log({source: 'app dev', level: 'info', message: `Preview URL: ${previewUrl}`}) + } + const developerPlatformClient = config.developerPlatformClient const app = { id: config.remoteApp.id, developerPlatformClient, } - return renderDev({ - processes: processesForTaskRunner, - previewUrl, - graphiqlUrl, - app, - abortController, - shopFqdn: config.storeFqdn, - devSessionStatusManager, - appURL: config.localApp.devApplicationURLs?.applicationUrl, - appName: config.remoteApp.title, - organizationName: config.commandOptions.organization.businessName, - configPath: config.localApp.configPath, - localURL: config.network.proxyUrl, - }) + const persistStatus = (status: DevSessionStatus) => { + devInstance?.update({details: {preview: previewStatus(status)}}).catch((error: Error) => { + outputDebug(`Failed to save dev status: ${error.message}`) + }) + } + if (devInstance) devSessionStatusManager.on('dev-session-update', persistStatus) + const stopOnLostLock = () => abortController.abort(devInstance?.abortSignal.reason) + try { + devInstance?.abortSignal.throwIfAborted() + devInstance?.abortSignal.addEventListener('abort', stopOnLostLock, {once: true}) + await renderDev({ + processes: processesForTaskRunner, + previewUrl, + graphiqlUrl, + app, + abortController, + shopFqdn: config.storeFqdn, + devSessionStatusManager, + appURL: config.localApp.devApplicationURLs?.applicationUrl, + appName: config.remoteApp.title, + organizationName: config.commandOptions.organization.businessName, + configPath: config.localApp.configPath, + localURL: config.network.proxyUrl, + }) + devInstance?.abortSignal.throwIfAborted() + } finally { + devInstance?.abortSignal.removeEventListener('abort', stopOnLostLock) + if (devInstance) devSessionStatusManager.off('dev-session-update', persistStatus) + } +} + +function previewStatus({isReady, statusMessage, errors}: DevSessionStatus) { + let state = isReady ? 'ready' : 'starting' + if (isReady && statusMessage?.type === 'loading') state = 'updating' + if (statusMessage?.type === 'error') state = 'error' + return {state, message: statusMessage?.message ?? null, errors: errors ?? []} } async function logMetadataForDev(options: { diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-logger.test.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-logger.test.ts index f00878f1a63..66dce20eb7e 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-logger.test.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-logger.test.ts @@ -44,7 +44,8 @@ describe('DevSessionLogger', () => { output = [] stdout = { write: (message: string) => { - output.push(message) + expect(message.endsWith('\n')).toBe(true) + output.push(message.slice(0, -1)) return true }, } as unknown as Writable diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-logger.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-logger.ts index 93bed1786ff..b997deb39c6 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-logger.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-logger.ts @@ -112,7 +112,7 @@ export class DevSessionLogger { // Helper function to print to terminal using output context with stripAnsi disabled. private async log(message: string, prefix?: string) { await useConcurrentOutputContext({outputPrefix: prefix ?? 'app-preview', stripAnsi: false}, () => { - this.stdout.write(message) + this.stdout.write(`${message}\n`) }) } } diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-status-manager.test.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-status-manager.test.ts index 0b577f0bcaf..532dc433296 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-status-manager.test.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-status-manager.test.ts @@ -140,3 +140,12 @@ describe('DevSessionStatusManager', () => { }) }) }) + +test('keeps current errors during an update and clears them after success', () => { + const manager = new DevSessionStatusManager() + manager.setMessage('BUILD_ERROR', ['discount: Invalid input']) + manager.setMessage('CHANGE_DETECTED') + expect(manager.status.errors).toEqual(['discount: Invalid input']) + manager.setMessage('UPDATED') + expect(manager.status.errors).toEqual([]) +}) diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-status-manager.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-status-manager.ts index 7d41b0d3f7d..23ac28baec6 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-status-manager.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-status-manager.ts @@ -15,6 +15,7 @@ const DevSessionStaticMessages = { export interface DevSessionStatus { isReady: boolean + errors?: string[] previewURL?: string graphiqlURL?: string appEmbedded?: boolean @@ -44,8 +45,10 @@ export class DevSessionStatusManager extends EventEmitter { this.emit('dev-session-update', newStatus) } - setMessage(message: keyof typeof DevSessionStaticMessages) { - this.updateStatus({statusMessage: DevSessionStaticMessages[message]}) + setMessage(message: keyof typeof DevSessionStaticMessages, errors?: string[]) { + const statusMessage = DevSessionStaticMessages[message] + const nextErrors = statusMessage.type === 'success' ? [] : errors + this.updateStatus({statusMessage, ...(nextErrors ? {errors: nextErrors} : {})}) } get status(): DevSessionStatus { diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts index d799ddf127f..0a9dbc318f7 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts @@ -162,6 +162,10 @@ export class DevSession { private async onStart(event: AppEvent) { const errors = this.parseBuildErrors(event) if (errors.length) { + this.statusManager.setMessage( + 'BUILD_ERROR', + errors.map(({prefix, error}) => `${prefix}: ${error}`), + ) await this.logger.logMultipleErrors(errors) await setImmediate(() => { const affected = errors.map((err) => err.prefix) @@ -193,7 +197,10 @@ export class DevSession { const errors = this.parseBuildErrors(event) if (errors.length) { await this.logger.logMultipleErrors(errors) - this.statusManager.setMessage('BUILD_ERROR') + this.statusManager.setMessage( + 'BUILD_ERROR', + errors.map(({prefix, error}) => `${prefix}: ${error}`), + ) return false } @@ -215,7 +222,10 @@ export class DevSession { private parseBuildErrors(event: AppEvent) { const buildErrors = event.extensionEvents.filter((eve) => eve.buildResult?.status === 'error') return buildErrors.map((error) => ({ - error: 'Build error. Please review your code and try again.', + error: + error.buildResult?.status === 'error' + ? error.buildResult.error || 'Build error. Please review your code and try again.' + : 'Build error. Please review your code and try again.', prefix: error.extension.handle, })) } @@ -237,12 +247,13 @@ export class DevSession { await this.logger.logExtensionUpdateMessages(event, result) this.statusManager.setMessage('READY') } else if (result.status === 'remote-error' || result.status === 'unknown-error') { + const errors = result.error instanceof Error ? [result.error.message] : result.error.map((error) => error.message) await this.logger.logUserErrors(result.error, event?.app.allExtensions ?? []) if (result.error instanceof Error && (result.error as Error & {cause?: string}).cause === 'validation-error') { - this.statusManager.setMessage('VALIDATION_ERROR') + this.statusManager.setMessage('VALIDATION_ERROR', errors) } else { if (event) this.failedEvents.push(event) - this.statusManager.setMessage('REMOTE_ERROR') + this.statusManager.setMessage('REMOTE_ERROR', errors) } } diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index ad6e27c29e8..b9134ebc410 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -27,7 +27,8 @@ describe('ui', () => { } const abortController = new AbortController() - await renderDev({ + const completed = vi.fn() + const running = renderDev({ processes: [concurrentProcess], previewUrl: 'https://lala.cloudflare.io/', graphiqlUrl: 'https://lala.cloudflare.io/graphiql', @@ -35,7 +36,12 @@ describe('ui', () => { abortController, shopFqdn: 'mystore.shopify.io', devSessionStatusManager, - }) + }).then(completed) + await Promise.resolve() + await Promise.resolve() + expect(completed).not.toHaveBeenCalled() + abortController.abort() + await running expect(concurrentProcess.action).toHaveBeenNthCalledWith( 1, @@ -48,9 +54,12 @@ describe('ui', () => { test("shows preview and GraphiQL URLs when terminal doesn't support TTY", async () => { vi.mocked(terminalSupportsPrompting).mockReturnValue(false) const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const abortController = new AbortController() const concurrentProcess = { prefix: 'prefix', - action: vi.fn(async (_stdout, _stderr, _signal) => {}), + action: vi.fn(async () => { + abortController.abort() + }), } await renderDev({ @@ -58,7 +67,7 @@ describe('ui', () => { previewUrl: 'https://lala.cloudflare.io/', graphiqlUrl: 'https://lala.cloudflare.io/graphiql', app: {id: '123', developerPlatformClient}, - abortController: new AbortController(), + abortController, shopFqdn: 'mystore.shopify.io', devSessionStatusManager, }) diff --git a/packages/app/src/cli/services/dev/ui.tsx b/packages/app/src/cli/services/dev/ui.tsx index 9eb23042433..3c3b207732e 100644 --- a/packages/app/src/cli/services/dev/ui.tsx +++ b/packages/app/src/cli/services/dev/ui.tsx @@ -81,9 +81,29 @@ async function renderDevNonInteractive({ process.stdout.write(`GraphiQL URL (Admin API): ${graphiqlUrl}\n`) } - return Promise.all( - processes.map(async (concurrentProcess) => { - await concurrentProcess.action(process.stdout, process.stderr, abortController.signal) - }), + const {signal} = abortController + const stop = () => abortController.abort() + process.once('SIGINT', stop) + process.once('SIGTERM', stop) + let onAbort: () => void = () => {} + const stopped = new Promise((resolve) => { + onAbort = resolve + signal.addEventListener('abort', onAbort, {once: true}) + if (signal.aborted) resolve() + }) + const running = processes.map(async (concurrentProcess) => + concurrentProcess.action(process.stdout, process.stderr, signal), ) + try { + // A watcher can finish setup while it continues serving. Keep the session registered until abort. + await Promise.all([...running, stopped]) + } catch (error) { + if (!signal.aborted) throw error + } finally { + abortController.abort() + await Promise.allSettled(running) + signal.removeEventListener('abort', onAbort) + process.removeListener('SIGINT', stop) + process.removeListener('SIGTERM', stop) + } } From 42ae66e6aa9f2cbf5c350194870c8c14cde2857e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Mon, 19 Oct 2026 17:59:13 +0200 Subject: [PATCH 02/23] Document session identity in the dev status file --- docs/cli/app-dev-agent-workflow.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cli/app-dev-agent-workflow.md b/docs/cli/app-dev-agent-workflow.md index d2fc10575a9..3d75a799194 100644 --- a/docs/cli/app-dev-agent-workflow.md +++ b/docs/cli/app-dev-agent-workflow.md @@ -16,11 +16,11 @@ This is a snapshot of the CLI's reported state, not a health check or proof that specific edit has reached the store. Check new output and test the behaviour before claiming success. Web-server and browser health require their own checks. -Each complete JSONL line contains `sessionId`, `timestamp`, `source`, and `message`, +Each complete JSONL line contains `timestamp`, `source`, and `message`, with `stream` or `level` where available. Keep a byte offset to read new lines. -Wait for an incomplete final line to finish. If the session ID changes, reset the -offset: each new run clears the log. Logs have no size limit or rotation, so a long -session can produce a large file. +Wait for an incomplete final line to finish. If `sessionId` in the instance JSON +changes, reset the offset: each new run clears the log. Logs have no size limit or +rotation, so a long session can produce a large file. ## Agent workflow From 3f0eb50b37736f73a3aa7ed92f4b997fb9c20d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 10:25:08 +0200 Subject: [PATCH 03/23] Keep app dev log messages and status transitions aligned with terminal output --- docs/cli/app-dev-agent-workflow.md | 4 +++ packages/app/src/cli/services/dev.test.ts | 34 +++++++++++++++++++++-- packages/app/src/cli/services/dev.ts | 31 ++++++++++++++++++--- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/docs/cli/app-dev-agent-workflow.md b/docs/cli/app-dev-agent-workflow.md index 3d75a799194..1d32de47b8e 100644 --- a/docs/cli/app-dev-agent-workflow.md +++ b/docs/cli/app-dev-agent-workflow.md @@ -22,6 +22,10 @@ Wait for an incomplete final line to finish. If `sessionId` in the instance JSON changes, reset the offset: each new run clears the log. Logs have no size limit or rotation, so a long session can produce a large file. +Process messages keep their terminal source, including extension handles. Changes +to the status message also produce a log record with source `app dev`. Terminal +colors, spinners, and screen redraws are not included. + ## Agent workflow 1. Find the instance file for the intended app configuration and check its app and diff --git a/packages/app/src/cli/services/dev.test.ts b/packages/app/src/cli/services/dev.test.ts index dd8ceaaabb2..dee418f91c6 100644 --- a/packages/app/src/cli/services/dev.test.ts +++ b/packages/app/src/cli/services/dev.test.ts @@ -3,6 +3,7 @@ import {setupDevProcesses} from './dev/processes/setup-dev-processes.js' import {renderDev} from './dev/ui.js' import {fetchAppRemoteConfiguration} from './app/select-app.js' import {DevSessionStatusManager} from './dev/processes/dev-session/dev-session-status-manager.js' +import {DevSessionLogger} from './dev/processes/dev-session/dev-session-logger.js' import { testAppLinked, testDeveloperPlatformClient, @@ -18,6 +19,7 @@ import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' +import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' import {Writable} from 'stream' vi.mock('./local-storage.js') @@ -96,10 +98,14 @@ describe('dev with a dev instance', () => { await inTemporaryDirectory(async (tmp) => { const store = testOrganizationStore({shopDomain: 'dev-store.myshopify.com'}) const app = testAppLinked({directory: tmp}) - const manager = new DevSessionStatusManager() + const manager = new DevSessionStatusManager({ + isReady: false, + statusMessage: {type: 'loading', message: 'Preparing dev preview'}, + }) const devInstance = await acquireDevInstance({command: 'app dev', directory: tmp}) const ownership = new AbortController() const previewUrl = 'https://dev-store.myshopify.com/admin/apps/api-key' + const graphiqlUrl = 'http://localhost:3456/graphiql' let infoWhileRunning: unknown vi.mocked(fetchAppRemoteConfiguration).mockResolvedValue({ @@ -117,18 +123,27 @@ describe('dev with a dev instance', () => { function: async ({stdout, stderr}: {stdout: Writable; stderr: Writable}) => { stdout.write('Server listening on 3000\n') stderr.write('Deprecation warning\n') + useConcurrentOutputContext({outputPrefix: 'discount'}, () => { + stdout.write('Building function discount...') + stdout.write('Building GraphQL types...\n') + }) + await new DevSessionLogger(stdout).info('Update accepted') }, options: {}, } as any, ], previewUrl, - graphiqlUrl: undefined, + graphiqlUrl, devSessionStatusManager: manager, }) const terminal = new Writable({write: (_chunk, _encoding, callback) => callback()}) vi.mocked(renderDev).mockImplementation(async ({processes, abortController}) => { + manager.updateStatus({previewURL: previewUrl}) + manager.setMessage('READY') + manager.setMessage('CHANGE_DETECTED') manager.updateStatus({isReady: true}) manager.setMessage('BUILD_ERROR', ['discount: Invalid input']) + manager.updateStatus({graphiqlURL: graphiqlUrl}) await vi.waitFor(async () => { infoWhileRunning = JSON.parse(await readFile(devInstance.paths.infoFile)) expect(infoWhileRunning).toHaveProperty('details.preview.errors', ['discount: Invalid input']) @@ -177,10 +192,25 @@ describe('dev with a dev instance', () => { expect(entries).toEqual( expect.arrayContaining([ expect.objectContaining({source: 'app dev', message: `Preview URL: ${previewUrl}`}), + expect.objectContaining({source: 'app dev', message: `GraphiQL URL (Admin API): ${graphiqlUrl}`}), expect.objectContaining({source: 'web', stream: 'stdout', message: 'Server listening on 3000'}), expect.objectContaining({source: 'web', stream: 'stderr', message: 'Deprecation warning'}), + expect.objectContaining({source: 'discount', message: 'Building function discount...'}), + expect.objectContaining({source: 'discount', message: 'Building GraphQL types...'}), + expect.objectContaining({source: 'app-preview', message: 'Update accepted'}), ]), ) + expect( + entries + .filter(({source, stream}) => source === 'app dev' && !stream) + .filter(({message}) => !message.startsWith('Preview URL:') && !message.startsWith('GraphiQL URL')) + .map(({level, message}) => ({level, message})), + ).toEqual([ + {level: 'info', message: 'Preparing dev preview'}, + {level: 'info', message: 'Ready, watching for changes in your app'}, + {level: 'info', message: 'Change detected, updating dev preview'}, + {level: 'error', message: 'Build error. Please review your code and try again'}, + ]) }) }) }) diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index ba0e56c40e7..d864e0123ac 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -337,8 +337,12 @@ async function launchDevProcesses({ prefix: process.prefix, action: async (stdout, stderr, signal) => { const fn = process.function as DevProcessFunction - const processStdout = devInstance ? devInstance.tee(stdout, {source: process.prefix, stream: 'stdout'}) : stdout - const processStderr = devInstance ? devInstance.tee(stderr, {source: process.prefix, stream: 'stderr'}) : stderr + const processStdout = devInstance + ? devInstance.tee(stdout, {source: process.prefix, stream: 'stdout', stringWritesAreMessages: true}) + : stdout + const processStderr = devInstance + ? devInstance.tee(stderr, {source: process.prefix, stream: 'stderr', stringWritesAreMessages: true}) + : stderr return fn({stdout: processStdout, stderr: processStderr, abortSignal: signal}, process.options) }, } @@ -358,6 +362,9 @@ async function launchDevProcesses({ details: {...urls, preview: previewStatus(devSessionStatusManager.status)}, }) await devInstance.log({source: 'app dev', level: 'info', message: `Preview URL: ${previewUrl}`}) + if (graphiqlUrl) { + await devInstance.log({source: 'app dev', level: 'info', message: `GraphiQL URL (Admin API): ${graphiqlUrl}`}) + } } const developerPlatformClient = config.developerPlatformClient @@ -366,12 +373,28 @@ async function launchDevProcesses({ developerPlatformClient, } + let lastStatusMessage: DevSessionStatus['statusMessage'] const persistStatus = (status: DevSessionStatus) => { - devInstance?.update({details: {preview: previewStatus(status)}}).catch((error: Error) => { + const writes = [devInstance?.update({details: {preview: previewStatus(status)}})] + const message = status.statusMessage + if (message && (message.message !== lastStatusMessage?.message || message.type !== lastStatusMessage?.type)) { + writes.push( + devInstance?.log({ + source: 'app dev', + level: message.type === 'error' ? 'error' : 'info', + message: message.message, + }), + ) + } + lastStatusMessage = message + Promise.all(writes).catch((error: Error) => { outputDebug(`Failed to save dev status: ${error.message}`) }) } - if (devInstance) devSessionStatusManager.on('dev-session-update', persistStatus) + if (devInstance) { + devSessionStatusManager.on('dev-session-update', persistStatus) + persistStatus(devSessionStatusManager.status) + } const stopOnLostLock = () => abortController.abort(devInstance?.abortSignal.reason) try { devInstance?.abortSignal.throwIfAborted() From 6ef6660cded2c4a2699b315c1e7b3dfdec9676f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 11:15:07 +0200 Subject: [PATCH 04/23] Add typed JSON output to app dev --- .changeset/bright-dev-stream.md | 5 + .../generated/generated_docs_data_v2.json | 11 +- docs/cli/json-output.md | 13 ++ packages/app/src/cli/commands/app/dev.test.ts | 165 ++++++++++++++++-- packages/app/src/cli/commands/app/dev.ts | 30 +++- packages/app/src/cli/services/dev/ui.test.tsx | 144 ++++++++++++++- packages/app/src/cli/services/dev/ui.tsx | 15 +- .../cli-kit/src/public/node/dev-instance.ts | 3 + packages/cli/README.md | 113 +++++++++++- packages/cli/oclif.manifest.json | 13 +- 10 files changed, 483 insertions(+), 29 deletions(-) create mode 100644 .changeset/bright-dev-stream.md diff --git a/.changeset/bright-dev-stream.md b/.changeset/bright-dev-stream.md new file mode 100644 index 00000000000..394dba8bdbf --- /dev/null +++ b/.changeset/bright-dev-stream.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add `app dev --json` to stream development output as JSON Lines. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index e1d654746ff..0a370bf496d 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/json-output.md b/docs/cli/json-output.md index aea695beaf9..6d075b89092 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -240,6 +240,19 @@ events from stderr; missing required input then produces a fatal error instead o ## Exempt only streaming commands +`shopify app dev --json` streams compact JSON Lines on stdout for the lifetime of the dev session. Each line is a +shared diagnostic or progress event, or the shared `{error: {...}}` envelope for a fatal failure. There is no final +success result. Preview errors remain diagnostic events so that the developer can fix the app while dev keeps running. +Process messages include their source prefix in `message`. The session's existing status and log files remain available. + +```jsonl +{"type":"diagnostic","timestamp":"2026-10-20T09:00:00Z","level":"info","message":"web: Server listening on 3000"} +{"type":"diagnostic","timestamp":"2026-10-20T09:00:01Z","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 e9c6aaede13..46416129247 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -15,6 +15,12 @@ import { testProject, } from '../../models/app/app.test-data.js' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {commandEventOutputSchema, emitCommandEvent} from '@shopify/cli-kit/node/command-events' +import {AbortError} 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, DEV_INSTANCE_ALREADY_RUNNING_CODE, @@ -64,6 +70,111 @@ describe('app dev command', () => { return {store} } + test('declares its streaming schema and retains inherited flags', () => { + expect(Dev.jsonOutputSchema).toBe(commandEventOutputSchema) + 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('CommandEvent') + }) + + test.each(['--json', '-j', 'env'])('%s streams shared events on stdout without a final result', async (source) => { + await inTemporaryDirectory(async (tmp) => { + const {store} = mockAppAndStore(tmp) + vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) + if (source === 'env') vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.mocked(dev).mockImplementation(async ({devInstance}) => { + outputInfo('Starting dev') + emitCommandEvent({type: 'progress', operation: 'app-dev-preview', status: 'updated', message: 'Updated'}) + 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(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({type: 'diagnostic', message: 'Starting dev'}), + expect.objectContaining({type: 'progress', message: 'Updated'}), + expect.objectContaining({type: 'diagnostic', message: 'web: Server ready'}), + expect.objectContaining({type: 'diagnostic', level: 'error', message: 'Build error'}), + ]), + ) + expect(stderr()).toBe('') + }) + } 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('a runtime failure ends the JSONL stream with the shared fatal error', async () => { + await inTemporaryDirectory(async (tmp) => { + const {store} = mockAppAndStore(tmp) + vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) + vi.mocked(dev).mockImplementation(async () => { + outputInfo('Starting dev') + throw new AbortError('Tunnel stopped') + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(Dev.run(['--path', tmp, '--store', store.shopDomain, '--json'], import.meta.url)).rejects.toThrow() + expect( + stdout() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .slice(-2), + ).toEqual([ + expect.objectContaining({type: 'diagnostic', message: 'Starting dev'}), + {error: {type: 'abort', message: 'Tunnel stopped'}}, + ]) + expect(stderr()).toBe('') + }) + 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], @@ -157,9 +268,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() @@ -183,21 +299,36 @@ 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}}) + // 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}}) - expect(storeContext).not.toHaveBeenCalled() - expect(dev).not.toHaveBeenCalled() - await running.release() - }) - }) + 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 71380f3b005..57093b7ee4a 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -7,10 +7,13 @@ 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' import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' import {basename} from '@shopify/cli-kit/node/path' +import {commandEventOutputSchema, emitCommandEvent, runWithCommandEvents} from '@shopify/cli-kit/node/command-events' +import {jsonOutputEnabled} from '@shopify/cli-kit/node/environment' +import {outputResult} from '@shopify/cli-kit/node/output' export default class Dev extends AppLinkedCommand { public static get requiresSyncAnalytics(): boolean { @@ -19,13 +22,20 @@ export default class Dev extends AppLinkedCommand { static summary = 'Run the app.' - 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 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). + +With \`--json\`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use \`--no-input\` for unattended runs.` + + static get jsonOutputSchema() { + return commandEventOutputSchema + } 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, @@ -156,6 +166,14 @@ export default class Dev extends AppLinkedCommand { appName: appContextResult.remoteApp.title, clientId: appContextResult.remoteApp.apiKey, }, + onLog: flags.json + ? (entry) => + emitCommandEvent({ + type: 'diagnostic', + level: entry.level ?? 'info', + message: entry.source === 'app dev' ? entry.message : `${entry.source}: ${entry.message}`, + }) + : undefined, }) try { @@ -194,4 +212,12 @@ export default class Dev extends AppLinkedCommand { } return {app: appContextResult.app} } + + protected async _run(): Promise { + if (!jsonOutputEnabled(process.env, this.argv)) return super._run() + return runWithCommandEvents( + {outputMode: 'json', sink: (event) => outputResult(JSON.stringify(commandEventOutputSchema.validate(event)))}, + () => super._run(), + ) + } } diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index b9134ebc410..cacbd8a9c38 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -3,9 +3,15 @@ import {DevSessionUI} from './ui/components/DevSessionUI.js' import {DevSessionStatusManager} from './processes/dev-session/dev-session-status-manager.js' import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {afterEach, describe, expect, test, vi} from 'vitest' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {AbortController} from '@shopify/cli-kit/node/abort' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import {commandEventOutputSchema, emitCommandEvent, runWithCommandEvents} from '@shopify/cli-kit/node/command-events' +import {outputResult} from '@shopify/cli-kit/node/output' +import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' +import {inTemporaryDirectory} 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') vi.mock('./ui/components/DevSessionUI.js') @@ -19,6 +25,142 @@ afterEach(() => { describe('ui', () => { describe('renderDev', () => { + 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: (event) => outputResult(JSON.stringify(commandEventOutputSchema.validate(event))), + }, + async () => { + const instance = await acquireDevInstance({ + command: 'app dev', + directory, + onLog: (entry) => + emitCommandEvent({type: 'diagnostic', level: 'info', message: `${entry.source}: ${entry.message}`}), + }) + 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', + stream: 'stdout', + stringWritesAreMessages: true, + }) + const childStderr = instance.tee(targetStderr, { + source: 'web', + stream: 'stderr', + 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(`web: line ${burstSize - 1}`)) + expect(completed).not.toHaveBeenCalled() + lateOutput?.write('After setup') + await vi.waitFor(() => expect(stdout()).toContain('web: After setup')) + lateOutput?.write(Buffer.from('Last partial line')) + abortController.abort() + await running + } finally { + abortController.abort() + await running + await instance.release() + } + const events = stdout() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toHaveLength(burstSize + 6) + const messages = events.map((event) => event.message) + expect(messages).toEqual( + expect.arrayContaining([ + 'web: Starting without a newline', + 'discount: Building function', + 'discount: Building types', + 'web: éclair', + 'web: After setup', + 'web: Last partial line', + ]), + ) + expect(messages.filter((message) => message.startsWith('web: line '))).toEqual( + Array.from({length: burstSize}, (_, index) => `web: line ${index}`), + ) + expect(vi.mocked(DevSessionUI)).not.toHaveBeenCalled() + expect(stdout()).not.toContain('\u001b') + expect(stderr()).toBe('') + }, + ) + }) + }) + }) + + test('a JSON process failure aborts and waits for the other processes to stop', async () => { + const abortController = new AbortController() + const failure = new Error('Process failed') + let stopped = false + const signalListeners = process.listenerCount('SIGTERM') + await withCapturedStandardStreams(async () => { + await expect( + runWithCommandEvents({outputMode: 'json', 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) + }) + 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 3c3b207732e..3e0c280adec 100644 --- a/packages/app/src/cli/services/dev/ui.tsx +++ b/packages/app/src/cli/services/dev/ui.tsx @@ -6,6 +6,8 @@ import {AbortController} from '@shopify/cli-kit/node/abort' import React from 'react' import {render} from '@shopify/cli-kit/node/ui' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import {commandEventOutputMode} from '@shopify/cli-kit/node/command-events' +import {Writable} from 'stream' interface DevProps { processes: OutputProcess[] @@ -40,7 +42,7 @@ export async function renderDev({ configPath?: string localURL?: string }) { - if (terminalSupportsPrompting()) { + if (commandEventOutputMode() !== 'json' && terminalSupportsPrompting()) { return 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 @@ -91,8 +94,10 @@ async function renderDevNonInteractive({ signal.addEventListener('abort', onAbort, {once: true}) if (signal.aborted) resolve() }) + // JSON events come from the session log, including output written after a watcher finishes setup. + const terminalOutput = new Writable({write: (_chunk, _encoding, next) => next()}) const running = processes.map(async (concurrentProcess) => - concurrentProcess.action(process.stdout, process.stderr, signal), + concurrentProcess.action(json ? terminalOutput : process.stdout, json ? terminalOutput : process.stderr, signal), ) try { // A watcher can finish setup while it continues serving. Keep the session registered until abort. diff --git a/packages/cli-kit/src/public/node/dev-instance.ts b/packages/cli-kit/src/public/node/dev-instance.ts index 25cd7cd3488..950ad1fbb12 100644 --- a/packages/cli-kit/src/public/node/dev-instance.ts +++ b/packages/cli-kit/src/public/node/dev-instance.ts @@ -85,6 +85,8 @@ export interface DevInstanceIdentity { export interface AcquireDevInstanceOptions extends DevInstanceIdentity { /** Initial command-specific details. */ details?: {[key: string]: unknown} + /** Receives each entry after it is written to the session log. */ + onLog?: (entry: DevInstanceLogEntry) => void } export interface DevInstanceUpdate { @@ -268,6 +270,7 @@ async function createDevInstance(options: AcquireDevInstanceOptions, paths: DevI ...entry, } await writeToStream(logStream, `${JSON.stringify(line)}\n`) + options.onLog?.(line) } const instance: DevInstance = { diff --git a/packages/cli/README.md b/packages/cli/README.md index 2066b2d7e79..14623c275ff 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1887,7 +1887,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] @@ -1897,6 +1897,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] @@ -1993,6 +1997,113 @@ 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). + + With `--json`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. + Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended + runs. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `CommandEvent` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/CommandDiagnosticEvent" + }, + { + "$ref": "#/definitions/CommandProgressEvent" + } + ], + "title": "CommandEvent", + "definitions": { + "CommandDiagnosticEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "diagnostic" + }, + "timestamp": { + "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." + }, + "level": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "message": { + "type": "string" + }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable diagnostic code, included only when known." + } + }, + "required": [ + "type", + "timestamp", + "level", + "message" + ], + "additionalProperties": false + }, + "CommandProgressEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "progress" + }, + "timestamp": { + "$ref": "#/definitions/CommandDiagnosticEvent/properties/timestamp" + }, + "status": { + "type": "string", + "enum": [ + "started", + "updated", + "retrying", + "completed", + "failed" + ] + }, + "operation": { + "type": "string" + }, + "message": { + "type": "string" + }, + "current": { + "type": "integer", + "minimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "type", + "timestamp", + "status", + "operation" + ], + "additionalProperties": false + } + }, + "$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 31f658eee73..7b255910958 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1191,8 +1191,8 @@ "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).", - "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).", + "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\nWith `--json`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `CommandEvent` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/CommandDiagnosticEvent\"\n },\n {\n \"$ref\": \"#/definitions/CommandProgressEvent\"\n }\n ],\n \"title\": \"CommandEvent\",\n \"definitions\": {\n \"CommandDiagnosticEvent\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"diagnostic\"\n },\n \"timestamp\": {\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 \"level\": {\n \"type\": \"string\",\n \"enum\": [\n \"debug\",\n \"info\",\n \"warning\",\n \"error\"\n ]\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable diagnostic code, included only when known.\"\n }\n },\n \"required\": [\n \"type\",\n \"timestamp\",\n \"level\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"CommandProgressEvent\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"progress\"\n },\n \"timestamp\": {\n \"$ref\": \"#/definitions/CommandDiagnosticEvent/properties/timestamp\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"started\",\n \"updated\",\n \"retrying\",\n \"completed\",\n \"failed\"\n ]\n },\n \"operation\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"current\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n }\n },\n \"required\": [\n \"type\",\n \"timestamp\",\n \"status\",\n \"operation\"\n ],\n \"additionalProperties\": false\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).\n\nWith `--json`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.", "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", @@ -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.", From 5755b1c84238bcd99d419bb1c170bdfc7630ce64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 12:01:43 +0200 Subject: [PATCH 05/23] Omit event type from app dev JSONL output --- docs/cli/json-output.md | 9 +++--- packages/app/src/cli/commands/app/dev.test.ts | 32 +++++++++++++------ packages/app/src/cli/commands/app/dev.ts | 10 ++++-- packages/app/src/cli/services/dev/types.ts | 15 +++++++++ packages/app/src/cli/services/dev/ui.test.tsx | 6 ++-- packages/cli/README.md | 24 ++++---------- packages/cli/oclif.manifest.json | 2 +- 7 files changed, 61 insertions(+), 37 deletions(-) create mode 100644 packages/app/src/cli/services/dev/types.ts diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index 6d075b89092..b3229287124 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -241,13 +241,14 @@ events from stderr; missing required input then produces a fatal error instead o ## Exempt only streaming commands `shopify app dev --json` streams compact JSON Lines on stdout for the lifetime of the dev session. Each line is a -shared diagnostic or progress event, or the shared `{error: {...}}` envelope for a fatal failure. There is no final -success result. Preview errors remain diagnostic events so that the developer can fix the app while dev keeps running. +diagnostic or progress record with the shared event's `type` field omitted, or the shared `{error: {...}}` envelope +for a fatal failure. There is no final success result. Preview errors remain diagnostic events so that the developer +can fix the app while dev keeps running. Process messages include their source prefix in `message`. The session's existing status and log files remain available. ```jsonl -{"type":"diagnostic","timestamp":"2026-10-20T09:00:00Z","level":"info","message":"web: Server listening on 3000"} -{"type":"diagnostic","timestamp":"2026-10-20T09:00:01Z","level":"info","message":"Ready, watching for changes in your app"} +{"timestamp":"2026-10-20T09:00:00Z","level":"info","message":"web: Server listening on 3000"} +{"timestamp":"2026-10-20T09:00:01Z","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 diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index 46416129247..9671cf6c1dd 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -5,6 +5,7 @@ import {dev} from '../../services/dev.js' import {linkedAppContext} from '../../services/app-context.js' import {storeContext} from '../../services/store-context.js' import {getTunnelMode} from '../../services/dev/tunnel-mode.js' +import {appDevJsonOutputSchema} from '../../services/dev/types.js' import {checkFolderIsValidApp} from '../../models/app/loader.js' import { testAppLinked, @@ -16,7 +17,7 @@ import { } from '../../models/app/app.test-data.js' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' -import {commandEventOutputSchema, emitCommandEvent} from '@shopify/cli-kit/node/command-events' +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {AbortError} from '@shopify/cli-kit/node/error' import {outputInfo} from '@shopify/cli-kit/node/output' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' @@ -71,12 +72,20 @@ describe('app dev command', () => { } test('declares its streaming schema and retains inherited flags', () => { - expect(Dev.jsonOutputSchema).toBe(commandEventOutputSchema) + expect(Dev.jsonOutputSchema).toBe(appDevJsonOutputSchema) 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('CommandEvent') + expect(Dev.descriptionForHelp()).toContain('AppDevEvent') + }) + + test.each([ + {timestamp: '2026-10-20T09:00:00Z', level: 'info', message: 'Starting dev'}, + {timestamp: '2026-10-20T09:00:00Z', operation: 'app-dev-preview', status: 'updated', message: 'Updated'}, + ] as const)('the stream schema accepts records without type and rejects extra type fields: %j', (entry) => { + expect(JSON.parse(appDevJsonOutputSchema.encode(entry))).toEqual(entry) + expect(() => appDevJsonOutputSchema.validate({...entry, type: 'diagnostic'})).toThrow() }) test.each(['--json', '-j', 'env'])('%s streams shared events on stdout without a final result', async (source) => { @@ -88,7 +97,7 @@ describe('app dev command', () => { outputInfo('Starting dev') emitCommandEvent({type: 'progress', operation: 'app-dev-preview', status: 'updated', message: 'Updated'}) await devInstance?.log({source: 'web', message: 'Server ready'}) - await devInstance?.log({source: 'app dev', level: 'error', message: 'Build error'}) + await devInstance?.log({source: 'app dev', level: 'error', message: 'Build error'}) }) try { await withCapturedStandardStreams(async ({stdout, stderr}) => { @@ -100,13 +109,16 @@ describe('app dev command', () => { .trim() .split('\n') .map((line) => JSON.parse(line)) - events.forEach((event) => expect(() => Dev.jsonOutputSchema.validate(event)).not.toThrow()) + events.forEach((event) => { + expect(() => Dev.jsonOutputSchema.validate(event)).not.toThrow() + expect(event).not.toHaveProperty('type') + }) expect(events).toEqual( expect.arrayContaining([ - expect.objectContaining({type: 'diagnostic', message: 'Starting dev'}), - expect.objectContaining({type: 'progress', message: 'Updated'}), - expect.objectContaining({type: 'diagnostic', message: 'web: Server ready'}), - expect.objectContaining({type: 'diagnostic', level: 'error', message: 'Build error'}), + expect.objectContaining({level: 'info', message: 'Starting dev'}), + expect.objectContaining({status: 'updated', operation: 'app-dev-preview', message: 'Updated'}), + expect.objectContaining({level: 'info', message: 'web: Server ready'}), + expect.objectContaining({level: 'error', message: 'Build error'}), ]), ) expect(stderr()).toBe('') @@ -164,7 +176,7 @@ describe('app dev command', () => { .map((line) => JSON.parse(line)) .slice(-2), ).toEqual([ - expect.objectContaining({type: 'diagnostic', message: 'Starting dev'}), + expect.objectContaining({level: 'info', message: 'Starting dev'}), {error: {type: 'abort', message: 'Tunnel stopped'}}, ]) expect(stderr()).toBe('') diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 57093b7ee4a..a98e6bc16d8 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -5,13 +5,14 @@ import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-link import {linkedAppContext} from '../../services/app-context.js' import {storeContext} from '../../services/store-context.js' import {getTunnelMode} from '../../services/dev/tunnel-mode.js' +import {appDevJsonOutputSchema} from '../../services/dev/types.js' import {Flags} from '@oclif/core' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {globalFlags, jsonFlag, portFlag} from '@shopify/cli-kit/node/cli' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' import {basename} from '@shopify/cli-kit/node/path' -import {commandEventOutputSchema, emitCommandEvent, runWithCommandEvents} from '@shopify/cli-kit/node/command-events' +import {emitCommandEvent, runWithCommandEvents} from '@shopify/cli-kit/node/command-events' import {jsonOutputEnabled} from '@shopify/cli-kit/node/environment' import {outputResult} from '@shopify/cli-kit/node/output' @@ -27,7 +28,7 @@ export default class Dev extends AppLinkedCommand { With \`--json\`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use \`--no-input\` for unattended runs.` static get jsonOutputSchema() { - return commandEventOutputSchema + return appDevJsonOutputSchema } static description = this.descriptionForHelp() @@ -216,7 +217,10 @@ With \`--json\`, streams diagnostic and progress events as JSON Lines to stdout protected async _run(): Promise { if (!jsonOutputEnabled(process.env, this.argv)) return super._run() return runWithCommandEvents( - {outputMode: 'json', sink: (event) => outputResult(JSON.stringify(commandEventOutputSchema.validate(event)))}, + { + outputMode: 'json', + sink: ({type: _, ...entry}) => outputResult(JSON.stringify(appDevJsonOutputSchema.validate(entry))), + }, () => super._run(), ) } diff --git a/packages/app/src/cli/services/dev/types.ts b/packages/app/src/cli/services/dev/types.ts new file mode 100644 index 00000000000..dafc47c7855 --- /dev/null +++ b/packages/app/src/cli/services/dev/types.ts @@ -0,0 +1,15 @@ +import {commandDiagnosticEventSchema, commandProgressEventSchema} from '@shopify/cli-kit/common/command-events' +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const diagnosticSchema = commandDiagnosticEventSchema.omit({type: true}) +const progressSchema = commandProgressEventSchema.omit({type: true}) + +export const appDevJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppDevEvent', + schema: zod.union([diagnosticSchema, progressSchema]), + definitions: { + AppDevDiagnostic: diagnosticSchema, + AppDevProgress: progressSchema, + }, +}) diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index cacbd8a9c38..dd1591bf8f4 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -1,12 +1,13 @@ import {renderDev} from './ui.js' import {DevSessionUI} from './ui/components/DevSessionUI.js' import {DevSessionStatusManager} from './processes/dev-session/dev-session-status-manager.js' +import {appDevJsonOutputSchema} from './types.js' import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {afterEach, describe, expect, test, vi} from 'vitest' import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {AbortController} from '@shopify/cli-kit/node/abort' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' -import {commandEventOutputSchema, emitCommandEvent, runWithCommandEvents} from '@shopify/cli-kit/node/command-events' +import {emitCommandEvent, runWithCommandEvents} from '@shopify/cli-kit/node/command-events' import {outputResult} from '@shopify/cli-kit/node/output' import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' @@ -33,7 +34,7 @@ describe('ui', () => { await runWithCommandEvents( { outputMode: 'json', - sink: (event) => outputResult(JSON.stringify(commandEventOutputSchema.validate(event))), + sink: ({type: _, ...entry}) => outputResult(JSON.stringify(appDevJsonOutputSchema.validate(entry))), }, async () => { const instance = await acquireDevInstance({ @@ -97,6 +98,7 @@ describe('ui', () => { .trim() .split('\n') .map((line) => JSON.parse(line)) + events.forEach((event) => expect(event).not.toHaveProperty('type')) expect(events).toHaveLength(burstSize + 6) const messages = events.map((event) => event.message) expect(messages).toEqual( diff --git a/packages/cli/README.md b/packages/cli/README.md index 14623c275ff..b75766122b4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2004,27 +2004,23 @@ DESCRIPTION Use `--json-schema` to print the result, error, and event schemas. - Output from `--json` conforms to the `CommandEvent` schema. + Output from `--json` conforms to the `AppDevEvent` schema. ```json { "anyOf": [ { - "$ref": "#/definitions/CommandDiagnosticEvent" + "$ref": "#/definitions/AppDevDiagnostic" }, { - "$ref": "#/definitions/CommandProgressEvent" + "$ref": "#/definitions/AppDevProgress" } ], - "title": "CommandEvent", + "title": "AppDevEvent", "definitions": { - "CommandDiagnosticEvent": { + "AppDevDiagnostic": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "diagnostic" - }, "timestamp": { "type": "string", "format": "date-time", @@ -2050,22 +2046,17 @@ DESCRIPTION } }, "required": [ - "type", "timestamp", "level", "message" ], "additionalProperties": false }, - "CommandProgressEvent": { + "AppDevProgress": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "progress" - }, "timestamp": { - "$ref": "#/definitions/CommandDiagnosticEvent/properties/timestamp" + "$ref": "#/definitions/AppDevDiagnostic/properties/timestamp" }, "status": { "type": "string", @@ -2093,7 +2084,6 @@ DESCRIPTION } }, "required": [ - "type", "timestamp", "status", "operation" diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 7b255910958..4543165cf9c 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).\n\nWith `--json`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `CommandEvent` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/CommandDiagnosticEvent\"\n },\n {\n \"$ref\": \"#/definitions/CommandProgressEvent\"\n }\n ],\n \"title\": \"CommandEvent\",\n \"definitions\": {\n \"CommandDiagnosticEvent\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"diagnostic\"\n },\n \"timestamp\": {\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 \"level\": {\n \"type\": \"string\",\n \"enum\": [\n \"debug\",\n \"info\",\n \"warning\",\n \"error\"\n ]\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable diagnostic code, included only when known.\"\n }\n },\n \"required\": [\n \"type\",\n \"timestamp\",\n \"level\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"CommandProgressEvent\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"progress\"\n },\n \"timestamp\": {\n \"$ref\": \"#/definitions/CommandDiagnosticEvent/properties/timestamp\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"started\",\n \"updated\",\n \"retrying\",\n \"completed\",\n \"failed\"\n ]\n },\n \"operation\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"current\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n }\n },\n \"required\": [\n \"type\",\n \"timestamp\",\n \"status\",\n \"operation\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "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\nWith `--json`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppDevEvent` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/AppDevDiagnostic\"\n },\n {\n \"$ref\": \"#/definitions/AppDevProgress\"\n }\n ],\n \"title\": \"AppDevEvent\",\n \"definitions\": {\n \"AppDevDiagnostic\": {\n \"type\": \"object\",\n \"properties\": {\n \"timestamp\": {\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 \"level\": {\n \"type\": \"string\",\n \"enum\": [\n \"debug\",\n \"info\",\n \"warning\",\n \"error\"\n ]\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable diagnostic code, included only when known.\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"level\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"AppDevProgress\": {\n \"type\": \"object\",\n \"properties\": {\n \"timestamp\": {\n \"$ref\": \"#/definitions/AppDevDiagnostic/properties/timestamp\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"started\",\n \"updated\",\n \"retrying\",\n \"completed\",\n \"failed\"\n ]\n },\n \"operation\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"current\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n }\n },\n \"required\": [\n \"timestamp\",\n \"status\",\n \"operation\"\n ],\n \"additionalProperties\": false\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).\n\nWith `--json`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.", "flags": { "auth-alias": { From 410d6f7d063b39c44d1a7bb35398a05d73d465ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 12:19:04 +0200 Subject: [PATCH 06/23] Write identical app dev JSONL to stdout and the session log --- docs/cli/app-dev-agent-workflow.md | 6 + docs/cli/json-output.md | 17 +- packages/app/src/cli/commands/app/dev.test.ts | 177 ++++++++------ packages/app/src/cli/commands/app/dev.ts | 138 +++++++---- packages/app/src/cli/services/dev/types.ts | 15 -- packages/app/src/cli/services/dev/ui.test.tsx | 38 +-- .../src/public/node/dev-instance.test.ts | 41 ++++ .../cli-kit/src/public/node/dev-instance.ts | 60 +++-- .../src/public/node/error-handler.test.ts | 26 ++- .../cli-kit/src/public/node/error-handler.ts | 3 +- packages/cli/README.md | 221 ++++++++++++++---- packages/cli/oclif.manifest.json | 4 +- 12 files changed, 512 insertions(+), 234 deletions(-) delete mode 100644 packages/app/src/cli/services/dev/types.ts diff --git a/docs/cli/app-dev-agent-workflow.md b/docs/cli/app-dev-agent-workflow.md index 1d32de47b8e..62b09e5c635 100644 --- a/docs/cli/app-dev-agent-workflow.md +++ b/docs/cli/app-dev-agent-workflow.md @@ -26,6 +26,12 @@ Process messages keep their terminal source, including extension handles. Change to the status message also produce a log record with source `app dev`. Terminal colors, spinners, and screen redraws are not included. +With `--json`, stdout and the session log contain identical JSONL bytes. Setup +messages wait until the command acquires its session log, then retain their original +timestamps in both outputs. Runtime failures include the shared CLI error under +`error` in the final record. A startup failure before log acquisition uses the +standard JSON error output and does not change another process's log. + ## Agent workflow 1. Find the instance file for the intended app configuration and check its app and diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index b3229287124..918189596fa 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -240,15 +240,18 @@ events from stderr; missing required input then produces a fatal error instead o ## Exempt only streaming commands -`shopify app dev --json` streams compact JSON Lines on stdout for the lifetime of the dev session. Each line is a -diagnostic or progress record with the shared event's `type` field omitted, or the shared `{error: {...}}` envelope -for a fatal failure. There is no final success result. Preview errors remain diagnostic events so that the developer -can fix the app while dev keeps running. -Process messages include their source prefix in `message`. The session's existing status and log files remain available. +`shopify app dev --json` writes the exact same JSONL bytes to stdout and the session log. Each record has `timestamp`, +`source`, and `message`, with `stream` or `level` when available. There is no final success result. Preview errors are +log records so that the developer can fix the app while dev keeps running. A fatal runtime failure adds the shared +CLI error under `error` in the final log record, then exits nonzero. + +Setup messages are buffered until the app configuration is selected and the command acquires its session log. +Their original timestamps are retained. If startup fails before acquiring a session, buffered messages and the +standard `{error: {...}}` envelope go to stdout; another process's session log is never changed. ```jsonl -{"timestamp":"2026-10-20T09:00:00Z","level":"info","message":"web: Server listening on 3000"} -{"timestamp":"2026-10-20T09:00:01Z","level":"info","message":"Ready, watching for changes in your app"} +{"timestamp":"2026-10-20T09:00:00Z","source":"web","stream":"stdout","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 diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index 9671cf6c1dd..8afc975226a 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -5,7 +5,6 @@ import {dev} from '../../services/dev.js' import {linkedAppContext} from '../../services/app-context.js' import {storeContext} from '../../services/store-context.js' import {getTunnelMode} from '../../services/dev/tunnel-mode.js' -import {appDevJsonOutputSchema} from '../../services/dev/types.js' import {checkFolderIsValidApp} from '../../models/app/loader.js' import { testAppLinked, @@ -15,7 +14,7 @@ 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 {emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {AbortError} from '@shopify/cli-kit/node/error' @@ -24,6 +23,8 @@ 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' @@ -72,62 +73,73 @@ describe('app dev command', () => { } test('declares its streaming schema and retains inherited flags', () => { - expect(Dev.jsonOutputSchema).toBe(appDevJsonOutputSchema) + expect(Dev.jsonOutputSchema).toBe(devInstanceLogOutputSchema) 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('AppDevEvent') + expect(Dev.descriptionForHelp()).toContain('DevInstanceLogEntry') }) test.each([ - {timestamp: '2026-10-20T09:00:00Z', level: 'info', message: 'Starting dev'}, - {timestamp: '2026-10-20T09:00:00Z', operation: 'app-dev-preview', status: 'updated', message: 'Updated'}, + {timestamp: '2026-10-20T09:00:00Z', source: 'app dev', level: 'info', message: 'Starting dev'}, + {timestamp: '2026-10-20T09:00:00Z', source: 'web', stream: 'stdout', message: 'Server ready'}, ] as const)('the stream schema accepts records without type and rejects extra type fields: %j', (entry) => { - expect(JSON.parse(appDevJsonOutputSchema.encode(entry))).toEqual(entry) - expect(() => appDevJsonOutputSchema.validate({...entry, type: 'diagnostic'})).toThrow() + expect(JSON.parse(devInstanceLogOutputSchema.encode(entry))).toEqual(entry) + expect(() => devInstanceLogOutputSchema.validate({...entry, type: 'diagnostic'})).toThrow() }) - test.each(['--json', '-j', 'env'])('%s streams shared events on stdout without a final result', async (source) => { - await inTemporaryDirectory(async (tmp) => { - const {store} = mockAppAndStore(tmp) - vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) - if (source === 'env') vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - vi.mocked(dev).mockImplementation(async ({devInstance}) => { - outputInfo('Starting dev') - emitCommandEvent({type: 'progress', operation: 'app-dev-preview', status: 'updated', message: 'Updated'}) - 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(events).toEqual( - expect.arrayContaining([ - expect.objectContaining({level: 'info', message: 'Starting dev'}), - expect.objectContaining({status: 'updated', operation: 'app-dev-preview', message: 'Updated'}), - expect.objectContaining({level: 'info', message: 'web: Server ready'}), - expect.objectContaining({level: 'error', message: 'Build error'}), - ]), - ) - expect(stderr()).toBe('') + test.each(['--json', '-j', 'env'])( + '%s writes identical startup, progress, and process 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'} }) - } finally { - vi.unstubAllEnvs() - } - }) - }) + if (source === 'env') vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + let logFile: string | undefined + vi.mocked(dev).mockImplementation(async ({devInstance}) => { + logFile = devInstance?.paths.logFile + outputInfo('Starting dev') + emitCommandEvent({type: 'progress', operation: 'app-dev-preview', status: 'updated', message: 'Updated'}) + 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(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({source: 'app dev', level: 'info', message: 'Resolving app setup'}), + expect.objectContaining({source: 'app dev', level: 'info', message: 'Starting dev'}), + expect.objectContaining({source: 'app dev', level: 'info', message: 'Updated'}), + 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()).toBe('') + }) + } finally { + vi.unstubAllEnvs() + } + }) + }, + ) test.each([false, true])('JSON preserves the input policy with no-input=%s', async (noInput) => { await inTemporaryDirectory(async (tmp) => { @@ -159,33 +171,48 @@ describe('app dev command', () => { }) }) - test('a runtime failure ends the JSONL stream with the shared fatal error', async () => { - await inTemporaryDirectory(async (tmp) => { - const {store} = mockAppAndStore(tmp) - vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) - vi.mocked(dev).mockImplementation(async () => { - outputInfo('Starting dev') - throw new AbortError('Tunnel stopped') - }) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect(Dev.run(['--path', tmp, '--store', store.shopDomain, '--json'], import.meta.url)).rejects.toThrow() - expect( - stdout() - .trim() - .split('\n') - .map((line) => JSON.parse(line)) - .slice(-2), - ).toEqual([ - expect.objectContaining({level: 'info', message: 'Starting dev'}), - {error: {type: 'abort', message: 'Tunnel stopped'}}, - ]) - expect(stderr()).toBe('') + test.each([false, true])( + 'a failure during store selection=%s writes the same final error record to both outputs', + 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('Starting dev') + throw new AbortError('Dev failed') + } + vi.mocked(dev).mockImplementation(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() + expect( + stdout() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .slice(-2), + ).toEqual([ + expect.objectContaining({level: 'info', message: 'Starting dev'}), + expect.objectContaining({ + source: 'app dev', + level: 'error', + message: 'Dev failed', + error: {type: 'abort', message: 'Dev failed'}, + }), + ]) + expect(logFile).toBeDefined() + expect(stdout()).toBe(await readFile(logFile)) + expect(stderr()).toBe('') + }) + await expect( + readDevInstance({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}), + ).resolves.toBeUndefined() }) - await expect( - readDevInstance({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}), - ).resolves.toBeUndefined() - }) - }) + }, + ) test.each([ ['app dev', Dev, dev], @@ -319,6 +346,9 @@ describe('app dev command', () => { vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) const running = await acquireDevInstance({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}) + await running.log({source: 'app dev', message: 'Existing session'}) + const existingLog = await readFile(running.paths.logFile) + // The command's error handler renders the AbortError and exits. await withCapturedStandardStreams(async ({stdout}) => { await expect( @@ -337,6 +367,7 @@ describe('app dev command', () => { 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 a98e6bc16d8..7456b6956cc 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -5,14 +5,18 @@ import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-link import {linkedAppContext} from '../../services/app-context.js' import {storeContext} from '../../services/store-context.js' import {getTunnelMode} from '../../services/dev/tunnel-mode.js' -import {appDevJsonOutputSchema} from '../../services/dev/types.js' -import {Flags} from '@oclif/core' +import {Errors, Flags} from '@oclif/core' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {globalFlags, jsonFlag, portFlag} from '@shopify/cli-kit/node/cli' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' -import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' +import { + acquireDevInstance, + devInstanceLogOutputSchema, + type DevInstance, + type DevInstanceLogEntry, +} from '@shopify/cli-kit/node/dev-instance' import {basename} from '@shopify/cli-kit/node/path' -import {emitCommandEvent, runWithCommandEvents} from '@shopify/cli-kit/node/command-events' +import {runWithCommandEvents} from '@shopify/cli-kit/node/command-events' import {jsonOutputEnabled} from '@shopify/cli-kit/node/environment' import {outputResult} from '@shopify/cli-kit/node/output' @@ -25,10 +29,10 @@ 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). -With \`--json\`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use \`--no-input\` for unattended runs.` +With \`--json\`, streams the exact JSON Lines written to the session log until stopped. There is no final result. Setup messages are buffered until the app configuration is selected and the session log is acquired. Runtime failures include the shared CLI error in the final log record; failures before a session is acquired use the standard JSON error envelope. Setup prompts remain on stderr; use \`--no-input\` for unattended runs.` static get jsonOutputSchema() { - return appDevJsonOutputSchema + return devInstanceLogOutputSchema } static description = this.descriptionForHelp() @@ -130,6 +134,30 @@ With \`--json\`, streams diagnostic and progress events as JSON Lines to stdout return 'app dev stop' } + private devInstance?: DevInstance + private startupLogs: DevInstanceLogEntry[] = [] + + async catch(error: Error & {skipOclifErrorHandling: boolean}): Promise { + const instance = this.devInstance + if (!instance || !jsonOutputEnabled(process.env, this.argv)) { + for (const entry of this.startupLogs) outputResult(JSON.stringify(entry)) + await instance?.release() + return super.catch(error) + } + error.skipOclifErrorHandling = true + const {errorHandler} = await import('@shopify/cli-kit/node/error-handler') + const {errorToJson} = await import('@shopify/cli-kit/node/error/serialization') + try { + await errorHandler(error, this.config, async (mappedError) => { + const jsonError = errorToJson(mappedError) + await instance.log({source: 'app dev', level: 'error', message: jsonError.message, error: jsonError}) + }) + } finally { + await instance.release() + } + return Errors.handle(error) + } + public async run(): Promise { const {flags} = await this.parse(Dev) @@ -167,61 +195,73 @@ With \`--json\`, streams diagnostic and progress events as JSON Lines to stdout appName: appContextResult.remoteApp.title, clientId: appContextResult.remoteApp.apiKey, }, - onLog: flags.json - ? (entry) => - emitCommandEvent({ - type: 'diagnostic', - level: entry.level ?? 'info', - message: entry.source === 'app dev' ? entry.message : `${entry.source}: ${entry.message}`, - }) - : undefined, + logOutput: flags.json ? process.stdout : undefined, }) + this.devInstance = devInstance + await Promise.all(this.startupLogs.map((entry) => devInstance.log(entry))) + this.startupLogs = [] - try { - const store = await storeContext({ - appContextResult, - storeFqdn: flags.store, - forceReselectStore: flags.reset, - storeCreationMode: 'selection-option', - }) - - const devOptions: DevOptions = { - ...appContextResult, - store, - directory: flags.path, - update: !flags['no-update'], - unsafeValidation: flags['unsafe-validation'], - skipDependenciesInstallation: flags['skip-dependencies-installation'], - commandConfig: this.config, - subscriptionProductUrl: flags['subscription-product-url'], - checkoutCartUrl: flags['checkout-cart-url'], - theme: flags.theme, - themeExtensionPort: flags['theme-app-extension-port'], - storePassword: flags['store-password'], - notify: flags.notify, - graphiqlPort: flags['graphiql-port'], - graphiqlKey: flags['graphiql-key'], - // Must stay undefined when the flag is absent: generateCertificate() only prompts when this is nullish. - installMkcert: flags['install-mkcert'], - tunnel: tunnelMode, - devInstance, - } + const store = await storeContext({ + appContextResult, + storeFqdn: flags.store, + forceReselectStore: flags.reset, + storeCreationMode: 'selection-option', + }) - await dev(devOptions) - } finally { - await devInstance.release() + const devOptions: DevOptions = { + ...appContextResult, + store, + directory: flags.path, + update: !flags['no-update'], + unsafeValidation: flags['unsafe-validation'], + skipDependenciesInstallation: flags['skip-dependencies-installation'], + commandConfig: this.config, + subscriptionProductUrl: flags['subscription-product-url'], + checkoutCartUrl: flags['checkout-cart-url'], + theme: flags.theme, + themeExtensionPort: flags['theme-app-extension-port'], + storePassword: flags['store-password'], + notify: flags.notify, + graphiqlPort: flags['graphiql-port'], + graphiqlKey: flags['graphiql-key'], + // Must stay undefined when the flag is absent: generateCertificate() only prompts when this is nullish. + installMkcert: flags['install-mkcert'], + tunnel: tunnelMode, + devInstance, } + + await dev(devOptions) return {app: appContextResult.app} } protected async _run(): Promise { - if (!jsonOutputEnabled(process.env, this.argv)) return super._run() + const execute = async () => { + try { + return await super._run() + } finally { + await this.devInstance?.release() + } + } + if (!jsonOutputEnabled(process.env, this.argv)) return execute() return runWithCommandEvents( { outputMode: 'json', - sink: ({type: _, ...entry}) => outputResult(JSON.stringify(appDevJsonOutputSchema.validate(entry))), + sink: (event) => { + const entry: DevInstanceLogEntry = { + timestamp: event.timestamp, + source: 'app dev', + level: event.type === 'diagnostic' ? event.level : 'info', + message: event.message ?? (event.type === 'progress' ? event.operation : ''), + } + if (event.type === 'progress' && event.status === 'failed') entry.level = 'error' + if (this.devInstance) { + this.devInstance.log(entry).catch(() => {}) + } else { + this.startupLogs.push(entry) + } + }, }, - () => super._run(), + execute, ) } } diff --git a/packages/app/src/cli/services/dev/types.ts b/packages/app/src/cli/services/dev/types.ts deleted file mode 100644 index dafc47c7855..00000000000 --- a/packages/app/src/cli/services/dev/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {commandDiagnosticEventSchema, commandProgressEventSchema} from '@shopify/cli-kit/common/command-events' -import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' -import {zod} from '@shopify/cli-kit/node/schema' - -const diagnosticSchema = commandDiagnosticEventSchema.omit({type: true}) -const progressSchema = commandProgressEventSchema.omit({type: true}) - -export const appDevJsonOutputSchema = defineJsonOutputSchema({ - name: 'AppDevEvent', - schema: zod.union([diagnosticSchema, progressSchema]), - definitions: { - AppDevDiagnostic: diagnosticSchema, - AppDevProgress: progressSchema, - }, -}) diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index dd1591bf8f4..aae31f883e6 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -1,16 +1,14 @@ import {renderDev} from './ui.js' import {DevSessionUI} from './ui/components/DevSessionUI.js' import {DevSessionStatusManager} from './processes/dev-session/dev-session-status-manager.js' -import {appDevJsonOutputSchema} from './types.js' import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {afterEach, describe, expect, test, vi} from 'vitest' import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {AbortController} from '@shopify/cli-kit/node/abort' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' -import {emitCommandEvent, runWithCommandEvents} from '@shopify/cli-kit/node/command-events' -import {outputResult} 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} from '@shopify/cli-kit/node/fs' +import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' import {Writable} from 'stream' @@ -34,14 +32,13 @@ describe('ui', () => { await runWithCommandEvents( { outputMode: 'json', - sink: ({type: _, ...entry}) => outputResult(JSON.stringify(appDevJsonOutputSchema.validate(entry))), + sink: () => {}, }, async () => { const instance = await acquireDevInstance({ command: 'app dev', directory, - onLog: (entry) => - emitCommandEvent({type: 'diagnostic', level: 'info', message: `${entry.source}: ${entry.message}`}), + logOutput: process.stdout, }) const abortController = new AbortController() const completed = vi.fn() @@ -82,10 +79,10 @@ describe('ui', () => { devSessionStatusManager: new DevSessionStatusManager(), }).then(completed) try { - await vi.waitFor(() => expect(stdout()).toContain(`web: line ${burstSize - 1}`)) + await vi.waitFor(() => expect(stdout()).toContain(`line ${burstSize - 1}`)) expect(completed).not.toHaveBeenCalled() lateOutput?.write('After setup') - await vi.waitFor(() => expect(stdout()).toContain('web: After setup')) + await vi.waitFor(() => expect(stdout()).toContain('After setup')) lateOutput?.write(Buffer.from('Last partial line')) abortController.abort() await running @@ -94,6 +91,7 @@ describe('ui', () => { await running await instance.release() } + expect(stdout()).toBe(await readFile(instance.paths.logFile)) const events = stdout() .trim() .split('\n') @@ -103,16 +101,22 @@ describe('ui', () => { const messages = events.map((event) => event.message) expect(messages).toEqual( expect.arrayContaining([ - 'web: Starting without a newline', - 'discount: Building function', - 'discount: Building types', - 'web: éclair', - 'web: After setup', - 'web: Last partial line', + 'Starting without a newline', + 'Building function', + 'Building types', + 'éclair', + 'After setup', + 'Last partial line', ]), ) - expect(messages.filter((message) => message.startsWith('web: line '))).toEqual( - Array.from({length: burstSize}, (_, index) => `web: line ${index}`), + expect(messages.filter((message) => message.startsWith('line '))).toEqual( + Array.from({length: burstSize}, (_, index) => `line ${index}`), + ) + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({source: 'discount', stream: 'stdout', message: 'Building function'}), + expect.objectContaining({source: 'web', stream: 'stderr', message: 'éclair'}), + ]), ) expect(vi.mocked(DevSessionUI)).not.toHaveBeenCalled() expect(stdout()).not.toContain('\u001b') diff --git a/packages/cli-kit/src/public/node/dev-instance.test.ts b/packages/cli-kit/src/public/node/dev-instance.test.ts index 5d45b60aeb2..b7ff999c9c0 100644 --- a/packages/cli-kit/src/public/node/dev-instance.test.ts +++ b/packages/cli-kit/src/public/node/dev-instance.test.ts @@ -38,6 +38,47 @@ async function readLog(path: string) { } describe('acquireDevInstance', () => { + test('writes identical JSONL bytes to the log and an output stream without changing supplied timestamps', async () => { + await inTemporaryDirectory(async (directory) => { + const output = collectingStream() + const instance = await acquireDevInstance({command: 'app dev', directory, logOutput: output.stream}) + await Promise.all([ + instance.log({timestamp: '2026-10-20T09:00:00Z', source: 'app dev', level: 'info', message: 'Setup'}), + instance.log({source: 'web', stream: 'stderr', message: 'Warning'}), + ]) + await instance.release() + expect(output.chunks.join('')).toBe(await readFile(instance.paths.logFile)) + expect(JSON.parse(output.chunks[0]!)).toEqual({ + timestamp: '2026-10-20T09:00:00Z', + source: 'app dev', + level: 'info', + message: 'Setup', + }) + expect(output.stream.writableEnded).toBe(false) + }) + }) + + test('aborts and releases ownership when the mirror fails', async () => { + await inTemporaryDirectory(async (directory) => { + let finishWrite: ((error?: Error | null) => void) | undefined + const output = new Writable({ + write: (_chunk, _encoding, callback) => { + finishWrite = callback + }, + }) + const instance = await acquireDevInstance({command: 'app dev', directory, logOutput: output}) + const failure = new Error('Output failed') + const writing = instance.log({source: 'web', message: 'Server ready'}) + const rejected = expect(writing).rejects.toBe(failure) + await vi.waitFor(() => expect(finishWrite).toBeDefined()) + finishWrite?.(failure) + await rejected + expect(instance.abortSignal.aborted).toBe(true) + await instance.release() + await expect(readDevInstance({command: 'app dev', directory})).resolves.toBeUndefined() + }) + }) + test('describes the instance in .shopify/dev and releases it', async () => { await inTemporaryDirectory(async (directory) => { // When diff --git a/packages/cli-kit/src/public/node/dev-instance.ts b/packages/cli-kit/src/public/node/dev-instance.ts index 950ad1fbb12..7ade9633c66 100644 --- a/packages/cli-kit/src/public/node/dev-instance.ts +++ b/packages/cli-kit/src/public/node/dev-instance.ts @@ -3,7 +3,9 @@ import {joinPath} from './path.js' import {fileExists, glob, mkdir, readFile, removeFile, removeFileSync, renameFile, rmdir} from './fs.js' import {AbortError} from './error.js' import {outputDebug, unstyled} from './output.js' -import {formatJsonOutputTimestamp} from './json-output-schema.js' +import {defineJsonOutputSchema, formatJsonOutputTimestamp, jsonOutputTimestampSchema} from './json-output-schema.js' +import {jsonErrorOutputSchema, JsonErrorSchema} from './error/schema.js' +import {zod} from './schema.js' import {randomUUID} from './crypto.js' import { ConcurrentOutputContext, @@ -51,16 +53,26 @@ export interface DevInstanceInfo { details: {[key: string]: unknown} } +export const devInstanceLogOutputSchema = defineJsonOutputSchema({ + name: 'DevInstanceLogEntry', + schema: zod + .object({ + timestamp: jsonOutputTimestampSchema, + source: zod.string().describe('What produced the line, for example a process prefix like web or extensions.'), + stream: zod + .enum(['stdout', 'stderr']) + .optional() + .describe('The stream the line was written to, when it comes from a process.'), + level: zod.enum(['debug', 'info', 'warning', 'error']).optional(), + message: zod.string(), + error: JsonErrorSchema.optional().describe('The shared CLI fatal error, included when the session fails.'), + }) + .strict(), + definitions: jsonErrorOutputSchema.definitions, +}) + /** One line of a dev instance's JSON Lines log. */ -export interface DevInstanceLogEntry { - timestamp: string - /** What produced the line, for example a process prefix like `web` or `extensions`. */ - source: string - /** The stream the line was written to, when it comes from a process. */ - stream?: 'stdout' | 'stderr' - level?: 'debug' | 'info' | 'warning' | 'error' - message: string -} +export type DevInstanceLogEntry = zod.infer export interface DevInstancePaths { /** The folder that holds every dev instance of the project: `.shopify/dev`. */ @@ -85,8 +97,8 @@ export interface DevInstanceIdentity { export interface AcquireDevInstanceOptions extends DevInstanceIdentity { /** Initial command-specific details. */ details?: {[key: string]: unknown} - /** Receives each entry after it is written to the session log. */ - onLog?: (entry: DevInstanceLogEntry) => void + /** Receives the exact JSONL bytes written to the session log. The caller owns this stream. */ + logOutput?: Writable } export interface DevInstanceUpdate { @@ -103,7 +115,7 @@ export interface DevInstance { /** Updates and persists the instance description. */ update: (changes: DevInstanceUpdate) => Promise /** Appends one entry to the JSON Lines log. */ - log: (entry: Omit) => Promise + log: (entry: Omit & {timestamp?: string}) => Promise /** * Wraps a stream so every line written to it is also appended to the log, without ANSI styling. * The returned stream forwards the original bytes to `target` unchanged. @@ -248,7 +260,9 @@ async function createDevInstance(options: AcquireDevInstanceOptions, paths: DevI // Each run starts a fresh log. const logStream: WriteStream = createWriteStream(paths.logFile, {flags: 'w', mode: 0o600}) - logStream.on('error', (error) => abortController.abort(error)) + const onOutputError = (error: Error) => abortController.abort(error) + logStream.on('error', onOutputError) + options.logOutput?.on('error', onOutputError) let updates = Promise.resolve() const outputs = new Set() @@ -263,14 +277,20 @@ async function createDevInstance(options: AcquireDevInstanceOptions, paths: DevI } process.once('exit', removeInfoOnExit) - const writeLogEntry = async (entry: Omit) => { + const writeLogEntry: DevInstance['log'] = async (entry) => { if (released || compromised) return - const line: DevInstanceLogEntry = { - timestamp: formatJsonOutputTimestamp(new Date()), + const record = devInstanceLogOutputSchema.validate({ ...entry, + timestamp: entry.timestamp ?? formatJsonOutputTimestamp(new Date()), + }) + const line = `${JSON.stringify(record)}\n` + try { + await writeToStream(logStream, line) + if (options.logOutput) await writeToStream(options.logOutput, line) + } catch (error) { + abortController.abort(error) + throw error } - await writeToStream(logStream, `${JSON.stringify(line)}\n`) - options.onLog?.(line) } const instance: DevInstance = { @@ -349,7 +369,9 @@ async function createDevInstance(options: AcquireDevInstanceOptions, paths: DevI process.removeListener('exit', removeInfoOnExit) try { await endStream(logStream) + if (options.logOutput && !options.logOutput.destroyed) await writeToStream(options.logOutput, '') } finally { + options.logOutput?.off('error', onOutputError) const owned = ownsInfo() await releaseLock().catch((error: Error) => outputDebug(`Failed to release the dev instance lock: ${error.message}`), diff --git a/packages/cli-kit/src/public/node/error-handler.test.ts b/packages/cli-kit/src/public/node/error-handler.test.ts index f30ca603b30..648f7e99b29 100644 --- a/packages/cli-kit/src/public/node/error-handler.test.ts +++ b/packages/cli-kit/src/public/node/error-handler.test.ts @@ -4,16 +4,21 @@ import {ciPlatform, cloudEnvironment, isUnitTest, macAddress} from './context/lo import {mockAndCaptureOutput} from './testing/output.js' import * as error from './error.js' import {hashString} from './crypto.js' +import {LocalStorage} from './local-storage.js' +import {joinPath} from './path.js' import {isLocalEnvironment} from '../../private/node/context/service.js' import {getLastSeenUserIdAfterAuth} from '../../private/node/session.js' import {GraphQLClientError} from '../../private/node/api/headers.js' - +import * as confStore from '../../private/node/conf-store.js' import {settings} from '@oclif/core' import {beforeEach, describe, expect, test, vi} from 'vitest' +import {mkdtemp, rm} from 'fs/promises' +import {tmpdir} from 'os' const onNotify = vi.fn() const capturedEventHandler = vi.fn() let lastBugsnagEvent: {addMetadata: ReturnType; groupingHash?: string} | undefined +const runWithRateLimit = confStore.runWithRateLimit vi.mock('process') vi.mock('@bugsnag/js', () => { @@ -51,7 +56,12 @@ vi.mock('@oclif/core', async (importOriginal) => ({ Interfaces: {}, })) -beforeEach(() => { +beforeEach(async () => { + const directory = await mkdtemp(joinPath(tmpdir(), 'shopify-error-handler-')) + const storage = new LocalStorage({cwd: directory}) + const rateLimit = vi + .spyOn(confStore, 'runWithRateLimit') + .mockImplementation((options) => runWithRateLimit(options, storage)) vi.mocked(ciPlatform).mockReturnValue({isCI: true, name: 'vitest', metadata: {}}) vi.mocked(macAddress).mockResolvedValue('macAddress') vi.mocked(cloudEnvironment).mockReturnValue({platform: 'localhost', editor: false}) @@ -63,9 +73,21 @@ beforeEach(() => { vi.mocked(settings).debug = false vi.mocked(isLocalEnvironment).mockReturnValue(false) vi.mocked(getLastSeenUserIdAfterAuth).mockResolvedValue('test-user-id-123') + return async () => { + rateLimit.mockRestore() + await rm(directory, {recursive: true, force: true}) + } }) describe('errorHandler', async () => { + test('reports errors when a command supplies its own renderer', async () => { + const failure = new error.BugError('Watcher failed') + const renderError = vi.fn().mockResolvedValue(undefined) + await errorHandler(failure, undefined, renderError) + expect(renderError).toHaveBeenCalledWith(failure) + expect(onNotify).toHaveBeenCalledTimes(1) + }) + test('finishes the execution without exiting the proccess when cancel execution exception is raised', async () => { // Given vi.spyOn(process, 'exit').mockResolvedValue(null as never) diff --git a/packages/cli-kit/src/public/node/error-handler.ts b/packages/cli-kit/src/public/node/error-handler.ts index 706249ed1d4..24335d21330 100644 --- a/packages/cli-kit/src/public/node/error-handler.ts +++ b/packages/cli-kit/src/public/node/error-handler.ts @@ -40,6 +40,7 @@ function sliceNameFromStartCommand(startCommand: string | undefined): string { export async function errorHandler( error: Error & {exitCode?: number | undefined}, config?: Interfaces.Config, + renderError: typeof handler = handler, ): Promise { if (error instanceof CancelExecution) { if (error.message && error.message !== '') { @@ -53,7 +54,7 @@ export async function errorHandler( } const mappedError = await errorMapper(error) - await handler(mappedError) + await renderError(mappedError) await reportError(mappedError, config) } diff --git a/packages/cli/README.md b/packages/cli/README.md index b75766122b4..ad5089feb20 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1998,43 +1998,109 @@ 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). - With `--json`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. - Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended - runs. + With `--json`, streams the exact JSON Lines written to the session log until stopped. There is no final result. Setup + messages are buffered until the app configuration is selected and the session log is acquired. Runtime failures + include the shared CLI error in the final log record; failures before a session is acquired use the standard JSON + error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs. Use `--json-schema` to print the result, error, and event schemas. - Output from `--json` conforms to the `AppDevEvent` schema. + Output from `--json` conforms to the `DevInstanceLogEntry` schema. ```json { - "anyOf": [ - { - "$ref": "#/definitions/AppDevDiagnostic" + "type": "object", + "properties": { + "timestamp": { + "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." }, - { - "$ref": "#/definitions/AppDevProgress" + "source": { + "type": "string", + "description": "What produced the line, for example a process prefix like web or extensions." + }, + "stream": { + "type": "string", + "enum": [ + "stdout", + "stderr" + ], + "description": "The stream the line was written to, when it comes from a process." + }, + "level": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "message": { + "type": "string" + }, + "error": { + "$ref": "#/definitions/JsonError", + "description": "The shared CLI fatal error, included when the session fails." } + }, + "required": [ + "timestamp", + "source", + "message" ], - "title": "AppDevEvent", + "additionalProperties": false, + "title": "DevInstanceLogEntry", "definitions": { - "AppDevDiagnostic": { + "JsonError": { + "anyOf": [ + { + "$ref": "#/definitions/JsonAbortError" + }, + { + "$ref": "#/definitions/JsonBugError" + }, + { + "$ref": "#/definitions/JsonExternalError" + } + ] + }, + "JsonErrorCustomSection": { "type": "object", "properties": { - "timestamp": { - "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." + "title": { + "type": "string" }, - "level": { - "type": "string", - "enum": [ - "debug", - "info", - "warning", - "error" + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false + }, + "JsonAbortError": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" }, "message": { "type": "string" @@ -2042,51 +2108,108 @@ DESCRIPTION "code": { "type": "string", "minLength": 1, - "description": "A stable diagnostic code, included only when known." + "description": "A stable error code, included only when known." + }, + "tryMessage": { + "type": "string" + }, + "nextSteps": { + "type": "array", + "items": { + "type": "string" + } + }, + "customSections": { + "type": "array", + "items": { + "$ref": "#/definitions/JsonErrorCustomSection" + } + }, + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." } }, "required": [ - "timestamp", - "level", + "type", "message" ], "additionalProperties": false }, - "AppDevProgress": { + "JsonBugError": { "type": "object", "properties": { - "timestamp": { - "$ref": "#/definitions/AppDevDiagnostic/properties/timestamp" - }, - "status": { + "type": { "type": "string", - "enum": [ - "started", - "updated", - "retrying", - "completed", - "failed" - ] + "const": "bug" }, - "operation": { + "message": { + "$ref": "#/definitions/JsonAbortError/properties/message" + }, + "code": { + "$ref": "#/definitions/JsonAbortError/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/JsonAbortError/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/JsonAbortError/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/JsonAbortError/properties/customSections" + }, + "details": { + "$ref": "#/definitions/JsonAbortError/properties/details" + }, + "stack": { "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + "JsonExternalError": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "external" }, "message": { - "type": "string" + "$ref": "#/definitions/JsonAbortError/properties/message" }, - "current": { - "type": "integer", - "minimum": 0 + "code": { + "$ref": "#/definitions/JsonAbortError/properties/code" }, - "total": { - "type": "integer", - "minimum": 0 + "tryMessage": { + "$ref": "#/definitions/JsonAbortError/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/JsonAbortError/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/JsonAbortError/properties/customSections" + }, + "details": { + "$ref": "#/definitions/JsonAbortError/properties/details" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "timestamp", - "status", - "operation" + "type", + "message", + "command", + "args" ], "additionalProperties": false } diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 4543165cf9c..f086e49c62b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1191,8 +1191,8 @@ "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).\n\nWith `--json`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppDevEvent` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/AppDevDiagnostic\"\n },\n {\n \"$ref\": \"#/definitions/AppDevProgress\"\n }\n ],\n \"title\": \"AppDevEvent\",\n \"definitions\": {\n \"AppDevDiagnostic\": {\n \"type\": \"object\",\n \"properties\": {\n \"timestamp\": {\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 \"level\": {\n \"type\": \"string\",\n \"enum\": [\n \"debug\",\n \"info\",\n \"warning\",\n \"error\"\n ]\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable diagnostic code, included only when known.\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"level\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"AppDevProgress\": {\n \"type\": \"object\",\n \"properties\": {\n \"timestamp\": {\n \"$ref\": \"#/definitions/AppDevDiagnostic/properties/timestamp\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"started\",\n \"updated\",\n \"retrying\",\n \"completed\",\n \"failed\"\n ]\n },\n \"operation\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"current\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n }\n },\n \"required\": [\n \"timestamp\",\n \"status\",\n \"operation\"\n ],\n \"additionalProperties\": false\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).\n\nWith `--json`, streams diagnostic and progress events as JSON Lines to stdout until stopped. There is no final result. Fatal errors use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.", + "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\nWith `--json`, streams the exact JSON Lines written to the session log until stopped. There is no final result. Setup messages are buffered until the app configuration is selected and the session log is acquired. Runtime failures include the shared CLI error in the final log record; failures before a session is acquired use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.\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 \"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 \"source\": {\n \"type\": \"string\",\n \"description\": \"What produced the line, for example a process prefix like web or extensions.\"\n },\n \"stream\": {\n \"type\": \"string\",\n \"enum\": [\n \"stdout\",\n \"stderr\"\n ],\n \"description\": \"The stream the line was written to, when it comes from a process.\"\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 \"error\": {\n \"$ref\": \"#/definitions/JsonError\",\n \"description\": \"The shared CLI fatal error, included when the session fails.\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"source\",\n \"message\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DevInstanceLogEntry\",\n \"definitions\": {\n \"JsonError\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/JsonAbortError\"\n },\n {\n \"$ref\": \"#/definitions/JsonBugError\"\n },\n {\n \"$ref\": \"#/definitions/JsonExternalError\"\n }\n ]\n },\n \"JsonErrorCustomSection\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n },\n \"JsonAbortError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/JsonErrorCustomSection\"\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonBugError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonExternalError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\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).\n\nWith `--json`, streams the exact JSON Lines written to the session log until stopped. There is no final result. Setup messages are buffered until the app configuration is selected and the session log is acquired. Runtime failures include the shared CLI error in the final log record; failures before a session is acquired use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.", "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", From 3f876a3619cf4c28185c2060040a4ed5fb33b719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 12:25:20 +0200 Subject: [PATCH 07/23] Remove stream from app dev JSONL records --- docs/cli/app-dev-agent-workflow.md | 2 +- docs/cli/json-output.md | 4 ++-- packages/app/src/cli/commands/app/dev.test.ts | 15 ++++++++---- packages/app/src/cli/services/dev.test.ts | 7 +++--- packages/app/src/cli/services/dev.ts | 4 ++-- packages/app/src/cli/services/dev/ui.test.tsx | 11 +++++---- .../src/public/node/dev-instance.test.ts | 24 +++++++++---------- .../cli-kit/src/public/node/dev-instance.ts | 9 ++----- packages/cli/README.md | 8 ------- packages/cli/oclif.manifest.json | 2 +- 10 files changed, 40 insertions(+), 46 deletions(-) diff --git a/docs/cli/app-dev-agent-workflow.md b/docs/cli/app-dev-agent-workflow.md index 62b09e5c635..f7061f388ba 100644 --- a/docs/cli/app-dev-agent-workflow.md +++ b/docs/cli/app-dev-agent-workflow.md @@ -17,7 +17,7 @@ specific edit has reached the store. Check new output and test the behaviour bef claiming success. Web-server and browser health require their own checks. Each complete JSONL line contains `timestamp`, `source`, and `message`, -with `stream` or `level` where available. Keep a byte offset to read new lines. +with `level` where available. Keep a byte offset to read new lines. Wait for an incomplete final line to finish. If `sessionId` in the instance JSON changes, reset the offset: each new run clears the log. Logs have no size limit or rotation, so a long session can produce a large file. diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index 918189596fa..d2832421723 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -241,7 +241,7 @@ events from stderr; missing required input then produces a fatal error instead o ## Exempt only streaming commands `shopify app dev --json` writes the exact same JSONL bytes to stdout and the session log. Each record has `timestamp`, -`source`, and `message`, with `stream` or `level` when available. There is no final success result. Preview errors are +`source`, and `message`, with `level` when available. There is no final success result. Preview errors are log records so that the developer can fix the app while dev keeps running. A fatal runtime failure adds the shared CLI error under `error` in the final log record, then exits nonzero. @@ -250,7 +250,7 @@ Their original timestamps are retained. If startup fails before acquiring a sess standard `{error: {...}}` envelope go to stdout; another process's session log is never changed. ```jsonl -{"timestamp":"2026-10-20T09:00:00Z","source":"web","stream":"stdout","message":"Server listening on 3000"} +{"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"} ``` diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index 8afc975226a..c836bb690ba 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -83,11 +83,15 @@ describe('app dev command', () => { test.each([ {timestamp: '2026-10-20T09:00:00Z', source: 'app dev', level: 'info', message: 'Starting dev'}, - {timestamp: '2026-10-20T09:00:00Z', source: 'web', stream: 'stdout', message: 'Server ready'}, - ] as const)('the stream schema accepts records without type and rejects extra type fields: %j', (entry) => { - expect(JSON.parse(devInstanceLogOutputSchema.encode(entry))).toEqual(entry) - expect(() => devInstanceLogOutputSchema.validate({...entry, type: 'diagnostic'})).toThrow() - }) + {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 writes identical startup, progress, and process records to stdout and the file', @@ -120,6 +124,7 @@ describe('app dev command', () => { events.forEach((event) => { expect(() => Dev.jsonOutputSchema.validate(event)).not.toThrow() expect(event).not.toHaveProperty('type') + expect(event).not.toHaveProperty('stream') }) expect(events).toEqual( expect.arrayContaining([ diff --git a/packages/app/src/cli/services/dev.test.ts b/packages/app/src/cli/services/dev.test.ts index dee418f91c6..857a528c272 100644 --- a/packages/app/src/cli/services/dev.test.ts +++ b/packages/app/src/cli/services/dev.test.ts @@ -189,12 +189,13 @@ describe('dev with a dev instance', () => { .split('\n') .filter(Boolean) .map((line) => JSON.parse(line)) + entries.forEach((entry) => expect(entry).not.toHaveProperty('stream')) expect(entries).toEqual( expect.arrayContaining([ expect.objectContaining({source: 'app dev', message: `Preview URL: ${previewUrl}`}), expect.objectContaining({source: 'app dev', message: `GraphiQL URL (Admin API): ${graphiqlUrl}`}), - expect.objectContaining({source: 'web', stream: 'stdout', message: 'Server listening on 3000'}), - expect.objectContaining({source: 'web', stream: 'stderr', message: 'Deprecation warning'}), + expect.objectContaining({source: 'web', message: 'Server listening on 3000'}), + expect.objectContaining({source: 'web', message: 'Deprecation warning'}), expect.objectContaining({source: 'discount', message: 'Building function discount...'}), expect.objectContaining({source: 'discount', message: 'Building GraphQL types...'}), expect.objectContaining({source: 'app-preview', message: 'Update accepted'}), @@ -202,7 +203,7 @@ describe('dev with a dev instance', () => { ) expect( entries - .filter(({source, stream}) => source === 'app dev' && !stream) + .filter(({source}) => source === 'app dev') .filter(({message}) => !message.startsWith('Preview URL:') && !message.startsWith('GraphiQL URL')) .map(({level, message}) => ({level, message})), ).toEqual([ diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index d864e0123ac..61768592ef7 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -338,10 +338,10 @@ async function launchDevProcesses({ action: async (stdout, stderr, signal) => { const fn = process.function as DevProcessFunction const processStdout = devInstance - ? devInstance.tee(stdout, {source: process.prefix, stream: 'stdout', stringWritesAreMessages: true}) + ? devInstance.tee(stdout, {source: process.prefix, stringWritesAreMessages: true}) : stdout const processStderr = devInstance - ? devInstance.tee(stderr, {source: process.prefix, stream: 'stderr', stringWritesAreMessages: true}) + ? devInstance.tee(stderr, {source: process.prefix, stringWritesAreMessages: true}) : stderr return fn({stdout: processStdout, stderr: processStderr, abortSignal: signal}, process.options) }, diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index aae31f883e6..73c033a2cb8 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -51,12 +51,10 @@ describe('ui', () => { action: async (targetStdout, targetStderr) => { const childStdout = instance.tee(targetStdout, { source: 'web', - stream: 'stdout', stringWritesAreMessages: true, }) const childStderr = instance.tee(targetStderr, { source: 'web', - stream: 'stderr', stringWritesAreMessages: true, }) childStdout.write('Starting without a newline') @@ -96,7 +94,10 @@ describe('ui', () => { .trim() .split('\n') .map((line) => JSON.parse(line)) - events.forEach((event) => expect(event).not.toHaveProperty('type')) + 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( @@ -114,8 +115,8 @@ describe('ui', () => { ) expect(events).toEqual( expect.arrayContaining([ - expect.objectContaining({source: 'discount', stream: 'stdout', message: 'Building function'}), - expect.objectContaining({source: 'web', stream: 'stderr', message: 'éclair'}), + expect.objectContaining({source: 'discount', message: 'Building function'}), + expect.objectContaining({source: 'web', message: 'éclair'}), ]), ) expect(vi.mocked(DevSessionUI)).not.toHaveBeenCalled() diff --git a/packages/cli-kit/src/public/node/dev-instance.test.ts b/packages/cli-kit/src/public/node/dev-instance.test.ts index b7ff999c9c0..e16c3f07a51 100644 --- a/packages/cli-kit/src/public/node/dev-instance.test.ts +++ b/packages/cli-kit/src/public/node/dev-instance.test.ts @@ -44,7 +44,7 @@ describe('acquireDevInstance', () => { const instance = await acquireDevInstance({command: 'app dev', directory, logOutput: output.stream}) await Promise.all([ instance.log({timestamp: '2026-10-20T09:00:00Z', source: 'app dev', level: 'info', message: 'Setup'}), - instance.log({source: 'web', stream: 'stderr', message: 'Warning'}), + instance.log({source: 'web', message: 'Warning'}), ]) await instance.release() expect(output.chunks.join('')).toBe(await readFile(instance.paths.logFile)) @@ -331,7 +331,7 @@ describe('DevInstance log', () => { callback() }, }) - const output = instance.tee(target, {source: 'web', stream: 'stdout', stringWritesAreMessages: true}) + const output = instance.tee(target, {source: 'web', stringWritesAreMessages: true}) const write = (chunk: string | Buffer) => new Promise((resolve, reject) => { output.write(chunk, (error) => (error ? reject(error) : resolve())) @@ -339,7 +339,7 @@ describe('DevInstance log', () => { try { await write('Bundling extension...') await expect(readLog(instance.paths.logFile)).resolves.toEqual([ - {timestamp: expect.any(String), source: 'web', stream: 'stdout', message: 'Bundling extension...'}, + {timestamp: expect.any(String), source: 'web', message: 'Bundling extension...'}, ]) await write('Extension built') const bytes = Buffer.from('café\n') @@ -370,7 +370,7 @@ describe('DevInstance log', () => { else callback() }, }) - const output = instance.tee(target, {source: 'app-preview', stream: 'stdout'}) + const output = instance.tee(target, {source: 'app-preview'}) try { useConcurrentOutputContext({outputPrefix: 'discount'}, () => { output.write('Building discount...\n') @@ -402,7 +402,7 @@ describe('DevInstance log', () => { test('keeps incomplete messages from different sources separate', async () => { await inTemporaryDirectory(async (directory) => { const instance = await acquireDevInstance({command: 'app dev', directory}) - const output = instance.tee(collectingStream().stream, {source: 'app-preview', stream: 'stdout'}) + const output = instance.tee(collectingStream().stream, {source: 'app-preview'}) useConcurrentOutputContext({outputPrefix: 'discount'}, () => output.write('first')) useConcurrentOutputContext({outputPrefix: 'admin-action'}, () => output.write('second')) await instance.release() @@ -418,7 +418,7 @@ describe('DevInstance log', () => { // Given const instance = await acquireDevInstance({command: 'app dev', directory}) const {stream: target, chunks} = collectingStream() - const stdout = instance.tee(target, {source: 'web', stream: 'stdout'}) + const stdout = instance.tee(target, {source: 'web'}) // When stdout.write('\u001b[32mready\u001b[39m in 20ms\npartial ') @@ -431,8 +431,8 @@ describe('DevInstance log', () => { expect(chunks.join('')).toBe('\u001b[32mready\u001b[39m in 20ms\npartial line\n\n') const entries = await readLog(instance.paths.logFile) expect(entries).toEqual([ - {timestamp: expect.any(String), source: 'web', stream: 'stdout', message: 'ready in 20ms'}, - {timestamp: expect.any(String), source: 'web', stream: 'stdout', message: 'partial line'}, + {timestamp: expect.any(String), source: 'web', message: 'ready in 20ms'}, + {timestamp: expect.any(String), source: 'web', message: 'partial line'}, {timestamp: expect.any(String), source: 'app dev', level: 'info', message: 'Preview URL: https://preview'}, ]) expect(entries[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/) @@ -442,13 +442,13 @@ describe('DevInstance log', () => { test('flushes a split UTF-8 line on release without requiring the source to end', async () => { await inTemporaryDirectory(async (directory) => { const instance = await acquireDevInstance({command: 'app dev', directory}) - const output = instance.tee(collectingStream().stream, {source: 'web', stream: 'stdout'}) + const output = instance.tee(collectingStream().stream, {source: 'web'}) const bytes = Buffer.from('café') output.write(bytes.subarray(0, 4)) output.write(bytes.subarray(4)) await instance.release() await expect(readLog(instance.paths.logFile)).resolves.toEqual([ - {timestamp: expect.any(String), source: 'web', stream: 'stdout', message: 'café'}, + {timestamp: expect.any(String), source: 'web', message: 'café'}, ]) }) }) @@ -484,7 +484,7 @@ describe('DevInstance log', () => { } }, }) - const output = instance.tee(target, {source: 'web', stream: 'stdout'}) + const output = instance.tee(target, {source: 'web'}) let written = false const completed = new Promise((resolve, reject) => { const available = output.write(`${'x'.repeat(output.writableHighWaterMark)}\n`, (error) => { @@ -516,7 +516,7 @@ describe('DevInstance log', () => { callback(new Error('Output failed')) }, }) - const output = instance.tee(target, {source: 'web', stream: 'stdout'}) + const output = instance.tee(target, {source: 'web'}) await expect( new Promise((resolve, reject) => { output.write('message\n', (error) => (error ? reject(error) : resolve())) diff --git a/packages/cli-kit/src/public/node/dev-instance.ts b/packages/cli-kit/src/public/node/dev-instance.ts index 7ade9633c66..805ebd8b9e7 100644 --- a/packages/cli-kit/src/public/node/dev-instance.ts +++ b/packages/cli-kit/src/public/node/dev-instance.ts @@ -59,10 +59,6 @@ export const devInstanceLogOutputSchema = defineJsonOutputSchema({ .object({ timestamp: jsonOutputTimestampSchema, source: zod.string().describe('What produced the line, for example a process prefix like web or extensions.'), - stream: zod - .enum(['stdout', 'stderr']) - .optional() - .describe('The stream the line was written to, when it comes from a process.'), level: zod.enum(['debug', 'info', 'warning', 'error']).optional(), message: zod.string(), error: JsonErrorSchema.optional().describe('The shared CLI fatal error, included when the session fails.'), @@ -124,7 +120,6 @@ export interface DevInstance { target: Writable, options: { source: string - stream: 'stdout' | 'stderr' /** Treat each string write as a complete message. Byte writes remain buffered into lines. */ stringWritesAreMessages?: boolean }, @@ -310,7 +305,7 @@ async function createDevInstance(options: AcquireDevInstanceOptions, paths: DevI await write }, log: writeLogEntry, - tee: (target, {source, stream, stringWritesAreMessages = false}) => { + tee: (target, {source, stringWritesAreMessages = false}) => { const decoder = new StringDecoder('utf8') let pending = '' let currentSource = source @@ -320,7 +315,7 @@ async function createDevInstance(options: AcquireDevInstanceOptions, paths: DevI await Promise.all( lines.map(async (line) => { const message = unstyled(line).trimEnd() - if (message.length > 0) await writeLogEntry({source: currentSource, stream, message}) + if (message.length > 0) await writeLogEntry({source: currentSource, message}) }), ) } diff --git a/packages/cli/README.md b/packages/cli/README.md index ad5089feb20..068eae95424 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2021,14 +2021,6 @@ DESCRIPTION "type": "string", "description": "What produced the line, for example a process prefix like web or extensions." }, - "stream": { - "type": "string", - "enum": [ - "stdout", - "stderr" - ], - "description": "The stream the line was written to, when it comes from a process." - }, "level": { "type": "string", "enum": [ diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index f086e49c62b..44d6bcb0fe1 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).\n\nWith `--json`, streams the exact JSON Lines written to the session log until stopped. There is no final result. Setup messages are buffered until the app configuration is selected and the session log is acquired. Runtime failures include the shared CLI error in the final log record; failures before a session is acquired use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.\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 \"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 \"source\": {\n \"type\": \"string\",\n \"description\": \"What produced the line, for example a process prefix like web or extensions.\"\n },\n \"stream\": {\n \"type\": \"string\",\n \"enum\": [\n \"stdout\",\n \"stderr\"\n ],\n \"description\": \"The stream the line was written to, when it comes from a process.\"\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 \"error\": {\n \"$ref\": \"#/definitions/JsonError\",\n \"description\": \"The shared CLI fatal error, included when the session fails.\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"source\",\n \"message\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DevInstanceLogEntry\",\n \"definitions\": {\n \"JsonError\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/JsonAbortError\"\n },\n {\n \"$ref\": \"#/definitions/JsonBugError\"\n },\n {\n \"$ref\": \"#/definitions/JsonExternalError\"\n }\n ]\n },\n \"JsonErrorCustomSection\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n },\n \"JsonAbortError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/JsonErrorCustomSection\"\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonBugError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonExternalError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "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\nWith `--json`, streams the exact JSON Lines written to the session log until stopped. There is no final result. Setup messages are buffered until the app configuration is selected and the session log is acquired. Runtime failures include the shared CLI error in the final log record; failures before a session is acquired use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.\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 \"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 \"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 \"error\": {\n \"$ref\": \"#/definitions/JsonError\",\n \"description\": \"The shared CLI fatal error, included when the session fails.\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"source\",\n \"message\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DevInstanceLogEntry\",\n \"definitions\": {\n \"JsonError\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/JsonAbortError\"\n },\n {\n \"$ref\": \"#/definitions/JsonBugError\"\n },\n {\n \"$ref\": \"#/definitions/JsonExternalError\"\n }\n ]\n },\n \"JsonErrorCustomSection\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n },\n \"JsonAbortError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/JsonErrorCustomSection\"\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonBugError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonExternalError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\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).\n\nWith `--json`, streams the exact JSON Lines written to the session log until stopped. There is no final result. Setup messages are buffered until the app configuration is selected and the session log is acquired. Runtime failures include the shared CLI error in the final log record; failures before a session is acquired use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.", "flags": { "auth-alias": { From f00458d8b333a5f8d0d62857c3d2b2a8a047eb60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 13:03:22 +0200 Subject: [PATCH 08/23] Move app dev session ownership into the service --- packages/app/src/cli/commands/app/dev.test.ts | 5 +- packages/app/src/cli/commands/app/dev.ts | 146 +++++------------- packages/app/src/cli/services/dev.test.ts | 29 +++- packages/app/src/cli/services/dev.ts | 82 +++++++++- .../node/base-command-error-rendering.test.ts | 48 ++++++ .../cli-kit/src/public/node/base-command.ts | 8 +- 6 files changed, 204 insertions(+), 114 deletions(-) create mode 100644 packages/cli-kit/src/public/node/base-command-error-rendering.test.ts diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index c836bb690ba..d78f3ef4378 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -31,7 +31,10 @@ import { import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' import {beforeEach, describe, expect, test, vi} from 'vitest' -vi.mock('../../services/dev.js') +vi.mock('../../services/dev.js', async (importOriginal) => ({ + ...(await importOriginal()), + dev: vi.fn(), +})) vi.mock('../../services/dev-clean.js') vi.mock('../../services/app-context.js') vi.mock('../../services/store-context.js') diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 7456b6956cc..2d6cde07ebc 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -1,24 +1,16 @@ import {appFlags} from '../../flags.js' -import {dev, DevOptions} from '../../services/dev.js' +import {dev, DevOptions, DevSession} from '../../services/dev.js' import {checkFolderIsValidApp} from '../../models/app/loader.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' import {linkedAppContext} from '../../services/app-context.js' import {storeContext} from '../../services/store-context.js' import {getTunnelMode} from '../../services/dev/tunnel-mode.js' -import {Errors, Flags} from '@oclif/core' +import {Flags} from '@oclif/core' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {globalFlags, jsonFlag, portFlag} from '@shopify/cli-kit/node/cli' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' -import { - acquireDevInstance, - devInstanceLogOutputSchema, - type DevInstance, - type DevInstanceLogEntry, -} from '@shopify/cli-kit/node/dev-instance' -import {basename} from '@shopify/cli-kit/node/path' -import {runWithCommandEvents} from '@shopify/cli-kit/node/command-events' +import {devInstanceLogOutputSchema} from '@shopify/cli-kit/node/dev-instance' import {jsonOutputEnabled} from '@shopify/cli-kit/node/environment' -import {outputResult} from '@shopify/cli-kit/node/output' export default class Dev extends AppLinkedCommand { public static get requiresSyncAnalytics(): boolean { @@ -134,29 +126,7 @@ With \`--json\`, streams the exact JSON Lines written to the session log until s return 'app dev stop' } - private devInstance?: DevInstance - private startupLogs: DevInstanceLogEntry[] = [] - - async catch(error: Error & {skipOclifErrorHandling: boolean}): Promise { - const instance = this.devInstance - if (!instance || !jsonOutputEnabled(process.env, this.argv)) { - for (const entry of this.startupLogs) outputResult(JSON.stringify(entry)) - await instance?.release() - return super.catch(error) - } - error.skipOclifErrorHandling = true - const {errorHandler} = await import('@shopify/cli-kit/node/error-handler') - const {errorToJson} = await import('@shopify/cli-kit/node/error/serialization') - try { - await errorHandler(error, this.config, async (mappedError) => { - const jsonError = errorToJson(mappedError) - await instance.log({source: 'app dev', level: 'error', message: jsonError.message, error: jsonError}) - }) - } finally { - await instance.release() - } - return Errors.handle(error) - } + private readonly session = new DevSession(jsonOutputEnabled(process.env, this.argv)) public async run(): Promise { const {flags} = await this.parse(Dev) @@ -184,84 +154,46 @@ With \`--json\`, streams the exact JSON Lines written to the session log until s userProvidedConfigName: flags.config, }) - // One `app dev` per app configuration: a second one reports the running instance and exits, - // so agents can discover a session started in another terminal and read its log. - const devInstance = await acquireDevInstance({ - command: 'app dev', - directory: appContextResult.app.directory, - key: basename(appContextResult.app.configPath), - details: { - configPath: appContextResult.app.configPath, - appName: appContextResult.remoteApp.title, - clientId: appContextResult.remoteApp.apiKey, - }, - logOutput: flags.json ? process.stdout : undefined, - }) - this.devInstance = devInstance - await Promise.all(this.startupLogs.map((entry) => devInstance.log(entry))) - this.startupLogs = [] + return this.session.start(appContextResult, async (devInstance) => { + const store = await storeContext({ + appContextResult, + storeFqdn: flags.store, + forceReselectStore: flags.reset, + storeCreationMode: 'selection-option', + }) - const store = await storeContext({ - appContextResult, - storeFqdn: flags.store, - forceReselectStore: flags.reset, - storeCreationMode: 'selection-option', - }) + const devOptions: DevOptions = { + ...appContextResult, + store, + directory: flags.path, + update: !flags['no-update'], + unsafeValidation: flags['unsafe-validation'], + skipDependenciesInstallation: flags['skip-dependencies-installation'], + commandConfig: this.config, + subscriptionProductUrl: flags['subscription-product-url'], + checkoutCartUrl: flags['checkout-cart-url'], + theme: flags.theme, + themeExtensionPort: flags['theme-app-extension-port'], + storePassword: flags['store-password'], + notify: flags.notify, + graphiqlPort: flags['graphiql-port'], + graphiqlKey: flags['graphiql-key'], + // Must stay undefined when the flag is absent: generateCertificate() only prompts when this is nullish. + installMkcert: flags['install-mkcert'], + tunnel: tunnelMode, + devInstance, + } - const devOptions: DevOptions = { - ...appContextResult, - store, - directory: flags.path, - update: !flags['no-update'], - unsafeValidation: flags['unsafe-validation'], - skipDependenciesInstallation: flags['skip-dependencies-installation'], - commandConfig: this.config, - subscriptionProductUrl: flags['subscription-product-url'], - checkoutCartUrl: flags['checkout-cart-url'], - theme: flags.theme, - themeExtensionPort: flags['theme-app-extension-port'], - storePassword: flags['store-password'], - notify: flags.notify, - graphiqlPort: flags['graphiql-port'], - graphiqlKey: flags['graphiql-key'], - // Must stay undefined when the flag is absent: generateCertificate() only prompts when this is nullish. - installMkcert: flags['install-mkcert'], - tunnel: tunnelMode, - devInstance, - } + await dev(devOptions) + return {app: appContextResult.app} + }) + } - await dev(devOptions) - return {app: appContextResult.app} + protected async handleError(error: Error): Promise { + return this.session.handleError(error, this.config, () => super.handleError(error)) } protected async _run(): Promise { - const execute = async () => { - try { - return await super._run() - } finally { - await this.devInstance?.release() - } - } - if (!jsonOutputEnabled(process.env, this.argv)) return execute() - return runWithCommandEvents( - { - outputMode: 'json', - sink: (event) => { - const entry: DevInstanceLogEntry = { - timestamp: event.timestamp, - source: 'app dev', - level: event.type === 'diagnostic' ? event.level : 'info', - message: event.message ?? (event.type === 'progress' ? event.operation : ''), - } - if (event.type === 'progress' && event.status === 'failed') entry.level = 'error' - if (this.devInstance) { - this.devInstance.log(entry).catch(() => {}) - } else { - this.startupLogs.push(entry) - } - }, - }, - execute, - ) + return this.session.run(() => super._run()) } } diff --git a/packages/app/src/cli/services/dev.test.ts b/packages/app/src/cli/services/dev.test.ts index 857a528c272..3c60429eca7 100644 --- a/packages/app/src/cli/services/dev.test.ts +++ b/packages/app/src/cli/services/dev.test.ts @@ -1,4 +1,4 @@ -import {dev, blockIfMigrationIncomplete} from './dev.js' +import {dev, blockIfMigrationIncomplete, DevSession} from './dev.js' import {setupDevProcesses} from './dev/processes/setup-dev-processes.js' import {renderDev} from './dev/ui.js' import {fetchAppRemoteConfiguration} from './app/select-app.js' @@ -17,7 +17,10 @@ import {describe, expect, test, vi} from 'vitest' import {hashString} from '@shopify/cli-kit/node/crypto' import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' -import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' +import {acquireDevInstance, readDevInstance} from '@shopify/cli-kit/node/dev-instance' +import {AbortSilentError, CancelExecution} from '@shopify/cli-kit/node/error' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {Config} from '@oclif/core' import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' import {Writable} from 'stream' @@ -32,6 +35,28 @@ vi.mock('@shopify/cli-kit/node/tcp') vi.mock('../utilities/mkcert.js') describe('dev', () => { + test.each(['silent', 'cancel'])('releases the service-owned instance before exit for a %s error', async (kind) => { + await inTemporaryDirectory(async (directory) => { + const app = testAppLinked({directory}) + const session = new DevSession(true) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await session.run(async () => { + const logFile = await session.start({app, remoteApp: testOrganizationApp()}, async (instance) => { + await instance.log({source: 'app dev', message: 'Starting dev'}) + return instance.paths.logFile + }) + const error = kind === 'silent' ? new AbortSilentError() : new CancelExecution('Stopped') + await session.handleError(error, {} as Config, async () => {}) + await expect( + readDevInstance({command: 'app dev', directory, key: 'shopify.app.toml'}), + ).resolves.toBeUndefined() + expect(stdout()).toBe(await readFile(logFile)) + expect(stderr()).toBe('') + }) + }) + }) + }) + test('logs store domain metadata when launching dev', async () => { const store = testOrganizationStore({shopDomain: 'dev-store.myshopify.com'}) const app = testAppLinked() diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index 61768592ef7..79c3e2b9e70 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -36,10 +36,13 @@ import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/ import {TunnelClient} from '@shopify/cli-kit/node/plugins/tunnel' import {getBackendPort} from '@shopify/cli-kit/node/environment' import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' -import {OutputProcess, outputDebug} from '@shopify/cli-kit/node/output' +import {OutputProcess, outputDebug, outputResult} from '@shopify/cli-kit/node/output' import {hashString} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' -import {DevInstance} from '@shopify/cli-kit/node/dev-instance' +import {acquireDevInstance, type DevInstance, type DevInstanceLogEntry} from '@shopify/cli-kit/node/dev-instance' +import {basename} from '@shopify/cli-kit/node/path' +import {runWithCommandEvents} from '@shopify/cli-kit/node/command-events' +import {errorToJson} from '@shopify/cli-kit/node/error/serialization' export interface DevOptions { app: AppLinkedInterface @@ -68,6 +71,81 @@ export interface DevOptions { devInstance?: DevInstance } +/** Owns the dev instance across setup, execution, error rendering, and cleanup. */ +export class DevSession { + private instance?: DevInstance + private startupLogs: DevInstanceLogEntry[] = [] + + constructor(private readonly json: boolean) {} + + async run(execute: () => Promise): Promise { + const run = async () => { + try { + return await execute() + } finally { + await this.instance?.release() + } + } + if (!this.json) return run() + return runWithCommandEvents( + { + outputMode: 'json', + sink: (event) => { + const entry: DevInstanceLogEntry = { + timestamp: event.timestamp, + source: 'app dev', + level: event.type === 'diagnostic' ? event.level : 'info', + message: event.message ?? (event.type === 'progress' ? event.operation : ''), + } + if (event.type === 'progress' && event.status === 'failed') entry.level = 'error' + if (this.instance) { + this.instance.log(entry).catch(() => {}) + } else { + this.startupLogs.push(entry) + } + }, + }, + run, + ) + } + + async start( + {app, remoteApp}: Pick, + execute: (instance: DevInstance) => Promise, + ): Promise { + const instance = await acquireDevInstance({ + command: 'app dev', + directory: app.directory, + key: basename(app.configPath), + details: {configPath: app.configPath, appName: remoteApp.title, clientId: remoteApp.apiKey}, + logOutput: this.json ? process.stdout : undefined, + }) + this.instance = instance + await Promise.all(this.startupLogs.map((entry) => instance.log(entry))) + this.startupLogs = [] + return execute(instance) + } + + async handleError(error: Error, config: Config, handleDefault: () => Promise): Promise { + const instance = this.instance + try { + if (!instance || !this.json) { + for (const entry of this.startupLogs) outputResult(JSON.stringify(entry)) + await instance?.release() + return handleDefault() + } + const {errorHandler} = await import('@shopify/cli-kit/node/error-handler') + await errorHandler(error, config, async (mappedError) => { + const jsonError = errorToJson(mappedError) + await instance.log({source: 'app dev', level: 'error', message: jsonError.message, error: jsonError}) + }) + } finally { + // Oclif can exit immediately after handling even a silent error, without unwinding the outer lifecycle. + await instance?.release() + } + } +} + export async function dev(commandOptions: DevOptions) { const config = await prepareForDev(commandOptions) await actionsBeforeSettingUpDevProcesses(config) diff --git a/packages/cli-kit/src/public/node/base-command-error-rendering.test.ts b/packages/cli-kit/src/public/node/base-command-error-rendering.test.ts new file mode 100644 index 00000000000..3b5f7c5a9b1 --- /dev/null +++ b/packages/cli-kit/src/public/node/base-command-error-rendering.test.ts @@ -0,0 +1,48 @@ +import BaseCommand from './base-command.js' +import {AbortError} from './error.js' +import {errorHandler} from './error-handler.js' +import {reportAnalyticsEvent} from './analytics.js' +import {outputResult} from './output.js' +import {globalFlags, jsonFlag} from './cli.js' +import {withCapturedStandardStreams} from './testing/output.js' +import {Config, Errors} from '@oclif/core' +import {expect, test, vi} from 'vitest' + +vi.mock('./analytics.js', () => ({reportAnalyticsEvent: vi.fn()})) + +test.each([false, true])('preserves error reporting and exit handling with a custom renderer=%s', async (custom) => { + const failure = new AbortError('Command failed') + class TestCommand extends BaseCommand { + static flags = {...globalFlags, ...jsonFlag} + + async run() { + await this.parse(TestCommand) + throw failure + } + + execute() { + return this._run() + } + + protected async init() { + return undefined + } + + protected async handleError(error: Error): Promise { + if (!custom) return super.handleError(error) + await errorHandler(error, this.config, async () => outputResult('{"handled":true}')) + } + } + + const config = await Config.load({root: __dirname}) + vi.spyOn(Errors, 'handle').mockImplementation(async (error) => { + throw error + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(new TestCommand(['--json'], config).execute()).rejects.toBe(failure) + expect(JSON.parse(stdout())).toEqual(custom ? {handled: true} : {error: {type: 'abort', message: 'Command failed'}}) + expect(stderr()).toBe('') + }) + expect(reportAnalyticsEvent).toHaveBeenCalledWith(expect.objectContaining({config, exitMode: 'expected_error'})) + expect(Errors.handle).toHaveBeenCalledWith(failure) +}) diff --git a/packages/cli-kit/src/public/node/base-command.ts b/packages/cli-kit/src/public/node/base-command.ts index 31f333933f5..7710c56f650 100644 --- a/packages/cli-kit/src/public/node/base-command.ts +++ b/packages/cli-kit/src/public/node/base-command.ts @@ -87,8 +87,7 @@ abstract class BaseCommand extends Command { async catch(error: Error & {skipOclifErrorHandling: boolean}): Promise { const handleError = async () => { error.skipOclifErrorHandling = true - const {errorHandler} = await import('./error-handler.js') - await errorHandler(error, this.config) + await this.handleError(error) return Errors.handle(error) } if (this.jsonOutputFromFlags && commandEventOutputMode() !== 'json') { @@ -97,6 +96,11 @@ abstract class BaseCommand extends Command { return handleError() } + protected async handleError(error: Error): Promise { + const {errorHandler} = await import('./error-handler.js') + await errorHandler(error, this.config) + } + protected async _run(): Promise { return runWithCommandEventsForCommand(this.argv, () => super._run()) } From 07014f3ffac42c8e5e4c207848d1671ce337e1de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 13:12:30 +0200 Subject: [PATCH 09/23] Restore app dev command description --- packages/app/src/cli/commands/app/dev.ts | 4 +--- packages/cli/README.md | 5 ----- packages/cli/oclif.manifest.json | 4 ++-- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 2d6cde07ebc..e41797c1d28 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -19,9 +19,7 @@ export default class Dev extends AppLinkedCommand { static summary = 'Run the app.' - 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). - -With \`--json\`, streams the exact JSON Lines written to the session log until stopped. There is no final result. Setup messages are buffered until the app configuration is selected and the session log is acquired. Runtime failures include the shared CLI error in the final log record; failures before a session is acquired use the standard JSON error envelope. Setup prompts remain on stderr; use \`--no-input\` for unattended runs.` + 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 devInstanceLogOutputSchema diff --git a/packages/cli/README.md b/packages/cli/README.md index 068eae95424..ee43c3d9749 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1998,11 +1998,6 @@ 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). - With `--json`, streams the exact JSON Lines written to the session log until stopped. There is no final result. Setup - messages are buffered until the app configuration is selected and the session log is acquired. Runtime failures - include the shared CLI error in the final log record; failures before a session is acquired use the standard JSON - error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs. - Use `--json-schema` to print the result, error, and event schemas. Output from `--json` conforms to the `DevInstanceLogEntry` schema. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 44d6bcb0fe1..67d286e938d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1191,8 +1191,8 @@ "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).\n\nWith `--json`, streams the exact JSON Lines written to the session log until stopped. There is no final result. Setup messages are buffered until the app configuration is selected and the session log is acquired. Runtime failures include the shared CLI error in the final log record; failures before a session is acquired use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.\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 \"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 \"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 \"error\": {\n \"$ref\": \"#/definitions/JsonError\",\n \"description\": \"The shared CLI fatal error, included when the session fails.\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"source\",\n \"message\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DevInstanceLogEntry\",\n \"definitions\": {\n \"JsonError\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/JsonAbortError\"\n },\n {\n \"$ref\": \"#/definitions/JsonBugError\"\n },\n {\n \"$ref\": \"#/definitions/JsonExternalError\"\n }\n ]\n },\n \"JsonErrorCustomSection\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n },\n \"JsonAbortError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/JsonErrorCustomSection\"\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonBugError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonExternalError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\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).\n\nWith `--json`, streams the exact JSON Lines written to the session log until stopped. There is no final result. Setup messages are buffered until the app configuration is selected and the session log is acquired. Runtime failures include the shared CLI error in the final log record; failures before a session is acquired use the standard JSON error envelope. Setup prompts remain on stderr; use `--no-input` for unattended runs.", + "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 \"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 \"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 \"error\": {\n \"$ref\": \"#/definitions/JsonError\",\n \"description\": \"The shared CLI fatal error, included when the session fails.\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"source\",\n \"message\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DevInstanceLogEntry\",\n \"definitions\": {\n \"JsonError\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/JsonAbortError\"\n },\n {\n \"$ref\": \"#/definitions/JsonBugError\"\n },\n {\n \"$ref\": \"#/definitions/JsonExternalError\"\n }\n ]\n },\n \"JsonErrorCustomSection\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n },\n \"JsonAbortError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/JsonErrorCustomSection\"\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonBugError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonExternalError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\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": { "description": "Alias of the Shopify account to use for authentication.", From d0694dfb93737f577e5d85c9f5198a0e93c12314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 13:33:54 +0200 Subject: [PATCH 10/23] Limit app dev session logs to the dev stream --- docs/cli/app-dev-agent-workflow.md | 9 +- docs/cli/json-output.md | 16 +- packages/app/src/cli/commands/app/dev.test.ts | 185 ++++++++++-------- packages/app/src/cli/commands/app/dev.ts | 8 - packages/app/src/cli/services/dev.test.ts | 29 +-- packages/app/src/cli/services/dev.ts | 64 +----- .../node/base-command-error-rendering.test.ts | 48 ----- .../cli-kit/src/public/node/base-command.ts | 8 +- .../src/public/node/error-handler.test.ts | 26 +-- .../cli-kit/src/public/node/error-handler.ts | 3 +- packages/cli/README.md | 165 ---------------- packages/cli/oclif.manifest.json | 2 +- 12 files changed, 133 insertions(+), 430 deletions(-) delete mode 100644 packages/cli-kit/src/public/node/base-command-error-rendering.test.ts diff --git a/docs/cli/app-dev-agent-workflow.md b/docs/cli/app-dev-agent-workflow.md index f7061f388ba..9e6e05fee9f 100644 --- a/docs/cli/app-dev-agent-workflow.md +++ b/docs/cli/app-dev-agent-workflow.md @@ -26,11 +26,10 @@ Process messages keep their terminal source, including extension handles. Change to the status message also produce a log record with source `app dev`. Terminal colors, spinners, and screen redraws are not included. -With `--json`, stdout and the session log contain identical JSONL bytes. Setup -messages wait until the command acquires its session log, then retain their original -timestamps in both outputs. Runtime failures include the shared CLI error under -`error` in the final record. A startup failure before log acquisition uses the -standard JSON error output and does not change another process's log. +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. ## Agent workflow diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index d2832421723..9f2aaf4fd3e 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -240,14 +240,14 @@ events from stderr; missing required input then produces a fatal error instead o ## Exempt only streaming commands -`shopify app dev --json` writes the exact same JSONL bytes to stdout and the session log. Each record has `timestamp`, -`source`, and `message`, with `level` when available. There is no final success result. Preview errors are -log records so that the developer can fix the app while dev keeps running. A fatal runtime failure adds the shared -CLI error under `error` in the final log record, then exits nonzero. - -Setup messages are buffered until the app configuration is selected and the command acquires its session log. -Their original timestamps are retained. If startup fails before acquiring a session, buffered messages and the -standard `{error: {...}}` envelope go to stdout; another process's session log is never changed. +`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"} diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index d78f3ef4378..cb7a5122b93 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -17,7 +17,7 @@ import { import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' -import {AbortError} from '@shopify/cli-kit/node/error' +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' @@ -96,58 +96,61 @@ describe('app dev command', () => { }, ) - test.each(['--json', '-j', 'env'])( - '%s writes identical startup, progress, and process 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('Starting dev') - emitCommandEvent({type: 'progress', operation: 'app-dev-preview', status: 'updated', message: 'Updated'}) - 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() + 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)) - events.forEach((event) => { - expect(() => Dev.jsonOutputSchema.validate(event)).not.toThrow() - expect(event).not.toHaveProperty('type') - expect(event).not.toHaveProperty('stream') - }) - expect(events).toEqual( - expect.arrayContaining([ - expect.objectContaining({source: 'app dev', level: 'info', message: 'Resolving app setup'}), - expect.objectContaining({source: 'app dev', level: 'info', message: 'Starting dev'}), - expect.objectContaining({source: 'app dev', level: 'info', message: 'Updated'}), - 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()).toBe('') - }) - } finally { - vi.unstubAllEnvs() - } - }) - }, - ) + .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) => { @@ -179,41 +182,69 @@ describe('app dev command', () => { }) }) - test.each([false, true])( - 'a failure during store selection=%s writes the same final error record to both outputs', - async (duringSetup) => { + 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 - const fail = async () => { - outputInfo('Starting dev') - throw new AbortError('Dev failed') - } - vi.mocked(dev).mockImplementation(fail) - if (duringSetup) vi.mocked(storeContext).mockImplementation(fail) + 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() - .trim() - .split('\n') - .map((line) => JSON.parse(line)) - .slice(-2), - ).toEqual([ - expect.objectContaining({level: 'info', message: 'Starting dev'}), - expect.objectContaining({ - source: 'app dev', - level: 'error', - message: 'Dev failed', - error: {type: 'abort', message: 'Dev failed'}, - }), - ]) - expect(logFile).toBeDefined() expect(stdout()).toBe(await readFile(logFile)) - expect(stderr()).toBe('') + 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'}), diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index e41797c1d28..bdcae892555 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -186,12 +186,4 @@ export default class Dev extends AppLinkedCommand { return {app: appContextResult.app} }) } - - protected async handleError(error: Error): Promise { - return this.session.handleError(error, this.config, () => super.handleError(error)) - } - - protected async _run(): Promise { - return this.session.run(() => super._run()) - } } diff --git a/packages/app/src/cli/services/dev.test.ts b/packages/app/src/cli/services/dev.test.ts index 3c60429eca7..857a528c272 100644 --- a/packages/app/src/cli/services/dev.test.ts +++ b/packages/app/src/cli/services/dev.test.ts @@ -1,4 +1,4 @@ -import {dev, blockIfMigrationIncomplete, DevSession} from './dev.js' +import {dev, blockIfMigrationIncomplete} from './dev.js' import {setupDevProcesses} from './dev/processes/setup-dev-processes.js' import {renderDev} from './dev/ui.js' import {fetchAppRemoteConfiguration} from './app/select-app.js' @@ -17,10 +17,7 @@ import {describe, expect, test, vi} from 'vitest' import {hashString} from '@shopify/cli-kit/node/crypto' import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' -import {acquireDevInstance, readDevInstance} from '@shopify/cli-kit/node/dev-instance' -import {AbortSilentError, CancelExecution} from '@shopify/cli-kit/node/error' -import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' -import {Config} from '@oclif/core' +import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' import {Writable} from 'stream' @@ -35,28 +32,6 @@ vi.mock('@shopify/cli-kit/node/tcp') vi.mock('../utilities/mkcert.js') describe('dev', () => { - test.each(['silent', 'cancel'])('releases the service-owned instance before exit for a %s error', async (kind) => { - await inTemporaryDirectory(async (directory) => { - const app = testAppLinked({directory}) - const session = new DevSession(true) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await session.run(async () => { - const logFile = await session.start({app, remoteApp: testOrganizationApp()}, async (instance) => { - await instance.log({source: 'app dev', message: 'Starting dev'}) - return instance.paths.logFile - }) - const error = kind === 'silent' ? new AbortSilentError() : new CancelExecution('Stopped') - await session.handleError(error, {} as Config, async () => {}) - await expect( - readDevInstance({command: 'app dev', directory, key: 'shopify.app.toml'}), - ).resolves.toBeUndefined() - expect(stdout()).toBe(await readFile(logFile)) - expect(stderr()).toBe('') - }) - }) - }) - }) - test('logs store domain metadata when launching dev', async () => { const store = testOrganizationStore({shopDomain: 'dev-store.myshopify.com'}) const app = testAppLinked() diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index 79c3e2b9e70..cd1b395046a 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -36,13 +36,11 @@ import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/ import {TunnelClient} from '@shopify/cli-kit/node/plugins/tunnel' import {getBackendPort} from '@shopify/cli-kit/node/environment' import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' -import {OutputProcess, outputDebug, outputResult} from '@shopify/cli-kit/node/output' +import {OutputProcess, outputDebug} from '@shopify/cli-kit/node/output' import {hashString} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' -import {acquireDevInstance, type DevInstance, type DevInstanceLogEntry} from '@shopify/cli-kit/node/dev-instance' +import {acquireDevInstance, type DevInstance} from '@shopify/cli-kit/node/dev-instance' import {basename} from '@shopify/cli-kit/node/path' -import {runWithCommandEvents} from '@shopify/cli-kit/node/command-events' -import {errorToJson} from '@shopify/cli-kit/node/error/serialization' export interface DevOptions { app: AppLinkedInterface @@ -71,44 +69,10 @@ export interface DevOptions { devInstance?: DevInstance } -/** Owns the dev instance across setup, execution, error rendering, and cleanup. */ +/** Owns the dev instance across setup, execution, and cleanup. */ export class DevSession { - private instance?: DevInstance - private startupLogs: DevInstanceLogEntry[] = [] - constructor(private readonly json: boolean) {} - async run(execute: () => Promise): Promise { - const run = async () => { - try { - return await execute() - } finally { - await this.instance?.release() - } - } - if (!this.json) return run() - return runWithCommandEvents( - { - outputMode: 'json', - sink: (event) => { - const entry: DevInstanceLogEntry = { - timestamp: event.timestamp, - source: 'app dev', - level: event.type === 'diagnostic' ? event.level : 'info', - message: event.message ?? (event.type === 'progress' ? event.operation : ''), - } - if (event.type === 'progress' && event.status === 'failed') entry.level = 'error' - if (this.instance) { - this.instance.log(entry).catch(() => {}) - } else { - this.startupLogs.push(entry) - } - }, - }, - run, - ) - } - async start( {app, remoteApp}: Pick, execute: (instance: DevInstance) => Promise, @@ -120,28 +84,10 @@ export class DevSession { details: {configPath: app.configPath, appName: remoteApp.title, clientId: remoteApp.apiKey}, logOutput: this.json ? process.stdout : undefined, }) - this.instance = instance - await Promise.all(this.startupLogs.map((entry) => instance.log(entry))) - this.startupLogs = [] - return execute(instance) - } - - async handleError(error: Error, config: Config, handleDefault: () => Promise): Promise { - const instance = this.instance try { - if (!instance || !this.json) { - for (const entry of this.startupLogs) outputResult(JSON.stringify(entry)) - await instance?.release() - return handleDefault() - } - const {errorHandler} = await import('@shopify/cli-kit/node/error-handler') - await errorHandler(error, config, async (mappedError) => { - const jsonError = errorToJson(mappedError) - await instance.log({source: 'app dev', level: 'error', message: jsonError.message, error: jsonError}) - }) + return await execute(instance) } finally { - // Oclif can exit immediately after handling even a silent error, without unwinding the outer lifecycle. - await instance?.release() + await instance.release() } } } diff --git a/packages/cli-kit/src/public/node/base-command-error-rendering.test.ts b/packages/cli-kit/src/public/node/base-command-error-rendering.test.ts deleted file mode 100644 index 3b5f7c5a9b1..00000000000 --- a/packages/cli-kit/src/public/node/base-command-error-rendering.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import BaseCommand from './base-command.js' -import {AbortError} from './error.js' -import {errorHandler} from './error-handler.js' -import {reportAnalyticsEvent} from './analytics.js' -import {outputResult} from './output.js' -import {globalFlags, jsonFlag} from './cli.js' -import {withCapturedStandardStreams} from './testing/output.js' -import {Config, Errors} from '@oclif/core' -import {expect, test, vi} from 'vitest' - -vi.mock('./analytics.js', () => ({reportAnalyticsEvent: vi.fn()})) - -test.each([false, true])('preserves error reporting and exit handling with a custom renderer=%s', async (custom) => { - const failure = new AbortError('Command failed') - class TestCommand extends BaseCommand { - static flags = {...globalFlags, ...jsonFlag} - - async run() { - await this.parse(TestCommand) - throw failure - } - - execute() { - return this._run() - } - - protected async init() { - return undefined - } - - protected async handleError(error: Error): Promise { - if (!custom) return super.handleError(error) - await errorHandler(error, this.config, async () => outputResult('{"handled":true}')) - } - } - - const config = await Config.load({root: __dirname}) - vi.spyOn(Errors, 'handle').mockImplementation(async (error) => { - throw error - }) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect(new TestCommand(['--json'], config).execute()).rejects.toBe(failure) - expect(JSON.parse(stdout())).toEqual(custom ? {handled: true} : {error: {type: 'abort', message: 'Command failed'}}) - expect(stderr()).toBe('') - }) - expect(reportAnalyticsEvent).toHaveBeenCalledWith(expect.objectContaining({config, exitMode: 'expected_error'})) - expect(Errors.handle).toHaveBeenCalledWith(failure) -}) diff --git a/packages/cli-kit/src/public/node/base-command.ts b/packages/cli-kit/src/public/node/base-command.ts index 7710c56f650..31f333933f5 100644 --- a/packages/cli-kit/src/public/node/base-command.ts +++ b/packages/cli-kit/src/public/node/base-command.ts @@ -87,7 +87,8 @@ abstract class BaseCommand extends Command { async catch(error: Error & {skipOclifErrorHandling: boolean}): Promise { const handleError = async () => { error.skipOclifErrorHandling = true - await this.handleError(error) + const {errorHandler} = await import('./error-handler.js') + await errorHandler(error, this.config) return Errors.handle(error) } if (this.jsonOutputFromFlags && commandEventOutputMode() !== 'json') { @@ -96,11 +97,6 @@ abstract class BaseCommand extends Command { return handleError() } - protected async handleError(error: Error): Promise { - const {errorHandler} = await import('./error-handler.js') - await errorHandler(error, this.config) - } - protected async _run(): Promise { return runWithCommandEventsForCommand(this.argv, () => super._run()) } diff --git a/packages/cli-kit/src/public/node/error-handler.test.ts b/packages/cli-kit/src/public/node/error-handler.test.ts index 648f7e99b29..f30ca603b30 100644 --- a/packages/cli-kit/src/public/node/error-handler.test.ts +++ b/packages/cli-kit/src/public/node/error-handler.test.ts @@ -4,21 +4,16 @@ import {ciPlatform, cloudEnvironment, isUnitTest, macAddress} from './context/lo import {mockAndCaptureOutput} from './testing/output.js' import * as error from './error.js' import {hashString} from './crypto.js' -import {LocalStorage} from './local-storage.js' -import {joinPath} from './path.js' import {isLocalEnvironment} from '../../private/node/context/service.js' import {getLastSeenUserIdAfterAuth} from '../../private/node/session.js' import {GraphQLClientError} from '../../private/node/api/headers.js' -import * as confStore from '../../private/node/conf-store.js' + import {settings} from '@oclif/core' import {beforeEach, describe, expect, test, vi} from 'vitest' -import {mkdtemp, rm} from 'fs/promises' -import {tmpdir} from 'os' const onNotify = vi.fn() const capturedEventHandler = vi.fn() let lastBugsnagEvent: {addMetadata: ReturnType; groupingHash?: string} | undefined -const runWithRateLimit = confStore.runWithRateLimit vi.mock('process') vi.mock('@bugsnag/js', () => { @@ -56,12 +51,7 @@ vi.mock('@oclif/core', async (importOriginal) => ({ Interfaces: {}, })) -beforeEach(async () => { - const directory = await mkdtemp(joinPath(tmpdir(), 'shopify-error-handler-')) - const storage = new LocalStorage({cwd: directory}) - const rateLimit = vi - .spyOn(confStore, 'runWithRateLimit') - .mockImplementation((options) => runWithRateLimit(options, storage)) +beforeEach(() => { vi.mocked(ciPlatform).mockReturnValue({isCI: true, name: 'vitest', metadata: {}}) vi.mocked(macAddress).mockResolvedValue('macAddress') vi.mocked(cloudEnvironment).mockReturnValue({platform: 'localhost', editor: false}) @@ -73,21 +63,9 @@ beforeEach(async () => { vi.mocked(settings).debug = false vi.mocked(isLocalEnvironment).mockReturnValue(false) vi.mocked(getLastSeenUserIdAfterAuth).mockResolvedValue('test-user-id-123') - return async () => { - rateLimit.mockRestore() - await rm(directory, {recursive: true, force: true}) - } }) describe('errorHandler', async () => { - test('reports errors when a command supplies its own renderer', async () => { - const failure = new error.BugError('Watcher failed') - const renderError = vi.fn().mockResolvedValue(undefined) - await errorHandler(failure, undefined, renderError) - expect(renderError).toHaveBeenCalledWith(failure) - expect(onNotify).toHaveBeenCalledTimes(1) - }) - test('finishes the execution without exiting the proccess when cancel execution exception is raised', async () => { // Given vi.spyOn(process, 'exit').mockResolvedValue(null as never) diff --git a/packages/cli-kit/src/public/node/error-handler.ts b/packages/cli-kit/src/public/node/error-handler.ts index 24335d21330..706249ed1d4 100644 --- a/packages/cli-kit/src/public/node/error-handler.ts +++ b/packages/cli-kit/src/public/node/error-handler.ts @@ -40,7 +40,6 @@ function sliceNameFromStartCommand(startCommand: string | undefined): string { export async function errorHandler( error: Error & {exitCode?: number | undefined}, config?: Interfaces.Config, - renderError: typeof handler = handler, ): Promise { if (error instanceof CancelExecution) { if (error.message && error.message !== '') { @@ -54,7 +53,7 @@ export async function errorHandler( } const mappedError = await errorMapper(error) - await renderError(mappedError) + await handler(mappedError) await reportError(mappedError, config) } diff --git a/packages/cli/README.md b/packages/cli/README.md index ee43c3d9749..4fc98b4141f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2027,10 +2027,6 @@ DESCRIPTION }, "message": { "type": "string" - }, - "error": { - "$ref": "#/definitions/JsonError", - "description": "The shared CLI fatal error, included when the session fails." } }, "required": [ @@ -2040,167 +2036,6 @@ DESCRIPTION ], "additionalProperties": false, "title": "DevInstanceLogEntry", - "definitions": { - "JsonError": { - "anyOf": [ - { - "$ref": "#/definitions/JsonAbortError" - }, - { - "$ref": "#/definitions/JsonBugError" - }, - { - "$ref": "#/definitions/JsonExternalError" - } - ] - }, - "JsonErrorCustomSection": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "body": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - } - } - } - ] - } - }, - "required": [ - "body" - ], - "additionalProperties": false - }, - "JsonAbortError": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "abort" - }, - "message": { - "type": "string" - }, - "code": { - "type": "string", - "minLength": 1, - "description": "A stable error code, included only when known." - }, - "tryMessage": { - "type": "string" - }, - "nextSteps": { - "type": "array", - "items": { - "type": "string" - } - }, - "customSections": { - "type": "array", - "items": { - "$ref": "#/definitions/JsonErrorCustomSection" - } - }, - "details": { - "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." - } - }, - "required": [ - "type", - "message" - ], - "additionalProperties": false - }, - "JsonBugError": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "bug" - }, - "message": { - "$ref": "#/definitions/JsonAbortError/properties/message" - }, - "code": { - "$ref": "#/definitions/JsonAbortError/properties/code" - }, - "tryMessage": { - "$ref": "#/definitions/JsonAbortError/properties/tryMessage" - }, - "nextSteps": { - "$ref": "#/definitions/JsonAbortError/properties/nextSteps" - }, - "customSections": { - "$ref": "#/definitions/JsonAbortError/properties/customSections" - }, - "details": { - "$ref": "#/definitions/JsonAbortError/properties/details" - }, - "stack": { - "type": "string" - } - }, - "required": [ - "type", - "message" - ], - "additionalProperties": false - }, - "JsonExternalError": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "external" - }, - "message": { - "$ref": "#/definitions/JsonAbortError/properties/message" - }, - "code": { - "$ref": "#/definitions/JsonAbortError/properties/code" - }, - "tryMessage": { - "$ref": "#/definitions/JsonAbortError/properties/tryMessage" - }, - "nextSteps": { - "$ref": "#/definitions/JsonAbortError/properties/nextSteps" - }, - "customSections": { - "$ref": "#/definitions/JsonAbortError/properties/customSections" - }, - "details": { - "$ref": "#/definitions/JsonAbortError/properties/details" - }, - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "type", - "message", - "command", - "args" - ], - "additionalProperties": false - } - }, "$schema": "http://json-schema.org/draft-07/schema#" } ``` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 67d286e938d..294e64499dd 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).\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 \"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 \"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 \"error\": {\n \"$ref\": \"#/definitions/JsonError\",\n \"description\": \"The shared CLI fatal error, included when the session fails.\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"source\",\n \"message\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DevInstanceLogEntry\",\n \"definitions\": {\n \"JsonError\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/JsonAbortError\"\n },\n {\n \"$ref\": \"#/definitions/JsonBugError\"\n },\n {\n \"$ref\": \"#/definitions/JsonExternalError\"\n }\n ]\n },\n \"JsonErrorCustomSection\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n },\n \"JsonAbortError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/JsonErrorCustomSection\"\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonBugError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonExternalError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "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 \"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 \"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 \"$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": { From 085850056769308f439b0f268934cf0b37b1d69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 13:45:01 +0200 Subject: [PATCH 11/23] Own app dev sessions in the service --- packages/app/src/cli/commands/app/dev.test.ts | 5 ++- packages/app/src/cli/commands/app/dev.ts | 27 +++---------- packages/app/src/cli/services/dev.ts | 23 ++++++++++- packages/app/src/cli/services/dev/ui.test.tsx | 39 ++++++++++++++++++- 4 files changed, 70 insertions(+), 24 deletions(-) diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index e9c6aaede13..a20efa5e5bd 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -23,7 +23,10 @@ import { import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' import {beforeEach, describe, expect, test, vi} from 'vitest' -vi.mock('../../services/dev.js') +vi.mock('../../services/dev.js', async (importOriginal) => ({ + ...(await importOriginal()), + dev: vi.fn(), +})) vi.mock('../../services/dev-clean.js') vi.mock('../../services/app-context.js') vi.mock('../../services/store-context.js') diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 71380f3b005..462f75ff63d 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -1,5 +1,5 @@ import {appFlags} from '../../flags.js' -import {dev, DevOptions} from '../../services/dev.js' +import {dev, DevOptions, DevSession} from '../../services/dev.js' import {checkFolderIsValidApp} from '../../models/app/loader.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' import {linkedAppContext} from '../../services/app-context.js' @@ -9,8 +9,6 @@ import {Flags} from '@oclif/core' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {globalFlags, portFlag} from '@shopify/cli-kit/node/cli' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' -import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' -import {basename} from '@shopify/cli-kit/node/path' export default class Dev extends AppLinkedCommand { public static get requiresSyncAnalytics(): boolean { @@ -119,6 +117,8 @@ export default class Dev extends AppLinkedCommand { return 'app dev stop' } + private readonly session = new DevSession() + public async run(): Promise { const {flags} = await this.parse(Dev) @@ -145,20 +145,7 @@ export default class Dev extends AppLinkedCommand { userProvidedConfigName: flags.config, }) - // One `app dev` per app configuration: a second one reports the running instance and exits, - // so agents can discover a session started in another terminal and read its log. - const devInstance = await acquireDevInstance({ - command: 'app dev', - directory: appContextResult.app.directory, - key: basename(appContextResult.app.configPath), - details: { - configPath: appContextResult.app.configPath, - appName: appContextResult.remoteApp.title, - clientId: appContextResult.remoteApp.apiKey, - }, - }) - - try { + return this.session.start(appContextResult, async (devInstance) => { const store = await storeContext({ appContextResult, storeFqdn: flags.store, @@ -189,9 +176,7 @@ export default class Dev extends AppLinkedCommand { } await dev(devOptions) - } finally { - await devInstance.release() - } - return {app: appContextResult.app} + return {app: appContextResult.app} + }) } } diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index 61768592ef7..fdd5518c580 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -39,7 +39,8 @@ import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' import {OutputProcess, outputDebug} from '@shopify/cli-kit/node/output' import {hashString} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' -import {DevInstance} from '@shopify/cli-kit/node/dev-instance' +import {acquireDevInstance, type DevInstance} from '@shopify/cli-kit/node/dev-instance' +import {basename} from '@shopify/cli-kit/node/path' export interface DevOptions { app: AppLinkedInterface @@ -68,6 +69,26 @@ export interface DevOptions { devInstance?: DevInstance } +/** Owns the dev instance across setup, execution, and cleanup. */ +export class DevSession { + async start( + {app, remoteApp}: Pick, + execute: (instance: DevInstance) => Promise, + ): Promise { + const instance = await acquireDevInstance({ + command: 'app dev', + directory: app.directory, + key: basename(app.configPath), + details: {configPath: app.configPath, appName: remoteApp.title, clientId: remoteApp.apiKey}, + }) + try { + return await execute(instance) + } finally { + await instance.release() + } + } +} + export async function dev(commandOptions: DevOptions) { const config = await prepareForDev(commandOptions) await actionsBeforeSettingUpDevProcesses(config) diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index b9134ebc410..d5fa8e18fcb 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -3,7 +3,7 @@ import {DevSessionUI} from './ui/components/DevSessionUI.js' import {DevSessionStatusManager} from './processes/dev-session/dev-session-status-manager.js' import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {afterEach, describe, expect, test, vi} from 'vitest' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {AbortController} from '@shopify/cli-kit/node/abort' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' @@ -19,6 +19,43 @@ afterEach(() => { describe('ui', () => { describe('renderDev', () => { + 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') + await withCapturedStandardStreams(async () => { + await expect( + 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) + }) + test("doesn't use ink when terminal doesn't support TTY", async () => { vi.mocked(terminalSupportsPrompting).mockReturnValue(false) const concurrentProcess = { From d10235fb85db005223f3bea896b5ea2375e7c292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 16:46:50 +0200 Subject: [PATCH 12/23] Document the 15-second dev stale interval --- docs/cli/app-dev-agent-workflow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/app-dev-agent-workflow.md b/docs/cli/app-dev-agent-workflow.md index a2e5c3dd73d..eb5b71a8a2b 100644 --- a/docs/cli/app-dev-agent-workflow.md +++ b/docs/cli/app-dev-agent-workflow.md @@ -30,7 +30,7 @@ colors, spinners, and screen redraws are not included. 1. Find the instance file for the intended app configuration and check its app and store details. Check the PID and lock heartbeat; a saved file alone does not prove - the process is alive. A live PID blocks takeover even after the fixed 30-second + the process is alive. A live PID blocks takeover even after the fixed 15-second stale interval. Stop an unresponsive owner before starting another instance. 2. Reuse an existing session. If none is available, run `shopify app dev` through the agent's background terminal facility. Supply the app, configuration, and From 15e4692ca5876677c3302283ccc81910d84b3a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 17:01:48 +0200 Subject: [PATCH 13/23] Flush app dev output before signal shutdown --- docs/cli/app-dev-agent-workflow.md | 1 + .../cli/services/dev/ui.integration.test.ts | 86 +++++++++++++++++++ packages/app/src/cli/services/dev/ui.test.tsx | 2 + packages/app/src/cli/services/dev/ui.tsx | 60 +++++++------ packages/cli/src/bootstrap.ts | 7 +- packages/cli/src/index.ts | 7 +- 6 files changed, 134 insertions(+), 29 deletions(-) create mode 100644 packages/app/src/cli/services/dev/ui.integration.test.ts diff --git a/docs/cli/app-dev-agent-workflow.md b/docs/cli/app-dev-agent-workflow.md index eb5b71a8a2b..7af087a503f 100644 --- a/docs/cli/app-dev-agent-workflow.md +++ b/docs/cli/app-dev-agent-workflow.md @@ -41,6 +41,7 @@ colors, spinners, and screen redraws are not included. in the development store. Use a bounded wait and report errors or timeouts. Normal shutdown removes the instance record and retains the log until the next run. +SIGINT and SIGTERM cancel active dev work and wait for queued log writes before shutdown. Crashes after startup recover automatically on the next start. If the CLI is killed during the short lock-acquisition step, a `.lock.acquire` directory can remain. The next start reports its path. Confirm that no other startup is running before removing that diff --git a/packages/app/src/cli/services/dev/ui.integration.test.ts b/packages/app/src/cli/services/dev/ui.integration.test.ts new file mode 100644 index 00000000000..6a15d8d899e --- /dev/null +++ b/packages/app/src/cli/services/dev/ui.integration.test.ts @@ -0,0 +1,86 @@ +import {test, expect} from 'vitest' +import {captureOutputWithExitCode} from '@shopify/cli-kit/node/system' +import {fileExists, inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' +import {devInstancePaths} from '@shopify/cli-kit/node/dev-instance' + +const bootstrapUrl = new URL('../../../../../cli/src/bootstrap.ts', import.meta.url).href +const serviceUrl = new URL('../dev.ts', import.meta.url).href +const uiUrl = new URL('./ui.tsx', import.meta.url).href +const statusUrl = new URL('./processes/dev-session/dev-session-status-manager.ts', import.meta.url).href +const sourceLoaderUrl = new URL('../../../../../cli-kit/test/fixtures/cli-kit-source-loader.js', import.meta.url).href + +test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( + '%s keeps the default bootstrap exit when no dev session is running', + {timeout: 20000}, + async (signal) => { + const script = ` + await import(${JSON.stringify(bootstrapUrl)}) + setInterval(() => {}, 1000) + process.kill(process.pid, ${JSON.stringify(signal)}) + ` + const result = await captureOutputWithExitCode( + process.execPath, + ['--loader', 'ts-node/esm', '--loader', sourceLoaderUrl, '--input-type=module', '--eval', script], + {env: {...process.env, NODE_NO_WARNINGS: '1', SHOPIFY_CLI_ENV: 'development'}}, + ) + expect(result.exitCode, result.stderr).toBe(1) + expect(result.stderr).toBe('') + }, +) + +test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( + '%s waits for dev cancellation and queued log writes with the real bootstrap', + {timeout: 20000}, + async (signal) => { + await inTemporaryDirectory(async (directory) => { + const script = ` + await import(${JSON.stringify(bootstrapUrl)}) + const {DevSession} = await import(${JSON.stringify(serviceUrl)}) + const {renderDev} = await import(${JSON.stringify(uiUrl)}) + const {DevSessionStatusManager} = await import(${JSON.stringify(statusUrl)}) + await new DevSession().start({ + app: {directory: ${JSON.stringify(directory)}, configPath: 'shopify.app.toml'}, + remoteApp: {title: 'Local fixture', apiKey: 'fixture'}, + }, async (instance) => { + const abortController = new AbortController() + await renderDev({ + processes: [{prefix: 'web', action: async (stdout, _stderr, signal) => { + const output = instance.tee(stdout, {source: 'web'}) + for (let index = 0; index < 1000; index++) output.write('line ' + index + '\\n') + const stopped = new Promise((resolve) => signal.addEventListener('abort', () => { + output.write('Stopped\\n') + resolve() + }, {once: true})) + process.kill(process.pid, ${JSON.stringify(signal)}) + await stopped + }}], + previewUrl: 'https://example.com', + app: {id: 'fixture', developerPlatformClient: {}}, + abortController, + shopFqdn: 'fixture.myshopify.com', + devSessionStatusManager: new DevSessionStatusManager(), + }) + }) + process.stdout.write('Session cleaned up\\n') + ` + const result = await captureOutputWithExitCode( + process.execPath, + ['--loader', 'ts-node/esm', '--loader', sourceLoaderUrl, '--input-type=module', '--eval', script], + {env: {...process.env, NODE_NO_WARNINGS: '1', SHOPIFY_CLI_ENV: 'development', SHOPIFY_CLI_NO_ANALYTICS: '1'}}, + ) + expect(result.exitCode, result.stderr).toBe(0) + expect(result.stdout).toContain('Session cleaned up') + const paths = devInstancePaths({command: 'app dev', directory, key: 'shopify.app.toml'}) + const records = (await readFile(paths.logFile)) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(records.map(({message}) => message)).toEqual([ + ...Array.from({length: 1000}, (_, index) => `line ${index}`), + 'Stopped', + ]) + await expect(fileExists(paths.infoFile)).resolves.toBe(false) + await expect(fileExists(paths.lockFile)).resolves.toBe(false) + }) + }, +) diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index d5fa8e18fcb..fb94b6dccd5 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -25,6 +25,7 @@ describe('ui', () => { 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({ @@ -54,6 +55,7 @@ describe('ui', () => { }) 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 () => { diff --git a/packages/app/src/cli/services/dev/ui.tsx b/packages/app/src/cli/services/dev/ui.tsx index 3c3b207732e..4bd462bec13 100644 --- a/packages/app/src/cli/services/dev/ui.tsx +++ b/packages/app/src/cli/services/dev/ui.tsx @@ -40,34 +40,40 @@ export async function renderDev({ configPath?: string localURL?: string }) { - if (terminalSupportsPrompting()) { - return render( - { - await app.developerPlatformClient.devSessionDelete({appId: app.id, shopFqdn}) - }} - />, - { - exitOnCtrlC: false, - }, - ) - } + const stop = () => abortController.abort() + process.on('shopify:signal', stop) + try { + if (terminalSupportsPrompting()) { + return await render( + { + await app.developerPlatformClient.devSessionDelete({appId: app.id, shopFqdn}) + }} + />, + { + exitOnCtrlC: false, + }, + ) + } - await renderDevNonInteractive({ - processes, - previewUrl, - graphiqlUrl, - abortController, - }) + await renderDevNonInteractive({ + processes, + previewUrl, + graphiqlUrl, + abortController, + }) + } finally { + process.removeListener('shopify:signal', stop) + } } async function renderDevNonInteractive({ diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 0eddb65e982..fb066d519d4 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -28,10 +28,15 @@ process.on('uncaughtException', async (err) => { await renderUncaughtError(err) process.exit(1) }) +const processEvents: NodeJS.EventEmitter = process +let shutdownRequested = false const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'] signals.forEach((signal) => { process.on(signal, () => { - process.exit(1) + // A repeated signal must not interrupt a command that is flushing its output. + if (shutdownRequested) return + shutdownRequested = processEvents.emit('shopify:signal', signal) + if (!shutdownRequested) process.exit(1) }) }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a7a087bd4ce..dc7320892c5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -59,10 +59,15 @@ process.on('uncaughtException', async (err) => { await renderUncaughtError(err) process.exit(1) }) +const processEvents: NodeJS.EventEmitter = process +let shutdownRequested = false const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'] signals.forEach((signal) => { process.on(signal, () => { - process.exit(1) + // A repeated signal must not interrupt a command that is flushing its output. + if (shutdownRequested) return + shutdownRequested = processEvents.emit('shopify:signal', signal) + if (!shutdownRequested) process.exit(1) }) }) From 35b94ce32d1fd5d7adedaa6118a0ba46038f6f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 17:01:56 +0200 Subject: [PATCH 14/23] Capture Javy diagnostics in dev session logs --- .../src/cli/services/function/build.test.ts | 47 +++++++++++++++++-- .../app/src/cli/services/function/build.ts | 5 +- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/app/src/cli/services/function/build.test.ts b/packages/app/src/cli/services/function/build.test.ts index 043ab427ec5..c1bebcd99cd 100644 --- a/packages/app/src/cli/services/function/build.test.ts +++ b/packages/app/src/cli/services/function/build.test.ts @@ -27,6 +27,8 @@ 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' +import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance' +import {Writable} from 'stream' vi.mock('@shopify/cli-kit/node/system') vi.mock('@shopify/cli-kit/node/node-package-manager', async () => { @@ -478,6 +480,43 @@ describe('bundleExtension', () => { }) describe('runJavy', () => { + test('captures compiler diagnostics in the dev session log in text mode', async () => { + const {exec: execute} = + await vi.importActual('@shopify/cli-kit/node/system') + vi.mocked(exec).mockImplementation(async (_command, _args, options) => + execute( + process.execPath, + ['-e', "process.stderr.write('Javy: missing function export\\n'); process.exitCode = 1"], + options, + ), + ) + await inTemporaryDirectory(async (directory) => { + const fun = await testFunctionExtension({dir: directory}) + const instance = await acquireDevInstance({command: 'app dev', directory}) + let terminal = '' + const target = new Writable({ + write: (chunk, _encoding, done) => { + terminal += chunk.toString() + done() + }, + }) + const output = instance.tee(target, {source: 'discount', stringWritesAreMessages: true}) + try { + await expect(runJavy(fun, {stdout: output, stderr: output, app}, derivedDeps)).rejects.toThrow() + await instance.release() + expect(terminal).toContain('Javy: missing function export') + expect( + (await readFile(instance.paths.logFile)) + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toContainEqual(expect.objectContaining({source: 'discount', message: 'Javy: missing function export'})) + } finally { + await instance.release() + } + }) + }) + test('JSON context honors the supplied compiler sinks and signal', async () => { await inTemporaryDirectory(async (directory) => { const fun = await testFunctionExtension({dir: directory}) @@ -516,8 +555,8 @@ describe('runJavy', () => { ], { cwd: ourFunction.directory, - stderr: 'inherit', - stdout: 'inherit', + stderr, + stdout, signal, }, ) @@ -723,8 +762,8 @@ describe('ExportJavyBuilder', () => { ], { cwd: ourFunction.directory, - stderr: 'inherit', - stdout: 'inherit', + stderr, + stdout, signal, }, ) diff --git a/packages/app/src/cli/services/function/build.ts b/packages/app/src/cli/services/function/build.ts index e1d6e792356..57d09db0137 100644 --- a/packages/app/src/cli/services/function/build.ts +++ b/packages/app/src/cli/services/function/build.ts @@ -29,7 +29,6 @@ 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' @@ -406,8 +405,8 @@ export async function runJavy( return exec(javy.path, args, { cwd: fun.directory, - stdout: commandEventOutputMode() === 'json' ? options.stdout : 'inherit', - stderr: commandEventOutputMode() === 'json' ? options.stderr : 'inherit', + stdout: options.stdout, + stderr: options.stderr, signal: options.signal, }) } From 37aa420699dfb5443b7c080730ef949f55c2c7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 18:19:24 +0200 Subject: [PATCH 15/23] Document dev log discovery with identity hashes --- docs/cli/app-dev-agent-workflow.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/cli/app-dev-agent-workflow.md b/docs/cli/app-dev-agent-workflow.md index 7af087a503f..e609d4f932e 100644 --- a/docs/cli/app-dev-agent-workflow.md +++ b/docs/cli/app-dev-agent-workflow.md @@ -3,6 +3,7 @@ `shopify app dev` writes one instance JSON file and one JSONL log under `/.shopify/dev/` for each app configuration. Agents can read these files when dev was started in another terminal. There is no IPC endpoint or extra command. +Use the instance JSON's `logFile` field to find the log; filenames include an identity hash. The instance JSON includes the PID, `sessionId`, URLs, `logFile`, and `details.preview`. The preview contains: From b25f53b38d64fa338ff4def3d7b177186b316554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 20 Oct 2026 18:50:04 +0200 Subject: [PATCH 16/23] Run dev shutdown subprocess tests from workspace sources --- .../src/cli/services/dev/ui.integration.test.ts | 16 +++++++++++++--- packages/app/test/fixtures/app-source-loader.js | 15 +++++++++++++++ packages/app/test/fixtures/tsconfig.json | 13 +++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 packages/app/test/fixtures/app-source-loader.js create mode 100644 packages/app/test/fixtures/tsconfig.json 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 6a15d8d899e..c75c414725a 100644 --- a/packages/app/src/cli/services/dev/ui.integration.test.ts +++ b/packages/app/src/cli/services/dev/ui.integration.test.ts @@ -2,12 +2,14 @@ import {test, expect} from 'vitest' import {captureOutputWithExitCode} from '@shopify/cli-kit/node/system' import {fileExists, inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' import {devInstancePaths} from '@shopify/cli-kit/node/dev-instance' +import {fileURLToPath} from 'url' const bootstrapUrl = new URL('../../../../../cli/src/bootstrap.ts', import.meta.url).href const serviceUrl = new URL('../dev.ts', import.meta.url).href const uiUrl = new URL('./ui.tsx', import.meta.url).href const statusUrl = new URL('./processes/dev-session/dev-session-status-manager.ts', import.meta.url).href -const sourceLoaderUrl = new URL('../../../../../cli-kit/test/fixtures/cli-kit-source-loader.js', import.meta.url).href +const sourceLoaderUrl = new URL('../../../../test/fixtures/app-source-loader.js', import.meta.url).href +const tsconfigPath = fileURLToPath(new URL('../../../../test/fixtures/tsconfig.json', import.meta.url)) test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( '%s keeps the default bootstrap exit when no dev session is running', @@ -21,7 +23,7 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( const result = await captureOutputWithExitCode( process.execPath, ['--loader', 'ts-node/esm', '--loader', sourceLoaderUrl, '--input-type=module', '--eval', script], - {env: {...process.env, NODE_NO_WARNINGS: '1', SHOPIFY_CLI_ENV: 'development'}}, + {env: {...process.env, NODE_NO_WARNINGS: '1', SHOPIFY_CLI_ENV: 'development', TS_NODE_PROJECT: tsconfigPath}}, ) expect(result.exitCode, result.stderr).toBe(1) expect(result.stderr).toBe('') @@ -66,7 +68,15 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( const result = await captureOutputWithExitCode( process.execPath, ['--loader', 'ts-node/esm', '--loader', sourceLoaderUrl, '--input-type=module', '--eval', script], - {env: {...process.env, NODE_NO_WARNINGS: '1', SHOPIFY_CLI_ENV: 'development', SHOPIFY_CLI_NO_ANALYTICS: '1'}}, + { + env: { + ...process.env, + NODE_NO_WARNINGS: '1', + SHOPIFY_CLI_ENV: 'development', + SHOPIFY_CLI_NO_ANALYTICS: '1', + TS_NODE_PROJECT: tsconfigPath, + }, + }, ) expect(result.exitCode, result.stderr).toBe(0) expect(result.stdout).toContain('Session cleaned up') diff --git a/packages/app/test/fixtures/app-source-loader.js b/packages/app/test/fixtures/app-source-loader.js new file mode 100644 index 00000000000..c94cc9320d5 --- /dev/null +++ b/packages/app/test/fixtures/app-source-loader.js @@ -0,0 +1,15 @@ +const cliKitLoader = await import( + new URL('../../../cli-kit/test/fixtures/cli-kit-source-loader.js', import.meta.url).href +) + +// Unit-test CI has no generated workspace modules for these imports. +const workspaceSources = new Map([ + ['@shopify/organizations', '../../../organizations/src/index.js'], + ['@shopify/theme', '../../../theme/src/index.js'], +]) + +export function resolve(specifier, context, nextResolve) { + const source = workspaceSources.get(specifier) + if (source) return nextResolve(new URL(source, import.meta.url).href, context) + return cliKitLoader.resolve(specifier, context, nextResolve) +} diff --git a/packages/app/test/fixtures/tsconfig.json b/packages/app/test/fixtures/tsconfig.json new file mode 100644 index 00000000000..837f0441caf --- /dev/null +++ b/packages/app/test/fixtures/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../configurations/tsconfig.json", + "compilerOptions": { + "paths": { + "@shopify/cli-kit/*": ["../../../cli-kit/src/public/*"], + "@shopify/organizations": ["../../../organizations/src/index.ts"], + "@shopify/plugin-did-you-mean": ["../../../plugin-did-you-mean/src/index.ts"], + "@shopify/store": ["../../../store/src/index.ts"], + "@shopify/theme": ["../../../theme/src/index.ts"], + "@shopify/theme/*": ["../../../theme/src/*"] + } + } +} From 77ab941f7fd21d320420194eb3991a858507b113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 21 Oct 2026 12:18:25 +0200 Subject: [PATCH 17/23] Give each JSON dev process its own output sink --- packages/app/src/cli/services/dev/ui.test.tsx | 52 +++++++++++++++++++ packages/app/src/cli/services/dev/ui.tsx | 13 +++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index 702c6947aa5..d50d807f4f6 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -24,6 +24,58 @@ afterEach(() => { describe('ui', () => { describe('renderDev', () => { + 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') + }, + })), + 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() diff --git a/packages/app/src/cli/services/dev/ui.tsx b/packages/app/src/cli/services/dev/ui.tsx index 8de07b6796a..e12909e9777 100644 --- a/packages/app/src/cli/services/dev/ui.tsx +++ b/packages/app/src/cli/services/dev/ui.tsx @@ -101,10 +101,15 @@ async function renderDevNonInteractive({ if (signal.aborted) resolve() }) // JSON events come from the session log, including output written after a watcher finishes setup. - const terminalOutput = new Writable({write: (_chunk, _encoding, next) => next()}) - const running = processes.map(async (concurrentProcess) => - concurrentProcess.action(json ? terminalOutput : process.stdout, json ? terminalOutput : process.stderr, signal), - ) + 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 : process.stdout, + json ? terminalOutput : process.stderr, + signal, + ) + }) try { // A watcher can finish setup while it continues serving. Keep the session registered until abort. await Promise.all([...running, stopped]) From ec08bdd80056bb3ddc87be9ee34ae8e899f272d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 21 Oct 2026 12:28:11 +0200 Subject: [PATCH 18/23] Exit non-interactive dev after the postrun hook --- .../app/src/cli/services/dev/ui.integration.test.ts | 11 ++++++++++- packages/app/src/cli/services/dev/ui.test.tsx | 1 + packages/app/src/cli/services/dev/ui.tsx | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) 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 c75c414725a..c7fb3bdae9c 100644 --- a/packages/app/src/cli/services/dev/ui.integration.test.ts +++ b/packages/app/src/cli/services/dev/ui.integration.test.ts @@ -8,6 +8,7 @@ const bootstrapUrl = new URL('../../../../../cli/src/bootstrap.ts', import.meta. const serviceUrl = new URL('../dev.ts', import.meta.url).href const uiUrl = new URL('./ui.tsx', import.meta.url).href const statusUrl = new URL('./processes/dev-session/dev-session-status-manager.ts', import.meta.url).href +const postrunUrl = new URL('../../../../../cli-kit/src/public/node/hooks/postrun.ts', import.meta.url).href const sourceLoaderUrl = new URL('../../../../test/fixtures/app-source-loader.js', import.meta.url).href const tsconfigPath = fileURLToPath(new URL('../../../../test/fixtures/tsconfig.json', import.meta.url)) @@ -31,7 +32,7 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( ) test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( - '%s waits for dev cancellation and queued log writes with the real bootstrap', + '%s flushes queued output and exits with background work after the postrun hook', {timeout: 20000}, async (signal) => { await inTemporaryDirectory(async (directory) => { @@ -40,6 +41,7 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( const {DevSession} = await import(${JSON.stringify(serviceUrl)}) const {renderDev} = await import(${JSON.stringify(uiUrl)}) const {DevSessionStatusManager} = await import(${JSON.stringify(statusUrl)}) + const {hook} = await import(${JSON.stringify(postrunUrl)}) await new DevSession().start({ app: {directory: ${JSON.stringify(directory)}, configPath: 'shopify.app.toml'}, remoteApp: {title: 'Local fixture', apiKey: 'fixture'}, @@ -47,6 +49,7 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( const abortController = new AbortController() await renderDev({ processes: [{prefix: 'web', action: async (stdout, _stderr, signal) => { + setInterval(() => {}, 1000) const output = instance.tee(stdout, {source: 'web'}) for (let index = 0; index < 1000; index++) output.write('line ' + index + '\\n') const stopped = new Promise((resolve) => signal.addEventListener('abort', () => { @@ -64,6 +67,9 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( }) }) process.stdout.write('Session cleaned up\\n') + await hook.call({}, {config: {}, Command: {id: 'app:dev'}}) + process.stdout.write('Postrun completed\\n') + setTimeout(() => process.exit(2), 5000) ` const result = await captureOutputWithExitCode( process.execPath, @@ -74,12 +80,15 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( NODE_NO_WARNINGS: '1', SHOPIFY_CLI_ENV: 'development', SHOPIFY_CLI_NO_ANALYTICS: '1', + SHOPIFY_CLI_FORCE_AUTO_UPGRADE: '0', + CI: '1', TS_NODE_PROJECT: tsconfigPath, }, }, ) expect(result.exitCode, result.stderr).toBe(0) expect(result.stdout).toContain('Session cleaned up') + expect(result.stdout).toContain('Postrun completed') const paths = devInstancePaths({command: 'app dev', directory, key: 'shopify.app.toml'}) const records = (await readFile(paths.logFile)) .trim() diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index fb94b6dccd5..556f11e3d43 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -8,6 +8,7 @@ import {AbortController} from '@shopify/cli-kit/node/abort' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' vi.mock('@shopify/cli-kit/node/system') +vi.mock('@shopify/cli-kit/node/hooks/postrun', () => ({waitForPostRunHookAndExit: vi.fn()})) vi.mock('./ui/components/DevSessionUI.js') const developerPlatformClient = testDeveloperPlatformClient() diff --git a/packages/app/src/cli/services/dev/ui.tsx b/packages/app/src/cli/services/dev/ui.tsx index 4bd462bec13..7d37c1a38ac 100644 --- a/packages/app/src/cli/services/dev/ui.tsx +++ b/packages/app/src/cli/services/dev/ui.tsx @@ -6,6 +6,7 @@ import {AbortController} from '@shopify/cli-kit/node/abort' 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' interface DevProps { processes: OutputProcess[] @@ -71,6 +72,7 @@ export async function renderDev({ graphiqlUrl, abortController, }) + waitForPostRunHookAndExit() } finally { process.removeListener('shopify:signal', stop) } From ce1708a7ea1e862b02e16ec75b51ab4164341bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 21 Oct 2026 12:30:34 +0200 Subject: [PATCH 19/23] Cover single-signal shutdown in JSON mode --- .../cli/services/dev/ui.integration.test.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) 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 c7fb3bdae9c..b074a76a326 100644 --- a/packages/app/src/cli/services/dev/ui.integration.test.ts +++ b/packages/app/src/cli/services/dev/ui.integration.test.ts @@ -31,10 +31,15 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( }, ) -test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( - '%s flushes queued output and exits with background work after the postrun hook', +test.skipIf(process.platform === 'win32').each([ + ['SIGINT', 'text'], + ['SIGTERM', 'text'], + ['SIGINT', 'json'], + ['SIGTERM', 'json'], +])( + '%s in %s mode flushes queued output and exits with background work after the postrun hook', {timeout: 20000}, - async (signal) => { + async (signal, mode) => { await inTemporaryDirectory(async (directory) => { const script = ` await import(${JSON.stringify(bootstrapUrl)}) @@ -42,7 +47,8 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( const {renderDev} = await import(${JSON.stringify(uiUrl)}) const {DevSessionStatusManager} = await import(${JSON.stringify(statusUrl)}) const {hook} = await import(${JSON.stringify(postrunUrl)}) - await new DevSession().start({ + const {runWithCommandEvents} = await import('@shopify/cli-kit/node/command-events') + await runWithCommandEvents({outputMode: ${JSON.stringify(mode)}, sink: () => {}}, () => new DevSession(${mode === 'json'}).start({ app: {directory: ${JSON.stringify(directory)}, configPath: 'shopify.app.toml'}, remoteApp: {title: 'Local fixture', apiKey: 'fixture'}, }, async (instance) => { @@ -65,16 +71,17 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( 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.exit(2), 5000) ` const result = await captureOutputWithExitCode( process.execPath, ['--loader', 'ts-node/esm', '--loader', sourceLoaderUrl, '--input-type=module', '--eval', script], { + stripFinalNewline: false, env: { ...process.env, NODE_NO_WARNINGS: '1', @@ -87,10 +94,12 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( }, ) 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)) From 057576871e53bc711a9988bb6ccb646076532c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 21 Oct 2026 12:40:48 +0200 Subject: [PATCH 20/23] Separate complete messages in non-interactive dev output --- packages/app/src/cli/services/dev/ui.test.tsx | 45 ++++++++++++++++++- packages/app/src/cli/services/dev/ui.tsx | 15 ++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index 556f11e3d43..0d35081b638 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -6,6 +6,8 @@ import {afterEach, describe, expect, test, vi} from 'vitest' import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' 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 {Writable} from 'stream' vi.mock('@shopify/cli-kit/node/system') vi.mock('@shopify/cli-kit/node/hooks/postrun', () => ({waitForPostRunHookAndExit: vi.fn()})) @@ -20,6 +22,45 @@ afterEach(() => { describe('ui', () => { describe('renderDev', () => { + test('separates CLI messages while preserving line endings and subprocess bytes', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + const abortController = new AbortController() + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await renderDev({ + processes: [ + { + prefix: 'app-logs', + action: async (output, errorOutput) => { + await Promise.all( + [output, errorOutput].map(async (stream) => { + stream.write('') + outputWarn('Error while polling app logs.', stream) + outputWarn('Retrying in 5 seconds.', stream) + stream.write('Existing line ending\r\n') + stream.write('636166c3a9', 'hex') + const unicode = Buffer.from('éclair\n') + stream.write(unicode.subarray(0, 1)) + await new Promise((resolve, reject) => + stream.write(unicode.subarray(1), (error) => (error ? reject(error) : resolve())), + ) + }), + ) + abortController.abort() + }, + }, + ], + previewUrl: 'https://example.com', + app: {id: '123', developerPlatformClient}, + abortController, + shopFqdn: 'dev-store.myshopify.com', + devSessionStatusManager: new DevSessionStatusManager(), + }) + const expected = 'Error while polling app logs.\nRetrying in 5 seconds.\nExisting line ending\r\ncafé\néclair\n' + expect(unstyled(stdout())).toBe(`\nPreview URL: https://example.com\n${expected}`) + expect(unstyled(stderr())).toBe(expected) + }) + }) + test('a process failure aborts and waits for the other processes to stop', async () => { vi.mocked(terminalSupportsPrompting).mockReturnValue(false) const abortController = new AbortController() @@ -85,8 +126,8 @@ describe('ui', () => { expect(concurrentProcess.action).toHaveBeenNthCalledWith( 1, - process.stdout, - process.stderr, + expect.any(Writable), + expect.any(Writable), abortController.signal, ) }) diff --git a/packages/app/src/cli/services/dev/ui.tsx b/packages/app/src/cli/services/dev/ui.tsx index 7d37c1a38ac..fb6c709ff24 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 {Writable} from 'stream' interface DevProps { processes: OutputProcess[] @@ -100,7 +101,7 @@ async function renderDevNonInteractive({ if (signal.aborted) resolve() }) const running = processes.map(async (concurrentProcess) => - concurrentProcess.action(process.stdout, process.stderr, signal), + concurrentProcess.action(createTextOutputStream(process.stdout), createTextOutputStream(process.stderr), signal), ) try { // A watcher can finish setup while it continues serving. Keep the session registered until abort. @@ -115,3 +116,15 @@ async function renderDevNonInteractive({ process.removeListener('SIGTERM', stop) } } + +function createTextOutputStream(target: Writable): Writable { + return new Writable({ + decodeStrings: false, + write(chunk: string | Buffer, encoding, callback) { + // CLI string writes are complete messages; subprocess byte chunks can contain partial lines. + const output = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk + const needsNewline = typeof chunk === 'string' && output.length > 0 && output[output.length - 1] !== 10 + target.write(needsNewline ? Buffer.concat([output, Buffer.from('\n')]) : output, callback) + }, + }) +} From 1ae53607d9b355143d8527760f626e2599246086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 21 Oct 2026 13:28:32 +0200 Subject: [PATCH 21/23] Use scoped dev instance ownership in the app service --- packages/app/src/cli/commands/app/dev.ts | 6 ++--- packages/app/src/cli/services/dev.ts | 26 ++++++++----------- .../cli/services/dev/ui.integration.test.ts | 4 +-- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 462f75ff63d..a32290c0cff 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -1,5 +1,5 @@ import {appFlags} from '../../flags.js' -import {dev, DevOptions, DevSession} from '../../services/dev.js' +import {dev, DevOptions, withDevSession} from '../../services/dev.js' import {checkFolderIsValidApp} from '../../models/app/loader.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' import {linkedAppContext} from '../../services/app-context.js' @@ -117,8 +117,6 @@ export default class Dev extends AppLinkedCommand { return 'app dev stop' } - private readonly session = new DevSession() - public async run(): Promise { const {flags} = await this.parse(Dev) @@ -145,7 +143,7 @@ export default class Dev extends AppLinkedCommand { userProvidedConfigName: flags.config, }) - return this.session.start(appContextResult, async (devInstance) => { + return withDevSession(appContextResult, 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 fdd5518c580..ef292d7dc30 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -39,7 +39,7 @@ import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' import {OutputProcess, outputDebug} from '@shopify/cli-kit/node/output' import {hashString} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' -import {acquireDevInstance, type DevInstance} from '@shopify/cli-kit/node/dev-instance' +import {withDevInstance, type DevInstance} from '@shopify/cli-kit/node/dev-instance' import {basename} from '@shopify/cli-kit/node/path' export interface DevOptions { @@ -69,24 +69,20 @@ export interface DevOptions { devInstance?: DevInstance } -/** Owns the dev instance across setup, execution, and cleanup. */ -export class DevSession { - async start( - {app, remoteApp}: Pick, - execute: (instance: DevInstance) => Promise, - ): Promise { - const instance = await acquireDevInstance({ +/** Runs app development while the configuration's dev instance is owned. */ +export function withDevSession( + {app, remoteApp}: Pick, + execute: (instance: DevInstance) => T | Promise, +): Promise { + return withDevInstance( + { command: 'app dev', directory: app.directory, key: basename(app.configPath), details: {configPath: app.configPath, appName: remoteApp.title, clientId: remoteApp.apiKey}, - }) - try { - return await execute(instance) - } finally { - await instance.release() - } - } + }, + execute, + ) } export async function dev(commandOptions: DevOptions) { 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 c7fb3bdae9c..e9851fb2fa8 100644 --- a/packages/app/src/cli/services/dev/ui.integration.test.ts +++ b/packages/app/src/cli/services/dev/ui.integration.test.ts @@ -38,11 +38,11 @@ test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])( await inTemporaryDirectory(async (directory) => { const script = ` await import(${JSON.stringify(bootstrapUrl)}) - const {DevSession} = await import(${JSON.stringify(serviceUrl)}) + const {withDevSession} = await import(${JSON.stringify(serviceUrl)}) const {renderDev} = await import(${JSON.stringify(uiUrl)}) const {DevSessionStatusManager} = await import(${JSON.stringify(statusUrl)}) const {hook} = await import(${JSON.stringify(postrunUrl)}) - await new DevSession().start({ + await withDevSession({ app: {directory: ${JSON.stringify(directory)}, configPath: 'shopify.app.toml'}, remoteApp: {title: 'Local fixture', apiKey: 'fixture'}, }, async (instance) => { From a6a9507fb2ea764d7375ccf08eb952a83b0fafb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 22 Oct 2026 15:23:03 +0200 Subject: [PATCH 22/23] Expose app dev session-file schemas --- .changeset/bright-dev-stream.md | 2 +- docs/cli/dev-instance-agent-workflow.md | 11 +- packages/app/src/cli/commands/app/dev.test.ts | 56 ++++++- packages/app/src/cli/commands/app/dev.ts | 4 +- packages/app/src/cli/services/dev/schema.ts | 49 +++++++ .../src/private/node/command-json-schema.ts | 1 + packages/cli/README.md | 138 +++++++++++++++++- packages/cli/oclif.manifest.json | 2 +- 8 files changed, 248 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/cli/services/dev/schema.ts diff --git a/.changeset/bright-dev-stream.md b/.changeset/bright-dev-stream.md index 394dba8bdbf..4fbdb05bb25 100644 --- a/.changeset/bright-dev-stream.md +++ b/.changeset/bright-dev-stream.md @@ -2,4 +2,4 @@ '@shopify/cli': minor --- -Add `app dev --json` to stream development output as JSON Lines. +Add `app dev --json` streaming and expose session-file schemas with `--json-schema`. diff --git a/docs/cli/dev-instance-agent-workflow.md b/docs/cli/dev-instance-agent-workflow.md index 19802cbdff7..b088f608d45 100644 --- a/docs/cli/dev-instance-agent-workflow.md +++ b/docs/cli/dev-instance-agent-workflow.md @@ -53,12 +53,13 @@ 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`. diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index cb7a5122b93..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' @@ -16,6 +17,7 @@ import { } from '../../models/app/app.test-data.js' 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' @@ -76,7 +78,8 @@ describe('app dev command', () => { } test('declares its streaming schema and retains inherited flags', () => { - expect(Dev.jsonOutputSchema).toBe(devInstanceLogOutputSchema) + 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') @@ -84,6 +87,55 @@ describe('app dev command', () => { 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'}, diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index ada2b9def62..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' @@ -9,7 +10,6 @@ import {Flags} from '@oclif/core' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {globalFlags, jsonFlag, portFlag} from '@shopify/cli-kit/node/cli' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' -import {devInstanceLogOutputSchema} from '@shopify/cli-kit/node/dev-instance' export default class Dev extends AppLinkedCommand { public static get requiresSyncAnalytics(): boolean { @@ -21,7 +21,7 @@ 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 devInstanceLogOutputSchema + return appDevOutputSchema } static description = this.descriptionForHelp() 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/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 05da1807873..ee60799f6fb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2007,10 +2007,7 @@ DESCRIPTION "type": "object", "properties": { "timestamp": { - "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." + "$ref": "#/definitions/DevInstance/properties/startedAt" }, "source": { "type": "string", @@ -2036,6 +2033,139 @@ DESCRIPTION ], "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#" } ``` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index a197662d7c1..cdb1ebae547 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).\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 \"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 \"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 \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "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": { From 087dc019ee31f9326bbe4c8ccbf7cbbfd1a6b203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 22 Oct 2026 16:15:33 +0200 Subject: [PATCH 23/23] Check named definitions in documentation schemas --- packages/cli/src/cli/commands/doc/fetch.test.ts | 6 ++++-- packages/cli/src/cli/commands/doc/search.test.ts | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) 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()