From fa5989ad054289adb48615afbd069460f5db5b40 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 12:54:01 +0200 Subject: [PATCH 1/2] Emit info and warning alerts as JSON diagnostics --- .changeset/json-alert-diagnostics.md | 5 + docs/cli/json-output.md | 6 +- .../cli-kit/src/private/node/json-error.ts | 31 +----- .../cli-kit/src/private/node/ui/alert.tsx | 54 ++++++++--- .../private/node/ui/components/token-item.ts | 30 ++++++ packages/cli-kit/src/public/node/ui.test.ts | 97 ++++++++++++++++++- 6 files changed, 174 insertions(+), 49 deletions(-) create mode 100644 .changeset/json-alert-diagnostics.md diff --git a/.changeset/json-alert-diagnostics.md b/.changeset/json-alert-diagnostics.md new file mode 100644 index 00000000000..8baac1800ef --- /dev/null +++ b/.changeset/json-alert-diagnostics.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': patch +--- + +Emit info and warning banners as JSON diagnostics on stderr in JSON mode. diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index ca873843073..5e1c86484e9 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -296,9 +296,9 @@ mode. Inheriting `--json-schema` or enabling `SHOPIFY_FLAG_JSON=1` doesn't conve bypasses the JSON fatal error path and can leave stdout empty. Put command validation in the command lifecycle and throw an `AbortError` so CLI Kit can encode the failure. - In the command event context, `outputInfo`, `outputWarn`, and `outputDebug` use diagnostic events in JSON mode when - using their default logger. Banners such as `renderSuccess` and `renderWarning` still render terminal text to stderr; - they aren't automatically converted to events. Use `emitCommandEvent` from `@shopify/cli-kit/node/command-events` - for diagnostics, and keep human-only banners in the text presenter. + using their default logger. `renderInfo` and `renderWarning` also emit diagnostic events on stderr in JSON mode, + retaining their banners in text mode. Their messages include the alert's body, next steps, references, links, and + custom sections. `renderSuccess` and `renderError` still render terminal text; keep these banners in text presenters. - Third-party loggers and child processes aren't automatically converted or silenced. Use `jsonOutputEnabled()` from `@shopify/cli-kit/node/environment` to silence or capture their output in JSON mode. Reserve stdout for the encoded result or fatal error document, and send diagnostics through the event helpers to stderr. diff --git a/packages/cli-kit/src/private/node/json-error.ts b/packages/cli-kit/src/private/node/json-error.ts index d1a9088d6c7..3f36df64262 100644 --- a/packages/cli-kit/src/private/node/json-error.ts +++ b/packages/cli-kit/src/private/node/json-error.ts @@ -1,4 +1,4 @@ -import {tokenItemToString, type Token, type TokenItem} from './ui/components/token-item.js' +import {tokenItemToJsonString, type TokenItem} from './ui/components/token-item.js' import {FatalErrorType} from '../../public/node/error.js' import {jsonErrorOutputSchema} from '../../public/node/error/schema.js' import {outputResult, unstyled} from '../../public/node/output.js' @@ -42,35 +42,6 @@ function jsonErrorType(error: FatalErrorLike, external: ExternalCommand | undefi return 'bug' } -function tokenToJsonString(token: Token): string { - if (typeof token === 'string') return token - - if ('link' in token) { - const {label, url} = token.link - return label && label !== url ? `${label} (${url})` : url - } - - if ('list' in token) { - const title = token.list.title ? tokenItemToJsonString(token.list.title).trim() : undefined - const items = token.list.items.map(tokenItemToJsonString).join('; ') - return title ? `${title}${items ? `: ${items}` : ''}` : items - } - - return tokenItemToString(token) -} - -function tokenItemToJsonString(token: TokenItem): string { - if (!Array.isArray(token)) return tokenToJsonString(token) - - return token - .map((item, index) => { - const value = tokenToJsonString(item) - const needsLeadingSpace = index !== 0 && !(typeof item !== 'string' && 'char' in item) - return needsLeadingSpace ? ` ${value}` : value - }) - .join('') -} - function jsonTokenItem(token: unknown): string | undefined { if (token === null || token === undefined) return diff --git a/packages/cli-kit/src/private/node/ui/alert.tsx b/packages/cli-kit/src/private/node/ui/alert.tsx index ad73332c2c0..7f15f0959a7 100644 --- a/packages/cli-kit/src/private/node/ui/alert.tsx +++ b/packages/cli-kit/src/private/node/ui/alert.tsx @@ -1,6 +1,8 @@ import {Alert, AlertProps} from './components/Alert.js' +import {tokenItemToJsonString} from './components/token-item.js' import {renderOnce} from '../ui.js' -import {LogLevel} from '../../../public/node/output.js' +import {commandEventOutputMode} from '../command-event-context.js' +import {LogLevel, outputInfo, outputWarn} from '../../../public/node/output.js' import React from 'react' import {RenderOptions} from 'ink' @@ -16,19 +18,25 @@ export interface AlertOptions extends AlertProps { renderOptions?: RenderOptions } -export function alert({ - type, - headline, - body, - nextSteps, - reference, - link, - customSections, - orderedNextSteps = false, - renderOptions, -}: AlertOptions) { - // eslint-disable-next-line prefer-rest-params - const {type: alertType, ..._eventProps} = arguments[0] +export function alert(options: AlertOptions) { + const { + type, + headline, + body, + nextSteps, + reference, + link, + customSections, + orderedNextSteps = false, + renderOptions, + } = options + + if ((type === 'info' || type === 'warning') && commandEventOutputMode() === 'json') { + const message = alertMessage(options) + if (type === 'warning') outputWarn(message) + else outputInfo(message) + return message + } return renderOnce( { + const content = + typeof section.body === 'object' && 'tabularData' in section.body + ? section.body.tabularData.map((row) => row.map(tokenItemToJsonString).join('\t')).join('\n') + : tokenItemToJsonString(section.body) + return section.title ? `${section.title}:\n${content}` : content + }), + ] + return sections.filter((section): section is string => section !== undefined).join('\n\n') +} diff --git a/packages/cli-kit/src/private/node/ui/components/token-item.ts b/packages/cli-kit/src/private/node/ui/components/token-item.ts index 2186e160e01..59da2ad96c8 100644 --- a/packages/cli-kit/src/private/node/ui/components/token-item.ts +++ b/packages/cli-kit/src/private/node/ui/components/token-item.ts @@ -103,3 +103,33 @@ export function tokenItemToString(token: TokenItem): string { export function appendToTokenItem(token: TokenItem, suffix: string): TokenItem { return Array.isArray(token) ? [...token, {char: suffix}] : [token, {char: suffix}] } + +/** Converts tokens to JSON message text, retaining link URLs and list titles. */ +export function tokenItemToJsonString(token: TokenItem): string { + if (!Array.isArray(token)) return tokenToJsonString(token) + + return token + .map((item, index) => { + const value = tokenToJsonString(item) + const needsLeadingSpace = index !== 0 && !(typeof item !== 'string' && 'char' in item) + return needsLeadingSpace ? ` ${value}` : value + }) + .join('') +} + +function tokenToJsonString(token: Token): string { + if (typeof token === 'string') return token + + if ('link' in token) { + const {label, url} = token.link + return label && label !== url ? `${label} (${url})` : url + } + + if ('list' in token) { + const title = token.list.title ? tokenItemToJsonString(token.list.title).trim() : undefined + const items = token.list.items.map(tokenItemToJsonString).join('; ') + return title ? `${title}${items ? `: ${items}` : ''}` : items + } + + return tokenItemToString(token) +} diff --git a/packages/cli-kit/src/public/node/ui.test.ts b/packages/cli-kit/src/public/node/ui.test.ts index 0276f4cae32..1e8434936c5 100644 --- a/packages/cli-kit/src/public/node/ui.test.ts +++ b/packages/cli-kit/src/public/node/ui.test.ts @@ -11,9 +11,9 @@ import { } from './ui.js' import {AbortSignal} from './abort.js' import {BugError, FatalError, AbortError, FatalErrorType} from './error.js' -import {renderCommandEventAsJson, runWithCommandEvents} from './command-events.js' +import {renderCommandEventAsJson, runWithCommandEvents, runWithCommandEventsForCommand} from './command-events.js' import {mockAndCaptureOutput, withCapturedStandardStreams} from './testing/output.js' -import {TokenizedString} from './output.js' +import {outputResult, TokenizedString, unstyled} from './output.js' import {Stdin} from '../../private/node/testing/ui.js' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import supportsHyperlinks from 'supports-hyperlinks' @@ -202,6 +202,99 @@ describe('renderWarning', async () => { }) }) +describe('info and warning diagnostics', () => { + test.each([ + {renderAlert: renderInfo, level: 'info'}, + {renderAlert: renderWarning, level: 'warning'}, + ] as const)('writes $level alerts as JSON on stderr with --json', async ({renderAlert, level}) => { + await withCapturedStandardStreams(({stdout, stderr}) => { + runWithCommandEventsForCommand(['--json'], () => { + renderAlert({headline: 'Scan directory ignored', body: ['Use', {command: '--no-git-ignore'}]}) + outputResult('{"files":[]}') + }) + + expect(stdout()).toBe('{"files":[]}\n') + expect(JSON.parse(stderr())).toEqual({ + type: 'diagnostic', + level, + timestamp: expect.any(String), + message: 'Scan directory ignored\n\nUse --no-git-ignore', + }) + expect(stderr()).not.toContain('╭') + }) + }) + + test('uses JSON diagnostics when SHOPIFY_FLAG_JSON enables JSON mode', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + try { + await withCapturedStandardStreams(({stdout, stderr}) => { + runWithCommandEventsForCommand([], () => renderInfo({headline: 'Loading app.'})) + + expect(stdout()).toBe('') + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'info', message: 'Loading app.'}) + }) + } finally { + vi.unstubAllEnvs() + } + }) + + test('retains alert details, URLs, token punctuation and tables without terminal styling', async () => { + await withCapturedStandardStreams(({stdout, stderr}) => { + runWithCommandEventsForCommand(['--json'], () => + renderWarning({ + headline: '\u001B[33mReview configuration\u001B[0m.', + body: [{list: {title: 'Files', items: [{filePath: '/tmp/app.toml'}, 'package.json']}}], + nextSteps: [['Run', {command: 'shopify app config validate'}, {char: '.'}]], + reference: [{link: {label: 'Documentation', url: 'https://shopify.dev'}}], + link: {label: 'Support', url: 'https://help.shopify.com'}, + customSections: [ + {title: 'Details', body: ['Client ID:', {userInput: 'abc123'}]}, + { + body: { + tabularData: [ + ['File', 'Status'], + [{filePath: '/tmp/app.toml'}, {warn: 'invalid'}], + ], + }, + }, + ], + }), + ) + + expect(stdout()).toBe('') + expect(JSON.parse(stderr()).message).toBe( + 'Review configuration.\n\nFiles: /tmp/app.toml; package.json\n\n' + + 'Next steps:\nRun shopify app config validate.\n\n' + + 'Reference:\nDocumentation (https://shopify.dev)\n\nSupport (https://help.shopify.com)\n\n' + + 'Details:\nClient ID: abc123\n\nFile\tStatus\n/tmp/app.toml\tinvalid', + ) + expect(stderr()).not.toContain('\u001B') + }) + }) + + test.each([renderInfo, renderWarning])('does not emit a diagnostic for an empty alert', (renderAlert) => { + const sink = vi.fn() + + runWithCommandEvents({outputMode: 'json', sink}, () => renderAlert({})) + + expect(sink).not.toHaveBeenCalled() + }) + + test.each([ + {renderAlert: renderInfo, banner: 'info'}, + {renderAlert: renderWarning, banner: 'warning'}, + ])('keeps the $banner banner on stderr in text mode', async ({renderAlert, banner}) => { + await withCapturedStandardStreams(({stdout, stderr}) => { + runWithCommandEvents({outputMode: 'text'}, () => renderAlert({headline: 'Title', body: 'Body'})) + + expect(stdout()).toBe('') + expect(unstyled(stderr())).toContain(`╭─ ${banner}`) + expect(unstyled(stderr())).toContain('Title') + expect(unstyled(stderr())).toContain('Body') + }) + }) +}) + describe('renderFatalError', async () => { test('renders a fatal error inside a banner', async () => { // Given From 503fe4cf18a181e1dd355f467e6c7f75d489a665 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 13:51:10 +0200 Subject: [PATCH 2/2] Simplify callers of JSON-aware diagnostic renderers --- .changeset/json-alert-diagnostics.md | 2 + packages/app/src/cli/services/logs.test.ts | 8 +-- packages/app/src/cli/services/logs.ts | 48 +++++++----------- .../multiple-installation-warning.test.ts | 15 ------ .../node/multiple-installation-warning.ts | 5 +- .../multiple-installation-warning.test.ts | 22 ++++++++- .../cli/commands/store/bulk/cancel.test.ts | 2 +- .../src/cli/commands/store/bulk/cancel.ts | 6 +-- .../cli/commands/store/bulk/execute.test.ts | 2 +- .../src/cli/commands/store/bulk/execute.ts | 6 +-- .../cli/commands/store/bulk/status.test.ts | 2 +- .../src/cli/commands/store/bulk/status.ts | 8 ++- .../cli/services/store/bulk/execute-result.ts | 8 ++- .../src/cli/services/store/bulk/progress.ts | 6 --- .../cli/services/store/bulk/result.test.ts | 49 ++++++++++++++++--- 15 files changed, 100 insertions(+), 89 deletions(-) diff --git a/.changeset/json-alert-diagnostics.md b/.changeset/json-alert-diagnostics.md index 8baac1800ef..03ba1f5e969 100644 --- a/.changeset/json-alert-diagnostics.md +++ b/.changeset/json-alert-diagnostics.md @@ -1,5 +1,7 @@ --- '@shopify/cli-kit': patch +'@shopify/app': patch +'@shopify/store': patch --- Emit info and warning banners as JSON diagnostics on stderr in JSON mode. diff --git a/packages/app/src/cli/services/logs.test.ts b/packages/app/src/cli/services/logs.test.ts index 1ed2a08eb14..6c9ff5e59e3 100644 --- a/packages/app/src/cli/services/logs.test.ts +++ b/packages/app/src/cli/services/logs.test.ts @@ -54,7 +54,7 @@ describe('logs', () => { }) // Then - expect(outputInfo).toHaveBeenCalledWith('{"message":"Waiting for app logs..."}') + expect(outputInfo).toHaveBeenCalledWith('Waiting for app logs...\n') expect(spy).toHaveBeenCalled() }) @@ -157,7 +157,7 @@ describe('logs', () => { expectedStoreMap.set('1', 'store-fqdn') expectedStoreMap.set('2', 'other-fqdn') expect(outputResult).toHaveBeenCalledWith('{"subscribedToStores":["store-fqdn","other-fqdn"]}') - expect(outputInfo).toHaveBeenCalledWith('{"message":"Waiting for app logs..."}') + expect(outputInfo).toHaveBeenCalledWith('Waiting for app logs...\n') expect(spy).toHaveBeenCalledWith({ options: { developerPlatformClient: expect.anything(), @@ -212,7 +212,7 @@ describe('logs', () => { }) }) - test('should render custom info box', async () => { + test.each(['text', 'json'] as const)('renders configuration info in %s mode', async (format) => { // Given const sources = ['extensions.source'] await setupDevContext(sources) @@ -220,7 +220,7 @@ describe('logs', () => { // When await logs({ - format: 'text', + format, app, remoteApp, organization, diff --git a/packages/app/src/cli/services/logs.ts b/packages/app/src/cli/services/logs.ts index 88a91017b49..d12537292b0 100644 --- a/packages/app/src/cli/services/logs.ts +++ b/packages/app/src/cli/services/logs.ts @@ -67,28 +67,18 @@ export async function logs(commandOptions: LogsOptions) { if (commandOptions.format === 'json') { outputResult(JSON.stringify({subscribedToStores: commandOptions.storeFqdns})) - outputInfo(JSON.stringify({message: 'Waiting for app logs...'})) - await renderJsonLogs({ - options: { - variables, - developerPlatformClient, - }, - pollOptions, - storeNameById: logsConfig.storeNameById, - organizationId: commandOptions.organization.id, - }) - } else { - outputInfo('Waiting for app logs...\n') - await renderLogs({ - options: { - variables, - developerPlatformClient, - }, - pollOptions, - storeNameById: logsConfig.storeNameById, - organizationId: commandOptions.organization.id, - }) } + outputInfo('Waiting for app logs...\n') + const render = commandOptions.format === 'json' ? renderJsonLogs : renderLogs + await render({ + options: { + variables, + developerPlatformClient, + }, + pollOptions, + storeNameById: logsConfig.storeNameById, + organizationId: commandOptions.organization.id, + }) } async function prepareForLogs(commandOptions: LogsOptions): Promise<{ @@ -98,15 +88,13 @@ async function prepareForLogs(commandOptions: LogsOptions): Promise<{ const {app, remoteApp, developerPlatformClient, primaryStore, organization} = commandOptions const configFile = basename(app.configPath) - if (commandOptions.format === 'text') { - renderAppLogsConfigInfo( - remoteApp.title, - primaryStore.shopDomain, - commandOptions.storeFqdns, - configFile, - organization.businessName, - ) - } + renderAppLogsConfigInfo( + remoteApp.title, + primaryStore.shopDomain, + commandOptions.storeFqdns, + configFile, + organization.businessName, + ) const storeNameById = new Map() storeNameById.set(primaryStore.shopId, primaryStore.shopDomain) if (commandOptions.storeFqdns && commandOptions.storeFqdns.length > 1) { diff --git a/packages/cli-kit/src/public/node/multiple-installation-warning.test.ts b/packages/cli-kit/src/public/node/multiple-installation-warning.test.ts index db0e35f167a..2b96be5a818 100644 --- a/packages/cli-kit/src/public/node/multiple-installation-warning.test.ts +++ b/packages/cli-kit/src/public/node/multiple-installation-warning.test.ts @@ -1,12 +1,10 @@ import {showMultipleCLIWarningIfNeeded} from './multiple-installation-warning.js' -import {jsonOutputEnabled} from './environment.js' import {currentProcessIsGlobal} from './is-global.js' import {renderInfo} from './ui.js' import {globalCLIVersion, localCLIVersion} from './version.js' import {runAtMinimumInterval} from '../../private/node/conf-store.js' import {describe, expect, test, vi, beforeEach} from 'vitest' -vi.mock('./environment.js') vi.mock('./is-global.js') vi.mock('./ui.js') vi.mock('./version.js') @@ -18,7 +16,6 @@ describe('showMultipleCLIWarningIfNeeded', () => { await task() return true }) - vi.mocked(jsonOutputEnabled).mockReturnValue(false) vi.mocked(currentProcessIsGlobal).mockReturnValue(false) vi.mocked(globalCLIVersion).mockResolvedValue('3.68.0') vi.mocked(localCLIVersion).mockResolvedValue('3.68.0') @@ -35,18 +32,6 @@ describe('showMultipleCLIWarningIfNeeded', () => { expect(renderInfo).not.toHaveBeenCalled() }) - test('does not run if json output is enabled', async () => { - // Given - vi.mocked(jsonOutputEnabled).mockReturnValue(true) - const dependencies = {'@shopify/cli': '3.68.0'} - - // When - await showMultipleCLIWarningIfNeeded('dir', dependencies) - - // Then - expect(renderInfo).not.toHaveBeenCalled() - }) - test('does not run if global CLIVersion is not available and not in global process', async () => { // Given vi.mocked(globalCLIVersion).mockResolvedValue(undefined) diff --git a/packages/cli-kit/src/public/node/multiple-installation-warning.ts b/packages/cli-kit/src/public/node/multiple-installation-warning.ts index b99fb13c3f2..73f51d04e42 100644 --- a/packages/cli-kit/src/public/node/multiple-installation-warning.ts +++ b/packages/cli-kit/src/public/node/multiple-installation-warning.ts @@ -1,4 +1,3 @@ -import {jsonOutputEnabled} from './environment.js' import {currentProcessIsGlobal} from './is-global.js' import {renderInfo} from './ui.js' import {globalCLIVersion, localCLIVersion} from './version.js' @@ -7,7 +6,7 @@ import {runAtMinimumInterval} from '../../private/node/conf-store.js' /** * Shows a warning if there are two Shopify CLI installations found (global and local). - * Won't show anything if the user included the --json flag. + * Emits a diagnostic on stderr in JSON mode. * * @param directory - The directory of the project. * @param dependencies - The dependencies of the project. @@ -18,7 +17,7 @@ export async function showMultipleCLIWarningIfNeeded( ): Promise { // Show the warning only once per day await runAtMinimumInterval('warn-on-multiple-versions', {days: 1}, async () => { - if (!dependencies['@shopify/cli'] || jsonOutputEnabled()) return + if (!dependencies['@shopify/cli']) return const isGlobal = currentProcessIsGlobal() diff --git a/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts b/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts index b5170d2085e..6ffe1c88168 100644 --- a/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts +++ b/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts @@ -1,7 +1,8 @@ import {clearCache} from '../cli.js' import {currentProcessIsGlobal} from '../is-global.js' import {showMultipleCLIWarningIfNeeded} from '../multiple-installation-warning.js' -import {mockAndCaptureOutput} from '../testing/output.js' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '../testing/output.js' +import {runWithCommandEventsForCommand} from '../command-events.js' import {globalCLIVersion, localCLIVersion} from '../version.js' import {CLI_KIT_VERSION} from '../../common/version.js' import {describe, beforeEach, test, vi, expect} from 'vitest' @@ -45,6 +46,25 @@ describe('showMultipleCLIWarningIfNeeded', () => { mockOutput.clear() }) + test('emits the installation warning as a JSON diagnostic on stderr', async () => { + vi.mocked(currentProcessIsGlobal).mockReturnValue(true) + vi.mocked(localCLIVersion).mockResolvedValue('3.70.0') + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => + showMultipleCLIWarningIfNeeded('path', {'@shopify/cli': '3.70.0'}), + ) + + expect(stdout()).toBe('') + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'info', + message: expect.stringContaining('Two Shopify CLI installations found'), + }) + expect(JSON.parse(stderr()).message).toContain('https://shopify.dev/docs/apps/build/cli-for-apps') + }) + }) + test('shows warning if using local CLI but app has global dependency', async () => { // Given vi.mocked(currentProcessIsGlobal).mockReturnValue(false) diff --git a/packages/store/src/cli/commands/store/bulk/cancel.test.ts b/packages/store/src/cli/commands/store/bulk/cancel.test.ts index 00980137caa..29ed978897f 100644 --- a/packages/store/src/cli/commands/store/bulk/cancel.test.ts +++ b/packages/store/src/cli/commands/store/bulk/cancel.test.ts @@ -24,7 +24,7 @@ describe('store bulk cancel command', () => { 'gid://shopify/BulkOperation/123', 'json', ) - expect(logBulkOperationStart).toHaveBeenCalledWith(expect.any(String), expect.any(Object), 'json') + expect(logBulkOperationStart).toHaveBeenCalledWith(expect.any(String), expect.any(Object)) expect(StoreBulkCancel.jsonOutputSchema).toBeDefined() expect(StoreBulkCancel.flags.json).toBeDefined() }) diff --git a/packages/store/src/cli/commands/store/bulk/cancel.ts b/packages/store/src/cli/commands/store/bulk/cancel.ts index d6984674000..864f9953341 100644 --- a/packages/store/src/cli/commands/store/bulk/cancel.ts +++ b/packages/store/src/cli/commands/store/bulk/cancel.ts @@ -33,11 +33,7 @@ export default class StoreBulkCancel extends StoreCommand { const {flags} = await this.parse(StoreBulkCancel) const format = flags.json ? 'json' : 'text' const adminSession = await prepareBulkAdminContext(flags.store) - logBulkOperationStart( - 'Canceling bulk operation.', - {storeFqdn: adminSession.storeFqdn, operationId: flags.id}, - format, - ) + logBulkOperationStart('Canceling bulk operation.', {storeFqdn: adminSession.storeFqdn, operationId: flags.id}) const result = await cancelBulkOperation({ adminSession, operationId: flags.id, diff --git a/packages/store/src/cli/commands/store/bulk/execute.test.ts b/packages/store/src/cli/commands/store/bulk/execute.test.ts index fdded49e2ec..6160a6ee0a6 100644 --- a/packages/store/src/cli/commands/store/bulk/execute.test.ts +++ b/packages/store/src/cli/commands/store/bulk/execute.test.ts @@ -74,7 +74,7 @@ describe('store bulk execute command', () => { watch: false, outputFile: undefined, }) - expect(logBulkOperationStart).toHaveBeenCalledWith('Starting bulk operation.', expect.any(Object), 'json') + expect(logBulkOperationStart).toHaveBeenCalledWith('Starting bulk operation.', expect.any(Object)) }) test('rejects an empty query', async () => { diff --git a/packages/store/src/cli/commands/store/bulk/execute.ts b/packages/store/src/cli/commands/store/bulk/execute.ts index 991fd456357..615f48b73fc 100644 --- a/packages/store/src/cli/commands/store/bulk/execute.ts +++ b/packages/store/src/cli/commands/store/bulk/execute.ts @@ -54,11 +54,7 @@ export default class StoreBulkExecute extends StoreCommand { ...(flags.version && {version: flags.version}), }) const format = flags.json ? 'json' : 'text' - logBulkOperationStart( - 'Starting bulk operation.', - {storeFqdn: input.adminSession.storeFqdn, version: input.version}, - format, - ) + logBulkOperationStart('Starting bulk operation.', {storeFqdn: input.adminSession.storeFqdn, version: input.version}) const result = await executeBulkOperation(input) await renderExecuteBulkOperationResult(result, {format, watch: input.watch, outputFile: flags['output-file']}) } diff --git a/packages/store/src/cli/commands/store/bulk/status.test.ts b/packages/store/src/cli/commands/store/bulk/status.test.ts index 7a26aa1094d..746bab3b500 100644 --- a/packages/store/src/cli/commands/store/bulk/status.test.ts +++ b/packages/store/src/cli/commands/store/bulk/status.test.ts @@ -31,7 +31,7 @@ describe('store bulk status command', () => { test('selects JSON presentation', async () => { await StoreBulkStatus.run(['--store', 'shop.myshopify.com', '--json']) expect(renderBulkOperationStatusResult).toHaveBeenCalledWith({operations: []}, 'json') - expect(logBulkOperationStart).toHaveBeenCalledWith(expect.any(String), expect.any(Object), 'json') + expect(logBulkOperationStart).toHaveBeenCalledWith(expect.any(String), expect.any(Object)) expect(StoreBulkStatus.jsonOutputSchema).toBeDefined() expect(StoreBulkStatus.flags.json).toBeDefined() }) diff --git a/packages/store/src/cli/commands/store/bulk/status.ts b/packages/store/src/cli/commands/store/bulk/status.ts index b008a45db82..cf32423d772 100644 --- a/packages/store/src/cli/commands/store/bulk/status.ts +++ b/packages/store/src/cli/commands/store/bulk/status.ts @@ -38,11 +38,9 @@ export default class StoreBulkStatus extends StoreCommand { const {flags} = await this.parse(StoreBulkStatus) const format = flags.json ? 'json' : 'text' const adminSession = await prepareBulkAdminContext(flags.store) - logBulkOperationStart( - flags.id ? 'Checking bulk operation status.' : 'Listing bulk operations.', - {storeFqdn: adminSession.storeFqdn}, - format, - ) + logBulkOperationStart(flags.id ? 'Checking bulk operation status.' : 'Listing bulk operations.', { + storeFqdn: adminSession.storeFqdn, + }) const result = flags.id ? await getBulkOperationStatus({adminSession, operationId: flags.id}) : await listBulkOperations({adminSession}) diff --git a/packages/store/src/cli/services/store/bulk/execute-result.ts b/packages/store/src/cli/services/store/bulk/execute-result.ts index d04f9fe2807..f7f178ed0c5 100644 --- a/packages/store/src/cli/services/store/bulk/execute-result.ts +++ b/packages/store/src/cli/services/store/bulk/execute-result.ts @@ -6,7 +6,7 @@ import { extractBulkOperationId, } from '@shopify/cli-kit/node/api/bulk-operations' import {renderSuccess, renderInfo, renderError, renderWarning, type TokenItem} from '@shopify/cli-kit/node/ui' -import {outputContent, outputToken, outputResult, outputWarn} from '@shopify/cli-kit/node/output' +import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {writeFile} from '@shopify/cli-kit/node/fs' import {resolvePath} from '@shopify/cli-kit/node/path' @@ -19,12 +19,10 @@ export async function renderExecuteBulkOperationResult( const {format, watch, outputFile} = options const {operation, userErrors, watchAborted, results} = result if (!operation && userErrors.length === 0) { - const warning = { + renderWarning({ headline: 'Bulk operation not created successfully.', body: 'This is an unexpected error. Please try again later.', - } - if (format === 'json') outputWarn(`${warning.headline} ${warning.body}`) - else renderWarning(warning) + }) throw new BugError('Bulk operation response returned null with no error message.') } diff --git a/packages/store/src/cli/services/store/bulk/progress.ts b/packages/store/src/cli/services/store/bulk/progress.ts index 75ea8f60304..ed079fc4bc4 100644 --- a/packages/store/src/cli/services/store/bulk/progress.ts +++ b/packages/store/src/cli/services/store/bulk/progress.ts @@ -1,16 +1,10 @@ import {formatOperationInfo} from './common.js' import {renderInfo} from '@shopify/cli-kit/node/ui' -import {outputInfo} from '@shopify/cli-kit/node/output' export function logBulkOperationStart( headline: string, context: {storeFqdn: string; version?: string; operationId?: string}, - format: 'text' | 'json', ): void { const items = [...(context.operationId ? [`ID: ${context.operationId}`] : []), ...formatOperationInfo(context)] - if (format === 'json') { - outputInfo(`${headline}\n${items.join('\n')}`) - return - } renderInfo({headline, body: [{list: {items}}]}) } diff --git a/packages/store/src/cli/services/store/bulk/result.test.ts b/packages/store/src/cli/services/store/bulk/result.test.ts index 72eb441c305..08c344e6d3a 100644 --- a/packages/store/src/cli/services/store/bulk/result.test.ts +++ b/packages/store/src/cli/services/store/bulk/result.test.ts @@ -8,7 +8,8 @@ import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' import {joinPath, relativePath, cwd} from '@shopify/cli-kit/node/path' import {renderInfo, renderSuccess, renderWarning, renderError, renderTable} from '@shopify/cli-kit/node/ui' import {AbortError, BugError} from '@shopify/cli-kit/node/error' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' import type {ExecuteBulkOperationResult} from './types.js' vi.mock('@shopify/cli-kit/node/ui') @@ -319,18 +320,29 @@ test('does not invent a list operation type or claim completeness for a capped l }) test('keeps the original starting banner in text mode', () => { - logBulkOperationStart('Starting bulk operation.', {storeFqdn: 'shop.myshopify.com', version: '2026-01'}, 'text') + logBulkOperationStart('Starting bulk operation.', {storeFqdn: 'shop.myshopify.com', version: '2026-01'}) expect(renderInfo).toHaveBeenCalledWith({ headline: 'Starting bulk operation.', body: [{list: {items: ['Store: shop.myshopify.com', 'API version: 2026-01']}}], }) }) -test('outputs starting information without a terminal banner in JSON mode', () => { - const output = mockAndCaptureOutput() - logBulkOperationStart('Starting bulk operation.', {storeFqdn: 'shop.myshopify.com'}, 'json') - expect(output.info()).toBe('Starting bulk operation.\nStore: shop.myshopify.com') - expect(renderInfo).not.toHaveBeenCalled() +test('outputs starting information as a JSON diagnostic only on stderr', async () => { + const actualUi = await vi.importActual('@shopify/cli-kit/node/ui') + vi.mocked(renderInfo).mockImplementation(actualUi.renderInfo) + + await withCapturedStandardStreams(({stdout, stderr}) => { + runWithCommandEventsForCommand(['--json'], () => + logBulkOperationStart('Starting bulk operation.', {storeFqdn: 'shop.myshopify.com'}), + ) + + expect(stdout()).toBe('') + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'info', + message: 'Starting bulk operation.\n\nStore: shop.myshopify.com', + }) + }) }) test('outputs native JSONL in text mode', async () => { @@ -375,3 +387,26 @@ test('keeps missing status and cancellation results nonfatal in text mode', () = ) expect(process.exitCode).toBe(originalExitCode) }) + +test('emits a JSON warning on stderr before the missing-operation bug failure', async () => { + const actualUi = await vi.importActual('@shopify/cli-kit/node/ui') + vi.mocked(renderWarning).mockImplementation(actualUi.renderWarning) + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect( + runWithCommandEventsForCommand(['--json'], () => + renderExecuteBulkOperationResult( + {operation: null, userErrors: [], watchAborted: false}, + {format: 'json', watch: false}, + ), + ), + ).rejects.toThrow(BugError) + + expect(stdout()).toBe('') + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'warning', + message: 'Bulk operation not created successfully.\n\nThis is an unexpected error. Please try again later.', + }) + }) +})