diff --git a/.changeset/config-yaml-default.md b/.changeset/config-yaml-default.md new file mode 100644 index 00000000..3103eeff --- /dev/null +++ b/.changeset/config-yaml-default.md @@ -0,0 +1,9 @@ +--- +"clerk": major +--- + +**Breaking:** `clerk config pull` now outputs **YAML by default** instead of JSON. Anything parsing its stdout as JSON (e.g. `clerk config pull | jq`, or agents/scripts that assume JSON) will break until updated. Pass `--json` to restore JSON output; a `--output` path ending in `.json` also writes JSON. + +Additionally, `clerk config patch` and `clerk config put` now accept YAML input (a superset of JSON, so existing JSON files keep working) and, when no `--file`/`--json`/stdin is provided, auto-detect a project config file in order: `.clerk/config.yaml` → `.clerk/config.yml` → `.clerk/config.json` (first found wins). + +Migration: add `--json` to any `clerk config pull` invocation whose output is consumed as JSON. diff --git a/packages/cli-core/src/commands/config/README.md b/packages/cli-core/src/commands/config/README.md index ed5c4b9f..8fc22644 100644 --- a/packages/cli-core/src/commands/config/README.md +++ b/packages/cli-core/src/commands/config/README.md @@ -6,13 +6,14 @@ Manage Clerk instance configuration. ### `clerk config pull` -Fetches the instance configuration from the Clerk Platform API and outputs it as JSON. +Fetches the instance configuration from the Clerk Platform API and outputs it as **YAML by default**. Pass `--json` to emit JSON instead. When `--output` is given, the format is inferred from the file extension: a path ending in `.json` writes JSON, anything else (including `.yaml`/`.yml`) writes YAML. `--json` always wins. ```sh clerk config pull clerk config pull --app app_123 clerk config pull --instance prod -clerk config pull --output clerk-config.json +clerk config pull --output clerk-config.yaml +clerk config pull --json clerk config pull --keys auth_email session ``` @@ -22,7 +23,8 @@ clerk config pull --keys auth_email session | ------------------ | ------------------------------------------------------------------------------------------ | | `--app ` | Application ID to target directly (works from any directory) | | `--instance ` | Instance to target (`dev`, `prod`, or a full instance ID). Defaults to development. | -| `--output ` | Write config to a file instead of stdout | +| `--output ` | Write config to a file instead of stdout (YAML, or JSON if the path ends in `.json`) | +| `--json` | Output JSON instead of the default YAML | | `--keys ` | Top-level config keys to retrieve, separated by spaces or commas (e.g. auth_email session) | #### Requirements @@ -82,14 +84,15 @@ clerk config schema --keys auth_email session Partially updates instance configuration using a PATCH request. Only the fields you include in the payload are modified; everything else remains unchanged. -Input can be provided via `--json` (inline), `--file` (path to a JSON file), or piped to stdin. When running interactively, the command shows the payload and prompts for confirmation before sending. +Input can be provided via `--json` (inline), `--file` (path to a YAML or JSON file), or piped to stdin. When none of these are given, the command auto-detects a project config file in this order: `.clerk/config.yaml` → `.clerk/config.yml` → `.clerk/config.json` (first found wins). YAML is a superset of JSON, so both formats parse. When running interactively, the command shows the payload and prompts for confirmation before sending. ```sh clerk config patch --json '{"session":{"lifetime":3600}}' clerk config patch --app app_123 --json '{"session":{"lifetime":3600}}' -clerk config patch --file partial-config.json -cat partial-config.json | clerk config patch -clerk config patch --file partial-config.json --dry-run +clerk config patch --file partial-config.yaml +clerk config patch # reads .clerk/config.yaml (or .yml/.json) +cat partial-config.yaml | clerk config patch +clerk config patch --file partial-config.yaml --dry-run ``` #### Options @@ -98,7 +101,7 @@ clerk config patch --file partial-config.json --dry-run | ----------------- | ----------------------------------------------------------------------------------- | | `--app ` | Application ID to target directly (works from any directory) | | `--instance ` | Instance to target (`dev`, `prod`, or a full instance ID). Defaults to development. | -| `--file ` | Read config JSON from a file | +| `--file ` | Read config from a YAML or JSON file | | `--json ` | Pass config JSON inline (takes priority over `--file`) | | `--dry-run` | Validate server-side and preview the projected result without persisting changes | | `--yes` | Skip confirmation prompts | @@ -122,14 +125,14 @@ clerk config patch --file partial-config.json --dry-run Replaces the entire instance configuration using a PUT request. The payload you send becomes the complete configuration, overwriting all existing values. -Input can be provided via `--json` (inline), `--file` (path to a JSON file), or piped to stdin. When running interactively, the command shows a destructive-action warning and prompts for confirmation before sending. +Input can be provided via `--json` (inline), `--file` (path to a YAML or JSON file), or piped to stdin. When none of these are given, the command auto-detects a project config file in this order: `.clerk/config.yaml` → `.clerk/config.yml` → `.clerk/config.json` (first found wins). When running interactively, the command shows a destructive-action warning and prompts for confirmation before sending. ```sh -clerk config put --file full-config.json -clerk config put --app app_123 --file full-config.json +clerk config put --file full-config.yaml +clerk config put --app app_123 --file full-config.yaml clerk config put --json '{"session":{"lifetime":3600},"sign_in":{"enabled":true}}' -cat full-config.json | clerk config put -clerk config put --file full-config.json --dry-run +cat full-config.yaml | clerk config put +clerk config put --file full-config.yaml --dry-run ``` #### Options @@ -138,7 +141,7 @@ clerk config put --file full-config.json --dry-run | ----------------- | ----------------------------------------------------------------------------------- | | `--app ` | Application ID to target directly (works from any directory) | | `--instance ` | Instance to target (`dev`, `prod`, or a full instance ID). Defaults to development. | -| `--file ` | Read config JSON from a file | +| `--file ` | Read config from a YAML or JSON file | | `--json ` | Pass config JSON inline (takes priority over `--file`) | | `--dry-run` | Validate server-side and preview the projected result without persisting changes | | `--yes` | Skip confirmation prompts | diff --git a/packages/cli-core/src/commands/config/index.ts b/packages/cli-core/src/commands/config/index.ts index 7c973556..0c4b0aeb 100644 --- a/packages/cli-core/src/commands/config/index.ts +++ b/packages/cli-core/src/commands/config/index.ts @@ -44,14 +44,16 @@ export function registerConfig(program: Program): void { .option("--app ", "Application ID to target (works from any directory)") .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") .option("--output ", "Write config to a file instead of stdout") + .option("--json", "Output JSON instead of the default YAML") .option( "--keys ", "Top-level config keys to retrieve, separated by spaces or commas (e.g. auth_email session)", ) .setExamples([ - { command: "clerk config pull", description: "Print dev config to stdout" }, + { command: "clerk config pull", description: "Print dev config (YAML) to stdout" }, { command: "clerk config pull --instance prod", description: "Pull production config" }, - { command: "clerk config pull --output config.json", description: "Save config to a file" }, + { command: "clerk config pull --output config.yaml", description: "Save config to a file" }, + { command: "clerk config pull --json", description: "Print config as JSON" }, ]) .action(configPull); diff --git a/packages/cli-core/src/commands/config/pull.test.ts b/packages/cli-core/src/commands/config/pull.test.ts index fed5d17d..2d3f5fec 100644 --- a/packages/cli-core/src/commands/config/pull.test.ts +++ b/packages/cli-core/src/commands/config/pull.test.ts @@ -2,6 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import YAML from "yaml"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; import { useCaptureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; @@ -63,7 +64,13 @@ describe("config pull", () => { // Dynamically import to get fresh module state async function runConfigPull( - options: { app?: string; instance?: string; output?: string; keys?: string[] } = {}, + options: { + app?: string; + instance?: string; + output?: string; + keys?: string[]; + json?: boolean; + } = {}, ) { const { configPull } = await import("./pull.ts"); return configPull(options); @@ -84,7 +91,7 @@ describe("config pull", () => { await expect(runConfigPull()).rejects.toThrow("Not authenticated"); }); - test("prints config JSON to stdout by default", async () => { + test("prints config as YAML to stdout by default", async () => { await setProfile(process.cwd(), { workspaceId: "org_1", appId: "app_1", @@ -92,6 +99,17 @@ describe("config pull", () => { }); await runConfigPull(); + expect(captured.out).toContain(YAML.stringify(mockConfig)); + }); + + test("prints config as JSON to stdout with --json", async () => { + await setProfile(process.cwd(), { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev" }, + }); + + await runConfigPull({ json: true }); expect(captured.out).toContain(JSON.stringify(mockConfig, null, 2)); }); @@ -112,11 +130,11 @@ describe("config pull", () => { throw new Error(`Unexpected fetch: ${url}`); }); - await runConfigPull({ app: "app_1" }); + await runConfigPull({ app: "app_1", json: true }); expect(captured.out).toContain(JSON.stringify(mockConfig, null, 2)); }); - test("writes config to file with --output", async () => { + test("writes JSON to file when --output ends in .json (extension guard)", async () => { await setProfile(process.cwd(), { workspaceId: "org_1", appId: "app_1", @@ -130,6 +148,20 @@ describe("config pull", () => { expect(captured.err).toContain("Config written to"); }); + test("writes YAML to file when --output ends in .yaml", async () => { + await setProfile(process.cwd(), { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev" }, + }); + const outFile = join(tempDir, "output.yaml"); + + await runConfigPull({ output: outFile }); + const written = YAML.parse(await Bun.file(outFile).text()); + expect(written).toEqual(mockConfig); + expect(captured.err).toContain("Config written to"); + }); + test("shows which environment is being pulled", async () => { await setProfile(process.cwd(), { workspaceId: "org_1", @@ -242,7 +274,7 @@ describe("config pull", () => { await runConfigPull({ keys: ["session"] }); expect(requestedUrl).toContain("keys=session"); - expect(captured.out).toContain(JSON.stringify({ session: { lifetime: 604800 } }, null, 2)); + expect(captured.out).toContain(YAML.stringify({ session: { lifetime: 604800 } })); }); test("--keys passes multiple keys as repeated query params", async () => { diff --git a/packages/cli-core/src/commands/config/pull.ts b/packages/cli-core/src/commands/config/pull.ts index 64a68dbd..ebd9f70e 100644 --- a/packages/cli-core/src/commands/config/pull.ts +++ b/packages/cli-core/src/commands/config/pull.ts @@ -3,12 +3,22 @@ import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; +import { stringify as stringifyYaml } from "yaml"; interface ConfigPullOptions { app?: string; instance?: string; output?: string; keys?: string[]; + json?: boolean; +} + +// Resolve output format: --json wins; else a .json output path stays JSON; +// otherwise default to YAML (stdout, .yaml, .yml, extensionless). +function useJsonOutput(options: ConfigPullOptions): boolean { + if (options.json) return true; + if (options.output && options.output.toLowerCase().endsWith(".json")) return true; + return false; } export async function configPull(options: ConfigPullOptions): Promise { @@ -24,13 +34,15 @@ export async function configPull(options: ConfigPullOptions): Promise { ), ); - const json = JSON.stringify(config, null, 2); + const serialized = useJsonOutput(options) + ? JSON.stringify(config, null, 2) + : stringifyYaml(config); if (options.output) { - await Bun.write(options.output, json + "\n"); + await Bun.write(options.output, serialized.endsWith("\n") ? serialized : serialized + "\n"); log.success(`Config written to ${options.output}`); } else { - log.data(json); + log.data(serialized); } }); } diff --git a/packages/cli-core/src/commands/config/push.test.ts b/packages/cli-core/src/commands/config/push.test.ts index 780eddde..da45a494 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; @@ -132,14 +132,15 @@ describe("config push", () => { await expect(runConfigPatch()).rejects.toThrow("No input"); }); - test("errors on invalid JSON input", async () => { + test("errors on malformed config input", async () => { await setProfile(process.cwd(), { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" }, }); - await expect(runConfigPatch({ json: "not-json" })).rejects.toThrow("Invalid JSON"); + // "a: b: c" is a YAML parse error (nested mapping in compact form). + await expect(runConfigPatch({ json: "a: b: c" })).rejects.toThrow("Invalid config input"); }); test("errors when JSON is an array", async () => { @@ -240,6 +241,83 @@ describe("config push", () => { expect(JSON.parse(capturedBody)).toEqual({ session: { lifetime: 7200 } }); }); + test("patch reads config from a YAML --file", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method && init.method !== "GET") capturedBody = init.body as string; + const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig; + return new Response(JSON.stringify(body), { status: 200 }); + }); + + const configFile = join(tempDir, "input.yaml"); + await Bun.write(configFile, "session:\n lifetime: 7200\n"); + + await setProfile(process.cwd(), { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev" }, + }); + + await runConfigPatch({ file: configFile, yes: true }); + expect(JSON.parse(capturedBody)).toEqual({ session: { lifetime: 7200 } }); + }); + + test("patch auto-detects .clerk/config.yaml when no input given", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method && init.method !== "GET") capturedBody = init.body as string; + const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig; + return new Response(JSON.stringify(body), { status: 200 }); + }); + + const prevCwd = process.cwd(); + process.chdir(tempDir); + try { + const cwd = process.cwd(); + await mkdir(join(cwd, ".clerk"), { recursive: true }); + await Bun.write(join(cwd, ".clerk/config.yaml"), "session:\n lifetime: 1234\n"); + await setProfile(cwd, { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev" }, + }); + await runConfigPatch({ yes: true }); + } finally { + process.chdir(prevCwd); + } + expect(JSON.parse(capturedBody)).toEqual({ session: { lifetime: 1234 } }); + }); + + test("patch prefers .clerk/config.yaml over .clerk/config.json", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method && init.method !== "GET") capturedBody = init.body as string; + const body = init?.method && init.method !== "GET" ? mockResponse : currentConfig; + return new Response(JSON.stringify(body), { status: 200 }); + }); + + const prevCwd = process.cwd(); + process.chdir(tempDir); + try { + const cwd = process.cwd(); + await mkdir(join(cwd, ".clerk"), { recursive: true }); + await Bun.write(join(cwd, ".clerk/config.yaml"), "session:\n lifetime: 11\n"); + await Bun.write( + join(cwd, ".clerk/config.json"), + JSON.stringify({ session: { lifetime: 22 } }), + ); + await setProfile(cwd, { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev" }, + }); + await runConfigPatch({ yes: true }); + } finally { + process.chdir(prevCwd); + } + expect(JSON.parse(capturedBody)).toEqual({ session: { lifetime: 11 } }); + }); + test("patch prints returned config to stdout", async () => { await setProfile(process.cwd(), { workspaceId: "org_1", diff --git a/packages/cli-core/src/commands/config/push.ts b/packages/cli-core/src/commands/config/push.ts index 36c61be1..901dc2fe 100644 --- a/packages/cli-core/src/commands/config/push.ts +++ b/packages/cli-core/src/commands/config/push.ts @@ -1,4 +1,6 @@ +import { parse as parseYaml } from "yaml"; import { resolveAppContext } from "../../lib/config.ts"; +import { CONFIG_FILE_PRECEDENCE, resolveConfigFile } from "../../lib/config-file.ts"; import { fetchInstanceConfig, putInstanceConfig, patchInstanceConfig } from "../../lib/plapi.ts"; import { isHuman } from "../../mode.ts"; import { @@ -67,10 +69,10 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise; try { - configPayload = JSON.parse(rawInput); + configPayload = parseYaml(rawInput) as Record; } catch { throwUsageError( - "Invalid JSON input. Please provide valid JSON.", + "Invalid config input (expected JSON or YAML).", undefined, ERROR_CODE.INVALID_JSON, ); @@ -170,26 +172,39 @@ export async function readInput(options: { file?: string; json?: string }): Prom if (!(await file.exists())) { throwUsageError(`File not found: ${options.file}`, undefined, ERROR_CODE.FILE_NOT_FOUND); } + log.debug(`config: reading config from ${options.file}`); return file.text(); } if (!process.stdin.isTTY) { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(Buffer.from(chunk)); + let text = ""; + try { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.from(chunk)); + } + text = Buffer.concat(chunks).toString("utf-8").trim(); + } catch { + // No readable stdin (e.g. closed pipe); fall through to default-file lookup. + text = ""; } - const text = Buffer.concat(chunks).toString("utf-8").trim(); - if (!text) { - throwUsageError("No input received from stdin"); + if (text) { + log.debug("config: reading config from stdin"); + return text; } - return text; + } + + const resolved = await resolveConfigFile(); + if (resolved) { + log.debug(`config: reading config from ${resolved}`); + return Bun.file(resolved).text(); } throwUsageError( - "No input provided. Use --file , --json , or pipe JSON to stdin.\n" + - " Example: clerk config patch --file config.json\n" + - ' Example: clerk config patch --json \'{"session":{"lifetime":3600}}\'\n' + - " Example: cat config.json | clerk config patch", + "No input provided. Use --file , --json , pipe to stdin, or add one of:\n" + + CONFIG_FILE_PRECEDENCE.map((p) => ` ${p}`).join("\n") + + "\n Example: clerk config patch --file config.yaml\n" + + ' Example: clerk config patch --json \'{"session":{"lifetime":3600}}\'', ); } diff --git a/packages/cli-core/src/lib/config-file.test.ts b/packages/cli-core/src/lib/config-file.test.ts new file mode 100644 index 00000000..09d4a15b --- /dev/null +++ b/packages/cli-core/src/lib/config-file.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CONFIG_FILE_PRECEDENCE, resolveConfigFile } from "./config-file.ts"; + +async function makeClerkDir(files: string[]): Promise { + const dir = mkdtempSync(join(tmpdir(), "clerk-cfg-")); + mkdirSync(join(dir, ".clerk"), { recursive: true }); + for (const f of files) await Bun.write(join(dir, f), "x"); + return dir; +} + +describe("resolveConfigFile", () => { + test("precedence list is yaml, yml, json", () => { + expect(CONFIG_FILE_PRECEDENCE).toEqual([ + ".clerk/config.yaml", + ".clerk/config.yml", + ".clerk/config.json", + ]); + }); + + test("prefers yaml over json when both exist", async () => { + const dir = await makeClerkDir([".clerk/config.yaml", ".clerk/config.json"]); + expect(await resolveConfigFile(dir)).toBe(join(dir, ".clerk/config.yaml")); + }); + + test("falls back to json when only json exists", async () => { + const dir = await makeClerkDir([".clerk/config.json"]); + expect(await resolveConfigFile(dir)).toBe(join(dir, ".clerk/config.json")); + }); + + test("returns undefined when no config file exists", async () => { + const dir = await makeClerkDir([]); + expect(await resolveConfigFile(dir)).toBeUndefined(); + }); +}); diff --git a/packages/cli-core/src/lib/config-file.ts b/packages/cli-core/src/lib/config-file.ts new file mode 100644 index 00000000..b1d72d83 --- /dev/null +++ b/packages/cli-core/src/lib/config-file.ts @@ -0,0 +1,18 @@ +import { join } from "node:path"; + +// Ordered precedence for locating a project config file: YAML wins over JSON. +export const CONFIG_FILE_PRECEDENCE = [ + ".clerk/config.yaml", + ".clerk/config.yml", + ".clerk/config.json", +] as const; + +// Returns the path of the first existing config file (relative to `dir`, +// default cwd), trying YAML before JSON. Returns undefined if none exist. +export async function resolveConfigFile(dir = "."): Promise { + for (const rel of CONFIG_FILE_PRECEDENCE) { + const path = join(dir, rel); + if (await Bun.file(path).exists()) return path; + } + return undefined; +} diff --git a/packages/cli-core/src/test/integration/config-management.test.ts b/packages/cli-core/src/test/integration/config-management.test.ts index bda78f66..18544595 100644 --- a/packages/cli-core/src/test/integration/config-management.test.ts +++ b/packages/cli-core/src/test/integration/config-management.test.ts @@ -32,8 +32,8 @@ test.each([{ mode: "human" }, { mode: "agent" }])( "/config": MOCK_CONFIG, }); - // Pull config - const { stdout: pullOutput } = await clerk("--mode", mode, "config", "pull"); + // Pull config (--json: assert the JSON round-trip; default output is YAML) + const { stdout: pullOutput } = await clerk("--mode", mode, "config", "pull", "--json"); expect(pullOutput).toContain( `"lifetime": ${(MOCK_CONFIG.session as { lifetime: number }).lifetime}`, ); diff --git a/packages/cli-core/src/test/integration/input-json.test.ts b/packages/cli-core/src/test/integration/input-json.test.ts index 0c23560b..41c6a17a 100644 --- a/packages/cli-core/src/test/integration/input-json.test.ts +++ b/packages/cli-core/src/test/integration/input-json.test.ts @@ -186,7 +186,8 @@ test("config pull with array keys via --input-json", async () => { "config", "pull", "--input-json", - '{"keys":["auth_email","session"]}', + // json:true so stdout is JSON (default output is now YAML) + '{"keys":["auth_email","session"],"json":true}', ); const parsed = JSON.parse(stdout); expect(parsed).toBeDefined();