Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-kit-json-ui-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': patch
---

Send finite concurrent process output as JSON diagnostic and progress events on stderr.
4 changes: 4 additions & 0 deletions docs/cli/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
}
Expand Down Expand Up @@ -42,6 +47,7 @@ function currentTime() {

interface ConcurrentOutputContext {
outputPrefix?: string
/** Controls ANSI stripping for terminal output. JSON diagnostics are always unstyled. */
stripAnsi?: boolean
}

Expand All @@ -51,6 +57,61 @@ function useConcurrentOutputContext<T>(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<ConcurrentOutputProps, 'processes' | 'abortSignal'>): Promise<void> {
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
Expand Down
146 changes: 146 additions & 0 deletions packages/cli-kit/src/public/node/ui.concurrent-json.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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"')
})
})
16 changes: 15 additions & 1 deletion packages/cli-kit/src/public/node/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -48,6 +52,7 @@ const defaultUIDebugOptions: UIDebugOptions = {
}

export interface RenderConcurrentOptions extends PartialBy<ConcurrentOutputProps, 'abortSignal'> {
/** Ink options for terminal UI. Finite JSON output uses the command event channel on stderr instead. */
renderOptions?: RenderOptions
}

Expand All @@ -65,6 +70,15 @@ export interface RenderConcurrentOptions extends PartialBy<ConcurrentOutputProps
export async function renderConcurrent({renderOptions, ...props}: RenderConcurrentOptions) {
const abortSignal = props.abortSignal ?? new AbortController().signal

// Streaming callers retain their existing Ink lifecycle; finite JSON results must keep stdout clear.
if (commandEventOutputMode() === 'json' && !props.keepRunningAfterProcessesResolve) {
if (props.processes.length === 0) return
return renderSingleTask({
title: outputContent`Running concurrent processes`,
task: async () => runConcurrentProcessesForJson({...props, abortSignal}),
})
}

return render(<ConcurrentOutput {...props} abortSignal={abortSignal} />, renderOptions)
}

Expand Down
Loading