Skip to content

Commit a808f8a

Browse files
committed
refactor(cli): improve help output with hierarchical schema descriptions
Enhance the proxifyOrpc schema description system to produce readable, hierarchical help output for complex Zod schemas. Previously, the CLI showed raw JSON Schema (anyOf, oneOf) for unions and nested objects. Key changes: - Add hierarchical YAML-like formatting for nested objects - Separate required/optional fields with clear headers - Handle discriminated unions by showing variant labels - Detect common discriminators (type/kind/tag) in plain unions - Replace complex fields with z.any().describe() to prevent trpc-cli from appending JSON Schema noise to descriptions - Convert void/undefined inputs to empty object schema for proper JSON Schema conversion - Preserve _zod property for Zod 4 detection in trpc-cli - Simplify api.ts by removing function wrapper and module-level init - Rename "oRPC API" to "mux API" for consistency
1 parent efccd99 commit a808f8a

File tree

4 files changed

+522
-58
lines changed

4 files changed

+522
-58
lines changed

src/cli/api.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,14 @@
88
import { createCli } from "trpc-cli";
99
import { router } from "@/node/orpc/router";
1010
import { proxifyOrpc } from "./proxifyOrpc";
11-
import { Command } from "commander";
11+
import type { Command } from "commander";
1212

13-
export async function runApiCli(parent: Command): Promise<void> {
14-
const baseUrl = process.env.MUX_SERVER_URL ?? "http://localhost:3000";
15-
const authToken = process.env.MUX_AUTH_TOKEN;
13+
const baseUrl = process.env.MUX_SERVER_URL ?? "http://localhost:3000";
14+
const authToken = process.env.MUX_SERVER_AUTH_TOKEN;
1615

17-
const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
18-
const cli = createCli({ router: proxiedRouter }).buildProgram() as Command;
16+
const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
17+
const cli = createCli({ router: proxiedRouter }).buildProgram() as Command;
1918

20-
cli.name("api");
21-
cli.description("Interact with the oRPC API via a running server");
22-
cli.parent = parent;
23-
cli.parse();
24-
}
19+
cli.name("mux api");
20+
cli.description("Interact with the mux API via a running server");
21+
cli.parse();

src/cli/index.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,14 @@ program
2525

2626
program
2727
.command("api")
28-
.description("Interact with the oRPC API via a running server")
28+
.description("Interact with the mux API via a running server")
2929
.helpOption(false)
3030
.allowUnknownOption()
3131
.allowExcessArguments()
32-
.action(async () => {
32+
.action(() => {
3333
process.argv.splice(2, 1);
34-
// eslint-disable-next-line no-restricted-syntax -- dynamic import needed for ESM-only trpc-cli
35-
const { runApiCli } = await import("./api");
36-
await runApiCli(program);
34+
// eslint-disable-next-line @typescript-eslint/no-require-imports
35+
require("./api");
3736
});
3837

3938
program

src/cli/proxifyOrpc.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { z } from "zod";
3+
import * as zod4Core from "zod/v4/core";
4+
import { router } from "@/node/orpc/router";
5+
import { proxifyOrpc } from "./proxifyOrpc";
6+
7+
describe("proxifyOrpc schema enhancement", () => {
8+
describe("describeZodType", () => {
9+
// Helper to get description from a schema via JSON Schema conversion
10+
function getJsonSchemaDescription(schema: z.ZodTypeAny): string | undefined {
11+
const jsonSchema = zod4Core.toJSONSchema(schema, {
12+
io: "input",
13+
unrepresentable: "any",
14+
override: (ctx) => {
15+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
16+
const meta = (ctx.zodSchema as any).meta?.();
17+
if (meta) Object.assign(ctx.jsonSchema, meta);
18+
},
19+
});
20+
return jsonSchema.description;
21+
}
22+
23+
test("described object schema has description in JSON Schema", () => {
24+
const schema = z.object({ foo: z.string() }).describe("Test description");
25+
const desc = getJsonSchemaDescription(schema);
26+
expect(desc).toBe("Test description");
27+
});
28+
29+
test("enum values are preserved in JSON Schema", () => {
30+
const schema = z.enum(["a", "b", "c"]);
31+
const jsonSchema = zod4Core.toJSONSchema(schema, {
32+
io: "input",
33+
unrepresentable: "any",
34+
});
35+
expect(jsonSchema.enum).toEqual(["a", "b", "c"]);
36+
});
37+
38+
test("optional fields are marked in JSON Schema", () => {
39+
const schema = z.object({
40+
required: z.string(),
41+
optional: z.string().optional(),
42+
});
43+
const jsonSchema = zod4Core.toJSONSchema(schema, {
44+
io: "input",
45+
unrepresentable: "any",
46+
override: (ctx) => {
47+
if (ctx.zodSchema?.constructor?.name === "ZodOptional") {
48+
ctx.jsonSchema.optional = true;
49+
}
50+
},
51+
});
52+
expect(jsonSchema.required).toContain("required");
53+
expect(jsonSchema.required).not.toContain("optional");
54+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
55+
expect((jsonSchema.properties as any)?.optional?.optional).toBe(true);
56+
});
57+
});
58+
59+
describe("void schema handling", () => {
60+
test("void schema converts to empty JSON Schema object", () => {
61+
// In the actual proxifyOrpc, void schemas are converted to z.object({})
62+
const emptyObj = z.object({});
63+
const jsonSchema = zod4Core.toJSONSchema(emptyObj, {
64+
io: "input",
65+
unrepresentable: "any",
66+
});
67+
expect(jsonSchema.type).toBe("object");
68+
expect(jsonSchema.properties).toEqual({});
69+
});
70+
});
71+
72+
describe("nested object descriptions", () => {
73+
test("described nested object preserves description", () => {
74+
const nested = z
75+
.object({
76+
field1: z.string(),
77+
field2: z.number(),
78+
})
79+
.describe("Required: field1: string, field2: number");
80+
81+
const parent = z.object({
82+
nested,
83+
});
84+
85+
const jsonSchema = zod4Core.toJSONSchema(parent, {
86+
io: "input",
87+
unrepresentable: "any",
88+
override: (ctx) => {
89+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
90+
const meta = (ctx.zodSchema as any).meta?.();
91+
if (meta) Object.assign(ctx.jsonSchema, meta);
92+
},
93+
});
94+
95+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
96+
const nestedJsonSchema = (jsonSchema.properties as any)?.nested;
97+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
98+
expect(nestedJsonSchema?.description).toBe("Required: field1: string, field2: number");
99+
});
100+
});
101+
});
102+
103+
describe("proxifyOrpc CLI help output", () => {
104+
test("workspace resume-stream shows options description", () => {
105+
const r = router();
106+
const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" });
107+
108+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
109+
const resumeStream = (proxied as any).workspace?.resumeStream;
110+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
111+
const inputSchema = resumeStream?.["~orpc"]?.inputSchema;
112+
113+
// The options field should have a description
114+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
115+
const optionsField = inputSchema?.def?.shape?.options;
116+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
117+
expect(optionsField?.description).toBeDefined();
118+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
119+
expect(optionsField?.description).toContain("model: string");
120+
});
121+
122+
test("workspace list has empty object schema (no options)", () => {
123+
const r = router();
124+
const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" });
125+
126+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
127+
const listProc = (proxied as any).workspace?.list;
128+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
129+
const inputSchema = listProc?.["~orpc"]?.inputSchema;
130+
131+
// void input should be converted to empty object
132+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
133+
expect(inputSchema?.def?.type).toBe("object");
134+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
135+
expect(Object.keys(inputSchema?.def?.shape ?? {})).toHaveLength(0);
136+
});
137+
138+
test("enhanced schema preserves _zod property for JSON Schema conversion", () => {
139+
const r = router();
140+
const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" });
141+
142+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
143+
const resumeStream = (proxied as any).workspace?.resumeStream;
144+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
145+
const inputSchema = resumeStream?.["~orpc"]?.inputSchema;
146+
147+
// Must have _zod for trpc-cli to detect Zod 4
148+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
149+
expect(inputSchema?._zod).toBeDefined();
150+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
151+
expect(inputSchema?._zod?.version?.major).toBe(4);
152+
153+
// _zod.def should have the enhanced shape
154+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
155+
const zodDefOptions = inputSchema?._zod?.def?.shape?.options;
156+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
157+
expect(zodDefOptions?.description).toBeDefined();
158+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
159+
expect(zodDefOptions?.description).toContain("model: string");
160+
});
161+
162+
test("JSON Schema for options includes description", () => {
163+
const r = router();
164+
const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" });
165+
166+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
167+
const resumeStream = (proxied as any).workspace?.resumeStream;
168+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
169+
const inputSchema = resumeStream?.["~orpc"]?.inputSchema;
170+
171+
// Convert to JSON Schema (what trpc-cli does)
172+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
173+
const jsonSchema = zod4Core.toJSONSchema(inputSchema, {
174+
io: "input",
175+
unrepresentable: "any",
176+
override: (ctx) => {
177+
if (ctx.zodSchema?.constructor?.name === "ZodOptional") {
178+
ctx.jsonSchema.optional = true;
179+
}
180+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
181+
const meta = (ctx.zodSchema as any).meta?.();
182+
if (meta) Object.assign(ctx.jsonSchema, meta);
183+
},
184+
});
185+
186+
// The options property should have a description
187+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
188+
const optionsJsonSchema = (jsonSchema.properties as any)?.options;
189+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
190+
expect(optionsJsonSchema?.description).toBeDefined();
191+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
192+
expect(optionsJsonSchema?.description).toContain("model: string");
193+
});
194+
});

0 commit comments

Comments
 (0)