diff --git a/docs/cli/error_handling.md b/docs/cli/error_handling.md index ede0a668a31..c17cbede637 100644 --- a/docs/cli/error_handling.md +++ b/docs/cli/error_handling.md @@ -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. diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index c7636580aed..ca873843073 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -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. @@ -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. @@ -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 diff --git a/packages/cli-kit/src/private/node/command-event-context.test.ts b/packages/cli-kit/src/private/node/command-event-context.test.ts index 17d8e6951e0..260b6540bf8 100644 --- a/packages/cli-kit/src/private/node/command-event-context.test.ts +++ b/packages/cli-kit/src/private/node/command-event-context.test.ts @@ -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', }) diff --git a/packages/cli-kit/src/private/node/json-error.test.ts b/packages/cli-kit/src/private/node/json-error.test.ts index d1b61a5711d..300added027 100644 --- a/packages/cli-kit/src/private/node/json-error.test.ts +++ b/packages/cli-kit/src/private/node/json-error.test.ts @@ -16,6 +16,22 @@ function renderedDocument(error: Parameters[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.'), { diff --git a/packages/cli-kit/src/private/node/json-error.ts b/packages/cli-kit/src/private/node/json-error.ts index 9945da03676..d1a9088d6c7 100644 --- a/packages/cli-kit/src/private/node/json-error.ts +++ b/packages/cli-kit/src/private/node/json-error.ts @@ -20,6 +20,7 @@ interface FatalErrorLike { command?: unknown args?: unknown details?: unknown + code?: unknown } interface ExternalCommand { @@ -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}), diff --git a/packages/cli-kit/src/public/common/command-events.test.ts b/packages/cli-kit/src/public/common/command-events.test.ts index 8a6ad2028f3..9f47b1825e2 100644 --- a/packages/cli-kit/src/public/common/command-events.test.ts +++ b/packages/cli-kit/src/public/common/command-events.test.ts @@ -5,7 +5,7 @@ describe('commandEventSchema', () => { test.each([ { type: 'diagnostic', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', level: 'warning', message: 'Using a fallback', code: 'fallback', @@ -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, @@ -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', } @@ -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) }, @@ -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', }) @@ -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( @@ -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}, diff --git a/packages/cli-kit/src/public/common/command-events.ts b/packages/cli-kit/src/public/common/command-events.ts index cf12aefd7ce..3084e60a716 100644 --- a/packages/cli-kit/src/public/common/command-events.ts +++ b/packages/cli-kit/src/public/common/command-events.ts @@ -1,13 +1,14 @@ +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() @@ -15,12 +16,12 @@ export const commandDiagnosticEventSchema = z 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() @@ -75,7 +76,7 @@ export interface CommandEventChannelOptions { * 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( options: CommandEventChannelOptions = {}, @@ -85,8 +86,9 @@ export function createCommandEventChannel { + const timestamp = formatJsonOutputTimestamp(new Date(input)) + + expect(timestamp).toBe(expected) + expect(jsonOutputTimestampSchema.parse(timestamp)).toBe(timestamp) +}) diff --git a/packages/cli-kit/src/public/common/json-output-schema.ts b/packages/cli-kit/src/public/common/json-output-schema.ts new file mode 100644 index 00000000000..34ede5f9d47 --- /dev/null +++ b/packages/cli-kit/src/public/common/json-output-schema.ts @@ -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') +} diff --git a/packages/cli-kit/src/public/node/cli-launcher.test.ts b/packages/cli-kit/src/public/node/cli-launcher.test.ts index 8ce9f67bade..2a245aa0407 100644 --- a/packages/cli-kit/src/public/node/cli-launcher.test.ts +++ b/packages/cli-kit/src/public/node/cli-launcher.test.ts @@ -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) }) }) diff --git a/packages/cli-kit/src/public/node/command-events.test.ts b/packages/cli-kit/src/public/node/command-events.test.ts index 05d6960c332..59a81cb5b1d 100644 --- a/packages/cli-kit/src/public/node/command-events.test.ts +++ b/packages/cli-kit/src/public/node/command-events.test.ts @@ -7,6 +7,7 @@ import { import {outputWarn} from './output.js' import {mockAndCaptureOutput} from './testing/output.js' import {beforeEach, describe, expect, test} from 'vitest' +import {Ajv} from 'ajv' import type {CommandEvent} from '../common/command-events.js' const outputMock = mockAndCaptureOutput() @@ -31,8 +32,8 @@ describe('commandEventOutputSchema', () => { CommandProgressEvent: { properties: { status: {enum: ['started', 'updated', 'retrying', 'completed', 'failed']}, - current: {type: 'number', minimum: 0}, - total: {type: 'number', minimum: 0}, + current: {type: 'integer', minimum: 0}, + total: {type: 'integer', minimum: 0}, }, required: ['type', 'timestamp', 'status', 'operation'], additionalProperties: false, @@ -40,6 +41,25 @@ describe('commandEventOutputSchema', () => { }, }) }) + + test('enforces timestamp precision and integer counts in the published JSON Schema', () => { + const validate = new Ajv({formats: {'date-time': true}}).compile(commandEventOutputSchema.jsonSchema) + const diagnostic = {type: 'diagnostic', timestamp: '2026-08-26T12:00:00Z', level: 'info', message: 'Ready'} + const progress = {type: 'progress', timestamp: diagnostic.timestamp, operation: 'upload', status: 'updated'} + + for (const event of [diagnostic, progress]) { + expect(validate(event)).toBe(true) + for (const timestamp of ['2026-08-26T12:00:00.000Z', '2026-08-26T12:00:00+00:00']) { + expect(validate({...event, timestamp})).toBe(false) + } + } + for (const field of ['current', 'total']) { + expect(validate({...progress, [field]: 0})).toBe(true) + expect(validate({...progress, [field]: 1.5})).toBe(false) + expect(validate({...progress, [field]: -1})).toBe(false) + } + expect(validate({...diagnostic, code: ''})).toBe(false) + }) }) describe('renderCommandEvent', () => { @@ -48,7 +68,7 @@ describe('renderCommandEvent', () => { renderCommandEvent({ type: 'diagnostic', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', level: 'error', message: 'One item could not be uploaded', }) @@ -60,7 +80,7 @@ describe('renderCommandEvent', () => { test('uses the operation as a fallback for progress without a message', () => { renderCommandEvent({ type: 'progress', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', operation: 'upload', status: 'started', }) @@ -70,7 +90,7 @@ describe('renderCommandEvent', () => { test('renders debug diagnostics to stderr through the debug output path', () => { renderCommandEvent({ type: 'diagnostic', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', level: 'debug', message: 'Resolving store', }) @@ -83,7 +103,7 @@ describe('renderCommandEvent', () => { test('renders info diagnostics to stderr through the info output path', () => { renderCommandEvent({ type: 'diagnostic', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', level: 'info', message: 'Store resolved', }) @@ -96,7 +116,7 @@ describe('renderCommandEvent', () => { test('renders warning diagnostics to stderr through the warning output path', () => { renderCommandEvent({ type: 'diagnostic', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', level: 'warning', message: 'Using a fallback', }) @@ -111,7 +131,7 @@ describe('renderCommandEvent', () => { type: 'progress' as const, operation: 'upload', status: 'updated' as const, - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', message: 'Uploading files', current: 2, total: 10, @@ -126,7 +146,7 @@ describe('renderCommandEvent', () => { type: 'progress', operation: 'upload', status: 'updated', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', message: 'Uploading files', current: 2, total: 10, @@ -138,7 +158,7 @@ describe('renderCommandEventAsJson', () => { test.each(['retrying', 'failed'] as const)('renders %s progress as JSON', (status) => { const event: CommandEvent = { type: 'progress', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', operation: 'upload', status, message: 'Uploading files', @@ -156,7 +176,7 @@ describe('renderCommandEventAsJson', () => { {type: 'diagnostic', level: 'info', message: 'Extra field', extra: true}, {type: 'diagnostic', level: 'info', message: 'Invalid timestamp', timestamp: 'invalid'}, ])('rejects invalid events before writing JSON: %j', (event) => { - expect(() => renderCommandEventAsJson({timestamp: '2026-08-26T12:00:00.000Z', ...event} as CommandEvent)).toThrow() + expect(() => renderCommandEventAsJson({timestamp: '2026-08-26T12:00:00Z', ...event} as CommandEvent)).toThrow() expect(outputMock.info()).toBe('') }) @@ -164,7 +184,7 @@ describe('renderCommandEventAsJson', () => { test('renders non-fatal error diagnostics as JSON', () => { const event: CommandEvent = { type: 'diagnostic', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', level: 'error', message: 'One item could not be uploaded', } @@ -179,14 +199,14 @@ describe('renderCommandEventAsJson', () => { type: 'progress', operation: 'upload', status: 'updated', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', message: 'Uploading files', current: 2, total: 10, }) expect(outputMock.info()).toBe( - '{"type":"progress","timestamp":"2026-08-26T12:00:00.000Z","status":"updated","operation":"upload","message":"Uploading files","current":2,"total":10}', + '{"type":"progress","timestamp":"2026-08-26T12:00:00Z","status":"updated","operation":"upload","message":"Uploading files","current":2,"total":10}', ) expect(outputMock.debug()).toBe('') expect(outputMock.warn()).toBe('') @@ -197,7 +217,7 @@ describe('renderCommandEventAsJson', () => { { outputMode: 'json', sink: renderCommandEventAsJson, - clock: () => new Date('2026-08-26T12:00:00.000Z'), + clock: () => new Date('2026-08-26T12:00:00.999Z'), }, () => outputWarn('Using a fallback'), ) @@ -206,7 +226,7 @@ describe('renderCommandEventAsJson', () => { type: 'diagnostic', level: 'warning', message: 'Using a fallback', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', }) }) }) diff --git a/packages/cli-kit/src/public/node/error/index.ts b/packages/cli-kit/src/public/node/error/index.ts index 88f1f594ee6..2b608baddb7 100644 --- a/packages/cli-kit/src/public/node/error/index.ts +++ b/packages/cli-kit/src/public/node/error/index.ts @@ -27,6 +27,8 @@ export abstract class FatalError extends Error { nextSteps?: TokenItem[] formattedMessage?: TokenItem customSections?: AlertCustomSection[] + /** A stable code for JSON consumers to identify this error, included only when known. */ + code?: string /** Selected JSON-serializable data to include in JSON errors. Never attach the raw error or request. */ details?: unknown skipOclifErrorHandling: boolean diff --git a/packages/cli-kit/src/public/node/error/schema.test.ts b/packages/cli-kit/src/public/node/error/schema.test.ts index 39ba7d7d94d..c08ea7d5f3c 100644 --- a/packages/cli-kit/src/public/node/error/schema.test.ts +++ b/packages/cli-kit/src/public/node/error/schema.test.ts @@ -24,6 +24,23 @@ describe('JSON error output schema', () => { expect(() => jsonErrorOutputSchema.validate({error: {type: 'external', message: 'Failed'}})).toThrow() }) + test.each([ + {type: 'abort' as const}, + {type: 'bug' as const}, + {type: 'external' as const, command: 'npm', args: ['install']}, + ])('accepts an optional stable code on $type errors', (variant) => { + const document = {error: {...variant, message: 'Failed', code: 'CONFIGURATION_INVALID'}} + const validate = new Ajv().compile(jsonErrorOutputSchema.jsonSchema) + + expect(JSON.parse(jsonErrorOutputSchema.encode(document))).toEqual(document) + expect(validate(document)).toBe(true) + for (const code of ['', 123, null]) { + const invalidDocument = {error: {...document.error, code}} + expect(() => jsonErrorOutputSchema.validate(invalidDocument)).toThrow() + expect(validate(invalidDocument)).toBe(false) + } + }) + test.each([ {type: 'abort' as const}, {type: 'bug' as const}, diff --git a/packages/cli-kit/src/public/node/error/schema.ts b/packages/cli-kit/src/public/node/error/schema.ts index 4ea4396b493..bab16f69343 100644 --- a/packages/cli-kit/src/public/node/error/schema.ts +++ b/packages/cli-kit/src/public/node/error/schema.ts @@ -10,10 +10,14 @@ export const JsonErrorCustomSectionSchema = zod const commonJsonErrorShape = { message: zod.string(), + code: zod.string().min(1).optional().describe('A stable error code, included only when known.'), tryMessage: zod.string().optional(), nextSteps: zod.array(zod.string()).optional(), customSections: zod.array(JsonErrorCustomSectionSchema).optional(), - details: zod.unknown().optional(), + details: zod + .unknown() + .optional() + .describe('Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.'), } export const JsonAbortErrorSchema = zod diff --git a/packages/cli-kit/src/public/node/json-output-schema.ts b/packages/cli-kit/src/public/node/json-output-schema.ts index 41b61841379..8e235111aff 100644 --- a/packages/cli-kit/src/public/node/json-output-schema.ts +++ b/packages/cli-kit/src/public/node/json-output-schema.ts @@ -1,13 +1,7 @@ -import {zod} from './schema.js' import {zodToJsonSchema} from 'zod-to-json-schema' import type {ZodTypeAny, z} from 'zod' -/** UTC instants in CLI-owned results use whole seconds and the Z timezone marker. */ -export const jsonOutputTimestampSchema = zod - .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.') +export {formatJsonOutputTimestamp, jsonOutputTimestampSchema} from '../common/json-output-schema.js' interface JsonOutputSchemaDefinition { readonly name: string diff --git a/packages/cli-kit/src/public/node/output.test.ts b/packages/cli-kit/src/public/node/output.test.ts index 8f3e01baaff..cf8f25d7fa2 100644 --- a/packages/cli-kit/src/public/node/output.test.ts +++ b/packages/cli-kit/src/public/node/output.test.ts @@ -49,14 +49,14 @@ describe('Output helpers', () => { try { renderCommandEventAsJson({ type: 'diagnostic', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', level: 'error', message: 'One item could not be uploaded', }) expect(stdout).not.toHaveBeenCalled() expect(stderr.mock.calls.map(([chunk]) => chunk).join('')).toBe( - '{"type":"diagnostic","timestamp":"2026-08-26T12:00:00.000Z","level":"error","message":"One item could not be uploaded"}\n', + '{"type":"diagnostic","timestamp":"2026-08-26T12:00:00Z","level":"error","message":"One item could not be uploaded"}\n', ) expect(process.exitCode).toBe(exitCode) } finally { @@ -177,13 +177,13 @@ describe('JSON command diagnostics', () => { const sink = vi.fn() if (level === 'debug') isVerboseMock.mockReturnValue(true) - runWithCommandEvents({sink, outputMode: 'json', clock: () => new Date('2026-08-26T12:00:00.000Z')}, () => + runWithCommandEvents({sink, outputMode: 'json', clock: () => new Date('2026-08-26T12:00:00Z')}, () => output('Diagnostic message'), ) expect(sink).toHaveBeenCalledWith({ type: 'diagnostic', - timestamp: '2026-08-26T12:00:00.000Z', + timestamp: '2026-08-26T12:00:00Z', level, message: 'Diagnostic message', }) diff --git a/packages/store/src/cli/services/store/bulk/json.ts b/packages/store/src/cli/services/store/bulk/json.ts index 1e57e481843..6eccbb8950d 100644 --- a/packages/store/src/cli/services/store/bulk/json.ts +++ b/packages/store/src/cli/services/store/bulk/json.ts @@ -1,4 +1,5 @@ import {extractMyshopifyHandle} from '@shopify/cli-kit/common/url' +import {formatJsonOutputTimestamp} from '@shopify/cli-kit/common/json-output-schema' import {BugError} from '@shopify/cli-kit/node/error' import type {BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations' import type {BulkOperationJson, ListedBulkOperationJson} from './types.js' @@ -22,10 +23,8 @@ export function toBulkOperationJson( ...('type' in operation ? {type: operation.type} : {}), status: operation.status, errorCode: operation.errorCode ?? null, - createdAt: new Date(String(operation.createdAt)).toISOString().replace(/\.\d{3}Z$/, 'Z'), - completedAt: operation.completedAt - ? new Date(String(operation.completedAt)).toISOString().replace(/\.\d{3}Z$/, 'Z') - : null, + createdAt: formatJsonOutputTimestamp(new Date(String(operation.createdAt))), + completedAt: operation.completedAt ? formatJsonOutputTimestamp(new Date(String(operation.completedAt))) : null, objectCount: String(operation.objectCount), url: operation.url ?? null, partialDataUrl: operation.partialDataUrl ?? null,