Add JSON result schema infrastructure - #8404
Conversation
9541c57 to
e8c7210
Compare
e8c7210 to
e5133a5
Compare
e5133a5 to
91a047b
Compare
fa0dbc1 to
0e3dfaf
Compare
0e3dfaf to
26b4b7c
Compare
26b4b7c to
04d0096
Compare
04d0096 to
02e9c32
Compare
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
2c2032e to
ad9970d
Compare
ad9970d to
ae6ec4b
Compare
ae6ec4b to
6c746ea
Compare
6c746ea to
09fde41
Compare
dmerand
left a comment
There was a problem hiding this comment.
I have a couple of comments from me and a couple from the 'bot. Overall this looks great!
| @@ -0,0 +1,54 @@ | |||
| # JSON output contracts | |||
|
|
|||
| Finite commands expose their successful result as typed data independently from terminal presentation. The command's | |||
There was a problem hiding this comment.
Nit: we should define what a finite command is here, or say something like "Commands with a single output" or "non-long-running commands" or similar.
There was a problem hiding this comment.
Added a description
| @@ -44,8 +50,10 @@ abstract class BaseCommand extends Command { | |||
|
|
|||
| // Replace markdown links to plain text like: "link label" (url) | |||
| public static descriptionWithoutMarkdown(): string | undefined { | |||
There was a problem hiding this comment.
Nit: this function name doesn't make a lot of sense to me now that it's also appending JSON result schema. We should probably rename it.
There was a problem hiding this comment.
Renamed to descriptionForHelp
|
|
||
| function renderArrayElementType(schema: ZodTypeAny, namedSchemas: ReadonlyMap<ZodTypeAny, string>): string { | ||
| const type = renderType(schema, namedSchemas) | ||
| return schema instanceof ZodUnion || schema instanceof ZodNullable ? `(${type})` : type |
There was a problem hiding this comment.
Could we also add parentheses around enum element types here?
z.array(z.enum(['a', 'b'])) currently prints:
type Result = "a" | "b"[]It should print:
type Result = ("a" | "b")[]The schema accepts and encodes ["a", "b"], but TypeScript rejects that value against the generated declaration. I reproduced this with the PR source and Zod 3.25.76. Could we add a regression test for arrays of enums?
There was a problem hiding this comment.
Fixed, thanks!
| namedSchemas: ReadonlyMap<ZodTypeAny, string>, | ||
| ): string { | ||
| const properties = Object.entries(schema.shape).map(([propertyName, propertySchema]) => { | ||
| const optional = propertySchema instanceof ZodOptional |
There was a problem hiding this comment.
Could we detect optional properties inside nullable wrappers, rather than checking only the outermost type?
z.object({value: z.string().optional().nullable()})This schema accepts and encodes {}, but the generated interface requires value: string | null. The property should be value?: string | null.
The reverse order, .nullable().optional(), already produces the optional property. I reproduced the difference with the PR source and Zod 3.25.76. Could we add a regression test for both wrapper orders?
09fde41 to
bc90be6
Compare
bc90be6 to
31f1ab8
Compare
31f1ab8 to
afb278b
Compare
Differences in type declarationsWe 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:
New type declarationspackages/cli-kit/dist/private/node/command-event-context.d.tsimport { 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.tsimport { 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/public/common/command-events.d.tsimport { 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", "error"]>;
message: z.ZodString;
code: z.ZodOptional<z.ZodString>;
}, "strict", z.ZodTypeAny, {
type: "diagnostic";
message: string;
timestamp: string;
level: "info" | "error" | "debug" | "warning";
code?: string | undefined;
}, {
type: "diagnostic";
message: string;
timestamp: string;
level: "info" | "error" | "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;
status: z.ZodEnum<["started", "updated", "completed"]>;
operation: z.ZodString;
message: z.ZodOptional<z.ZodString>;
current: z.ZodOptional<z.ZodNumber>;
total: z.ZodOptional<z.ZodNumber>;
}, "strict", z.ZodTypeAny, {
type: "progress";
status: "started" | "updated" | "completed";
timestamp: string;
operation: string;
message?: string | undefined;
current?: number | undefined;
total?: number | undefined;
}, {
type: "progress";
status: "started" | "updated" | "completed";
timestamp: string;
operation: string;
message?: string | undefined;
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", "error"]>;
message: z.ZodString;
code: z.ZodOptional<z.ZodString>;
}, "strict", z.ZodTypeAny, {
type: "diagnostic";
message: string;
timestamp: string;
level: "info" | "error" | "debug" | "warning";
code?: string | undefined;
}, {
type: "diagnostic";
message: string;
timestamp: string;
level: "info" | "error" | "debug" | "warning";
code?: string | undefined;
}>, z.ZodObject<{
type: z.ZodLiteral<"progress">;
timestamp: z.ZodString;
status: z.ZodEnum<["started", "updated", "completed"]>;
operation: z.ZodString;
message: z.ZodOptional<z.ZodString>;
current: z.ZodOptional<z.ZodNumber>;
total: z.ZodOptional<z.ZodNumber>;
}, "strict", z.ZodTypeAny, {
type: "progress";
status: "started" | "updated" | "completed";
timestamp: string;
operation: string;
message?: string | undefined;
current?: number | undefined;
total?: number | undefined;
}, {
type: "progress";
status: "started" | "updated" | "completed";
timestamp: string;
operation: string;
message?: string | undefined;
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.
* 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.
*/
export declare function createCommandEventChannel<TEvent extends CommandEvent = CommandEvent>(options?: CommandEventChannelOptions<TEvent>): CommandEventChannel<TEvent>;
packages/cli-kit/dist/public/node/command-events.d.tsimport { 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.tsimport { 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 {};
Existing type declarationspackages/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,14 +11,19 @@ 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 descriptionForHelp(): string | undefined;
+ /** @deprecated Use descriptionForHelp instead. */
static descriptionWithoutMarkdown(): string | undefined;
static analyticsNameOverride(): string | undefined;
static analyticsStopCommand(): string | undefined;
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.
*
|

WHY are these changes introduced?
Closes shop/issues-develop#23660
Finite commands need one typed contract for validating JSON results and documenting them in command help.
Based on these prototypes:
WHAT is this pull request doing?
BaseCommandand generated command help.How to test your changes?
See #8415
Checklist