diff --git a/.changeset/cli-kit-json-ui-routing.md b/.changeset/cli-kit-json-ui-routing.md new file mode 100644 index 00000000000..5f00d2b8c30 --- /dev/null +++ b/.changeset/cli-kit-json-ui-routing.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': patch +--- + +Send finite concurrent process output as JSON diagnostic and progress events on stderr. diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index 5e1c86484e9..a2696292bbe 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -234,6 +234,10 @@ breaking changes so consumers can migrate. Keep independently versioned native a silently disable prompts, and non-interactive execution must not silently select JSON. A command that can prompt should support and test the relevant combinations explicitly. +When a JSON command prompts, stderr also contains human-readable UI and terminal control sequences. In this +interactive mode, stderr is not a pure JSONL stream. For automation, use `--json --no-input` to capture JSON side +events from stderr; missing required input then produces a fatal error instead of a prompt. + ## Exempt only streaming commands Long-lived commands that produce an open-ended event stream don't have one finite result. Track these exemptions in diff --git a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx index b866189e5ae..081b16667ff 100644 --- a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx +++ b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx @@ -1,4 +1,4 @@ -import {OutputProcess} from '../../../../public/node/output.js' +import {outputInfo, type OutputProcess} from '../../../../public/node/output.js' import {AbortSignal} from '../../../../public/node/abort.js' import {useComplete} from '../../ui.js' import React, {FunctionComponent, useCallback, useEffect, useMemo, useState} from 'react' @@ -8,12 +8,17 @@ import stripAnsi from 'strip-ansi' import {Writable} from 'stream' import {AsyncLocalStorage} from 'node:async_hooks' +import {StringDecoder} from 'node:string_decoder' export interface ConcurrentOutputProps { processes: OutputProcess[] prefixColumnSize?: number abortSignal: AbortSignal showTimestamps?: boolean + /** + * Keeps terminal UI running after all processes finish. Defaults to false. + * In JSON mode, false uses finite progress/diagnostic events; true retains streaming terminal UI. + */ keepRunningAfterProcessesResolve?: boolean useAlternativeColorPalette?: boolean } @@ -42,6 +47,7 @@ function currentTime() { interface ConcurrentOutputContext { outputPrefix?: string + /** Controls ANSI stripping for terminal output. JSON diagnostics are always unstyled. */ stripAnsi?: boolean } @@ -51,6 +57,61 @@ function useConcurrentOutputContext(context: ConcurrentOutputContext, callbac return outputContextStore.run(context, callback) } +/** Runs finite processes concurrently and routes their output through the shared diagnostic context. */ +export async function runConcurrentProcessesForJson({ + processes, + abortSignal, +}: Pick): Promise { + await Promise.all( + processes.map(async (process) => { + const createStream = () => { + const decoder = new StringDecoder('utf8') + let pending = '' + let hasPendingLine = false + let prefix = process.prefix + const emitLine = (line: string) => { + const message = stripAnsi(line) + if (message.trim().length > 0) outputInfo(`${prefix}: ${message}`) + } + const stream = new Writable({ + write(chunk, _encoding, next) { + if (chunk.length === 0) { + next() + return + } + const currentPrefix = outputContextStore.getStore()?.outputPrefix ?? process.prefix + if (!hasPendingLine) prefix = currentPrefix + const lines = (pending + decoder.write(chunk)).split(/\r?\n/) + pending = lines.pop() ?? '' + for (const line of lines) { + emitLine(line) + prefix = currentPrefix + } + hasPendingLine = chunk[chunk.length - 1] !== 10 + next() + }, + }) + return { + stream, + flush: () => { + emitLine((pending + decoder.end()).replace(/\r$/, '')) + pending = '' + hasPendingLine = false + }, + } + } + const stdout = createStream() + const stderr = createStream() + try { + await process.action(stdout.stream, stderr.stream, abortSignal) + } finally { + stdout.flush() + stderr.flush() + } + }), + ) +} + /** * Renders output from concurrent processes to the terminal. * Output will be divided in a three column layout diff --git a/packages/cli-kit/src/public/node/ui.concurrent-json.test.ts b/packages/cli-kit/src/public/node/ui.concurrent-json.test.ts new file mode 100644 index 00000000000..e40c25c4206 --- /dev/null +++ b/packages/cli-kit/src/public/node/ui.concurrent-json.test.ts @@ -0,0 +1,146 @@ +import {renderConcurrent} from './ui.js' +import {runWithCommandEventsForCommand} from './command-events.js' +import {withCapturedStandardStreams} from './testing/output.js' +import {outputResult} from './output.js' +import {useConcurrentOutputContext} from '../../private/node/ui/components/ConcurrentOutput.js' +import {expect, test} from 'vitest' + +function events(stderr: string) { + return stderr + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) +} + +test('finite concurrent JSON output uses typed stderr events and leaves stdout for one result', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], async () => { + await renderConcurrent({ + processes: [ + { + prefix: 'web-backend', + action: async (childStdout, childStderr) => { + childStdout.write('build output\r\nsecond line\n\n') + childStderr.write('build ') + childStderr.write('diagnostic\r') + childStderr.write('\n') + const utf8 = Buffer.from('éclair\r\n') + useConcurrentOutputContext({outputPrefix: 'unicode'}, () => childStderr.write(utf8.subarray(0, 1))) + childStderr.write(utf8.subarray(1)) + useConcurrentOutputContext({outputPrefix: 'nested', stripAnsi: false}, () => { + childStdout.write('\u001b[') + childStdout.write('31mnested output\u001b[0m\n') + }) + childStdout.write('final line') + }, + }, + ], + showTimestamps: false, + renderOptions: {stdout: process.stdout}, + }) + outputResult('{"status":"success"}') + }) + expect(JSON.parse(stdout())).toStrictEqual({status: 'success'}) + const sideEvents = events(stderr()) + expect(sideEvents.filter((event) => event.type === 'diagnostic').map((event) => event.message)).toStrictEqual([ + 'web-backend: build output', + 'web-backend: second line', + 'web-backend: build diagnostic', + 'unicode: éclair', + 'nested: nested output', + 'web-backend: final line', + ]) + expect(sideEvents.filter((event) => event.type === 'progress').map((event) => event.status)).toStrictEqual([ + 'started', + 'completed', + ]) + expect(stderr()).not.toContain('\u001b') + }) +}) + +test('concurrent JSON actions start together and receive the supplied abort signal', async () => { + const controller = new AbortController() + const started: number[] = [] + let complete!: () => void + const ready = new Promise((resolve) => { + complete = resolve + }) + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEventsForCommand(['--json'], () => + renderConcurrent({ + abortSignal: controller.signal, + processes: [1, 2].map((id) => ({ + prefix: String(id), + action: async (_stdout, _stderr, signal) => { + expect(signal).toBe(controller.signal) + started.push(id) + if (started.length === 2) complete() + await ready + }, + })), + }), + ) + expect(started).toStrictEqual([1, 2]) + expect(stdout()).toBe('') + }) +}) + +test('concurrent JSON rejects with the original failure and does not emit completion', async () => { + const failure = new Error('build failed') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect( + runWithCommandEventsForCommand(['--json'], () => + renderConcurrent({ + processes: [ + { + prefix: 'extension', + action: async (_stdout, stderr) => { + stderr.write('failure diagnostic') + throw failure + }, + }, + ], + }), + ), + ).rejects.toBe(failure) + expect(stdout()).toBe('') + expect(events(stderr())).toContainEqual( + expect.objectContaining({type: 'diagnostic', message: 'extension: failure diagnostic'}), + ) + expect( + events(stderr()) + .filter((event) => event.type === 'progress') + .map((event) => event.status), + ).toStrictEqual(['started', 'failed']) + }) +}) + +test('empty concurrent JSON work prints no progress or terminal output', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => renderConcurrent({processes: []})) + expect(stdout()).toBe('') + expect(stderr()).toBe('') + }) +}) + +test('the text concurrent renderer keeps child rows and does not encode side events', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand([], () => + renderConcurrent({ + processes: [ + { + prefix: 'web', + action: async (stream) => { + stream.write('text build output\n') + }, + }, + ], + showTimestamps: false, + renderOptions: {exitOnCtrlC: false, patchConsole: false}, + }), + ) + expect(stdout()).toContain('text build output') + expect(stderr()).not.toContain('"type":"diagnostic"') + }) +}) diff --git a/packages/cli-kit/src/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index 593433f0cfb..6f65e8152e5 100644 --- a/packages/cli-kit/src/public/node/ui.tsx +++ b/packages/cli-kit/src/public/node/ui.tsx @@ -6,7 +6,11 @@ import {outputContent, outputDebug, outputToken, TokenizedString, unstyled} from import {terminalSupportsPrompting} from './system.js' import {AbortController} from './abort.js' import {runWithTimer} from './metadata.js' -import {ConcurrentOutput, ConcurrentOutputProps} from '../../private/node/ui/components/ConcurrentOutput.js' +import { + ConcurrentOutput, + ConcurrentOutputProps, + runConcurrentProcessesForJson, +} from '../../private/node/ui/components/ConcurrentOutput.js' import {handleCtrlC, render, renderOnce} from '../../private/node/ui.js' import {alert, AlertOptions} from '../../private/node/ui/alert.js' import {CustomSection} from '../../private/node/ui/components/Alert.js' @@ -48,6 +52,7 @@ const defaultUIDebugOptions: UIDebugOptions = { } export interface RenderConcurrentOptions extends PartialBy { + /** Ink options for terminal UI. Finite JSON output uses the command event channel on stderr instead. */ renderOptions?: RenderOptions } @@ -65,6 +70,15 @@ export interface RenderConcurrentOptions extends PartialBy runConcurrentProcessesForJson({...props, abortSignal}), + }) + } + return render(, renderOptions) }