Skip to content

Emit fatal errors as JSON - #8405

Open
gonzaloriestra wants to merge 1 commit into
gonzalo/json-result-schema-infrastructurefrom
gonzalo/json-error-handling-v2
Open

Emit fatal errors as JSON#8405
gonzaloriestra wants to merge 1 commit into
gonzalo/json-result-schema-infrastructurefrom
gonzalo/json-error-handling-v2

Conversation

@gonzaloriestra

@gonzaloriestra gonzaloriestra commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

WHY are these changes introduced?

Closes shop/issues-develop#23662

Commands using JSON output need one machine-readable fatal error on stdout.

Based on #8182.

WHAT is this pull request doing?

  • Emits JSON fatal errors with the same meaningful content as regular errors.
  • Keeps diagnostics and progress off stdout.
  • Handles uncaught errors consistently and preserves nonzero exit codes.
  • Keeps silent aborts silent and ignores --json after the -- boundary.

How to test your changes?

  • pnpm shopify app info --wrong-flag
  • pnpm shopify app info --wrong-flag --json

Add if (1 === 1) throw new Error('boom') at the beginning of commands/app/info.ts and run:

  • pnpm shopify app info
  • pnpm shopify app info --json

Checklist

  • I've considered possible cross-platform impacts (Mac, Linux, Windows)
  • I've considered possible documentation changes
  • I've considered analytics changes to measure impact
  • The change is user-facing — I've identified the correct bump type and added a changeset

@github-actions github-actions Bot added the Area: @shopify/cli @shopify/cli package issues label Aug 26, 2026
@gonzaloriestra

Copy link
Copy Markdown
Contributor Author

/snapit

@github-actions

Copy link
Copy Markdown
Contributor

🫰✨ Thanks @gonzaloriestra! Your snapshot has been published to npm.

Test the snapshot by installing your package globally:

pnpm i -g --@shopify:registry=https://registry.npmjs.org @shopify/cli@0.0.0-snapshot-20260827101450

Caution

After installing, validate the version by running shopify version in your terminal.
If the versions don't match, you might have multiple global instances installed.
Use which shopify to find out which one you are running and uninstall it.

