From 34a50279fbe3a903a15e19fde09239aeabaa8373 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 28 Aug 2026 14:35:48 +0200 Subject: [PATCH] Add command side event infrastructure --- .../src/private/node/command-event-context.ts | 70 +++++++ .../src/private/node/command-event-output.ts | 15 ++ .../src/public/common/command-events.test.ts | 81 ++++++++ .../src/public/common/command-events.ts | 93 +++++++++ .../src/public/node/base-command.test.ts | 70 ++++++- .../cli-kit/src/public/node/base-command.ts | 5 + .../src/public/node/command-events.test.ts | 195 ++++++++++++++++++ .../cli-kit/src/public/node/command-events.ts | 111 ++++++++++ .../public/node/custom-oclif-loader.test.ts | 25 +++ .../src/public/node/custom-oclif-loader.ts | 41 ++-- .../cli-kit/src/public/node/environment.ts | 5 +- .../cli-kit/src/public/node/output.test.ts | 64 ++++++ packages/cli-kit/src/public/node/output.ts | 23 +++ packages/cli-kit/src/public/node/ui.test.ts | 43 ++++ packages/cli-kit/src/public/node/ui.tsx | 24 ++- 15 files changed, 841 insertions(+), 24 deletions(-) create mode 100644 packages/cli-kit/src/private/node/command-event-context.ts create mode 100644 packages/cli-kit/src/private/node/command-event-output.ts create mode 100644 packages/cli-kit/src/public/common/command-events.test.ts create mode 100644 packages/cli-kit/src/public/common/command-events.ts create mode 100644 packages/cli-kit/src/public/node/command-events.test.ts create mode 100644 packages/cli-kit/src/public/node/command-events.ts diff --git a/packages/cli-kit/src/private/node/command-event-context.ts b/packages/cli-kit/src/private/node/command-event-context.ts new file mode 100644 index 00000000000..11ab645f8ba --- /dev/null +++ b/packages/cli-kit/src/private/node/command-event-context.ts @@ -0,0 +1,70 @@ +import { + createCommandEventChannel, + type CommandEvent, + type CommandEventChannel, + type CommandEventChannelOptions, + type CommandEventEmissionOptions, + type CommandEventInput, +} from '../../public/common/command-events.js' +import {AsyncLocalStorage} from 'node:async_hooks' + +export type CommandEventOutputMode = 'text' | 'json' + +interface CommandEventContext { + channel: CommandEventChannel + outputMode: CommandEventOutputMode +} + +interface RunWithCommandEventsOptions extends CommandEventChannelOptions { + outputMode?: CommandEventOutputMode +} + +const commandEventStorageKey = Symbol.for('@shopify/cli-kit/command-event-storage') +const existingCommandEventStorage = Reflect.get(globalThis, commandEventStorageKey) as + | AsyncLocalStorage + | undefined +const commandEventStorage = existingCommandEventStorage ?? new AsyncLocalStorage() + +if (!existingCommandEventStorage) { + // cli-kit can be loaded both externally and inside the bundled CLI. Both copies must observe + // the same command execution context so output helpers consistently emit JSON events. + Reflect.set(globalThis, commandEventStorageKey, commandEventStorage) +} + +/** + * Runs a command execution with an event channel available to all nested asynchronous work. + * + * @param options - The event sink, clock, and output mode used by the channel. + * @param execute - The command execution to run with the channel. + * @returns The result of the command execution. + */ +export function runWithCommandEvents(options: RunWithCommandEventsOptions, execute: () => TResult): TResult { + return commandEventStorage.run( + { + channel: createCommandEventChannel(options), + outputMode: options.outputMode ?? 'text', + }, + execute, + ) +} + +/** + * Emits an event for the current command execution. + * + * Events emitted outside a command execution are ignored. + * + * @param event - The event to emit before its timestamp is added. + * @param options - Presentation details that are not included in the event. + */ +export function emitCommandEvent(event: CommandEventInput, options?: CommandEventEmissionOptions): void { + commandEventStorage.getStore()?.channel.emit(event, options) +} + +/** + * Returns how command events are presented for the current execution. + * + * @returns The current event output mode, or undefined outside a command event context. + */ +export function commandEventOutputMode(): CommandEventOutputMode | undefined { + return commandEventStorage.getStore()?.outputMode +} diff --git a/packages/cli-kit/src/private/node/command-event-output.ts b/packages/cli-kit/src/private/node/command-event-output.ts new file mode 100644 index 00000000000..92903fdb1ad --- /dev/null +++ b/packages/cli-kit/src/private/node/command-event-output.ts @@ -0,0 +1,15 @@ +import {consoleWarn} from './output.js' +import {isUnitTest} from '../../public/node/context/local.js' +import {collectLog, outputWhereAppropriate} from '../../public/node/output.js' +import type {CommandEvent} from '../../public/common/command-events.js' + +/** + * Writes a command event as JSON without routing it back through the command event context. + * + * @param event - The event to write. + */ +export function outputCommandEventAsJson(event: CommandEvent): void { + const message = JSON.stringify(event) + if (isUnitTest()) collectLog('info', message) + outputWhereAppropriate('info', consoleWarn, message) +} diff --git a/packages/cli-kit/src/public/common/command-events.test.ts b/packages/cli-kit/src/public/common/command-events.test.ts new file mode 100644 index 00000000000..ed8e9158f34 --- /dev/null +++ b/packages/cli-kit/src/public/common/command-events.test.ts @@ -0,0 +1,81 @@ +import {createCommandEventChannel, commandEventSchema, type CommandEvent} from './command-events.js' +import {describe, expect, test, vi} from 'vitest' + +describe('commandEventSchema', () => { + test.each([ + { + type: 'diagnostic', + timestamp: '2026-08-26T12:00:00.000Z', + level: 'warning', + message: 'Using a fallback', + code: 'fallback', + }, + { + type: 'progress', + timestamp: '2026-08-26T12:00:01.000Z', + message: 'Uploading files', + current: 2, + total: 10, + }, + ])('accepts a $type event', (event) => { + expect(commandEventSchema.parse(event)).toEqual(event) + }) + + test('rejects an event without a timestamp', () => { + expect(() => commandEventSchema.parse({type: 'diagnostic', level: 'info', message: 'Missing timestamp'})).toThrow() + }) +}) + +describe('createCommandEventChannel', () => { + test('adds the timestamp when the event is emitted and delivers synchronously', () => { + const calls: string[] = [] + const sink = vi.fn((event: CommandEvent) => calls.push(event.timestamp)) + const channel = createCommandEventChannel({ + sink, + clock: () => new Date('2026-08-26T12:00:00.000Z'), + }) + + calls.push('before') + channel.emit({type: 'diagnostic', level: 'debug', message: 'Resolving store'}) + calls.push('after') + + expect(calls).toEqual(['before', '2026-08-26T12:00:00.000Z', 'after']) + expect(sink).toHaveBeenCalledWith({ + type: 'diagnostic', + timestamp: '2026-08-26T12:00:00.000Z', + level: 'debug', + message: 'Resolving store', + }) + }) + + test('preserves event order', () => { + const receivedMessages: string[] = [] + const channel = createCommandEventChannel({ + sink: (event) => receivedMessages.push(event.message), + }) + + channel.emit({type: 'progress', message: 'First'}) + channel.emit({type: 'progress', message: 'Second'}) + + expect(receivedMessages).toEqual(['First', 'Second']) + }) + + test('delivers presentation details without adding them to the event', () => { + const sink = vi.fn() + const channel = createCommandEventChannel({ + sink, + clock: () => new Date('2026-08-26T12:00:00.000Z'), + }) + + channel.emit({type: 'progress', message: 'Uploading files'}, {alreadyRendered: true}) + + expect(sink).toHaveBeenCalledWith( + { + type: 'progress', + timestamp: '2026-08-26T12:00:00.000Z', + message: 'Uploading files', + }, + {alreadyRendered: true}, + ) + }) +}) diff --git a/packages/cli-kit/src/public/common/command-events.ts b/packages/cli-kit/src/public/common/command-events.ts new file mode 100644 index 00000000000..91072e60832 --- /dev/null +++ b/packages/cli-kit/src/public/common/command-events.ts @@ -0,0 +1,93 @@ +import {z} from 'zod' + +/** Schema for a diagnostic emitted while a command executes. */ +export const commandDiagnosticEventSchema = z + .object({ + type: z.literal('diagnostic'), + timestamp: z.string().datetime({offset: true}), + level: z.enum(['debug', 'info', 'warning']), + message: z.string(), + code: z.string().optional(), + }) + .strict() + +/** Schema for a progress update emitted while a command executes. */ +export const commandProgressEventSchema = z + .object({ + type: z.literal('progress'), + timestamp: z.string().datetime({offset: true}), + message: z.string(), + current: z.number().nonnegative().optional(), + total: z.number().nonnegative().optional(), + }) + .strict() + +/** Schema for side events emitted while a command executes. */ +export const commandEventSchema = z.discriminatedUnion('type', [ + commandDiagnosticEventSchema, + commandProgressEventSchema, +]) + +/** A diagnostic emitted while a command executes. */ +export type CommandDiagnosticEvent = z.infer + +/** A progress update emitted while a command executes. */ +export type CommandProgressEvent = z.infer + +/** A side event emitted while a command executes. */ +export type CommandEvent = z.infer + +/** An event before its emission timestamp is added. */ +export type CommandEventInput = TEvent extends unknown + ? Omit + : never + +/** Presentation details that are not included in the emitted event. */ +export interface CommandEventEmissionOptions { + /** The event is already visible in the command's text UI. */ + alreadyRendered?: boolean +} + +/** Receives one timestamped event from a command execution. */ +export type CommandEventSink = ( + event: TEvent, + options?: CommandEventEmissionOptions, +) => void + +/** Emits timestamped side events from one command execution. */ +export interface CommandEventChannel { + emit: (event: CommandEventInput, options?: CommandEventEmissionOptions) => void +} + +/** Supplies the current time when an event is emitted. */ +export type CommandEventClock = () => Date + +/** Options for a command event channel. */ +export interface CommandEventChannelOptions { + sink?: CommandEventSink + clock?: CommandEventClock +} + +/** + * Creates a synchronous, execution-scoped channel for command side events. + * + * @param options - The event sink and clock used by the channel. + * @returns A channel that adds an ISO timestamp before synchronously delivering each event. + */ +export function createCommandEventChannel( + options: CommandEventChannelOptions = {}, +): CommandEventChannel { + const sink = options.sink ?? (() => {}) + const clock = options.clock ?? (() => new Date()) + + return { + emit(event, emissionOptions) { + const timestampedEvent = {...event, timestamp: clock().toISOString()} as TEvent + if (emissionOptions === undefined) { + sink(timestampedEvent) + } else { + sink(timestampedEvent, emissionOptions) + } + }, + } +} diff --git a/packages/cli-kit/src/public/node/base-command.test.ts b/packages/cli-kit/src/public/node/base-command.test.ts index 582b5986327..2e998c1ff48 100644 --- a/packages/cli-kit/src/public/node/base-command.test.ts +++ b/packages/cli-kit/src/public/node/base-command.test.ts @@ -1,7 +1,8 @@ import Command from './base-command.js' import {Environments} from './environments.js' import {encodeToml as encodeTOML} from './toml/codec.js' -import {globalFlags, requiredIfNonInteractive} from './cli.js' +import {globalFlags, jsonFlag, requiredIfNonInteractive} from './cli.js' +import {emitCommandEvent} from './command-events.js' import {inTemporaryDirectory, mkdir, writeFile} from './fs.js' import {joinPath, resolvePath, cwd} from './path.js' import {mockAndCaptureOutput} from './testing/output.js' @@ -25,6 +26,7 @@ beforeEach(() => { afterEach(() => { Object.defineProperty(process.stdin, 'isTTY', {value: originalStdinIsTTY, configurable: true, writable: true}) Object.defineProperty(process.stdout, 'isTTY', {value: originalStdoutIsTTY, configurable: true, writable: true}) + mockAndCaptureOutput().clear() }) let testResult: Record = {} @@ -149,6 +151,26 @@ class MockCommandWithoutEnvironmentFlag extends Command { } } +class MockCommandWithEvents extends Command { + static enableJsonFlag = true + static flags = {...jsonFlag} + + async run(): Promise { + await this.parse(MockCommandWithEvents) + emitCommandEvent({type: 'progress', message: 'Command event'}) + } +} + +class MockCommandWithAlreadyRenderedEvent extends Command { + static enableJsonFlag = true + static flags = {...jsonFlag} + + async run(): Promise { + await this.parse(MockCommandWithAlreadyRenderedEvent) + emitCommandEvent({type: 'progress', message: 'Displayed by task UI'}, {alreadyRendered: true}) + } +} + const validEnvironment = { someString: 'stringy', someBoolean: true, @@ -207,6 +229,52 @@ const allEnvironments: Environments = { }, } +describe('command events', () => { + test('renders events for commands', async () => { + const outputMock = mockAndCaptureOutput() + outputMock.clear() + + await MockCommandWithEvents.run([]) + + expect(outputMock.info()).toContain('Command event') + }) + + test('renders events as JSON for JSON commands', async () => { + const outputMock = mockAndCaptureOutput() + outputMock.clear() + + await MockCommandWithEvents.run(['--json']) + + expect(JSON.parse(outputMock.info())).toEqual({ + type: 'progress', + timestamp: expect.any(String), + message: 'Command event', + }) + }) + + test('does not duplicate events already displayed by the text UI', async () => { + const outputMock = mockAndCaptureOutput() + outputMock.clear() + + await MockCommandWithAlreadyRenderedEvent.run([]) + + expect(outputMock.info()).toBe('') + }) + + test('renders UI-managed events as JSON without presentation details', async () => { + const outputMock = mockAndCaptureOutput() + outputMock.clear() + + await MockCommandWithAlreadyRenderedEvent.run(['--json']) + + expect(JSON.parse(outputMock.info())).toEqual({ + type: 'progress', + timestamp: expect.any(String), + message: 'Displayed by task UI', + }) + }) +}) + describe('applying environments', async () => { const runTestInTmpDir = (testName: string, testFunc: (tmpDir: string) => Promise) => { test(testName, async () => { diff --git a/packages/cli-kit/src/public/node/base-command.ts b/packages/cli-kit/src/public/node/base-command.ts index ec80b0ad220..c6b392655a9 100644 --- a/packages/cli-kit/src/public/node/base-command.ts +++ b/packages/cli-kit/src/public/node/base-command.ts @@ -1,6 +1,7 @@ import {isDevelopment} from './context/local.js' import {addPublicMetadata} from './metadata.js' import {AbortError} from './error.js' +import {runWithCommandEventsForCommand} from './command-events.js' import {outputContent, outputResult, outputToken} from './output.js' import {setCurrentSessionAlias} from './session.js' import {terminalSupportsPrompting} from './system.js' @@ -62,6 +63,10 @@ abstract class BaseCommand extends Command { return Errors.handle(error) } + protected async _run(): Promise { + return runWithCommandEventsForCommand(this.argv, () => super._run()) + } + protected async init(): Promise { this.exitWithTimestampWhenEnvVariablePresent() setCurrentCommandId(this.id ?? '') diff --git a/packages/cli-kit/src/public/node/command-events.test.ts b/packages/cli-kit/src/public/node/command-events.test.ts new file mode 100644 index 00000000000..d0e47e820ff --- /dev/null +++ b/packages/cli-kit/src/public/node/command-events.test.ts @@ -0,0 +1,195 @@ +import { + commandEventOutputMode, + emitCommandEvent, + renderCommandEvent, + renderCommandEventAsJson, + runWithCommandEvents, +} from './command-events.js' +import {outputWarn} from './output.js' +import {mockAndCaptureOutput} from './testing/output.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +const outputMock = mockAndCaptureOutput() + +beforeEach(() => { + outputMock.clear() +}) + +describe('command event context', () => { + test('shares the context across separately loaded cli-kit module instances', async () => { + const firstModule = await import('../../private/node/command-event-context.js') + vi.resetModules() + const secondModule = await import('../../private/node/command-event-context.js') + + firstModule.runWithCommandEvents({outputMode: 'json'}, () => { + expect(secondModule.commandEventOutputMode()).toBe('json') + }) + }) + + test('makes the channel available to nested asynchronous work', async () => { + const sink = vi.fn() + + await runWithCommandEvents({sink, clock: () => new Date('2026-08-26T12:00:00.000Z')}, async () => { + await Promise.resolve() + emitCommandEvent({type: 'diagnostic', level: 'debug', message: 'Resolving store'}) + }) + + expect(sink).toHaveBeenCalledWith({ + type: 'diagnostic', + timestamp: '2026-08-26T12:00:00.000Z', + level: 'debug', + message: 'Resolving store', + }) + }) + + test('isolates concurrent command executions', async () => { + const firstSink = vi.fn() + const secondSink = vi.fn() + + await Promise.all([ + runWithCommandEvents({sink: firstSink}, async () => { + await Promise.resolve() + emitCommandEvent({type: 'progress', message: 'First'}) + }), + runWithCommandEvents({sink: secondSink}, async () => { + await Promise.resolve() + emitCommandEvent({type: 'progress', message: 'Second'}) + }), + ]) + + expect(firstSink).toHaveBeenCalledWith(expect.objectContaining({message: 'First'})) + expect(firstSink).not.toHaveBeenCalledWith(expect.objectContaining({message: 'Second'})) + expect(secondSink).toHaveBeenCalledWith(expect.objectContaining({message: 'Second'})) + expect(secondSink).not.toHaveBeenCalledWith(expect.objectContaining({message: 'First'})) + }) + + test('restores the outer channel after a nested execution', () => { + const outerSink = vi.fn() + const innerSink = vi.fn() + + runWithCommandEvents({sink: outerSink}, () => { + emitCommandEvent({type: 'progress', message: 'Before'}) + runWithCommandEvents({sink: innerSink}, () => { + emitCommandEvent({type: 'progress', message: 'Nested'}) + }) + emitCommandEvent({type: 'progress', message: 'After'}) + }) + + expect(outerSink.mock.calls.map(([event]) => event.message)).toEqual(['Before', 'After']) + expect(innerSink).toHaveBeenCalledWith(expect.objectContaining({message: 'Nested'})) + }) + + test('ignores events emitted outside a command execution', () => { + expect(() => emitCommandEvent({type: 'progress', message: 'Ignored'})).not.toThrow() + }) + + test('exposes the current output mode to nested work', () => { + expect(commandEventOutputMode()).toBeUndefined() + + runWithCommandEvents({outputMode: 'json'}, () => { + expect(commandEventOutputMode()).toBe('json') + }) + + expect(commandEventOutputMode()).toBeUndefined() + }) +}) + +describe('renderCommandEvent', () => { + test('renders debug diagnostics to stderr through the debug output path', () => { + renderCommandEvent({ + type: 'diagnostic', + timestamp: '2026-08-26T12:00:00.000Z', + level: 'debug', + message: 'Resolving store', + }) + + expect(outputMock.debug()).toBe('Resolving store') + expect(outputMock.info()).toBe('') + expect(outputMock.warn()).toBe('') + }) + + test('renders info diagnostics to stderr through the info output path', () => { + renderCommandEvent({ + type: 'diagnostic', + timestamp: '2026-08-26T12:00:00.000Z', + level: 'info', + message: 'Store resolved', + }) + + expect(outputMock.info()).toBe('Store resolved') + expect(outputMock.debug()).toBe('') + expect(outputMock.warn()).toBe('') + }) + + test('renders warning diagnostics to stderr through the warning output path', () => { + renderCommandEvent({ + type: 'diagnostic', + timestamp: '2026-08-26T12:00:00.000Z', + level: 'warning', + message: 'Using a fallback', + }) + + expect(outputMock.warn()).toBe('Using a fallback') + expect(outputMock.debug()).toBe('') + expect(outputMock.info()).toBe('') + }) + + test('renders progress to stderr without changing the structured event', () => { + const event = { + type: 'progress' as const, + timestamp: '2026-08-26T12:00:00.000Z', + message: 'Uploading files', + current: 2, + total: 10, + } + + renderCommandEvent(event) + + expect(outputMock.info()).toBe('Uploading files') + expect(outputMock.debug()).toBe('') + expect(outputMock.warn()).toBe('') + expect(event).toEqual({ + type: 'progress', + timestamp: '2026-08-26T12:00:00.000Z', + message: 'Uploading files', + current: 2, + total: 10, + }) + }) +}) + +describe('renderCommandEventAsJson', () => { + test('renders a compact JSON event to stderr', () => { + renderCommandEventAsJson({ + type: 'progress', + timestamp: '2026-08-26T12:00:00.000Z', + message: 'Uploading files', + current: 2, + total: 10, + }) + + expect(outputMock.info()).toBe( + '{"type":"progress","timestamp":"2026-08-26T12:00:00.000Z","message":"Uploading files","current":2,"total":10}', + ) + expect(outputMock.debug()).toBe('') + expect(outputMock.warn()).toBe('') + }) + + test('renders automatic diagnostics without emitting recursively', () => { + runWithCommandEvents( + { + outputMode: 'json', + sink: renderCommandEventAsJson, + clock: () => new Date('2026-08-26T12:00:00.000Z'), + }, + () => outputWarn('Using a fallback'), + ) + + expect(JSON.parse(outputMock.info())).toEqual({ + type: 'diagnostic', + level: 'warning', + message: 'Using a fallback', + timestamp: '2026-08-26T12:00:00.000Z', + }) + }) +}) diff --git a/packages/cli-kit/src/public/node/command-events.ts b/packages/cli-kit/src/public/node/command-events.ts new file mode 100644 index 00000000000..dc26f569814 --- /dev/null +++ b/packages/cli-kit/src/public/node/command-events.ts @@ -0,0 +1,111 @@ +import {outputDebug, outputInfo, outputWarn} from './output.js' +import {jsonOutputEnabled} from './environment.js' +import { + type CommandEvent, + type CommandEventChannelOptions, + type CommandEventEmissionOptions, + type CommandEventInput, +} from '../common/command-events.js' +import {outputCommandEventAsJson} from '../../private/node/command-event-output.js' +import { + commandEventOutputMode as currentCommandEventOutputMode, + emitCommandEvent as emitCommandEventInContext, + runWithCommandEvents as runWithCommandEventContext, + type CommandEventOutputMode, +} from '../../private/node/command-event-context.js' + +export type {CommandEventOutputMode} from '../../private/node/command-event-context.js' + +interface RunWithCommandEventsOptions extends CommandEventChannelOptions { + outputMode?: CommandEventOutputMode +} + +/** + * Runs a command execution with an event channel available to all nested asynchronous work. + * + * @param options - The event sink, clock, and output mode used by the channel. + * @param execute - The command execution to run with the channel. + * @returns The result of the command execution. + */ +export function runWithCommandEvents(options: RunWithCommandEventsOptions, execute: () => TResult): TResult { + return runWithCommandEventContext(options, execute) +} + +/** + * Runs the complete CLI lifecycle with the event presentation selected by its arguments. + * + * @param argv - The command arguments used to determine whether JSON output is enabled. + * @param execute - The command lifecycle to run. + * @returns The result of the command lifecycle. + */ +export function runWithCommandEventsForCommand(argv: string[], execute: () => TResult): TResult { + if (commandEventOutputMode() !== undefined) return execute() + + const outputMode = jsonOutputEnabled(process.env, argv) ? 'json' : 'text' + return runWithCommandEvents( + { + outputMode, + sink: + outputMode === 'json' + ? renderCommandEventAsJson + : (event, options) => { + if (!options?.alreadyRendered) renderCommandEvent(event) + }, + }, + execute, + ) +} + +/** + * Emits an event for the current command execution. + * + * Events emitted outside a command execution are ignored. + * + * @param event - The event to emit before its timestamp is added. + * @param options - Presentation details that are not included in the event. + */ +export function emitCommandEvent(event: CommandEventInput, options?: CommandEventEmissionOptions): void { + emitCommandEventInContext(event, options) +} + +/** + * Returns how command events are presented for the current execution. + * + * @returns The current event output mode, or undefined outside a command event context. + */ +export function commandEventOutputMode(): CommandEventOutputMode | undefined { + return currentCommandEventOutputMode() +} + +/** + * Renders a command side event to stderr using the existing CLI output behavior. + * + * @param event - The event to render. + */ +export function renderCommandEvent(event: CommandEvent): void { + if (event.type === 'progress') { + outputInfo(event.message) + return + } + + switch (event.level) { + case 'debug': + outputDebug(event.message) + break + case 'info': + outputInfo(event.message) + break + case 'warning': + outputWarn(event.message) + break + } +} + +/** + * Renders a command side event as compact JSON to stderr. + * + * @param event - The event to render. + */ +export function renderCommandEventAsJson(event: CommandEvent): void { + outputCommandEventAsJson(event) +} diff --git a/packages/cli-kit/src/public/node/custom-oclif-loader.test.ts b/packages/cli-kit/src/public/node/custom-oclif-loader.test.ts index 83dfae536e1..b0745dc1a08 100644 --- a/packages/cli-kit/src/public/node/custom-oclif-loader.test.ts +++ b/packages/cli-kit/src/public/node/custom-oclif-loader.test.ts @@ -1,4 +1,6 @@ import {ShopifyConfig} from './custom-oclif-loader.js' +import {outputInfo} from './output.js' +import {mockAndCaptureOutput} from './testing/output.js' import {Config} from '@oclif/core' import {describe, expect, test, vi} from 'vitest' @@ -79,6 +81,29 @@ describe('ShopifyConfig', () => { }) }) + test('keeps command hooks in the JSON event context', async () => { + const output = mockAndCaptureOutput() + output.clear() + const config = new ShopifyConfig({root: import.meta.url}) + const mockCommand = {id: 'test-command', plugin: {}} as any + config.findCommand = vi.fn().mockReturnValue(mockCommand) + config.setLazyCommandLoader(vi.fn().mockResolvedValue({run: vi.fn()})) + config.runHook = vi.fn().mockImplementation(async (hookName) => { + if (hookName === 'postrun') outputInfo('Completed command test-command') + return {successes: [], failures: []} + }) + + await config.runCommand('test-command', ['--json']) + + expect(JSON.parse(output.info())).toEqual({ + type: 'diagnostic', + timestamp: expect.any(String), + level: 'info', + message: 'Completed command test-command', + }) + output.clear() + }) + test('loads and runs command with fallback plugin when command plugin is not set', async () => { const config = new ShopifyConfig({root: import.meta.url}) const mockCommand = {id: 'test-command'} as any diff --git a/packages/cli-kit/src/public/node/custom-oclif-loader.ts b/packages/cli-kit/src/public/node/custom-oclif-loader.ts index 4db5d926a7d..4bc9a87e223 100644 --- a/packages/cli-kit/src/public/node/custom-oclif-loader.ts +++ b/packages/cli-kit/src/public/node/custom-oclif-loader.ts @@ -1,3 +1,4 @@ +import {runWithCommandEventsForCommand} from './command-events.js' import {Command, Config} from '@oclif/core' /** @@ -38,27 +39,29 @@ export class ShopifyConfig extends Config { argv: string[] = [], cachedCommand: Command.Loadable | null = null, ): Promise { - if (!this.lazyCommandLoader) { - return super.runCommand(id, argv, cachedCommand) - } + return runWithCommandEventsForCommand(argv, async () => { + if (!this.lazyCommandLoader) { + return super.runCommand(id, argv, cachedCommand) + } - const cmd = cachedCommand ?? this.findCommand(id) - if (!cmd) { - return super.runCommand(id, argv, cachedCommand) - } + const cmd = cachedCommand ?? this.findCommand(id) + if (!cmd) { + return super.runCommand(id, argv, cachedCommand) + } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const commandClass = (await this.lazyCommandLoader(id)) as any - if (!commandClass) { - return super.runCommand(id, argv, cachedCommand) - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const commandClass = (await this.lazyCommandLoader(id)) as any + if (!commandClass) { + return super.runCommand(id, argv, cachedCommand) + } - commandClass.id = id - // eslint-disable-next-line @typescript-eslint/no-explicit-any - commandClass.plugin = cmd.plugin ?? (this as any).rootPlugin - await this.runHook('prerun', {argv, Command: commandClass}) - const result = (await commandClass.run(argv, this)) as T - await this.runHook('postrun', {argv, Command: commandClass, result}) - return result + commandClass.id = id + // eslint-disable-next-line @typescript-eslint/no-explicit-any + commandClass.plugin = cmd.plugin ?? (this as any).rootPlugin + await this.runHook('prerun', {argv, Command: commandClass}) + const result = (await commandClass.run(argv, this)) as T + await this.runHook('postrun', {argv, Command: commandClass, result}) + return result + }) } } diff --git a/packages/cli-kit/src/public/node/environment.ts b/packages/cli-kit/src/public/node/environment.ts index aa7b86e1502..981e936250e 100644 --- a/packages/cli-kit/src/public/node/environment.ts +++ b/packages/cli-kit/src/public/node/environment.ts @@ -73,10 +73,11 @@ export function getIdentityTokenInformation(): {accessToken: string; refreshToke * Checks if the JSON output is enabled via flag (--json or -j) or environment variable (SHOPIFY_FLAG_JSON). * * @param environment - Process environment variables. + * @param argv - Command arguments to inspect for JSON flags. * @returns True if the JSON output is enabled, false otherwise. */ -export function jsonOutputEnabled(environment = getEnvironmentVariables()): boolean { - return sniffForJson() || isTruthy(environment[environmentVariables.json]) +export function jsonOutputEnabled(environment = getEnvironmentVariables(), argv = process.argv): boolean { + return sniffForJson(argv) || isTruthy(environment[environmentVariables.json]) } /** diff --git a/packages/cli-kit/src/public/node/output.test.ts b/packages/cli-kit/src/public/node/output.test.ts index bdcef55a737..6824e170002 100644 --- a/packages/cli-kit/src/public/node/output.test.ts +++ b/packages/cli-kit/src/public/node/output.test.ts @@ -3,12 +3,18 @@ import { clearCollectedLogs, LogLevel, outputDebug, + outputCompleted, + outputInfo, + outputNewline, + outputSuccess, + outputWarn, outputWhereAppropriate, outputToken, shouldDisplayColors, formatPackageManagerCommand, } from './output.js' +import {runWithCommandEvents} from './command-events.js' import {currentProcessIsGlobal} from './is-global.js' import {beforeEach, describe, expect, test, vi} from 'vitest' import {Writable} from 'stream' @@ -131,6 +137,64 @@ describe('outputDebug', () => { }) }) +describe('JSON command diagnostics', () => { + test.each([ + {output: outputDebug, level: 'debug'}, + {output: outputInfo, level: 'info'}, + {output: outputSuccess, level: 'info'}, + {output: outputCompleted, level: 'info'}, + {output: outputWarn, level: 'warning'}, + ] as const)('emits output helpers as $level diagnostics', ({output, level}) => { + const sink = vi.fn() + if (level === 'debug') isVerboseMock.mockReturnValue(true) + + runWithCommandEvents({sink, outputMode: 'json', clock: () => new Date('2026-08-26T12:00:00.000Z')}, () => + output('Diagnostic message'), + ) + + expect(sink).toHaveBeenCalledWith({ + type: 'diagnostic', + timestamp: '2026-08-26T12:00:00.000Z', + level, + message: 'Diagnostic message', + }) + }) + + test('preserves debug verbosity filtering', () => { + const sink = vi.fn() + + runWithCommandEvents({sink, outputMode: 'json'}, () => outputDebug('Hidden diagnostic')) + + expect(sink).not.toHaveBeenCalled() + }) + + test('does not turn output sent to a custom logger into a command event', () => { + const sink = vi.fn() + const logger = vi.fn() + + runWithCommandEvents({sink, outputMode: 'json'}, () => outputInfo('Stream message', logger)) + + expect(sink).not.toHaveBeenCalled() + expect(logger).toHaveBeenCalledWith('Stream message', 'info') + }) + + test('does not emit blank diagnostics', () => { + const sink = vi.fn() + + runWithCommandEvents({sink, outputMode: 'json'}, () => outputInfo('\n')) + + expect(sink).not.toHaveBeenCalled() + }) + + test('suppresses standalone newlines', () => { + const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + runWithCommandEvents({outputMode: 'json'}, () => outputNewline()) + + expect(stderrWrite).not.toHaveBeenCalled() + }) +}) + describe('formatPackageManagerCommand', () => { test('can format yarn commands', () => { // Given diff --git a/packages/cli-kit/src/public/node/output.ts b/packages/cli-kit/src/public/node/output.ts index edf605e6312..8c6e7f27630 100644 --- a/packages/cli-kit/src/public/node/output.ts +++ b/packages/cli-kit/src/public/node/output.ts @@ -20,6 +20,7 @@ import { SubHeadingContentToken, } from '../../private/node/content-tokens.js' import {tokenItemToString} from '../../private/node/ui/components/token-item.js' +import {commandEventOutputMode, emitCommandEvent} from '../../private/node/command-event-context.js' import {consoleLog, consoleWarn, output} from '../../private/node/output.js' import stripAnsi from 'strip-ansi' import {Writable} from 'stream' @@ -264,6 +265,8 @@ export function outputResult(content: OutputMessage): void { * @param logger - The logging function to use to output to the user. */ export function outputInfo(content: OutputMessage, logger: Logger = consoleWarn): void { + if (emitJsonDiagnostic(content, 'info', logger)) return + const message = stringifyMessage(content) if (isUnitTest()) collectLog('info', content) outputWhereAppropriate('info', logger, message) @@ -278,6 +281,8 @@ export function outputInfo(content: OutputMessage, logger: Logger = consoleWarn) * @param logger - The logging function to use to output to the user. */ export function outputSuccess(content: OutputMessage, logger: Logger = consoleWarn): void { + if (emitJsonDiagnostic(content, 'info', logger)) return + const message = colors.bold(`✅ Success! ${stringifyMessage(content)}.`) if (isUnitTest()) collectLog('success', content) outputWhereAppropriate('info', logger, message) @@ -292,6 +297,8 @@ export function outputSuccess(content: OutputMessage, logger: Logger = consoleWa * @param logger - The logging function to use to output to the user. */ export function outputCompleted(content: OutputMessage, logger: Logger = consoleWarn): void { + if (emitJsonDiagnostic(content, 'info', logger)) return + const message = `${colors.green('✔')} ${stringifyMessage(content)}` if (isUnitTest()) collectLog('completed', content) outputWhereAppropriate('info', logger, message) @@ -306,6 +313,8 @@ export function outputCompleted(content: OutputMessage, logger: Logger = console * @param logger - The logging function to use to output to the user. */ export function outputDebug(content: OutputMessage, logger: Logger = consoleWarn): void { + if (emitJsonDiagnostic(content, 'debug', logger)) return + if (isUnitTest()) collectLog('debug', content) if (!shouldOutput('debug')) return @@ -322,6 +331,8 @@ export function outputDebug(content: OutputMessage, logger: Logger = consoleWarn * @param logger - The logging function to use to output to the user. */ export function outputWarn(content: OutputMessage, logger: Logger = consoleWarn): void { + if (emitJsonDiagnostic(content, 'warning', logger)) return + if (isUnitTest()) collectLog('warn', content) const message = colors.yellow(stringifyMessage(content)) outputWhereAppropriate('warn', logger, message) @@ -331,6 +342,7 @@ export function outputWarn(content: OutputMessage, logger: Logger = consoleWarn) * Prints a new line in the terminal. */ export function outputNewline(): void { + if (commandEventOutputMode() === 'json') return consoleWarn('') } @@ -403,6 +415,17 @@ export function unstyled(message: string): string { return stripAnsi(message) } +function emitJsonDiagnostic(content: OutputMessage, level: 'debug' | 'info' | 'warning', logger: Logger): boolean { + if (logger !== consoleWarn || commandEventOutputMode() !== 'json') return false + if (level === 'debug' && !isUnitTest() && !shouldOutput('debug')) return true + + const message = unstyled(stringifyMessage(content)) + if (message.trim().length > 0) { + emitCommandEvent({type: 'diagnostic', level, message}) + } + return true +} + /** * Checks if the console outputs should display colors or not. * diff --git a/packages/cli-kit/src/public/node/ui.test.ts b/packages/cli-kit/src/public/node/ui.test.ts index 901870f83b4..346d419f4ff 100644 --- a/packages/cli-kit/src/public/node/ui.test.ts +++ b/packages/cli-kit/src/public/node/ui.test.ts @@ -10,6 +10,7 @@ import { } from './ui.js' import {AbortSignal} from './abort.js' import {BugError, FatalError, AbortError, FatalErrorType} from './error.js' +import {runWithCommandEvents} from './command-events.js' import {mockAndCaptureOutput} from './testing/output.js' import {TokenizedString} from './output.js' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' @@ -424,6 +425,48 @@ describe('keypress', async () => { }) describe('renderSingleTask', async () => { + test('emits progress when the task starts, updates, and completes', async () => { + const sink = vi.fn() + + await runWithCommandEvents({sink}, () => + renderSingleTask({ + title: new TokenizedString('Creating store'), + task: async (updateStatus) => { + updateStatus(new TokenizedString('Saving session')) + return 'store' + }, + }), + ) + + expect(sink).toHaveBeenCalledTimes(3) + expect(sink.mock.calls).toEqual([ + [expect.objectContaining({type: 'progress', message: 'Creating store'}), {alreadyRendered: true}], + [expect.objectContaining({type: 'progress', message: 'Saving session'}), {alreadyRendered: true}], + [ + expect.objectContaining({type: 'progress', message: 'Saving session', current: 1, total: 1}), + {alreadyRendered: true}, + ], + ]) + }) + + test('uses progress events instead of task UI for JSON output', async () => { + const sink = vi.fn() + const write = vi.fn((_chunk, _encoding, callback: () => void) => callback()) + const stdout = new Writable({write}) + + const result = await runWithCommandEvents({sink, outputMode: 'json'}, () => + renderSingleTask({ + title: new TokenizedString('Creating store'), + task: async () => 'store', + renderOptions: {stdout: stdout as unknown as NodeJS.WriteStream}, + }), + ) + + expect(result).toBe('store') + expect(write).not.toHaveBeenCalled() + expect(sink).toHaveBeenCalledTimes(2) + }) + test('returns promise result when task resolves successfully', async () => { // Given const expectedResult = {id: 123, name: 'test-result'} diff --git a/packages/cli-kit/src/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index 08f15ff7dcb..92ca534a5f8 100644 --- a/packages/cli-kit/src/public/node/ui.tsx +++ b/packages/cli-kit/src/public/node/ui.tsx @@ -1,6 +1,7 @@ /* eslint-disable tsdoc/syntax */ import {AbortError, AbortSilentError, FatalError as Fatal} from './error.js' -import {outputContent, outputDebug, outputToken, TokenizedString} from './output.js' +import {commandEventOutputMode, emitCommandEvent} from './command-events.js' +import {outputContent, outputDebug, outputToken, TokenizedString, unstyled} from './output.js' import {terminalSupportsPrompting} from './system.js' import {AbortController} from './abort.js' import {runWithTimer} from './metadata.js' @@ -529,11 +530,30 @@ export async function renderSingleTask({ onAbort, renderOptions, }: RenderSingleTaskOptions): Promise { + let currentStatus = title + const taskWithProgressEvents = async (updateStatus: (status: TokenizedString) => void): Promise => { + emitCommandEvent({type: 'progress', message: unstyled(currentStatus.value)}, {alreadyRendered: true}) + const result = await task((status) => { + currentStatus = status + emitCommandEvent({type: 'progress', message: unstyled(status.value)}, {alreadyRendered: true}) + updateStatus(status) + }) + emitCommandEvent( + {type: 'progress', message: unstyled(currentStatus.value), current: 1, total: 1}, + {alreadyRendered: true}, + ) + return result + } + + if (commandEventOutputMode() === 'json') { + return taskWithProgressEvents(() => {}) + } + let taskResult: T await render( { taskResult = result }}