From e8a1e1b001f86c8edba049cd323548a5018c62e8 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 16 Jun 2026 20:04:36 -0300 Subject: [PATCH] fix(input-json): only read stdin with explicit --input-json - Implicit stdin consumption (when stdin was not a TTY) parsed any piped data as the --input-json options payload, breaking `while read` loops and commands that read their own stdin (e.g. `cat body.json | clerk api`). Require the explicit `--input-json -` marker instead. Fixes #333 --- .changeset/input-json-explicit-stdin.md | 5 ++ .../cli-core/src/commands/users/README.md | 2 +- packages/cli-core/src/lib/input-json.test.ts | 40 ++++++---------- packages/cli-core/src/lib/input-json.ts | 47 +++++-------------- 4 files changed, 32 insertions(+), 62 deletions(-) create mode 100644 .changeset/input-json-explicit-stdin.md diff --git a/.changeset/input-json-explicit-stdin.md b/.changeset/input-json-explicit-stdin.md new file mode 100644 index 00000000..912f9195 --- /dev/null +++ b/.changeset/input-json-explicit-stdin.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Only read stdin as `--input-json` when `--input-json -` is passed explicitly. Previously any piped stdin was consumed and parsed as the options payload, which broke shell loops (`while read … | clerk …`) and commands that read their own stdin (`cat body.json | clerk api …`) with a confusing `invalid_json` error. diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index 276edb1e..4b6ee4ee 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -35,7 +35,7 @@ In agent mode all interactive flows are disabled and the same invocations exit w Two complementary mechanisms for JSON input work across the users command family: -- **`--input-json `** (program-level). Expands JSON object keys into argv flags before Commander parses them. Drive the curated flags with structured JSON, from an agent or a pipeline: `clerk users create --input-json '{"email":"alice@example.com","first-name":"Alice","yes":true}'`. Accepts inline JSON, `@path/to/file.json`, or `-` for stdin. Piped stdin is auto-detected when `--input-json` is absent. +- **`--input-json `** (program-level). Expands JSON object keys into argv flags before Commander parses them. Drive the curated flags with structured JSON, from an agent or a pipeline: `clerk users create --input-json '{"email":"alice@example.com","first-name":"Alice","yes":true}'`. Accepts inline JSON, `@path/to/file.json`, or `-` for stdin. Stdin is only read with an explicit `--input-json -`, so shell loops and commands that read their own stdin are never disturbed. - **`-d, --data ` plus `--file `** (per-command). Send a raw BAPI request body directly to `/v1/users`. Use this when you need a BAPI field the curated flags don't expose (for example, `primary_email_address_id` or `web3_wallets`). Mirrors `clerk api -d` / `--file`. ## Commands diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts index 954ac2ee..cf6f9c78 100644 --- a/packages/cli-core/src/lib/input-json.test.ts +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -325,25 +325,16 @@ describe("expandInputJson", () => { expect(result.result).toContain("--yes"); }); - test("auto-detects piped stdin when --input-json is absent", async () => { + test("ignores piped stdin when --input-json is absent", async () => { const result = await expandViaStdin(["clerk", "init"], '{"framework":"next","yes":true}'); - expect(result.result).toContain("--framework"); - expect(result.result).toContain("next"); - expect(result.result).toContain("--yes"); - // Original argv args are preserved before expanded flags - expect(result.result![0]).toBe("clerk"); - expect(result.result![1]).toBe("init"); - }); - - test("auto-stdin appends flags after existing argv", async () => { - const result = await expandViaStdin(["clerk", "init", "--yes"], '{"framework":"next"}'); - // Explicit --yes comes first, then expanded --framework next - expect(result.result).toEqual(["clerk", "init", "--yes", "--framework", "next"]); + // Without an explicit --input-json -, stdin is left untouched. + expect(result.result).toEqual(["clerk", "init"]); }); - test("auto-stdin ignores empty stdin", async () => { - const result = await expandViaStdin(["clerk", "init", "--yes"], ""); - expect(result.result).toEqual(["clerk", "init", "--yes"]); + test("ignores piped non-JSON stdin (shell loops, command bodies)", async () => { + const result = await expandViaStdin(["clerk", "whoami"], "not json\nmore lines\n"); + expect(result.error).toBeUndefined(); + expect(result.result).toEqual(["clerk", "whoami"]); }); test("--input-json - errors on invalid JSON from stdin", async () => { @@ -356,18 +347,17 @@ describe("expandInputJson", () => { expect(result.error).toContain("No JSON received on stdin"); }); - test("auto-stdin errors on invalid JSON", async () => { - const result = await expandViaStdin(["clerk", "init"], "{bad}"); - expect(result.error).toContain("Invalid JSON"); - }); - - test("auto-stdin errors on JSON array", async () => { + test("ignores piped JSON array when --input-json is absent", async () => { const result = await expandViaStdin(["clerk", "init"], "[1,2,3]"); - expect(result.error).toContain("must be a JSON object"); + expect(result.error).toBeUndefined(); + expect(result.result).toEqual(["clerk", "init"]); }); - test("auto-stdin handles camelCase keys", async () => { - const result = await expandViaStdin(["clerk", "config", "patch"], '{"dryRun":true}'); + test("--input-json - handles camelCase keys", async () => { + const result = await expandViaStdin( + ["clerk", "config", "patch", "--input-json", "-"], + '{"dryRun":true}', + ); expect(result.result).toEqual(["clerk", "config", "patch", "--dry-run"]); }); }); diff --git a/packages/cli-core/src/lib/input-json.ts b/packages/cli-core/src/lib/input-json.ts index bcc37281..6674ba56 100644 --- a/packages/cli-core/src/lib/input-json.ts +++ b/packages/cli-core/src/lib/input-json.ts @@ -74,11 +74,6 @@ async function readStdin(): Promise { return text; } -async function readOptionalStdin(): Promise { - const text = await Bun.stdin.text(); - return text.trim() ? text : undefined; -} - /** * Resolve the raw --input-json value to a JSON string. * - `"-"` reads from stdin. @@ -121,46 +116,26 @@ function requireValue(argv: string[], idx: number): string { ); } -/** - * Check whether stdin has piped data available (i.e. is not a TTY). - */ -function hasStdinPipe(): boolean { - return !process.stdin.isTTY; -} - /** * Process an argv array: find `--input-json`, expand JSON to flags, return * a new argv with the expanded flags spliced in (so explicit CLI flags that * appear later in argv naturally take precedence). * - * If `--input-json` is not present but stdin is piped (not a TTY), reads - * JSON from stdin and appends the expanded flags to the end of argv. + * Stdin is only consumed when the value is the explicit `-` marker + * (`--input-json -`). Piped stdin is never read implicitly, so shell loops + * (`while read … | clerk …`) and commands that read their own stdin (e.g. + * `cat body.json | clerk api …`) are left untouched. * - * If neither `--input-json` nor stdin pipe is present, returns the original - * array unchanged. + * If `--input-json` is not present, returns the original array unchanged. */ export async function expandInputJson(argv: string[]): Promise { const idx = argv.indexOf(INPUT_JSON_FLAG); + if (idx === -1) return argv; - if (idx !== -1) { - const rawValue = requireValue(argv, idx); - const jsonStr = await resolveJsonValue(rawValue); - const parsed = parseJsonString(jsonStr); - assertJsonObject(parsed); - argv.splice(idx, 2, ...expandJsonToFlags(parsed)); - return argv; - } - - // No explicit --input-json flag — check for piped stdin - if (hasStdinPipe()) { - const jsonStr = await readOptionalStdin(); - if (jsonStr === undefined) return argv; - const parsed = parseJsonString(jsonStr); - assertJsonObject(parsed); - // Append expanded flags at the end; explicit CLI flags already in argv - // appear before these, so they naturally take precedence (last-flag-wins). - argv.push(...expandJsonToFlags(parsed)); - } - + const rawValue = requireValue(argv, idx); + const jsonStr = await resolveJsonValue(rawValue); + const parsed = parseJsonString(jsonStr); + assertJsonObject(parsed); + argv.splice(idx, 2, ...expandJsonToFlags(parsed)); return argv; }