From 540ae6214bf7d9459ef7cf87f1036cff7bfb641d 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/public/common/command-events.test.ts | 62 +++++++++++++ .../src/public/common/command-events.ts | 79 ++++++++++++++++ .../src/public/node/command-events.test.ts | 91 +++++++++++++++++++ .../cli-kit/src/public/node/command-events.ts | 35 +++++++ 4 files changed, 267 insertions(+) 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/public/common/command-events.test.ts b/packages/cli-kit/src/public/common/command-events.test.ts new file mode 100644 index 00000000000..c963c8ffaff --- /dev/null +++ b/packages/cli-kit/src/public/common/command-events.test.ts @@ -0,0 +1,62 @@ +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']) + }) +}) 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..7b220604f38 --- /dev/null +++ b/packages/cli-kit/src/public/common/command-events.ts @@ -0,0 +1,79 @@ +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 + +/** Receives one timestamped event from a command execution. */ +export type CommandEventSink = (event: TEvent) => void + +/** Emits timestamped side events from one command execution. */ +export interface CommandEventChannel { + emit: (event: CommandEventInput) => 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) { + sink({...event, timestamp: clock().toISOString()} as TEvent) + }, + } +} 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..2f656a645cf --- /dev/null +++ b/packages/cli-kit/src/public/node/command-events.test.ts @@ -0,0 +1,91 @@ +import {renderCommandEvent, renderCommandEventAsJson} from './command-events.js' +import {mockAndCaptureOutput} from './testing/output.js' +import {beforeEach, describe, expect, test} from 'vitest' + +const outputMock = mockAndCaptureOutput() + +beforeEach(() => { + outputMock.clear() +}) + +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('') + }) +}) 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..3ecf7fd21ac --- /dev/null +++ b/packages/cli-kit/src/public/node/command-events.ts @@ -0,0 +1,35 @@ +import {outputDebug, outputInfo, outputWarn} from './output.js' +import type {CommandEvent} from '../common/command-events.js' + +/** + * 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 { + outputInfo(JSON.stringify(event)) +}