From 9945ebf9be95aad7c8d6b621409067a8c6453324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 6 Oct 2026 18:26:48 +0200 Subject: [PATCH 1/4] Keep JSON prompts and concurrent process output on stderr --- .changeset/cli-kit-json-ui-routing.md | 5 + packages/cli-kit/src/private/node/ui.tsx | 3 + .../node/ui/components/ConcurrentOutput.tsx | 23 ++- .../public/node/ui.concurrent-json.test.ts | 131 +++++++++++++++ .../src/public/node/ui.prompt-json.test.ts | 152 ++++++++++++++++++ packages/cli-kit/src/public/node/ui.tsx | 15 +- 6 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 .changeset/cli-kit-json-ui-routing.md create mode 100644 packages/cli-kit/src/public/node/ui.concurrent-json.test.ts create mode 100644 packages/cli-kit/src/public/node/ui.prompt-json.test.ts diff --git a/.changeset/cli-kit-json-ui-routing.md b/.changeset/cli-kit-json-ui-routing.md new file mode 100644 index 00000000000..11660e7ec65 --- /dev/null +++ b/.changeset/cli-kit-json-ui-routing.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': patch +--- + +Keep interactive prompts and concurrent process output on stderr in JSON mode. diff --git a/packages/cli-kit/src/private/node/ui.tsx b/packages/cli-kit/src/private/node/ui.tsx index 7e53ea3e04d..cda54a5fb1e 100644 --- a/packages/cli-kit/src/private/node/ui.tsx +++ b/packages/cli-kit/src/private/node/ui.tsx @@ -1,4 +1,5 @@ import {output} from './output.js' +import {commandEventOutputMode} from './command-event-context.js' import {Logger, LogLevel} from '../../public/node/output.js' import {isUnitTest} from '../../public/node/context/local.js' import {treeKill} from '../../public/node/tree-kill.js' @@ -68,6 +69,8 @@ export async function render(element: JSX.Element, options?: RenderOptions) { const {waitUntilExit} = inkRender({element}, { patchConsole: !isUnitTest(), ...options, + // Interactive UI is not a result document or a diagnostic event. + stdout: options?.stdout ?? (commandEventOutputMode() === 'json' ? process.stderr : process.stdout), }) await waitUntilExit() } 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..bd495a511cc 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' @@ -51,6 +51,27 @@ 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 = () => + new Writable({ + write(chunk, _encoding, next) { + const prefix = outputContextStore.getStore()?.outputPrefix ?? process.prefix + const message = stripAnsi(chunk.toString('utf8')).replace(/\n$/, '') + if (message.trim().length > 0) outputInfo(`${prefix}: ${message}`) + next() + }, + }) + await process.action(createStream(), createStream(), abortSignal) + }), + ) +} + /** * 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..d8f237ab1f5 --- /dev/null +++ b/packages/cli-kit/src/public/node/ui.concurrent-json.test.ts @@ -0,0 +1,131 @@ +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\n') + childStderr.write('build diagnostic\n') + useConcurrentOutputContext({outputPrefix: 'nested'}, () => + childStdout.write('\u001b[31mnested output\u001b[0m\n'), + ) + }, + }, + ], + showTimestamps: false, + }) + 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: build diagnostic', + 'nested: nested output', + ]) + 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 () => { + throw failure + }, + }, + ], + }), + ), + ).rejects.toBe(failure) + expect(stdout()).toBe('') + 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.prompt-json.test.ts b/packages/cli-kit/src/public/node/ui.prompt-json.test.ts new file mode 100644 index 00000000000..cbf1cbc0ff9 --- /dev/null +++ b/packages/cli-kit/src/public/node/ui.prompt-json.test.ts @@ -0,0 +1,152 @@ +import {renderAutocompletePrompt} from './ui.js' +import {runWithCommandEventsForCommand} from './command-events.js' +import {outputInfo, outputResult, unstyled} from './output.js' +import {withCapturedStandardStreams} from './testing/output.js' +import {defineJsonOutputSchema} from './json-output-schema.js' +import {zod} from './schema.js' +import {terminalSupportsPrompting} from './system.js' +import {AbortError} from './error.js' +import {Stdin} from '../../private/node/testing/ui.js' +import {expect, test, vi} from 'vitest' + +const choices = [ + {label: 'Store A', value: 'store-a'}, + {label: 'Store B', value: 'store-b'}, +] +const resultSchema = defineJsonOutputSchema({ + name: 'PromptSelectionResult', + schema: zod.object({selected: zod.string()}).strict(), +}) + +// Keep the real stream writers. Only terminal attributes and the controlled input are fixtures. +async function withTerminal(run: () => Promise): Promise { + const objects = [process.stdout, process.stderr, process.stdin] + const descriptors = objects.map( + (object) => new Map(['isTTY', 'columns', 'rows'].map((key) => [key, Object.getOwnPropertyDescriptor(object, key)])), + ) + objects.forEach((object) => { + Object.defineProperty(object, 'isTTY', {configurable: true, value: true}) + Object.defineProperty(object, 'columns', {configurable: true, value: 80}) + Object.defineProperty(object, 'rows', {configurable: true, value: 40}) + }) + vi.stubEnv('CI', '0') + vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '0') + try { + return await run() + } finally { + objects.forEach((object, index) => { + for (const [key, descriptor] of descriptors[index]!) { + if (descriptor) Object.defineProperty(object, key, descriptor) + else Reflect.deleteProperty(object, key) + } + }) + vi.unstubAllEnvs() + } +} + +async function waitForPrompt(input: Stdin, text: () => string): Promise { + await vi.waitFor( + () => { + expect(unstyled(text())).toContain('Which store?') + expect(input.listenerCount('readable')).toBeGreaterThan(0) + }, + {timeout: 2000, interval: 10}, + ) + await new Promise((resolve) => setImmediate(resolve)) +} + +test.each([false, true])('actual autocomplete keeps input enabled and routes default UI: json=%s', async (json) => { + await withTerminal(async () => { + const input = new Stdin() + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(json ? ['--json'] : [], async () => { + const pending = renderAutocompletePrompt({ + message: 'Which store?', + choices, + renderOptions: {stdin: input as unknown as NodeJS.ReadStream, debug: true, patchConsole: false}, + }) + await waitForPrompt(input, () => stdout() + stderr()) + input.write('\r') + const selected = await pending + expect(selected).toBe('store-a') + outputInfo('Selection complete') + outputResult(resultSchema.encode({selected})) + }) + if (json) { + expect(JSON.parse(stdout())).toStrictEqual({selected: 'store-a'}) + expect(unstyled(stderr())).toContain('Which store?') + expect(stderr()).toContain('"type":"diagnostic"') + // Interactive UI is human text on stderr; this is deliberately not a pure-event assertion. + } else { + expect(unstyled(stdout())).toContain('Which store?') + expect(stderr()).not.toContain('Which store?') + } + }) + }) +}) + +test('appropriate explicit text rendering options still select stderr', async () => { + await withTerminal(async () => { + const input = new Stdin() + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand([], async () => { + const pending = renderAutocompletePrompt({ + message: 'Which store?', + choices, + renderOptions: { + stdin: input as unknown as NodeJS.ReadStream, + stdout: process.stderr, + debug: true, + patchConsole: false, + }, + }) + await waitForPrompt(input, stderr) + input.write('\r') + await expect(pending).resolves.toBe('store-a') + }) + expect(stdout()).toBe('') + expect(unstyled(stderr())).toContain('Which store?') + }) + }) +}) + +test.each([false, true])('no-input rejects before rendering even with JSON: json=%s', async (json) => { + await withTerminal(async () => { + expect(terminalSupportsPrompting()).toBe(true) + vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '1') + expect(terminalSupportsPrompting()).toBe(false) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect( + runWithCommandEventsForCommand(json ? ['--json', '--no-input'] : ['--no-input'], () => + renderAutocompletePrompt({message: 'Which store?', choices}), + ), + ).rejects.toBeInstanceOf(AbortError) + expect(stdout()).toBe('') + expect(stderr()).toBe('') + }) + }) +}) + +test('JSON prompt cancellation retains the supplied abort reason and writes no result', async () => { + await withTerminal(async () => { + const input = new Stdin() + const controller = new AbortController() + const reason = new Error('Harmless fixture cancellation') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], async () => { + const pending = renderAutocompletePrompt({ + message: 'Which store?', + choices, + abortSignal: controller.signal, + renderOptions: {stdin: input as unknown as NodeJS.ReadStream, debug: true, patchConsole: false}, + }) + const rejected = expect(pending).rejects.toBe(reason) + await waitForPrompt(input, stderr) + controller.abort(reason) + await rejected + }) + expect(stdout()).toBe('') + expect(unstyled(stderr())).toContain('Which store?') + }) + }) +}) diff --git a/packages/cli-kit/src/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index 593433f0cfb..4878f32bb68 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' @@ -65,6 +69,15 @@ export interface RenderConcurrentOptions extends PartialBy runConcurrentProcessesForJson({...props, abortSignal}), + }) + } + return render(, renderOptions) } From 395a0938fd3d942f7d25229c80839cdc63b1aba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 10:56:44 +0200 Subject: [PATCH 2/4] Fix redirected JSON prompts and frame concurrent diagnostics by line --- packages/cli-kit/src/private/node/ui.tsx | 5 +- .../node/ui/components/ConcurrentOutput.tsx | 52 +++++++++++-- .../src/private/node/ui/output-stream.ts | 6 ++ packages/cli-kit/src/public/node/system.ts | 7 +- .../public/node/ui.concurrent-json.test.ts | 27 +++++-- .../src/public/node/ui.prompt-json.test.ts | 77 ++++++++++--------- packages/cli-kit/src/public/node/ui.tsx | 19 +++-- 7 files changed, 134 insertions(+), 59 deletions(-) create mode 100644 packages/cli-kit/src/private/node/ui/output-stream.ts diff --git a/packages/cli-kit/src/private/node/ui.tsx b/packages/cli-kit/src/private/node/ui.tsx index cda54a5fb1e..19ab6182da6 100644 --- a/packages/cli-kit/src/private/node/ui.tsx +++ b/packages/cli-kit/src/private/node/ui.tsx @@ -1,5 +1,5 @@ import {output} from './output.js' -import {commandEventOutputMode} from './command-event-context.js' +import {getUIOutputStream} from './ui/output-stream.js' import {Logger, LogLevel} from '../../public/node/output.js' import {isUnitTest} from '../../public/node/context/local.js' import {treeKill} from '../../public/node/tree-kill.js' @@ -69,8 +69,7 @@ export async function render(element: JSX.Element, options?: RenderOptions) { const {waitUntilExit} = inkRender({element}, { patchConsole: !isUnitTest(), ...options, - // Interactive UI is not a result document or a diagnostic event. - stdout: options?.stdout ?? (commandEventOutputMode() === 'json' ? process.stderr : process.stdout), + stdout: getUIOutputStream(options?.stdout), }) await waitUntilExit() } 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 bd495a511cc..081b16667ff 100644 --- a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx +++ b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx @@ -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 } @@ -58,16 +64,50 @@ export async function runConcurrentProcessesForJson({ }: Pick): Promise { await Promise.all( processes.map(async (process) => { - const createStream = () => - new Writable({ + 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) { - const prefix = outputContextStore.getStore()?.outputPrefix ?? process.prefix - const message = stripAnsi(chunk.toString('utf8')).replace(/\n$/, '') - if (message.trim().length > 0) outputInfo(`${prefix}: ${message}`) + 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() }, }) - await process.action(createStream(), createStream(), abortSignal) + 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() + } }), ) } diff --git a/packages/cli-kit/src/private/node/ui/output-stream.ts b/packages/cli-kit/src/private/node/ui/output-stream.ts new file mode 100644 index 00000000000..f87d0ce9553 --- /dev/null +++ b/packages/cli-kit/src/private/node/ui/output-stream.ts @@ -0,0 +1,6 @@ +import {commandEventOutputMode} from '../command-event-context.js' + +/** Selects the stream used for interactive terminal UI. */ +export function getUIOutputStream(stdout?: NodeJS.WriteStream): NodeJS.WriteStream { + return stdout ?? (commandEventOutputMode() === 'json' ? process.stderr : process.stdout) +} diff --git a/packages/cli-kit/src/public/node/system.ts b/packages/cli-kit/src/public/node/system.ts index 3d5659c418a..d47720a863e 100644 --- a/packages/cli-kit/src/public/node/system.ts +++ b/packages/cli-kit/src/public/node/system.ts @@ -8,6 +8,7 @@ import {platformAndArch} from './os.js' import {shouldDisplayColors, outputDebug} from './output.js' import {isCloudEnvironment} from './context/local.js' import {isInputDisabled} from './no-input.js' +import {getUIOutputStream} from '../../private/node/ui/output-stream.js' import {execa, ExecaChildProcess} from 'execa' import supportsHyperlinks from 'supports-hyperlinks' import which from 'which' @@ -341,12 +342,16 @@ export function terminalSupportsHyperlinks(): boolean { return supportsHyperlinks.stdout } +interface TerminalPromptingOptions { + stdout?: NodeJS.WriteStream +} + /** * Check if standard input and standard error are terminals that support prompting. * * @returns True if standard input and standard error support prompting. */ -export function terminalSupportsPrompting(): boolean { +export function terminalSupportsPrompting(options: TerminalPromptingOptions = {}): boolean { if (isInputDisabled() || isTruthy(process.env.CI)) { return false } 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 index d8f237ab1f5..e40c25c4206 100644 --- a/packages/cli-kit/src/public/node/ui.concurrent-json.test.ts +++ b/packages/cli-kit/src/public/node/ui.concurrent-json.test.ts @@ -21,15 +21,23 @@ test('finite concurrent JSON output uses typed stderr events and leaves stdout f { prefix: 'web-backend', action: async (childStdout, childStderr) => { - childStdout.write('build output\n') - childStderr.write('build diagnostic\n') - useConcurrentOutputContext({outputPrefix: 'nested'}, () => - childStdout.write('\u001b[31mnested output\u001b[0m\n'), - ) + 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"}') }) @@ -37,8 +45,11 @@ test('finite concurrent JSON output uses typed stderr events and leaves stdout f 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', @@ -84,7 +95,8 @@ test('concurrent JSON rejects with the original failure and does not emit comple processes: [ { prefix: 'extension', - action: async () => { + action: async (_stdout, stderr) => { + stderr.write('failure diagnostic') throw failure }, }, @@ -93,6 +105,9 @@ test('concurrent JSON rejects with the original failure and does not emit comple ), ).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') diff --git a/packages/cli-kit/src/public/node/ui.prompt-json.test.ts b/packages/cli-kit/src/public/node/ui.prompt-json.test.ts index cbf1cbc0ff9..9bcc598ff1b 100644 --- a/packages/cli-kit/src/public/node/ui.prompt-json.test.ts +++ b/packages/cli-kit/src/public/node/ui.prompt-json.test.ts @@ -19,20 +19,23 @@ const resultSchema = defineJsonOutputSchema({ }) // Keep the real stream writers. Only terminal attributes and the controlled input are fixtures. -async function withTerminal(run: () => Promise): Promise { - const objects = [process.stdout, process.stderr, process.stdin] +async function withTerminal(run: (input: Stdin) => Promise, stdoutIsTTY = true): Promise { + const input = new Stdin() + const stdinDescriptor = Object.getOwnPropertyDescriptor(process, 'stdin') + Object.defineProperty(process, 'stdin', {configurable: true, value: input}) + const objects = [process.stdout, process.stderr, input] const descriptors = objects.map( (object) => new Map(['isTTY', 'columns', 'rows'].map((key) => [key, Object.getOwnPropertyDescriptor(object, key)])), ) objects.forEach((object) => { - Object.defineProperty(object, 'isTTY', {configurable: true, value: true}) + Object.defineProperty(object, 'isTTY', {configurable: true, value: object === process.stdout ? stdoutIsTTY : true}) Object.defineProperty(object, 'columns', {configurable: true, value: 80}) Object.defineProperty(object, 'rows', {configurable: true, value: 40}) }) vi.stubEnv('CI', '0') vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '0') try { - return await run() + return await run(input) } finally { objects.forEach((object, index) => { for (const [key, descriptor] of descriptors[index]!) { @@ -40,6 +43,8 @@ async function withTerminal(run: () => Promise): Promise { else Reflect.deleteProperty(object, key) } }) + if (stdinDescriptor) Object.defineProperty(process, 'stdin', stdinDescriptor) + else Reflect.deleteProperty(process, 'stdin') vi.unstubAllEnvs() } } @@ -55,46 +60,48 @@ async function waitForPrompt(input: Stdin, text: () => string): Promise { await new Promise((resolve) => setImmediate(resolve)) } -test.each([false, true])('actual autocomplete keeps input enabled and routes default UI: json=%s', async (json) => { - await withTerminal(async () => { - const input = new Stdin() - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEventsForCommand(json ? ['--json'] : [], async () => { - const pending = renderAutocompletePrompt({ - message: 'Which store?', - choices, - renderOptions: {stdin: input as unknown as NodeJS.ReadStream, debug: true, patchConsole: false}, +test.each([false, true])( + 'autocomplete uses the terminal UI stream with redirected JSON stdout: json=%s', + async (json) => { + await withTerminal(async (input) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(json ? ['--json'] : [], async () => { + expect(process.stdout.isTTY).toBe(!json) + expect(terminalSupportsPrompting()).toBe(true) + const pending = renderAutocompletePrompt({ + message: 'Which store?', + choices, + renderOptions: {debug: true, patchConsole: false}, + }) + await waitForPrompt(input, () => stdout() + stderr()) + input.write('\r') + const selected = await pending + expect(selected).toBe('store-a') + outputInfo('Selection complete') + outputResult(resultSchema.encode({selected})) }) - await waitForPrompt(input, () => stdout() + stderr()) - input.write('\r') - const selected = await pending - expect(selected).toBe('store-a') - outputInfo('Selection complete') - outputResult(resultSchema.encode({selected})) + if (json) { + expect(JSON.parse(stdout())).toStrictEqual({selected: 'store-a'}) + expect(unstyled(stderr())).toContain('Which store?') + expect(stderr()).toContain('"type":"diagnostic"') + // Interactive UI is human text on stderr; this is deliberately not a pure-event assertion. + } else { + expect(unstyled(stdout())).toContain('Which store?') + expect(stderr()).not.toContain('Which store?') + } }) - if (json) { - expect(JSON.parse(stdout())).toStrictEqual({selected: 'store-a'}) - expect(unstyled(stderr())).toContain('Which store?') - expect(stderr()).toContain('"type":"diagnostic"') - // Interactive UI is human text on stderr; this is deliberately not a pure-event assertion. - } else { - expect(unstyled(stdout())).toContain('Which store?') - expect(stderr()).not.toContain('Which store?') - } - }) - }) -}) + }, !json) + }, +) test('appropriate explicit text rendering options still select stderr', async () => { - await withTerminal(async () => { - const input = new Stdin() + await withTerminal(async (input) => { await withCapturedStandardStreams(async ({stdout, stderr}) => { await runWithCommandEventsForCommand([], async () => { const pending = renderAutocompletePrompt({ message: 'Which store?', choices, renderOptions: { - stdin: input as unknown as NodeJS.ReadStream, stdout: process.stderr, debug: true, patchConsole: false, @@ -107,7 +114,7 @@ test('appropriate explicit text rendering options still select stderr', async () expect(stdout()).toBe('') expect(unstyled(stderr())).toContain('Which store?') }) - }) + }, false) }) test.each([false, true])('no-input rejects before rendering even with JSON: json=%s', async (json) => { diff --git a/packages/cli-kit/src/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index 4878f32bb68..aaf079321ba 100644 --- a/packages/cli-kit/src/public/node/ui.tsx +++ b/packages/cli-kit/src/public/node/ui.tsx @@ -52,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 } @@ -293,7 +294,7 @@ export async function renderSelectPrompt( {renderOptions, isConfirmationPrompt, ...props}: RenderSelectPromptOptions, uiDebugOptions: UIDebugOptions = defaultUIDebugOptions, ): Promise { - throwInNonTTY({message: props.message, stdin: renderOptions?.stdin}, uiDebugOptions) + throwInNonTTY({message: props.message, stdin: renderOptions?.stdin, stdout: renderOptions?.stdout}, uiDebugOptions) return runWithTimer('cmd_all_timing_prompts_ms')(async () => { let selectedValue: T @@ -428,7 +429,7 @@ export async function renderAutocompletePrompt( {renderOptions, ...props}: RenderAutocompleteOptions, uiDebugOptions: UIDebugOptions = defaultUIDebugOptions, ): Promise { - throwInNonTTY({message: props.message, stdin: renderOptions?.stdin}, uiDebugOptions) + throwInNonTTY({message: props.message, stdin: renderOptions?.stdin, stdout: renderOptions?.stdout}, uiDebugOptions) // The default search filters in-memory choices synchronously, so it doesn't need // throttling. Skipping the throttle makes the keystroke-to-result latency feel @@ -665,7 +666,7 @@ export async function renderTextPrompt( {renderOptions, ...props}: RenderTextPromptOptions, uiDebugOptions: UIDebugOptions = defaultUIDebugOptions, ): Promise { - throwInNonTTY({message: props.message, stdin: renderOptions?.stdin}, uiDebugOptions) + throwInNonTTY({message: props.message, stdin: renderOptions?.stdin, stdout: renderOptions?.stdout}, uiDebugOptions) return runWithTimer('cmd_all_timing_prompts_ms')(async () => { let enteredText = '' @@ -718,7 +719,7 @@ export async function renderDangerousConfirmationPrompt( {renderOptions, ...props}: RenderDangerousConfirmationPromptOptions, uiDebugOptions: UIDebugOptions = defaultUIDebugOptions, ): Promise { - throwInNonTTY({message: props.message, stdin: renderOptions?.stdin}, uiDebugOptions) + throwInNonTTY({message: props.message, stdin: renderOptions?.stdin, stdout: renderOptions?.stdout}, uiDebugOptions) return runWithTimer('cmd_all_timing_prompts_ms')(async () => { let confirmed: boolean @@ -771,21 +772,23 @@ export const keypress = async (stdin = process.stdin, uiDebugOptions: UIDebugOpt interface IsTTYOptions { stdin?: NodeJS.ReadStream + stdout?: NodeJS.WriteStream uiDebugOptions?: UIDebugOptions } -export function isTTY({stdin = undefined, uiDebugOptions = defaultUIDebugOptions}: IsTTYOptions = {}) { +export function isTTY({stdin = undefined, stdout, uiDebugOptions = defaultUIDebugOptions}: IsTTYOptions = {}) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- false should fall through to stdin/terminalSupportsPrompting - return Boolean(uiDebugOptions.skipTTYCheck || stdin || terminalSupportsPrompting()) + return Boolean(uiDebugOptions.skipTTYCheck || stdin || terminalSupportsPrompting({stdout})) } interface ThrowInNonTTYOptions { message: TokenItem stdin?: NodeJS.ReadStream + stdout?: NodeJS.WriteStream } -function throwInNonTTY({message, stdin = undefined}: ThrowInNonTTYOptions, uiDebugOptions: UIDebugOptions) { - if (isTTY({stdin, uiDebugOptions})) return +function throwInNonTTY({message, stdin = undefined, stdout}: ThrowInNonTTYOptions, uiDebugOptions: UIDebugOptions) { + if (isTTY({stdin, stdout, uiDebugOptions})) return const promptText = tokenItemToString(message) const errorMessage = `Failed to prompt: From cde37eb6e33a3a01203db53cdd836b4dfeb48644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 11:17:36 +0200 Subject: [PATCH 3/4] Clarify interactive JSON stderr output --- docs/cli/json-output.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index ca873843073..71569ebffa8 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 From a2b5dad2e7204b35204b43988e2cd5a6dca72136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 13:05:47 +0200 Subject: [PATCH 4/4] Use shared stderr prompt defaults and retain concurrent JSON output --- .changeset/cli-kit-json-ui-routing.md | 2 +- packages/cli-kit/src/private/node/ui.tsx | 2 - .../src/private/node/ui/output-stream.ts | 6 - packages/cli-kit/src/public/node/system.ts | 7 +- .../src/public/node/ui.prompt-json.test.ts | 159 ------------------ packages/cli-kit/src/public/node/ui.tsx | 18 +- 6 files changed, 10 insertions(+), 184 deletions(-) delete mode 100644 packages/cli-kit/src/private/node/ui/output-stream.ts delete mode 100644 packages/cli-kit/src/public/node/ui.prompt-json.test.ts diff --git a/.changeset/cli-kit-json-ui-routing.md b/.changeset/cli-kit-json-ui-routing.md index 11660e7ec65..5f00d2b8c30 100644 --- a/.changeset/cli-kit-json-ui-routing.md +++ b/.changeset/cli-kit-json-ui-routing.md @@ -2,4 +2,4 @@ '@shopify/cli-kit': patch --- -Keep interactive prompts and concurrent process output on stderr in JSON mode. +Send finite concurrent process output as JSON diagnostic and progress events on stderr. diff --git a/packages/cli-kit/src/private/node/ui.tsx b/packages/cli-kit/src/private/node/ui.tsx index 19ab6182da6..7e53ea3e04d 100644 --- a/packages/cli-kit/src/private/node/ui.tsx +++ b/packages/cli-kit/src/private/node/ui.tsx @@ -1,5 +1,4 @@ import {output} from './output.js' -import {getUIOutputStream} from './ui/output-stream.js' import {Logger, LogLevel} from '../../public/node/output.js' import {isUnitTest} from '../../public/node/context/local.js' import {treeKill} from '../../public/node/tree-kill.js' @@ -69,7 +68,6 @@ export async function render(element: JSX.Element, options?: RenderOptions) { const {waitUntilExit} = inkRender({element}, { patchConsole: !isUnitTest(), ...options, - stdout: getUIOutputStream(options?.stdout), }) await waitUntilExit() } diff --git a/packages/cli-kit/src/private/node/ui/output-stream.ts b/packages/cli-kit/src/private/node/ui/output-stream.ts deleted file mode 100644 index f87d0ce9553..00000000000 --- a/packages/cli-kit/src/private/node/ui/output-stream.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {commandEventOutputMode} from '../command-event-context.js' - -/** Selects the stream used for interactive terminal UI. */ -export function getUIOutputStream(stdout?: NodeJS.WriteStream): NodeJS.WriteStream { - return stdout ?? (commandEventOutputMode() === 'json' ? process.stderr : process.stdout) -} diff --git a/packages/cli-kit/src/public/node/system.ts b/packages/cli-kit/src/public/node/system.ts index d47720a863e..3d5659c418a 100644 --- a/packages/cli-kit/src/public/node/system.ts +++ b/packages/cli-kit/src/public/node/system.ts @@ -8,7 +8,6 @@ import {platformAndArch} from './os.js' import {shouldDisplayColors, outputDebug} from './output.js' import {isCloudEnvironment} from './context/local.js' import {isInputDisabled} from './no-input.js' -import {getUIOutputStream} from '../../private/node/ui/output-stream.js' import {execa, ExecaChildProcess} from 'execa' import supportsHyperlinks from 'supports-hyperlinks' import which from 'which' @@ -342,16 +341,12 @@ export function terminalSupportsHyperlinks(): boolean { return supportsHyperlinks.stdout } -interface TerminalPromptingOptions { - stdout?: NodeJS.WriteStream -} - /** * Check if standard input and standard error are terminals that support prompting. * * @returns True if standard input and standard error support prompting. */ -export function terminalSupportsPrompting(options: TerminalPromptingOptions = {}): boolean { +export function terminalSupportsPrompting(): boolean { if (isInputDisabled() || isTruthy(process.env.CI)) { return false } diff --git a/packages/cli-kit/src/public/node/ui.prompt-json.test.ts b/packages/cli-kit/src/public/node/ui.prompt-json.test.ts deleted file mode 100644 index 9bcc598ff1b..00000000000 --- a/packages/cli-kit/src/public/node/ui.prompt-json.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import {renderAutocompletePrompt} from './ui.js' -import {runWithCommandEventsForCommand} from './command-events.js' -import {outputInfo, outputResult, unstyled} from './output.js' -import {withCapturedStandardStreams} from './testing/output.js' -import {defineJsonOutputSchema} from './json-output-schema.js' -import {zod} from './schema.js' -import {terminalSupportsPrompting} from './system.js' -import {AbortError} from './error.js' -import {Stdin} from '../../private/node/testing/ui.js' -import {expect, test, vi} from 'vitest' - -const choices = [ - {label: 'Store A', value: 'store-a'}, - {label: 'Store B', value: 'store-b'}, -] -const resultSchema = defineJsonOutputSchema({ - name: 'PromptSelectionResult', - schema: zod.object({selected: zod.string()}).strict(), -}) - -// Keep the real stream writers. Only terminal attributes and the controlled input are fixtures. -async function withTerminal(run: (input: Stdin) => Promise, stdoutIsTTY = true): Promise { - const input = new Stdin() - const stdinDescriptor = Object.getOwnPropertyDescriptor(process, 'stdin') - Object.defineProperty(process, 'stdin', {configurable: true, value: input}) - const objects = [process.stdout, process.stderr, input] - const descriptors = objects.map( - (object) => new Map(['isTTY', 'columns', 'rows'].map((key) => [key, Object.getOwnPropertyDescriptor(object, key)])), - ) - objects.forEach((object) => { - Object.defineProperty(object, 'isTTY', {configurable: true, value: object === process.stdout ? stdoutIsTTY : true}) - Object.defineProperty(object, 'columns', {configurable: true, value: 80}) - Object.defineProperty(object, 'rows', {configurable: true, value: 40}) - }) - vi.stubEnv('CI', '0') - vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '0') - try { - return await run(input) - } finally { - objects.forEach((object, index) => { - for (const [key, descriptor] of descriptors[index]!) { - if (descriptor) Object.defineProperty(object, key, descriptor) - else Reflect.deleteProperty(object, key) - } - }) - if (stdinDescriptor) Object.defineProperty(process, 'stdin', stdinDescriptor) - else Reflect.deleteProperty(process, 'stdin') - vi.unstubAllEnvs() - } -} - -async function waitForPrompt(input: Stdin, text: () => string): Promise { - await vi.waitFor( - () => { - expect(unstyled(text())).toContain('Which store?') - expect(input.listenerCount('readable')).toBeGreaterThan(0) - }, - {timeout: 2000, interval: 10}, - ) - await new Promise((resolve) => setImmediate(resolve)) -} - -test.each([false, true])( - 'autocomplete uses the terminal UI stream with redirected JSON stdout: json=%s', - async (json) => { - await withTerminal(async (input) => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEventsForCommand(json ? ['--json'] : [], async () => { - expect(process.stdout.isTTY).toBe(!json) - expect(terminalSupportsPrompting()).toBe(true) - const pending = renderAutocompletePrompt({ - message: 'Which store?', - choices, - renderOptions: {debug: true, patchConsole: false}, - }) - await waitForPrompt(input, () => stdout() + stderr()) - input.write('\r') - const selected = await pending - expect(selected).toBe('store-a') - outputInfo('Selection complete') - outputResult(resultSchema.encode({selected})) - }) - if (json) { - expect(JSON.parse(stdout())).toStrictEqual({selected: 'store-a'}) - expect(unstyled(stderr())).toContain('Which store?') - expect(stderr()).toContain('"type":"diagnostic"') - // Interactive UI is human text on stderr; this is deliberately not a pure-event assertion. - } else { - expect(unstyled(stdout())).toContain('Which store?') - expect(stderr()).not.toContain('Which store?') - } - }) - }, !json) - }, -) - -test('appropriate explicit text rendering options still select stderr', async () => { - await withTerminal(async (input) => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEventsForCommand([], async () => { - const pending = renderAutocompletePrompt({ - message: 'Which store?', - choices, - renderOptions: { - stdout: process.stderr, - debug: true, - patchConsole: false, - }, - }) - await waitForPrompt(input, stderr) - input.write('\r') - await expect(pending).resolves.toBe('store-a') - }) - expect(stdout()).toBe('') - expect(unstyled(stderr())).toContain('Which store?') - }) - }, false) -}) - -test.each([false, true])('no-input rejects before rendering even with JSON: json=%s', async (json) => { - await withTerminal(async () => { - expect(terminalSupportsPrompting()).toBe(true) - vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '1') - expect(terminalSupportsPrompting()).toBe(false) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect( - runWithCommandEventsForCommand(json ? ['--json', '--no-input'] : ['--no-input'], () => - renderAutocompletePrompt({message: 'Which store?', choices}), - ), - ).rejects.toBeInstanceOf(AbortError) - expect(stdout()).toBe('') - expect(stderr()).toBe('') - }) - }) -}) - -test('JSON prompt cancellation retains the supplied abort reason and writes no result', async () => { - await withTerminal(async () => { - const input = new Stdin() - const controller = new AbortController() - const reason = new Error('Harmless fixture cancellation') - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEventsForCommand(['--json'], async () => { - const pending = renderAutocompletePrompt({ - message: 'Which store?', - choices, - abortSignal: controller.signal, - renderOptions: {stdin: input as unknown as NodeJS.ReadStream, debug: true, patchConsole: false}, - }) - const rejected = expect(pending).rejects.toBe(reason) - await waitForPrompt(input, stderr) - controller.abort(reason) - await rejected - }) - expect(stdout()).toBe('') - expect(unstyled(stderr())).toContain('Which store?') - }) - }) -}) diff --git a/packages/cli-kit/src/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index aaf079321ba..6f65e8152e5 100644 --- a/packages/cli-kit/src/public/node/ui.tsx +++ b/packages/cli-kit/src/public/node/ui.tsx @@ -294,7 +294,7 @@ export async function renderSelectPrompt( {renderOptions, isConfirmationPrompt, ...props}: RenderSelectPromptOptions, uiDebugOptions: UIDebugOptions = defaultUIDebugOptions, ): Promise { - throwInNonTTY({message: props.message, stdin: renderOptions?.stdin, stdout: renderOptions?.stdout}, uiDebugOptions) + throwInNonTTY({message: props.message, stdin: renderOptions?.stdin}, uiDebugOptions) return runWithTimer('cmd_all_timing_prompts_ms')(async () => { let selectedValue: T @@ -429,7 +429,7 @@ export async function renderAutocompletePrompt( {renderOptions, ...props}: RenderAutocompleteOptions, uiDebugOptions: UIDebugOptions = defaultUIDebugOptions, ): Promise { - throwInNonTTY({message: props.message, stdin: renderOptions?.stdin, stdout: renderOptions?.stdout}, uiDebugOptions) + throwInNonTTY({message: props.message, stdin: renderOptions?.stdin}, uiDebugOptions) // The default search filters in-memory choices synchronously, so it doesn't need // throttling. Skipping the throttle makes the keystroke-to-result latency feel @@ -666,7 +666,7 @@ export async function renderTextPrompt( {renderOptions, ...props}: RenderTextPromptOptions, uiDebugOptions: UIDebugOptions = defaultUIDebugOptions, ): Promise { - throwInNonTTY({message: props.message, stdin: renderOptions?.stdin, stdout: renderOptions?.stdout}, uiDebugOptions) + throwInNonTTY({message: props.message, stdin: renderOptions?.stdin}, uiDebugOptions) return runWithTimer('cmd_all_timing_prompts_ms')(async () => { let enteredText = '' @@ -719,7 +719,7 @@ export async function renderDangerousConfirmationPrompt( {renderOptions, ...props}: RenderDangerousConfirmationPromptOptions, uiDebugOptions: UIDebugOptions = defaultUIDebugOptions, ): Promise { - throwInNonTTY({message: props.message, stdin: renderOptions?.stdin, stdout: renderOptions?.stdout}, uiDebugOptions) + throwInNonTTY({message: props.message, stdin: renderOptions?.stdin}, uiDebugOptions) return runWithTimer('cmd_all_timing_prompts_ms')(async () => { let confirmed: boolean @@ -772,23 +772,21 @@ export const keypress = async (stdin = process.stdin, uiDebugOptions: UIDebugOpt interface IsTTYOptions { stdin?: NodeJS.ReadStream - stdout?: NodeJS.WriteStream uiDebugOptions?: UIDebugOptions } -export function isTTY({stdin = undefined, stdout, uiDebugOptions = defaultUIDebugOptions}: IsTTYOptions = {}) { +export function isTTY({stdin = undefined, uiDebugOptions = defaultUIDebugOptions}: IsTTYOptions = {}) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- false should fall through to stdin/terminalSupportsPrompting - return Boolean(uiDebugOptions.skipTTYCheck || stdin || terminalSupportsPrompting({stdout})) + return Boolean(uiDebugOptions.skipTTYCheck || stdin || terminalSupportsPrompting()) } interface ThrowInNonTTYOptions { message: TokenItem stdin?: NodeJS.ReadStream - stdout?: NodeJS.WriteStream } -function throwInNonTTY({message, stdin = undefined, stdout}: ThrowInNonTTYOptions, uiDebugOptions: UIDebugOptions) { - if (isTTY({stdin, stdout, uiDebugOptions})) return +function throwInNonTTY({message, stdin = undefined}: ThrowInNonTTYOptions, uiDebugOptions: UIDebugOptions) { + if (isTTY({stdin, uiDebugOptions})) return const promptText = tokenItemToString(message) const errorMessage = `Failed to prompt: