diff --git a/.changeset/plain-text-body-metadata.md b/.changeset/plain-text-body-metadata.md new file mode 100644 index 0000000000..b680b63cf2 --- /dev/null +++ b/.changeset/plain-text-body-metadata.md @@ -0,0 +1,11 @@ +--- +"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. 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 c2fbe6c164..ba7ddd8a8d 100644 --- a/packages/cli/generator/plugin/emit/builder.ts +++ b/packages/cli/generator/plugin/emit/builder.ts @@ -23,6 +23,26 @@ function option(name: string, fields: [string, Value | undefined][]): string { const quoted = (v: readonly string[]) => v.map((c) => `'${c}'`).join(", "); +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 { + if ( + op.requestBodyDescription !== undefined && + op.requestBodyDescription !== "" + ) { + return op.requestBodyDescription; + } + return isPlainTextBody(op) + ? "Plain-text request body (without JSON quotes)" + : JSON_BODY_DESCRIPTION; +} + /** `generateBuilderLines`. */ export function builderLines(ctx: Ctx): string[] { const d = ctx.derived; @@ -91,11 +111,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..e05c52e580 --- /dev/null +++ b/packages/cli/src/__tests__/generator/plugin/body-description.test.ts @@ -0,0 +1,86 @@ +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)"; + +async function bodyDescriptions(contentTypes: 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: Object.fromEntries( + contentTypes.map((type) => [type, { schema: { type: "string" } }]) + ), + }, + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + const metadata = JSON.parse(files.get("_meta/commands.json") ?? "") as { + commands: CommandMeta[]; + }; + 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.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([ + { 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",