Skip to content

Commit 57f7feb

Browse files
committed
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
1 parent 41ad30e commit 57f7feb

4 files changed

Lines changed: 32 additions & 62 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"clerk": patch
3+
---
4+
5+
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.

packages/cli-core/src/commands/users/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ In agent mode all interactive flows are disabled and the same invocations exit w
3535

3636
Two complementary mechanisms for JSON input work across the users command family:
3737

38-
- **`--input-json <json|@file|->`** (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.
38+
- **`--input-json <json|@file|->`** (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.
3939
- **`-d, --data <json>` plus `--file <path>`** (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`.
4040

4141
## Commands

packages/cli-core/src/lib/input-json.test.ts

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -325,25 +325,16 @@ describe("expandInputJson", () => {
325325
expect(result.result).toContain("--yes");
326326
});
327327

328-
test("auto-detects piped stdin when --input-json is absent", async () => {
328+
test("ignores piped stdin when --input-json is absent", async () => {
329329
const result = await expandViaStdin(["clerk", "init"], '{"framework":"next","yes":true}');
330-
expect(result.result).toContain("--framework");
331-
expect(result.result).toContain("next");
332-
expect(result.result).toContain("--yes");
333-
// Original argv args are preserved before expanded flags
334-
expect(result.result![0]).toBe("clerk");
335-
expect(result.result![1]).toBe("init");
336-
});
337-
338-
test("auto-stdin appends flags after existing argv", async () => {
339-
const result = await expandViaStdin(["clerk", "init", "--yes"], '{"framework":"next"}');
340-
// Explicit --yes comes first, then expanded --framework next
341-
expect(result.result).toEqual(["clerk", "init", "--yes", "--framework", "next"]);
330+
// Without an explicit --input-json -, stdin is left untouched.
331+
expect(result.result).toEqual(["clerk", "init"]);
342332
});
343333

344-
test("auto-stdin ignores empty stdin", async () => {
345-
const result = await expandViaStdin(["clerk", "init", "--yes"], "");
346-
expect(result.result).toEqual(["clerk", "init", "--yes"]);
334+
test("ignores piped non-JSON stdin (shell loops, command bodies)", async () => {
335+
const result = await expandViaStdin(["clerk", "whoami"], "not json\nmore lines\n");
336+
expect(result.error).toBeUndefined();
337+
expect(result.result).toEqual(["clerk", "whoami"]);
347338
});
348339

349340
test("--input-json - errors on invalid JSON from stdin", async () => {
@@ -356,18 +347,17 @@ describe("expandInputJson", () => {
356347
expect(result.error).toContain("No JSON received on stdin");
357348
});
358349

359-
test("auto-stdin errors on invalid JSON", async () => {
360-
const result = await expandViaStdin(["clerk", "init"], "{bad}");
361-
expect(result.error).toContain("Invalid JSON");
362-
});
363-
364-
test("auto-stdin errors on JSON array", async () => {
350+
test("ignores piped JSON array when --input-json is absent", async () => {
365351
const result = await expandViaStdin(["clerk", "init"], "[1,2,3]");
366-
expect(result.error).toContain("must be a JSON object");
352+
expect(result.error).toBeUndefined();
353+
expect(result.result).toEqual(["clerk", "init"]);
367354
});
368355

369-
test("auto-stdin handles camelCase keys", async () => {
370-
const result = await expandViaStdin(["clerk", "config", "patch"], '{"dryRun":true}');
356+
test("--input-json - handles camelCase keys", async () => {
357+
const result = await expandViaStdin(
358+
["clerk", "config", "patch", "--input-json", "-"],
359+
'{"dryRun":true}',
360+
);
371361
expect(result.result).toEqual(["clerk", "config", "patch", "--dry-run"]);
372362
});
373363
});

packages/cli-core/src/lib/input-json.ts

Lines changed: 11 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,6 @@ async function readStdin(): Promise<string> {
7474
return text;
7575
}
7676

77-
async function readOptionalStdin(): Promise<string | undefined> {
78-
const text = await Bun.stdin.text();
79-
return text.trim() ? text : undefined;
80-
}
81-
8277
/**
8378
* Resolve the raw --input-json value to a JSON string.
8479
* - `"-"` reads from stdin.
@@ -121,46 +116,26 @@ function requireValue(argv: string[], idx: number): string {
121116
);
122117
}
123118

124-
/**
125-
* Check whether stdin has piped data available (i.e. is not a TTY).
126-
*/
127-
function hasStdinPipe(): boolean {
128-
return !process.stdin.isTTY;
129-
}
130-
131119
/**
132120
* Process an argv array: find `--input-json`, expand JSON to flags, return
133121
* a new argv with the expanded flags spliced in (so explicit CLI flags that
134122
* appear later in argv naturally take precedence).
135123
*
136-
* If `--input-json` is not present but stdin is piped (not a TTY), reads
137-
* JSON from stdin and appends the expanded flags to the end of argv.
124+
* Stdin is only consumed when the value is the explicit `-` marker
125+
* (`--input-json -`). Piped stdin is never read implicitly, so shell loops
126+
* (`while read … | clerk …`) and commands that read their own stdin (e.g.
127+
* `cat body.json | clerk api …`) are left untouched.
138128
*
139-
* If neither `--input-json` nor stdin pipe is present, returns the original
140-
* array unchanged.
129+
* If `--input-json` is not present, returns the original array unchanged.
141130
*/
142131
export async function expandInputJson(argv: string[]): Promise<string[]> {
143132
const idx = argv.indexOf(INPUT_JSON_FLAG);
133+
if (idx === -1) return argv;
144134

145-
if (idx !== -1) {
146-
const rawValue = requireValue(argv, idx);
147-
const jsonStr = await resolveJsonValue(rawValue);
148-
const parsed = parseJsonString(jsonStr);
149-
assertJsonObject(parsed);
150-
argv.splice(idx, 2, ...expandJsonToFlags(parsed));
151-
return argv;
152-
}
153-
154-
// No explicit --input-json flag — check for piped stdin
155-
if (hasStdinPipe()) {
156-
const jsonStr = await readOptionalStdin();
157-
if (jsonStr === undefined) return argv;
158-
const parsed = parseJsonString(jsonStr);
159-
assertJsonObject(parsed);
160-
// Append expanded flags at the end; explicit CLI flags already in argv
161-
// appear before these, so they naturally take precedence (last-flag-wins).
162-
argv.push(...expandJsonToFlags(parsed));
163-
}
164-
135+
const rawValue = requireValue(argv, idx);
136+
const jsonStr = await resolveJsonValue(rawValue);
137+
const parsed = parseJsonString(jsonStr);
138+
assertJsonObject(parsed);
139+
argv.splice(idx, 2, ...expandJsonToFlags(parsed));
165140
return argv;
166141
}

0 commit comments

Comments
 (0)