From b941fcecce0448d75ea144e667390de2d9db9515 Mon Sep 17 00:00:00 2001 From: Christopher Little-Savage Date: Wed, 4 Nov 2026 12:07:30 +0000 Subject: [PATCH 1/2] fix: describe plain-text --body correctly in command metadata _meta/commands.json hardcoded "Raw JSON request body" for every --body, so cf tools schemas told callers to send JSON to text/plain endpoints such as zaraz workflow update. For text/plain-only bodies, metadata now uses the same description as --help. Other bodies are unchanged. Co-Authored-By: Claude Opus 5.5 --- .changeset/plain-text-body-metadata.md | 9 ++++ packages/cli/generator/plugin/emit/builder.ts | 21 ++++++-- packages/cli/generator/plugin/files.ts | 15 +++++- .../generator/plugin/body-description.test.ts | 48 +++++++++++++++++++ 4 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 .changeset/plain-text-body-metadata.md create mode 100644 packages/cli/src/__tests__/generator/plugin/body-description.test.ts diff --git a/.changeset/plain-text-body-metadata.md b/.changeset/plain-text-body-metadata.md new file mode 100644 index 0000000000..029d2c51eb --- /dev/null +++ b/.changeset/plain-text-body-metadata.md @@ -0,0 +1,9 @@ +--- +"cf": patch +--- + +Describe plain-text `--body` correctly in command metadata + +For commands that send `--body` as `text/plain`, command metadata used by +`cf tools` now gives the same description as `--help` instead of asking for +raw JSON. diff --git a/packages/cli/generator/plugin/emit/builder.ts b/packages/cli/generator/plugin/emit/builder.ts index c2fbe6c164..13085cac74 100644 --- a/packages/cli/generator/plugin/emit/builder.ts +++ b/packages/cli/generator/plugin/emit/builder.ts @@ -24,6 +24,21 @@ function option(name: string, fields: [string, Value | undefined][]): string { const quoted = (v: readonly string[]) => v.map((c) => `'${c}'`).join(", "); /** `generateBuilderLines`. */ +export const JSON_BODY_DESCRIPTION = + "Raw JSON request body (bypasses individual flags)"; + +export function bodyOptionDescription(op: Ctx["op"]): string { + return op.requestBodyDescription !== undefined && + op.requestBodyDescription !== "" + ? op.requestBodyDescription + : JSON_BODY_DESCRIPTION; +} + +/** A body sent only as `text/plain`, whose `--help` says how to write it. */ +export const isPlainTextBody = (op: Ctx["op"]) => + op.requestContentTypes.length > 0 && + op.requestContentTypes.every((type) => type === "text/plain"); + export function builderLines(ctx: Ctx): string[] { const d = ctx.derived; const op = ctx.op; @@ -91,11 +106,7 @@ export function builderLines(ctx: Ctx): string[] { ); } if (d.hasBody) { - const desc = - op.requestBodyDescription !== undefined && - op.requestBodyDescription !== "" - ? op.requestBodyDescription - : "Raw JSON request body (bypasses individual flags)"; + const desc = bodyOptionDescription(op); lines.push(" .option('body', {"); lines.push(" type: 'string',"); lines.push(` description: '${escapeSingle(desc)}',`); diff --git a/packages/cli/generator/plugin/files.ts b/packages/cli/generator/plugin/files.ts index d2effb49ad..a3def10e1f 100644 --- a/packages/cli/generator/plugin/files.ts +++ b/packages/cli/generator/plugin/files.ts @@ -19,7 +19,12 @@ import { scalar, } from "./args.js"; import { requiresConfirmation } from "./confirmation.js"; -import { builderLines } from "./emit/builder.js"; +import { + bodyOptionDescription, + builderLines, + isPlainTextBody, + JSON_BODY_DESCRIPTION, +} from "./emit/builder.js"; import { ACCOUNT_OR_ZONE_HELP, handler, imports } from "./emit/handler.js"; import { context, escapeSingle } from "./emit/index.js"; import { operationLiteral, operationPath, usesPathTag } from "./emit/sdk.js"; @@ -346,7 +351,13 @@ class Walk { ["name", "body"], ["type", "string"], ["required", false], - ["description", "Raw JSON request body (bypasses individual flags)"], + [ + "description", + // `cf tools` schemas must not tell callers to send JSON here. + isPlainTextBody(op) + ? bodyOptionDescription(op) + : JSON_BODY_DESCRIPTION, + ], ]) ); } diff --git a/packages/cli/src/__tests__/generator/plugin/body-description.test.ts b/packages/cli/src/__tests__/generator/plugin/body-description.test.ts new file mode 100644 index 0000000000..b197de2f00 --- /dev/null +++ b/packages/cli/src/__tests__/generator/plugin/body-description.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; +import { generateFiles } from "../../helpers/cli-plugin.js"; +import type { CommandMeta } from "../../../lib/command-meta.js"; + +const JSON_BODY = "Raw JSON request body (bypasses individual flags)"; +const PLAIN = "Send the workflow name as plain text, without JSON quotes."; + +function metadataDescription(contentType: string, description?: string) { + const files = generateFiles({ + openapi: "3.0.3", + info: { title: "Body descriptions", version: "1" }, + paths: { + "/things": { + put: { + "x-fern-sdk-group-name": "things", + "x-fern-sdk-method-name": "update", + summary: "Update a thing", + requestBody: { + required: true, + ...(description === undefined ? {} : { description }), + content: { [contentType]: { schema: { type: "string" } } }, + }, + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + const metadata = JSON.parse(files.get("_meta/commands.json") ?? "") as { + commands: CommandMeta[]; + }; + return metadata.commands + .find((c) => c.command === "cf things update") + ?.options.find((o) => o.name === "body")?.description; +} + +describe("--body metadata description", () => { + it("matches --help for a text/plain body", () => { + expect(metadataDescription("text/plain", PLAIN)).toBe(PLAIN); + }); + + it.each([ + ["text/plain", undefined], + ["application/json", PLAIN], + ["application/octet-stream", PLAIN], + ])("keeps the JSON wording for %s (description: %j)", (type, description) => { + expect(metadataDescription(type, description)).toBe(JSON_BODY); + }); +}); From c1427a39c9a82cb1eb9f953bcd32b150644ecefd Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Wed, 4 Nov 2026 21:24:24 +0000 Subject: [PATCH 2/2] fix: cover plain-text body metadata edge cases --- .changeset/plain-text-body-metadata.md | 4 +- packages/cli/generator/plugin/emit/builder.ts | 21 ++++--- .../generator/plugin/body-description.test.ts | 60 +++++++++++++++---- .../commands/_generated/_meta/commands.json | 4 +- .../_generated/analytics-engine/sql/query.ts | 2 +- .../cli/src/commands/_generated/logs/query.ts | 2 +- 6 files changed, 69 insertions(+), 24 deletions(-) diff --git a/.changeset/plain-text-body-metadata.md b/.changeset/plain-text-body-metadata.md index 029d2c51eb..b680b63cf2 100644 --- a/.changeset/plain-text-body-metadata.md +++ b/.changeset/plain-text-body-metadata.md @@ -6,4 +6,6 @@ Describe plain-text `--body` correctly in command metadata For commands that send `--body` as `text/plain`, command metadata used by `cf tools` now gives the same description as `--help` instead of asking for -raw JSON. +raw JSON. This includes media types with parameters such as +`text/plain;charset=UTF-8`. When no description is provided, both metadata +and `--help` use plain-text wording. diff --git a/packages/cli/generator/plugin/emit/builder.ts b/packages/cli/generator/plugin/emit/builder.ts index 13085cac74..ba7ddd8a8d 100644 --- a/packages/cli/generator/plugin/emit/builder.ts +++ b/packages/cli/generator/plugin/emit/builder.ts @@ -23,22 +23,27 @@ function option(name: string, fields: [string, Value | undefined][]): string { const quoted = (v: readonly string[]) => v.map((c) => `'${c}'`).join(", "); -/** `generateBuilderLines`. */ export const JSON_BODY_DESCRIPTION = "Raw JSON request body (bypasses individual flags)"; +/** Matches the plain-text body classification in `wire()`. */ +export const isPlainTextBody = (op: Ctx["op"]) => + op.requestContentTypes.length > 0 && + op.requestContentTypes.every((type) => type.startsWith("text/plain")); + export function bodyOptionDescription(op: Ctx["op"]): string { - return op.requestBodyDescription !== undefined && + if ( + op.requestBodyDescription !== undefined && op.requestBodyDescription !== "" - ? op.requestBodyDescription + ) { + return op.requestBodyDescription; + } + return isPlainTextBody(op) + ? "Plain-text request body (without JSON quotes)" : JSON_BODY_DESCRIPTION; } -/** A body sent only as `text/plain`, whose `--help` says how to write it. */ -export const isPlainTextBody = (op: Ctx["op"]) => - op.requestContentTypes.length > 0 && - op.requestContentTypes.every((type) => type === "text/plain"); - +/** `generateBuilderLines`. */ export function builderLines(ctx: Ctx): string[] { const d = ctx.derived; const op = ctx.op; diff --git a/packages/cli/src/__tests__/generator/plugin/body-description.test.ts b/packages/cli/src/__tests__/generator/plugin/body-description.test.ts index b197de2f00..e05c52e580 100644 --- a/packages/cli/src/__tests__/generator/plugin/body-description.test.ts +++ b/packages/cli/src/__tests__/generator/plugin/body-description.test.ts @@ -1,11 +1,15 @@ +import { stripTypeScriptTypes } from "node:module"; +import { Script } from "node:vm"; import { describe, expect, it } from "vite-plus/test"; +import yargs, { type CommandModule } from "yargs"; import { generateFiles } from "../../helpers/cli-plugin.js"; import type { CommandMeta } from "../../../lib/command-meta.js"; const JSON_BODY = "Raw JSON request body (bypasses individual flags)"; const PLAIN = "Send the workflow name as plain text, without JSON quotes."; +const PLAIN_BODY = "Plain-text request body (without JSON quotes)"; -function metadataDescription(contentType: string, description?: string) { +async function bodyDescriptions(contentTypes: string[], description?: string) { const files = generateFiles({ openapi: "3.0.3", info: { title: "Body descriptions", version: "1" }, @@ -18,7 +22,9 @@ function metadataDescription(contentType: string, description?: string) { requestBody: { required: true, ...(description === undefined ? {} : { description }), - content: { [contentType]: { schema: { type: "string" } } }, + content: Object.fromEntries( + contentTypes.map((type) => [type, { schema: { type: "string" } }]) + ), }, responses: { "200": { description: "OK" } }, }, @@ -28,21 +34,53 @@ function metadataDescription(contentType: string, description?: string) { const metadata = JSON.parse(files.get("_meta/commands.json") ?? "") as { commands: CommandMeta[]; }; - return metadata.commands + const bodyDescription = metadata.commands .find((c) => c.command === "cf things update") ?.options.find((o) => o.name === "body")?.description; + const source = stripTypeScriptTypes(files.get("things/update.ts") ?? "") + .replace(/^import[\s\S]*?;\n/gm, "") + .replace("export default command;", "command;"); + const command = new Script(source).runInNewContext() as CommandModule; + const help = await yargs(["update"]).command(command).wrap(null).getHelp(); + return { metadata: bodyDescription, help }; } -describe("--body metadata description", () => { - it("matches --help for a text/plain body", () => { - expect(metadataDescription("text/plain", PLAIN)).toBe(PLAIN); +describe.each([ + { contentTypes: ["text/plain"] }, + { contentTypes: ["text/plain;charset=UTF-8"] }, + { contentTypes: ["text/plain", "text/plain; charset=UTF-8"] }, +])("plain-text --body descriptions ($contentTypes)", ({ contentTypes }) => { + it("uses the provided description in metadata and --help", async () => { + const result = await bodyDescriptions(contentTypes, PLAIN); + expect(result.metadata).toBe(PLAIN); + expect(result.help).toContain(PLAIN); }); + it.each([undefined, ""])( + "uses a plain-text fallback when the description is %j", + async (description) => { + const result = await bodyDescriptions(contentTypes, description); + expect(result.metadata).toBe(PLAIN_BODY); + expect(result.help).toContain(PLAIN_BODY); + expect(result.help).not.toContain(JSON_BODY); + } + ); +}); + +describe("other --body metadata descriptions", () => { it.each([ - ["text/plain", undefined], - ["application/json", PLAIN], - ["application/octet-stream", PLAIN], - ])("keeps the JSON wording for %s (description: %j)", (type, description) => { - expect(metadataDescription(type, description)).toBe(JSON_BODY); + { contentTypes: ["application/json"] }, + { contentTypes: ["application/octet-stream"] }, + { contentTypes: ["text/plain", "application/json"] }, + { contentTypes: ["application/json", "text/plain"] }, + ])("keeps the JSON wording for $contentTypes", async ({ contentTypes }) => { + const result = await bodyDescriptions(contentTypes, PLAIN); + expect(result.metadata).toBe(JSON_BODY); + }); + + it("omits --body when no content types are declared", async () => { + const result = await bodyDescriptions([], PLAIN); + expect(result.metadata).toBeUndefined(); + expect(result.help).not.toContain("--body"); }); }); diff --git a/packages/cli/src/commands/_generated/_meta/commands.json b/packages/cli/src/commands/_generated/_meta/commands.json index 0b94f16c35..ea7a7184c8 100644 --- a/packages/cli/src/commands/_generated/_meta/commands.json +++ b/packages/cli/src/commands/_generated/_meta/commands.json @@ -15606,7 +15606,7 @@ "name": "body", "type": "string", "required": false, - "description": "Raw JSON request body (bypasses individual flags)" + "description": "Plain-text request body (without JSON quotes)" }, { "name": "file", @@ -67470,7 +67470,7 @@ "name": "body", "type": "string", "required": false, - "description": "Raw JSON request body (bypasses individual flags)" + "description": "Plain-text request body (without JSON quotes)" }, { "name": "file", diff --git a/packages/cli/src/commands/_generated/analytics-engine/sql/query.ts b/packages/cli/src/commands/_generated/analytics-engine/sql/query.ts index 1a88aedcbd..c5ade6a3c8 100644 --- a/packages/cli/src/commands/_generated/analytics-engine/sql/query.ts +++ b/packages/cli/src/commands/_generated/analytics-engine/sql/query.ts @@ -31,7 +31,7 @@ function builder(yargs: Argv) { }) .option("body", { type: "string", - description: "Raw JSON request body (bypasses individual flags)", + description: "Plain-text request body (without JSON quotes)", }) .option("file", { type: "string", diff --git a/packages/cli/src/commands/_generated/logs/query.ts b/packages/cli/src/commands/_generated/logs/query.ts index d7ff895c60..e2064f633c 100644 --- a/packages/cli/src/commands/_generated/logs/query.ts +++ b/packages/cli/src/commands/_generated/logs/query.ts @@ -32,7 +32,7 @@ function builder(yargs: Argv) { }) .option("body", { type: "string", - description: "Raw JSON request body (bypasses individual flags)", + description: "Plain-text request body (without JSON quotes)", }) .option("file", { type: "string",