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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/cli/error_handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ When `--json` or `-j` is active, a fatal error writes one document to stdout:

`type` is one of `abort`, `bug`, or `external`. `type` and `message` are always included. The regular error output's optional `tryMessage`, `nextSteps`, and `customSections` content is included as unstyled strings. Link URLs remain visible. Bug errors include `stack`; external errors include `command` and `args`. Other error properties are excluded.

Set `error.code` to a stable nonempty string when consumers need to identify the failure without parsing its message.
Unknown codes are omitted. Upstream API codes stay inside their native `details` payload.

Callers can explicitly attach selected, JSON-serializable data to `error.details`. The renderer includes this field as
data, so consumers do not need to parse display strings. Do not attach a raw error, request, credentials, or other private
properties. For example, `store execute` exposes GraphQL errors at `error.details.errors`, including their error codes.
Expand Down
11 changes: 11 additions & 0 deletions docs/cli/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ envelope. Keep domain details under `details`, and add stable error codes when c
retain `details.errors`, `details.extensions`, and partial `details.data` when available. Do not print a result followed
by a second fatal document.

Fatal errors and diagnostic events may include a nonempty string `code` when a stable code is known. Omit unknown
codes rather than using `null` or an empty string. Keep upstream error codes inside their native `details` payload.

A completed validation is a result with `valid` and consistent issues, even if it finds problems. Use a nonzero exit
when the selected blocking policy fails. Infrastructure failures remain fatal errors. Preserve completed work in batch
or partial results rather than discarding successful items.
Expand Down Expand Up @@ -146,6 +149,10 @@ Use open records or `.passthrough()` only at documented native boundaries such a
Validate URLs, ID formats, counts, and timestamps according to their meaning. A schema alone does not ensure every
execution path emits the right result or exit code.

For CLI-owned instants, use `jsonOutputTimestampSchema` to validate public fields and
`formatJsonOutputTimestamp(date)` to format them. Both are exported from
`@shopify/cli-kit/common/json-output-schema` and `@shopify/cli-kit/node/json-output-schema`.

## Connect the command and encoder

Expose the contract from the command and encode through it. Encoding validates the value before serialization.
Expand Down Expand Up @@ -190,6 +197,10 @@ on terminal rendering (including React/Ink), Oclif, filesystem output, or CLI er

Events are separate from finite results. Progress events can drive spinners or status messages while the command is
running, but they aren't fields in the final JSON result. Fatal errors continue through the standard CLI error path.

Diagnostic and progress event timestamps follow the instant convention: UTC whole seconds ending in `Z`, with
fractional seconds truncated rather than rounded. Optional progress `current` and `total` counts are nonnegative
integers; omit counts that aren't known.
Completed validation reports and partial or batch outcomes remain results with the exit policy described above.