@gonzaloriestra
gonzaloriestra force-pushed the gonzalo/json-error-handling-v2 branch from 2b6397c to 2e72af5 Compare August 27, 2026 11:42
@gonzaloriestra
gonzaloriestra changed the base branch from main to gonzalo/json-result-schema-infrastructure August 27, 2026 11:42
@gonzaloriestra
gonzaloriestra marked this pull request as ready for review August 27, 2026 11:42
@gonzaloriestra
gonzaloriestra requested a review from a team as a code owner August 27, 2026 11:42
@gonzaloriestra
gonzaloriestra marked this pull request as draft August 27, 2026 11:47
@github-actions github-actions Bot added no-changelog This PR doesn't include a changeset entry. Is an internal only change not relevant to end users. and removed Area: @shopify/cli @shopify/cli package issues labels Aug 27, 2026
@gonzaloriestra
gonzaloriestra force-pushed the gonzalo/json-error-handling-v2 branch from a987a5b to 2867bf1 Compare August 27, 2026 11:55
@github-actions github-actions Bot added Area: @shopify/cli @shopify/cli package issues and removed no-changelog This PR doesn't include a changeset entry. Is an internal only change not relevant to end users. labels Aug 27, 2026
@gonzaloriestra
gonzaloriestra force-pushed the gonzalo/json-error-handling-v2 branch 2 times, most recently from 1173d2e to c40ff3d Compare August 27, 2026 12:44
@gonzaloriestra
gonzaloriestra force-pushed the gonzalo/json-error-handling-v2 branch from c40ff3d to ea20e9d Compare August 27, 2026 13:47
@gonzaloriestra
gonzaloriestra force-pushed the gonzalo/json-error-handling-v2 branch 2 times, most recently from da3e80a to c0693c5 Compare August 27, 2026 14:56
@gonzaloriestra
gonzaloriestra force-pushed the gonzalo/json-error-handling-v2 branch from c0693c5 to 2a0f445 Compare August 28, 2026 12:07
Comment on lines +147 to +159
const {jsonOutputEnabled} = await import('../environment.js')
if (jsonOutputEnabled()) {
try {
const {renderFatalErrorAsJson} = await import('../../../private/node/json-error.js')
renderFatalErrorAsJson(fatal)
return Promise.resolve(error)
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (serializationError) {
outputDebug(`Failed to render the error as JSON: ${serializationError}`)
}
}

const {renderFatalError} = await import('../ui.js')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The content of this file was moved from ../error.ts. These are the only changes here.

@gonzaloriestra
gonzaloriestra force-pushed the gonzalo/json-error-handling-v2 branch from 2a0f445 to 66fafe4 Compare August 28, 2026 12:29
@gonzaloriestra
gonzaloriestra marked this pull request as ready for review August 28, 2026 12:34
@gonzaloriestra
gonzaloriestra force-pushed the gonzalo/json-error-handling-v2 branch from 66fafe4 to 97527e8 Compare August 28, 2026 12:36

gonzaloriestra commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@gonzaloriestra
gonzaloriestra force-pushed the gonzalo/json-error-handling-v2 branch from a946666 to 28d0cab Compare August 31, 2026 14:57
@github-actions

Copy link
Copy Markdown
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

packages/cli-kit/dist/private/node/command-event-context.d.ts
import { type CommandEvent, type CommandEventChannelOptions, type CommandEventEmissionOptions, type CommandEventInput } from '../../public/common/command-events.js';
export type CommandEventOutputMode = 'text' | 'json';
interface RunWithCommandEventsOptions extends CommandEventChannelOptions<CommandEvent> {
    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 declare function runWithCommandEvents<TResult>(options: RunWithCommandEventsOptions, execute: () => TResult): TResult;
/**
 * 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 declare function emitCommandEvent(event: CommandEventInput, options?: CommandEventEmissionOptions): void;
/**
 * Returns how command events are presented for the current execution.
 *
 * @returns The current event output mode, or undefined outside a command event context.
 */
export declare function commandEventOutputMode(): CommandEventOutputMode | undefined;
export {};
packages/cli-kit/dist/private/node/command-event-output.d.ts
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 declare function outputCommandEventAsJson(event: CommandEvent): void;
packages/cli-kit/dist/private/node/json-error.d.ts
interface FatalErrorLike {
    type?: number;
    message?: unknown;
    formattedMessage?: unknown;
    tryMessage?: unknown;
    nextSteps?: unknown;
    customSections?: unknown;
    stack?: unknown;
    command?: unknown;
    args?: unknown;
}
/**
 * Writes the public JSON representation of a fatal error to stdout.
 *
 * The allow-list mirrors the meaningful content of the regular fatal-error renderer.
 * Arbitrary error properties remain private and are never copied to stdout.
 *
 * @param error - Fatal error to serialize.
 */
export declare function renderFatalErrorAsJson(error: FatalErrorLike): void;
export {};
packages/cli-kit/dist/public/common/command-events.d.ts
import { z } from 'zod';
/** Schema for a diagnostic emitted while a command executes. */
export declare const commandDiagnosticEventSchema: z.ZodObject<{
    type: z.ZodLiteral<"diagnostic">;
    timestamp: z.ZodString;
    level: z.ZodEnum<["debug", "info", "warning"]>;
    message: z.ZodString;
    code: z.ZodOptional<z.ZodString>;
}, "strict", z.ZodTypeAny, {
    type: "diagnostic";
    message: string;
    timestamp: string;
    level: "info" | "debug" | "warning";
    code?: string | undefined;
}, {
    type: "diagnostic";
    message: string;
    timestamp: string;
    level: "info" | "debug" | "warning";
    code?: string | undefined;
}>;
/** Schema for a progress update emitted while a command executes. */
export declare const commandProgressEventSchema: z.ZodObject<{
    type: z.ZodLiteral<"progress">;
    timestamp: z.ZodString;
    message: z.ZodString;
    current: z.ZodOptional<z.ZodNumber>;
    total: z.ZodOptional<z.ZodNumber>;
}, "strict", z.ZodTypeAny, {
    type: "progress";
    message: string;
    timestamp: string;
    current?: number | undefined;
    total?: number | undefined;
}, {
    type: "progress";
    message: string;
    timestamp: string;
    current?: number | undefined;
    total?: number | undefined;
}>;
/** Schema for side events emitted while a command executes. */
export declare const commandEventSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
    type: z.ZodLiteral<"diagnostic">;
    timestamp: z.ZodString;
    level: z.ZodEnum<["debug", "info", "warning"]>;
    message: z.ZodString;
    code: z.ZodOptional<z.ZodString>;
}, "strict", z.ZodTypeAny, {
    type: "diagnostic";
    message: string;
    timestamp: string;
    level: "info" | "debug" | "warning";
    code?: string | undefined;
}, {
    type: "diagnostic";
    message: string;
    timestamp: string;
    level: "info" | "debug" | "warning";
    code?: string | undefined;
}>, z.ZodObject<{
    type: z.ZodLiteral<"progress">;
    timestamp: z.ZodString;
    message: z.ZodString;
    current: z.ZodOptional<z.ZodNumber>;
    total: z.ZodOptional<z.ZodNumber>;
}, "strict", z.ZodTypeAny, {
    type: "progress";
    message: string;
    timestamp: string;
    current?: number | undefined;
    total?: number | undefined;
}, {
    type: "progress";
    message: string;
    timestamp: string;
    current?: number | undefined;
    total?: number | undefined;
}>]>;
/** A diagnostic emitted while a command executes. */
export type CommandDiagnosticEvent = z.infer<typeof commandDiagnosticEventSchema>;
/** A progress update emitted while a command executes. */
export type CommandProgressEvent = z.infer<typeof commandProgressEventSchema>;
/** A side event emitted while a command executes. */
export type CommandEvent = z.infer<typeof commandEventSchema>;
/** An event before its emission timestamp is added. */
export type CommandEventInput<TEvent extends CommandEvent = CommandEvent> = TEvent extends unknown ? Omit<TEvent, 'timestamp'> : 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<TEvent extends CommandEvent = CommandEvent> = (event: TEvent, options?: CommandEventEmissionOptions) => void;
/** Emits timestamped side events from one command execution. */
export interface CommandEventChannel<TEvent extends CommandEvent = CommandEvent> {
    emit: (event: CommandEventInput<TEvent>, 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<TEvent extends CommandEvent> {
    sink?: CommandEventSink<TEvent>;
    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 declare function createCommandEventChannel<TEvent extends CommandEvent = CommandEvent>(options?: CommandEventChannelOptions<TEvent>): CommandEventChannel<TEvent>;
packages/cli-kit/dist/public/node/command-events.d.ts
import { type CommandEvent, type CommandEventChannelOptions, type CommandEventEmissionOptions, type CommandEventInput } from '../common/command-events.js';
import { type CommandEventOutputMode } from '../../private/node/command-event-context.js';
export type { CommandEventOutputMode } from '../../private/node/command-event-context.js';
interface RunWithCommandEventsOptions extends CommandEventChannelOptions<CommandEvent> {
    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 declare function runWithCommandEvents<TResult>(options: RunWithCommandEventsOptions, execute: () => TResult): TResult;
/**
 * 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 declare function runWithCommandEventsForCommand<TResult>(argv: string[], execute: () => TResult): TResult;
/**
 * 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 declare function emitCommandEvent(event: CommandEventInput, options?: CommandEventEmissionOptions): void;
/**
 * Returns how command events are presented for the current execution.
 *
 * @returns The current event output mode, or undefined outside a command event context.
 */
export declare function commandEventOutputMode(): CommandEventOutputMode | undefined;
/**
 * Renders a command side event to stderr using the existing CLI output behavior.
 *
 * @param event - The event to render.
 */
export declare function renderCommandEvent(event: CommandEvent): void;
/**
 * Renders a command side event as compact JSON to stderr.
 *
 * @param event - The event to render.
 */
export declare function renderCommandEventAsJson(event: CommandEvent): void;
packages/cli-kit/dist/public/node/json-output-schema.d.ts
import { ZodTypeAny, type z } from 'zod';
interface JsonOutputSchemaDefinition<TSchema extends ZodTypeAny = ZodTypeAny> {
    readonly name: string;
    readonly schema: TSchema;
    readonly definitions: Readonly<Record<string, ZodTypeAny>>;
}
export interface JsonOutputSchema<TSchema extends ZodTypeAny = ZodTypeAny> extends JsonOutputSchemaDefinition<TSchema> {
    readonly typescript: string;
    validate(value: unknown): z.output<TSchema>;
    encode(value: z.input<TSchema>): string;
}
export type InferJsonOutputSchema<TOutputSchema extends JsonOutputSchema> = z.output<TOutputSchema['schema']>;
interface DefineJsonOutputSchemaOptions<TSchema extends ZodTypeAny> {
    name: string;
    schema: TSchema;
    definitions?: Readonly<Record<string, ZodTypeAny>>;
}
/**
 * Defines the runtime validator, encoder, and documented TypeScript type for a command's JSON output.
 *
 * @param options - The root type name, its Zod schema, and any named nested schemas.
 * @returns The complete JSON output contract.
 */
export declare function defineJsonOutputSchema<TSchema extends ZodTypeAny>(options: DefineJsonOutputSchemaOptions<TSchema>): JsonOutputSchema<TSchema>;
/**
 * Renders the named schemas in a JSON output contract as TypeScript declarations.
 *
 * @param outputSchema - The root schema and its named nested schemas.
 * @returns TypeScript declarations suitable for command help.
 */
export declare function renderJsonOutputSchema(outputSchema: JsonOutputSchemaDefinition): string;
export {};
packages/cli-kit/dist/public/node/error/index.d.ts
import { OutputMessage } from '../output.js';
import { type InlineToken, type TokenItem } from '../../../private/node/ui/components/token-item.js';
import type { AlertCustomSection } from '../ui.js';
export declare enum FatalErrorType {
    Abort = 0,
    AbortSilent = 1,
    Bug = 2
}
export declare class CancelExecution extends Error {
}
/**
 * A fatal error represents an error shouldn't be rescued and that causes the execution to terminate.
 * There shouldn't be code that catches fatal errors.
 */
export declare abstract class FatalError extends Error {
    tryMessage: TokenItem | null;
    type: FatalErrorType;
    nextSteps?: TokenItem<InlineToken>[];
    formattedMessage?: TokenItem;
    customSections?: AlertCustomSection[];
    skipOclifErrorHandling: boolean;
    /**
     * Creates a new FatalError error.
     *
     * @param message - The error message.
     * @param type - The type of fatal error.
     * @param tryMessage - The message that recommends next steps to the user.
     * You can pass a string a {@link TokenizedString} or a {@link TokenItem}
     * if you need to style the message inside the error Banner component.
     * @param nextSteps - Message to show as "next steps" with suggestions to solve the issue.
     * @param customSections - Custom sections to show in the error banner. To be used if nextSteps is not enough.
     */
    constructor(message: TokenItem | OutputMessage, type: FatalErrorType, tryMessage?: TokenItem | OutputMessage | null, nextSteps?: TokenItem<InlineToken>[], customSections?: AlertCustomSection[]);
}
/**
 * An abort error is a fatal error that shouldn't be reported as a bug.
 * Those usually represent unexpected scenarios that we can't handle and that usually require some action from the developer.
 */
export declare class AbortError extends FatalError {
    constructor(message: TokenItem | OutputMessage, tryMessage?: TokenItem | OutputMessage | null, nextSteps?: TokenItem<InlineToken>[], customSections?: AlertCustomSection[]);
}
/**
 * An external error is similar to Abort but has extra command and args attributes.
 * This is useful to represent errors coming from external commands, usually executed by execa.
 */
export declare class ExternalError extends FatalError {
    command: string;
    args: string[];
    constructor(message: OutputMessage, command: string, args: string[], tryMessage?: TokenItem | OutputMessage | null);
}
export declare class AbortSilentError extends FatalError {
    constructor();
}
/**
 * A bug error is an error that represents a bug and therefore should be reported.
 */
export declare class BugError extends FatalError {
    constructor(message: TokenItem | OutputMessage, tryMessage?: TokenItem | OutputMessage | null);
}
/**
 * A function that handles errors that blow up in the CLI.
 *
 * @param error - Error to be handled.
 * @returns A promise that resolves with the error passed.
 */
export declare function handler(error: unknown): Promise<unknown>;
/**
 * A function that maps an error to an Abort with the stack trace when coming from the CLI.
 *
 * @param error - Error to be mapped.
 * @returns A promise that resolves with the new error object.
 */
export declare function errorMapper(error: unknown): Promise<unknown>;
/**
 * A function that checks if an error should be reported as unexpected.
 *
 * @param error - Error to be checked.
 * @returns A boolean indicating if the error should be reported as unexpected.
 */
export declare function shouldReportErrorAsUnexpected(error: unknown): boolean;
/**
 * Stack traces usually have file:// - we strip that and also remove the Windows drive designation.
 *
 * @param filePath - Path to be cleaned.
 * @returns The cleaned path.
 */
export declare function cleanSingleStackTracePath(filePath: string): string;
packages/cli-kit/dist/public/node/error/schema.d.ts
import { zod } from '../schema.js';
export declare const JsonErrorCustomSectionSchema: zod.ZodObject<{
    title: zod.ZodOptional<zod.ZodString>;
    body: zod.ZodUnion<[zod.ZodString, zod.ZodArray<zod.ZodArray<zod.ZodString, "many">, "many">]>;
}, "strict", zod.ZodTypeAny, {
    body: string | string[][];
    title?: string | undefined;
}, {
    body: string | string[][];
    title?: string | undefined;
}>;
export declare const JsonAbortErrorSchema: zod.ZodObject<{
    message: zod.ZodString;
    tryMessage: zod.ZodOptional<zod.ZodString>;
    nextSteps: zod.ZodOptional<zod.ZodArray<zod.ZodString, "many">>;
    customSections: zod.ZodOptional<zod.ZodArray<zod.ZodObject<{
        title: zod.ZodOptional<zod.ZodString>;
        body: zod.ZodUnion<[zod.ZodString, zod.ZodArray<zod.ZodArray<zod.ZodString, "many">, "many">]>;
    }, "strict", zod.ZodTypeAny, {
        body: string | string[][];
        title?: string | undefined;
    }, {
        body: string | string[][];
        title?: string | undefined;
    }>, "many">>;
    type: zod.ZodLiteral<"abort">;
}, "strict", zod.ZodTypeAny, {
    type: "abort";
    message: string;
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}, {
    type: "abort";
    message: string;
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}>;
export declare const JsonBugErrorSchema: zod.ZodObject<{
    stack: zod.ZodOptional<zod.ZodString>;
    message: zod.ZodString;
    tryMessage: zod.ZodOptional<zod.ZodString>;
    nextSteps: zod.ZodOptional<zod.ZodArray<zod.ZodString, "many">>;
    customSections: zod.ZodOptional<zod.ZodArray<zod.ZodObject<{
        title: zod.ZodOptional<zod.ZodString>;
        body: zod.ZodUnion<[zod.ZodString, zod.ZodArray<zod.ZodArray<zod.ZodString, "many">, "many">]>;
    }, "strict", zod.ZodTypeAny, {
        body: string | string[][];
        title?: string | undefined;
    }, {
        body: string | string[][];
        title?: string | undefined;
    }>, "many">>;
    type: zod.ZodLiteral<"bug">;
}, "strict", zod.ZodTypeAny, {
    type: "bug";
    message: string;
    stack?: string | undefined;
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}, {
    type: "bug";
    message: string;
    stack?: string | undefined;
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}>;
export declare const JsonExternalErrorSchema: zod.ZodObject<{
    command: zod.ZodString;
    args: zod.ZodArray<zod.ZodString, "many">;
    message: zod.ZodString;
    tryMessage: zod.ZodOptional<zod.ZodString>;
    nextSteps: zod.ZodOptional<zod.ZodArray<zod.ZodString, "many">>;
    customSections: zod.ZodOptional<zod.ZodArray<zod.ZodObject<{
        title: zod.ZodOptional<zod.ZodString>;
        body: zod.ZodUnion<[zod.ZodString, zod.ZodArray<zod.ZodArray<zod.ZodString, "many">, "many">]>;
    }, "strict", zod.ZodTypeAny, {
        body: string | string[][];
        title?: string | undefined;
    }, {
        body: string | string[][];
        title?: string | undefined;
    }>, "many">>;
    type: zod.ZodLiteral<"external">;
}, "strict", zod.ZodTypeAny, {
    type: "external";
    message: string;
    command: string;
    args: string[];
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}, {
    type: "external";
    message: string;
    command: string;
    args: string[];
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}>;
export declare const JsonErrorSchema: zod.ZodUnion<[zod.ZodObject<{
    message: zod.ZodString;
    tryMessage: zod.ZodOptional<zod.ZodString>;
    nextSteps: zod.ZodOptional<zod.ZodArray<zod.ZodString, "many">>;
    customSections: zod.ZodOptional<zod.ZodArray<zod.ZodObject<{
        title: zod.ZodOptional<zod.ZodString>;
        body: zod.ZodUnion<[zod.ZodString, zod.ZodArray<zod.ZodArray<zod.ZodString, "many">, "many">]>;
    }, "strict", zod.ZodTypeAny, {
        body: string | string[][];
        title?: string | undefined;
    }, {
        body: string | string[][];
        title?: string | undefined;
    }>, "many">>;
    type: zod.ZodLiteral<"abort">;
}, "strict", zod.ZodTypeAny, {
    type: "abort";
    message: string;
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}, {
    type: "abort";
    message: string;
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}>, zod.ZodObject<{
    stack: zod.ZodOptional<zod.ZodString>;
    message: zod.ZodString;
    tryMessage: zod.ZodOptional<zod.ZodString>;
    nextSteps: zod.ZodOptional<zod.ZodArray<zod.ZodString, "many">>;
    customSections: zod.ZodOptional<zod.ZodArray<zod.ZodObject<{
        title: zod.ZodOptional<zod.ZodString>;
        body: zod.ZodUnion<[zod.ZodString, zod.ZodArray<zod.ZodArray<zod.ZodString, "many">, "many">]>;
    }, "strict", zod.ZodTypeAny, {
        body: string | string[][];
        title?: string | undefined;
    }, {
        body: string | string[][];
        title?: string | undefined;
    }>, "many">>;
    type: zod.ZodLiteral<"bug">;
}, "strict", zod.ZodTypeAny, {
    type: "bug";
    message: string;
    stack?: string | undefined;
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}, {
    type: "bug";
    message: string;
    stack?: string | undefined;
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}>, zod.ZodObject<{
    command: zod.ZodString;
    args: zod.ZodArray<zod.ZodString, "many">;
    message: zod.ZodString;
    tryMessage: zod.ZodOptional<zod.ZodString>;
    nextSteps: zod.ZodOptional<zod.ZodArray<zod.ZodString, "many">>;
    customSections: zod.ZodOptional<zod.ZodArray<zod.ZodObject<{
        title: zod.ZodOptional<zod.ZodString>;
        body: zod.ZodUnion<[zod.ZodString, zod.ZodArray<zod.ZodArray<zod.ZodString, "many">, "many">]>;
    }, "strict", zod.ZodTypeAny, {
        body: string | string[][];
        title?: string | undefined;
    }, {
        body: string | string[][];
        title?: string | undefined;
    }>, "many">>;
    type: zod.ZodLiteral<"external">;
}, "strict", zod.ZodTypeAny, {
    type: "external";
    message: string;
    command: string;
    args: string[];
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}, {
    type: "external";
    message: string;
    command: string;
    args: string[];
    nextSteps?: string[] | undefined;
    customSections?: {
        body: string | string[][];
        title?: string | undefined;
    }[] | undefined;
    tryMessage?: string | undefined;
}>]>;
export declare const jsonErrorOutputSchema: import("../json-output-schema.js").JsonOutputSchema<zod.ZodObject<{
    error: zod.ZodUnion<[zod.ZodObject<{
        message: zod.ZodString;
        tryMessage: zod.ZodOptional<zod.ZodString>;
        nextSteps: zod.ZodOptional<zod.ZodArray<zod.ZodString, "many">>;
        customSections: zod.ZodOptional<zod.ZodArray<zod.ZodObject<{
            title: zod.ZodOptional<zod.ZodString>;
            body: zod.ZodUnion<[zod.ZodString, zod.ZodArray<zod.ZodArray<zod.ZodString, "many">, "many">]>;
        }, "strict", zod.ZodTypeAny, {
            body: string | string[][];
            title?: string | undefined;
        }, {
            body: string | string[][];
            title?: string | undefined;
        }>, "many">>;
        type: zod.ZodLiteral<"abort">;
    }, "strict", zod.ZodTypeAny, {
        type: "abort";
        message: string;
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    }, {
        type: "abort";
        message: string;
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    }>, zod.ZodObject<{
        stack: zod.ZodOptional<zod.ZodString>;
        message: zod.ZodString;
        tryMessage: zod.ZodOptional<zod.ZodString>;
        nextSteps: zod.ZodOptional<zod.ZodArray<zod.ZodString, "many">>;
        customSections: zod.ZodOptional<zod.ZodArray<zod.ZodObject<{
            title: zod.ZodOptional<zod.ZodString>;
            body: zod.ZodUnion<[zod.ZodString, zod.ZodArray<zod.ZodArray<zod.ZodString, "many">, "many">]>;
        }, "strict", zod.ZodTypeAny, {
            body: string | string[][];
            title?: string | undefined;
        }, {
            body: string | string[][];
            title?: string | undefined;
        }>, "many">>;
        type: zod.ZodLiteral<"bug">;
    }, "strict", zod.ZodTypeAny, {
        type: "bug";
        message: string;
        stack?: string | undefined;
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    }, {
        type: "bug";
        message: string;
        stack?: string | undefined;
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    }>, zod.ZodObject<{
        command: zod.ZodString;
        args: zod.ZodArray<zod.ZodString, "many">;
        message: zod.ZodString;
        tryMessage: zod.ZodOptional<zod.ZodString>;
        nextSteps: zod.ZodOptional<zod.ZodArray<zod.ZodString, "many">>;
        customSections: zod.ZodOptional<zod.ZodArray<zod.ZodObject<{
            title: zod.ZodOptional<zod.ZodString>;
            body: zod.ZodUnion<[zod.ZodString, zod.ZodArray<zod.ZodArray<zod.ZodString, "many">, "many">]>;
        }, "strict", zod.ZodTypeAny, {
            body: string | string[][];
            title?: string | undefined;
        }, {
            body: string | string[][];
            title?: string | undefined;
        }>, "many">>;
        type: zod.ZodLiteral<"external">;
    }, "strict", zod.ZodTypeAny, {
        type: "external";
        message: string;
        command: string;
        args: string[];
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    }, {
        type: "external";
        message: string;
        command: string;
        args: string[];
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    }>]>;
}, "strict", zod.ZodTypeAny, {
    error: {
        type: "abort";
        message: string;
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    } | {
        type: "bug";
        message: string;
        stack?: string | undefined;
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    } | {
        type: "external";
        message: string;
        command: string;
        args: string[];
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    };
}, {
    error: {
        type: "abort";
        message: string;
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    } | {
        type: "bug";
        message: string;
        stack?: string | undefined;
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    } | {
        type: "external";
        message: string;
        command: string;
        args: string[];
        nextSteps?: string[] | undefined;
        customSections?: {
            body: string | string[][];
            title?: string | undefined;
        }[] | undefined;
        tryMessage?: string | undefined;
    };
}>>;
packages/cli-kit/dist/public/node/error/types.d.ts
export type JsonErrorType = 'abort' | 'bug' | 'external';
export interface JsonErrorCustomSection {
    title?: string;
    body: string | string[][];
}
interface JsonErrorBase {
    message: string;
    tryMessage?: string;
    nextSteps?: string[];
    customSections?: JsonErrorCustomSection[];
}
export interface JsonAbortError extends JsonErrorBase {
    type: 'abort';
}
export interface JsonBugError extends JsonErrorBase {
    type: 'bug';
    stack?: string;
}
export interface JsonExternalError extends JsonErrorBase {
    type: 'external';
    command: string;
    args: string[];
}
export type JsonError = JsonAbortError | JsonBugError | JsonExternalError;
export interface JsonErrorDocument {
    error: JsonError;
}
export {};

Existing type declarations

packages/cli-kit/dist/public/node/base-command.d.ts
@@ -1,5 +1,6 @@
 import { Command } from '@oclif/core';
 import { OutputFlags, Input, ParserOutput, FlagInput, OutputArgs } from '@oclif/core/parser';
+import type { JsonOutputSchema } from './json-output-schema.js';
 export type ArgOutput = OutputArgs<any>;
 export type FlagOutput = OutputFlags<any>;
 export interface NonTTYFlagRequirement {
@@ -10,6 +11,8 @@ export interface NonTTYFlagRequirement {
 }
 declare abstract class BaseCommand extends Command {
     static baseFlags: FlagInput<{}>;
+    static descriptionWithMarkdown?: string;
+    static get jsonOutputSchema(): JsonOutputSchema | undefined;
     static get requiresSyncAnalytics(): boolean;
     static nonTTYFlagRequirements(_flags: FlagOutput): NonTTYFlagRequirement[];
     static descriptionWithoutMarkdown(): string | undefined;
@@ -18,6 +21,7 @@ declare abstract class BaseCommand extends Command {
     catch(error: Error & {
         skipOclifErrorHandling: boolean;
     }): Promise<void>;
+    protected _run<T>(): Promise<T>;
     protected init(): Promise<unknown>;
     protected showNpmFlagWarning(): void;
     protected exitWithTimestampWhenEnvVariablePresent(): void;
packages/cli-kit/dist/public/node/environment.d.ts
@@ -42,9 +42,10 @@ export declare function getIdentityTokenInformation(): {
  * 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 declare function jsonOutputEnabled(environment?: NodeJS.ProcessEnv): boolean;
+export declare function jsonOutputEnabled(environment?: NodeJS.ProcessEnv, argv?: string[]): boolean;
 /**
  * If true, the CLI should not use the network level retry.
  *
packages/cli-kit/dist/public/node/error.d.ts
@@ -1,87 +1 @@
-import { OutputMessage } from './output.js';
-import { type InlineToken, type TokenItem } from '../../private/node/ui/components/token-item.js';
-import type { AlertCustomSection } from './ui.js';
-export declare enum FatalErrorType {
-    Abort = 0,
-    AbortSilent = 1,
-    Bug = 2
-}
-export declare class CancelExecution extends Error {
-}
-/**
- * A fatal error represents an error shouldn't be rescued and that causes the execution to terminate.
- * There shouldn't be code that catches fatal errors.
- */
-export declare abstract class FatalError extends Error {
-    tryMessage: TokenItem | null;
-    type: FatalErrorType;
-    nextSteps?: TokenItem<InlineToken>[];
-    formattedMessage?: TokenItem;
-    customSections?: AlertCustomSection[];
-    skipOclifErrorHandling: boolean;
-    /**
-     * Creates a new FatalError error.
-     *
-     * @param message - The error message.
-     * @param type - The type of fatal error.
-     * @param tryMessage - The message that recommends next steps to the user.
-     * You can pass a string a {@link TokenizedString} or a {@link TokenItem}
-     * if you need to style the message inside the error Banner component.
-     * @param nextSteps - Message to show as "next steps" with suggestions to solve the issue.
-     * @param customSections - Custom sections to show in the error banner. To be used if nextSteps is not enough.
-     */
-    constructor(message: TokenItem | OutputMessage, type: FatalErrorType, tryMessage?: TokenItem | OutputMessage | null, nextSteps?: TokenItem<InlineToken>[], customSections?: AlertCustomSection[]);
-}
-/**
- * An abort error is a fatal error that shouldn't be reported as a bug.
- * Those usually represent unexpected scenarios that we can't handle and that usually require some action from the developer.
- */
-export declare class AbortError extends FatalError {
-    constructor(message: TokenItem | OutputMessage, tryMessage?: TokenItem | OutputMessage | null, nextSteps?: TokenItem<InlineToken>[], customSections?: AlertCustomSection[]);
-}
-/**
- * An external error is similar to Abort but has extra command and args attributes.
- * This is useful to represent errors coming from external commands, usually executed by execa.
- */
-export declare class ExternalError extends FatalError {
-    command: string;
-    args: string[];
-    constructor(message: OutputMessage, command: string, args: string[], tryMessage?: TokenItem | OutputMessage | null);
-}
-export declare class AbortSilentError extends FatalError {
-    constructor();
-}
-/**
- * A bug error is an error that represents a bug and therefore should be reported.
- */
-export declare class BugError extends FatalError {
-    constructor(message: TokenItem | OutputMessage, tryMessage?: TokenItem | OutputMessage | null);
-}
-/**
- * A function that handles errors that blow up in the CLI.
- *
- * @param error - Error to be handled.
- * @returns A promise that resolves with the error passed.
- */
-export declare function handler(error: unknown): Promise<unknown>;
-/**
- * A function that maps an error to an Abort with the stack trace when coming from the CLI.
- *
- * @param error - Error to be mapped.
- * @returns A promise that resolves with the new error object.
- */
-export declare function errorMapper(error: unknown): Promise<unknown>;
-/**
- * A function that checks if an error should be reported as unexpected.
- *
- * @param error - Error to be checked.
- * @returns A boolean indicating if the error should be reported as unexpected.
- */
-export declare function shouldReportErrorAsUnexpected(error: unknown): boolean;
-/**
- * Stack traces usually have file:// - we strip that and also remove the Windows drive designation.
- *
- * @param filePath - Path to be cleaned.
- * @returns The cleaned path.
- */
-export declare function cleanSingleStackTracePath(filePath: string): string;
\ No newline at end of file
+export * from './error/index.js';
\ No newline at end of file

@tizmagik

Copy link
Copy Markdown

This closes the empty-stdout problem, which is what actually blocks scripting against --json. Good to see it moving. One follow-up while the shape is still open.

For store execute, the interesting part of a failure is the GraphQL errors array, and after this change it arrives as a serialized JSON string inside tryMessage. That comes from the throw site in admin-transport.ts:

throw new AbortError('GraphQL operation failed.', JSON.stringify({errors: error.response.errors}, null, 2))

json-error.ts passes a plain-string tryMessage through verbatim, so the data survives intact. A consumer can JSON.parse the document, then JSON.parse the tryMessage, and recover the error codes exactly. That works, and it beats parsing the rendered banner.

What I would push back on is that it works by accident. tryMessage is typed zod.string() and named for display. The only reason it carries structured data is that this one call site serializes into it. The token handling in json-error.ts invites turning a message like that into token items or a customSections table, and the moment someone does, every consumer's second JSON.parse fails. No test would catch that. On main, the only test asserting on tryMessage in that file covers the 402 case and checks for a human-readable phrase, so the GraphQL branch's payload is unpinned.

Is there room in this design for structured error detail that is not a display string? Two shapes would do it.

  1. An optional details field on JsonError, carrying unknown-shaped data from the raising site. admin-transport.ts then passes the errors array as data and keeps tryMessage for humans. This is the bigger change, since it touches the schema the rest of your stack validates against.
  2. Leave JsonError alone and let store execute handle its own failure, returning the GraphQL error payload through writeOrOutputStoreExecuteResult, which already serializes cleanly in json mode.

I have a working branch for the second one, scoped to store execute. It adds an AbortError subclass carrying readonly response, and the command emits error.response as JSON when --json is set. Identical banner for humans, unchanged success shape, tests included. Happy to open it against your branch, hand it over, or drop it if the first shape is where you would rather go. Your call, this is your stack.

Separate and smaller, probably its own issue rather than folded in here. store execute drops extensions on both the success and failure paths, so a client cannot read cost.throttleStatus and honor the leaky bucket. It passes handleErrors: false with no onResponse, and autoRateLimitRestore defaults to false.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: @shopify/cli @shopify/cli package issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants