Skip to content
Draft
9 changes: 9 additions & 0 deletions .changeset/config-yaml-default.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 17 additions & 14 deletions packages/cli-core/src/commands/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -22,7 +23,8 @@ clerk config pull --keys auth_email session
| ------------------ | ------------------------------------------------------------------------------------------ |
| `--app <id>` | Application ID to target directly (works from any directory) |
| `--instance <id>` | Instance to target (`dev`, `prod`, or a full instance ID). Defaults to development. |
| `--output <file>` | Write config to a file instead of stdout |
| `--output <file>` | 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 <keys...>` | Top-level config keys to retrieve, separated by spaces or commas (e.g. auth_email session) |

#### Requirements
Expand Down Expand Up @@ -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
Expand All @@ -98,7 +101,7 @@ clerk config patch --file partial-config.json --dry-run
| ----------------- | ----------------------------------------------------------------------------------- |
| `--app <id>` | Application ID to target directly (works from any directory) |
| `--instance <id>` | Instance to target (`dev`, `prod`, or a full instance ID). Defaults to development. |
| `--file <path>` | Read config JSON from a file |
| `--file <path>` | Read config from a YAML or JSON file |
| `--json <string>` | 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 |
Expand All @@ -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
Expand All @@ -138,7 +141,7 @@ clerk config put --file full-config.json --dry-run
| ----------------- | ----------------------------------------------------------------------------------- |
| `--app <id>` | Application ID to target directly (works from any directory) |
| `--instance <id>` | Instance to target (`dev`, `prod`, or a full instance ID). Defaults to development. |
| `--file <path>` | Read config JSON from a file |
| `--file <path>` | Read config from a YAML or JSON file |
| `--json <string>` | 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 |
Expand Down
6 changes: 4 additions & 2 deletions packages/cli-core/src/commands/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,16 @@ export function registerConfig(program: Program): void {
.option("--app <id>", "Application ID to target (works from any directory)")
.option("--instance <id>", "Instance to target (dev, prod, or a full instance ID)")
.option("--output <file>", "Write config to a file instead of stdout")
.option("--json", "Output JSON instead of the default YAML")
.option(
"--keys <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);

Expand Down
42 changes: 37 additions & 5 deletions packages/cli-core/src/commands/config/pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand All @@ -84,14 +91,25 @@ 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",
instances: { development: "ins_dev" },
});

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));
});

Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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 () => {
Expand Down
18 changes: 15 additions & 3 deletions packages/cli-core/src/commands/config/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -24,13 +34,15 @@ export async function configPull(options: ConfigPullOptions): Promise<void> {
),
);

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);
}
});
}
84 changes: 81 additions & 3 deletions packages/cli-core/src/commands/config/push.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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",
Expand Down
Loading