### Task progress events
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ describe('command event context', () => {
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 runWithCommandEvents({sink, clock: () => new Date('2026-08-26T12:00:00Z')}, async () => {
await Promise.resolve()
emitCommandEvent({type: 'diagnostic', level: 'debug', message: 'Resolving store'})
})

expect(sink).toHaveBeenCalledWith({
type: 'diagnostic',
timestamp: '2026-08-26T12:00:00.000Z',
timestamp: '2026-08-26T12:00:00Z',
level: 'debug',
message: 'Resolving store',
})
Expand Down
16 changes: 16 additions & 0 deletions packages/cli-kit/src/private/node/json-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ function renderedDocument(error: Parameters<typeof renderFatalErrorAsJson>[0]):
}

describe('renderFatalErrorAsJson', () => {
test.each([
new AbortError('Expected failure'),
new BugError('Unexpected failure'),
new ExternalError('External failure', 'npm', ['install']),
])('preserves a stable error code for fatal error type $type', (error) => {
error.code = 'CONFIGURATION_INVALID'

expect(renderedDocument(error)).toMatchObject({error: {code: 'CONFIGURATION_INVALID'}})
})

test.each([undefined, '', 123, null])('omits unknown or invalid error codes: %j', (code) => {
const error = Object.assign(new AbortError('Expected failure'), {code})

expect(renderedDocument(error)).toEqual({error: {type: 'abort', message: 'Expected failure'}})
})

test('includes only explicitly selected structured details', () => {
const details = {errors: [{message: 'Invalid field', extensions: {code: 'UNDEFINED_FIELD'}}]}
const error = Object.assign(new AbortError('GraphQL operation failed.'), {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-kit/src/private/node/json-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface FatalErrorLike {
command?: unknown
args?: unknown
details?: unknown
code?: unknown
}

interface ExternalCommand {
Expand Down Expand Up @@ -134,6 +135,7 @@ function jsonErrorDocument(error: FatalErrorLike): JsonErrorDocument | undefined

const commonFields = {
message,
...(typeof error.code === 'string' && error.code.length > 0 ? {code: error.code} : {}),
...(tryMessage === undefined ? {} : {tryMessage}),
...(nextSteps === undefined ? {} : {nextSteps}),
...(customSections === undefined ? {} : {customSections}),
Expand Down
51 changes: 40 additions & 11 deletions packages/cli-kit/src/public/common/command-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ describe('commandEventSchema', () => {
test.each<CommandEvent>([
{
type: 'diagnostic',
timestamp: '2026-08-26T12:00:00.000Z',
timestamp: '2026-08-26T12:00:00Z',
level: 'warning',
message: 'Using a fallback',
code: 'fallback',
Expand All @@ -14,7 +14,7 @@ describe('commandEventSchema', () => {
type: 'progress',
operation: 'upload',
status: 'updated',
timestamp: '2026-08-26T12:00:01.000Z',
timestamp: '2026-08-26T12:00:01Z',
message: 'Uploading files',
current: 2,
total: 10,
Expand All @@ -27,10 +27,39 @@ describe('commandEventSchema', () => {
expect(() => commandEventSchema.parse({type: 'diagnostic', level: 'info', message: 'Missing timestamp'})).toThrow()
})

test.each([
'2026-08-26T12:00:00.000Z',
'2026-08-26T12:00:00.999Z',
'2026-08-26T12:00:00+00:00',
'2026-08-26T14:00:00+02:00',
'2026-02-30T12:00:00Z',
])('rejects noncanonical timestamps on both event types: %s', (timestamp) => {
for (const event of [
{type: 'diagnostic', level: 'info', message: 'Resolving store'},
{type: 'progress', operation: 'upload', status: 'started'},
]) {
expect(commandEventSchema.safeParse({...event, timestamp}).success).toBe(false)
}
})

test.each(['current', 'total'])('requires %s to be a nonnegative integer', (field) => {
for (const count of [-1, 1.5, Infinity, NaN]) {
expect(
commandEventSchema.safeParse({
type: 'progress',
timestamp: '2026-08-26T12:00:00Z',
operation: 'upload',
status: 'updated',
[field]: count,
}).success,
).toBe(false)
}
})

test('accepts non-fatal error diagnostics', () => {
const event = {
type: 'diagnostic',
timestamp: '2026-08-26T12:00:00.000Z',
timestamp: '2026-08-26T12:00:00Z',
level: 'error',
message: 'One item could not be uploaded',
}
Expand All @@ -41,7 +70,7 @@ describe('commandEventSchema', () => {
test.each(['started', 'updated', 'retrying', 'completed', 'failed'])(
'accepts %s progress without a message',
(status) => {
const event = {type: 'progress', timestamp: '2026-08-26T12:00:00.000Z', operation: 'upload', status}
const event = {type: 'progress', timestamp: '2026-08-26T12:00:00Z', operation: 'upload', status}

expect(commandEventSchema.parse(event)).toEqual(event)
},
Expand All @@ -51,29 +80,29 @@ describe('commandEventSchema', () => {
'rejects incomplete or invalid progress metadata: %j',
(metadata) => {
expect(() =>
commandEventSchema.parse({type: 'progress', timestamp: '2026-08-26T12:00:00.000Z', ...metadata}),
commandEventSchema.parse({type: 'progress', timestamp: '2026-08-26T12:00:00Z', ...metadata}),
).toThrow()
},
)
})

describe('createCommandEventChannel', () => {
test('adds the timestamp when the event is emitted and delivers synchronously', () => {
test('truncates fractional seconds without rounding 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'),
clock: () => new Date('2026-08-26T12:00:00.999Z'),
})

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(calls).toEqual(['before', '2026-08-26T12:00:00Z', 'after'])
expect(sink).toHaveBeenCalledWith({
type: 'diagnostic',
timestamp: '2026-08-26T12:00:00.000Z',
timestamp: '2026-08-26T12:00:00Z',
level: 'debug',
message: 'Resolving store',
})
Expand All @@ -95,7 +124,7 @@ describe('createCommandEventChannel', () => {
const sink = vi.fn()
const channel = createCommandEventChannel({
sink,
clock: () => new Date('2026-08-26T12:00:00.000Z'),
clock: () => new Date('2026-08-26T12:00:00Z'),
})

channel.emit(
Expand All @@ -108,7 +137,7 @@ describe('createCommandEventChannel', () => {
type: 'progress',
operation: 'upload',
status: 'updated',
timestamp: '2026-08-26T12:00:00.000Z',
timestamp: '2026-08-26T12:00:00Z',
message: 'Uploading files',
},
{alreadyRendered: true},
Expand Down
16 changes: 9 additions & 7 deletions packages/cli-kit/src/public/common/command-events.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
import {formatJsonOutputTimestamp, jsonOutputTimestampSchema} from './json-output-schema.js'
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}),
timestamp: jsonOutputTimestampSchema,
level: z.enum(['debug', 'info', 'warning', 'error']),
message: z.string(),
code: z.string().optional(),
code: z.string().min(1).optional().describe('A stable diagnostic code, included only when known.'),
})
.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}),
timestamp: jsonOutputTimestampSchema,
status: z.enum(['started', 'updated', 'retrying', 'completed', 'failed']),
operation: z.string(),
message: z.string().optional(),
current: z.number().nonnegative().optional(),
total: z.number().nonnegative().optional(),
current: z.number().int().nonnegative().optional(),
total: z.number().int().nonnegative().optional(),
})
.strict()

Expand Down Expand Up @@ -75,7 +76,7 @@ export interface CommandEventChannelOptions<TEvent extends CommandEvent> {
* Adapters validate events at their output boundary; the channel preserves domain-specific event fields.
*
* @param options - The event sink and clock used by the channel.
* @returns A channel that adds an ISO timestamp before synchronously delivering each event.
* @returns A channel that adds a whole-second UTC timestamp before synchronously delivering each event.
*/
export function createCommandEventChannel<TEvent extends CommandEvent = CommandEvent>(
options: CommandEventChannelOptions<TEvent> = {},
Expand All @@ -85,8 +86,9 @@ export function createCommandEventChannel<TEvent extends CommandEvent = CommandE

return {
emit(event, emissionOptions) {
const timestamp = formatJsonOutputTimestamp(clock())
// TypeScript cannot reconstruct the generic event from its distributive Omit.
const timestampedEvent = {...event, timestamp: clock().toISOString()} as unknown as TEvent
const timestampedEvent = {...event, timestamp} as unknown as TEvent
if (emissionOptions === undefined) {
sink(timestampedEvent)
} else {
Expand Down
13 changes: 13 additions & 0 deletions packages/cli-kit/src/public/common/json-output-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {formatJsonOutputTimestamp, jsonOutputTimestampSchema} from './json-output-schema.js'
import {expect, test} from 'vitest'

test.each([
['2026-09-30T12:34:56Z', '2026-09-30T12:34:56Z'],
['2026-09-30T12:34:56.999Z', '2026-09-30T12:34:56Z'],
['2026-09-30T14:34:56.789+02:00', '2026-09-30T12:34:56Z'],
])('formats %s as a canonical JSON timestamp', (input, expected) => {
const timestamp = formatJsonOutputTimestamp(new Date(input))

expect(timestamp).toBe(expected)
expect(jsonOutputTimestampSchema.parse(timestamp)).toBe(timestamp)
})
18 changes: 18 additions & 0 deletions packages/cli-kit/src/public/common/json-output-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {z} from 'zod'

/** UTC instants in CLI-owned JSON use whole seconds and the Z timezone marker. */
export const jsonOutputTimestampSchema = z
.string()
.datetime({precision: 0})
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
.describe('A UTC ISO 8601 instant with whole seconds and the Z timezone marker.')

/**
* Formats an instant for CLI-owned JSON, truncating fractional seconds without rounding.
*
* @param date - The instant to format.
* @returns A UTC ISO 8601 timestamp with whole seconds and the Z timezone marker.
*/
export function formatJsonOutputTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
}
11 changes: 7 additions & 4 deletions packages/cli-kit/src/public/node/cli-launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,18 @@ describe('JSON output schema flag', () => {
const validate = new Ajv({validateFormats: false}).compile(schema)
expect(validate({value: 'ready'})).toBe(true)
expect(validate({error: {type: 'abort', message: 'Failed'}})).toBe(true)
expect(validate({type: 'diagnostic', timestamp: '2026-08-26T12:00:00Z', level: 'info', message: 'Ready'})).toBe(
true,
)
expect(
validate({type: 'diagnostic', timestamp: '2026-08-26T12:00:00.000Z', level: 'info', message: 'Ready'}),
validate({type: 'progress', timestamp: '2026-08-26T12:00:00Z', status: 'started', operation: 'upload'}),
).toBe(true)
expect(
validate({type: 'progress', timestamp: '2026-08-26T12:00:00.000Z', status: 'started', operation: 'upload'}),
).toBe(true)
validate({type: 'diagnostic', timestamp: '2026-08-26T12:00:00.000Z', level: 'info', message: 'Ready'}),
).toBe(false)
expect(validate({value: 1})).toBe(false)
expect(validate({error: {type: 'external', message: 'Missing command and args'}})).toBe(false)
expect(validate({type: 'progress', timestamp: '2026-08-26T12:00:00.000Z', status: 'started'})).toBe(false)
expect(validate({type: 'progress', timestamp: '2026-08-26T12:00:00Z', status: 'started'})).toBe(false)
})
})

Expand Down
Loading
Loading