Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions .claude/rules/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Adjust the relative path to `lib/log.ts` based on the file's location under `pac
| `log.error()` | stderr | Errors (red, auto-prefixed `error:`) |
| `log.debug()` | stderr | Diagnostic info, only with `--verbose` |
| `log.raw()` | stderr | Machine-readable JSON for agent mode |
| `log.ui()` | stderr | Pre-formatted UI (spinner, intro/outro brackets) |
| `log.blank()` | stderr | Blank line |

`log.data()` writes to **stdout** — this is what gets piped (e.g., `clerk apps list | jq`). Everything else writes to **stderr** as UI for humans. Never mix these.
Expand Down Expand Up @@ -57,15 +58,27 @@ log.info("Linked to `my-app` on `development`");

## Testing log output

Use `captureLog()` from `src/test/lib/stubs.ts`. Capture is scoped via `AsyncLocalStorage` — no teardown needed:
Use `useCaptureLog()` from `src/test/lib/stubs.ts` at file or `describe` scope. It registers `beforeEach`/`afterEach` hooks that install a fresh buffer for each test and clear it after — no per-test wiring needed:

```ts
import { captureLog } from "../../test/lib/stubs.ts";

test("outputs result", async () => {
const captured = captureLog();
await captured.run(() => myCommand());
expect(captured.out).toContain("expected stdout"); // log.data()
expect(captured.err).toContain("expected stderr"); // log.info/warn/etc.
import { useCaptureLog } from "../../test/lib/stubs.ts";

describe("my command", () => {
const captured = useCaptureLog();

test("outputs result", async () => {
await myCommand();
expect(captured.out).toContain("expected stdout"); // log.data()
expect(captured.err).toContain("expected stderr"); // log.info/warn/etc.
});

test("ignores setup noise", async () => {
await setUp();
captured.clear();
await myCommand();
expect(captured.err).toContain("done");
});
});
```

Just suppressing log noise without assertions? Call `useCaptureLog()` without keeping the return.
4 changes: 4 additions & 0 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ export function createProgram() {
.name("clerk")
.description("Clerk CLI")
.configureHelp(clerkHelpConfig())
.configureOutput({
writeOut: (msg) => log.data(msg.replace(/\n$/, "")),
writeErr: (msg) => log.ui(msg),
})
.version(getCurrentVersion(), "-v, --version", "Output the version number")
.helpOption("-h, --help", "Display help for command")
.addHelpCommand("help [command]", "Display help for command")
Expand Down
8 changes: 3 additions & 5 deletions packages/cli-core/src/commands/api/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test";
import { captureLog, stubFetch } from "../../test/lib/stubs.ts";
import { useCaptureLog, stubFetch } from "../../test/lib/stubs.ts";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -179,25 +179,23 @@ describe("loadCatalog", () => {
const originalFetch = globalThis.fetch;
let tempDir: string;
let errorSpy: ReturnType<typeof spyOn>;
let captured: ReturnType<typeof captureLog>;
const captured = useCaptureLog();

beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "clerk-catalog-test-"));
_setCacheDir(tempDir);
errorSpy = spyOn(console, "error").mockImplementation(() => {});
captured = captureLog();
});

afterEach(async () => {
captured.teardown();
_setCacheDir(undefined);
globalThis.fetch = originalFetch;
errorSpy.mockRestore();
await rm(tempDir, { recursive: true, force: true });
});

function runLoadCatalog(options?: Parameters<typeof loadCatalog>[0]) {
return captured.run(() => loadCatalog(options));
return loadCatalog(options);
}

test("fetches and caches on first load", async () => {
Expand Down
9 changes: 3 additions & 6 deletions packages/cli-core/src/commands/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import {
captureLog,
useCaptureLog,
credentialStoreStubs,
gitStubs,
configStubs,
Expand Down Expand Up @@ -183,7 +183,7 @@ describe("api command", () => {
let logSpy: ReturnType<typeof spyOn>;
let errorSpy: ReturnType<typeof spyOn>;
let exitSpy: ReturnType<typeof spyOn>;
let captured: ReturnType<typeof captureLog>;
const captured = useCaptureLog();

const mockUsers = { data: [{ id: "user_1", email: "test@example.com" }] };

Expand All @@ -208,13 +208,10 @@ describe("api command", () => {
exitSpy = spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
captured = captureLog();

stubFetch(async () => new Response(JSON.stringify(mockUsers), { status: 200 }));
});

afterEach(async () => {
captured.teardown();
_setConfigDir(undefined);
process.env = { ...originalEnv };
globalThis.fetch = originalFetch;
Expand All @@ -232,7 +229,7 @@ describe("api command", () => {

async function runApi(endpoint: string, options: Record<string, unknown> = {}) {
const { api } = await import("./index.ts");
return captured.run(() => api(endpoint, undefined, options));
return api(endpoint, undefined, options);
}

// --- GET requests ---
Expand Down
8 changes: 3 additions & 5 deletions packages/cli-core/src/commands/api/interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +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 { captureLog, promptsStubs, listageStubs, stubFetch } from "../../test/lib/stubs.ts";
import { useCaptureLog, promptsStubs, listageStubs, stubFetch } from "../../test/lib/stubs.ts";

let _mode = "human";
mock.module("../../mode.ts", () => ({
Expand Down Expand Up @@ -77,7 +77,7 @@ describe("apiInteractive", () => {
let errorSpy: ReturnType<typeof spyOn>;
let logSpy: ReturnType<typeof spyOn>;
let exitSpy: ReturnType<typeof spyOn>;
let captured: ReturnType<typeof captureLog>;
const captured = useCaptureLog();
const originalFetch = globalThis.fetch;
const originalIsTTY = process.stdin.isTTY;

Expand Down Expand Up @@ -105,7 +105,6 @@ describe("apiInteractive", () => {
exitSpy = spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
captured = captureLog();
// Capture fetch calls from the real api handler
stubFetch(async (input, init) => {
fetchCalls.push({ url: input.toString(), method: init?.method ?? "GET" });
Expand All @@ -120,7 +119,6 @@ describe("apiInteractive", () => {
});

afterEach(async () => {
captured.teardown();
_setCacheDir(undefined);
process.env = { ...originalEnv };
globalThis.fetch = originalFetch;
Expand All @@ -137,7 +135,7 @@ describe("apiInteractive", () => {

async function runApiInteractive(options: Record<string, unknown> = {}) {
const { apiInteractive } = await import("./interactive.ts");
return captured.run(() => apiInteractive(options));
return apiInteractive(options);
}

test("shows help and returns in agent mode", async () => {
Expand Down
8 changes: 3 additions & 5 deletions packages/cli-core/src/commands/api/ls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { parseSpec, _setCacheDir } from "./catalog.ts";
import { captureLog, stubFetch } from "../../test/lib/stubs.ts";
import { useCaptureLog, stubFetch } from "../../test/lib/stubs.ts";
import { apiLs } from "./ls.ts";

const MINIMAL_SPEC = `
Expand Down Expand Up @@ -45,7 +45,7 @@ describe("apiLs", () => {
let tempDir: string;
let logSpy: ReturnType<typeof spyOn>;
let errorSpy: ReturnType<typeof spyOn>;
let captured: ReturnType<typeof captureLog>;
const captured = useCaptureLog();
const originalFetch = globalThis.fetch;

beforeEach(async () => {
Expand All @@ -59,14 +59,12 @@ describe("apiLs", () => {

logSpy = spyOn(console, "log").mockImplementation(() => {});
errorSpy = spyOn(console, "error").mockImplementation(() => {});
captured = captureLog();
stubFetch(async () => {
throw new Error("Should not fetch");
});
});

afterEach(async () => {
captured.teardown();
_setCacheDir(undefined);
globalThis.fetch = originalFetch;
logSpy.mockRestore();
Expand All @@ -75,7 +73,7 @@ describe("apiLs", () => {
});

function runApiLs(filter: string | undefined, options: Parameters<typeof apiLs>[1]) {
return captured.run(() => apiLs(filter, options));
return apiLs(filter, options);
}

test("prints all endpoints in table format", async () => {
Expand Down
12 changes: 5 additions & 7 deletions packages/cli-core/src/commands/apps/create.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { captureLog } from "../../test/lib/stubs.ts";
import { useCaptureLog } from "../../test/lib/stubs.ts";

const mockCreateApplication = mock();
const mockFetchApplication = mock();
Expand Down Expand Up @@ -41,7 +41,7 @@ const mockApp = {
describe("apps create", () => {
let logSpy: ReturnType<typeof spyOn>;
let errorSpy: ReturnType<typeof spyOn>;
let captured: ReturnType<typeof captureLog>;
const captured = useCaptureLog();

beforeEach(() => {
mockIsAgent.mockReturnValue(false);
Expand All @@ -52,11 +52,9 @@ describe("apps create", () => {
mockFetchApplication.mockResolvedValue(mockApp);
logSpy = spyOn(console, "log").mockImplementation(() => {});
errorSpy = spyOn(console, "error").mockImplementation(() => {});
captured = captureLog();
});

afterEach(() => {
captured.teardown();
mockCreateApplication.mockReset();
mockFetchApplication.mockReset();
mockIsAgent.mockReset();
Expand All @@ -65,7 +63,7 @@ describe("apps create", () => {
});

function runCreate(name: string, options?: Parameters<typeof create>[1]) {
return captured.run(() => create(name, options));
return create(name, options);
}

test("calls createApplication then fetchApplication", async () => {
Expand Down Expand Up @@ -156,14 +154,14 @@ describe("apps create", () => {
test("propagates createApplication failure without fetching", async () => {
mockCreateApplication.mockRejectedValue(new Error("Unprocessable Entity"));

await expect(create("Bad App")).rejects.toThrow("Unprocessable Entity");
await expect(runCreate("Bad App")).rejects.toThrow("Unprocessable Entity");
expect(mockFetchApplication).not.toHaveBeenCalled();
});

test("propagates fetchApplication failure after create", async () => {
mockFetchApplication.mockRejectedValue(new Error("Service Unavailable"));

await expect(create("My SaaS App")).rejects.toThrow("Service Unavailable");
await expect(runCreate("My SaaS App")).rejects.toThrow("Service Unavailable");
});
});
});
8 changes: 3 additions & 5 deletions packages/cli-core/src/commands/apps/list.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { captureLog } from "../../test/lib/stubs.ts";
import { useCaptureLog } from "../../test/lib/stubs.ts";

const mockListApplications = mock();
mock.module("../../lib/plapi.ts", () => ({
Expand Down Expand Up @@ -52,25 +52,23 @@ const mockApps = [
describe("apps list", () => {
let logSpy: ReturnType<typeof spyOn>;
let errorSpy: ReturnType<typeof spyOn>;
let captured: ReturnType<typeof captureLog>;
const captured = useCaptureLog();

beforeEach(() => {
mockIsAgent.mockReturnValue(false);
logSpy = spyOn(console, "log").mockImplementation(() => {});
errorSpy = spyOn(console, "error").mockImplementation(() => {});
captured = captureLog();
});

afterEach(() => {
captured.teardown();
mockListApplications.mockReset();
mockIsAgent.mockReset();
logSpy.mockRestore();
errorSpy.mockRestore();
});

function runList(options: Parameters<typeof list>[0] = {}) {
return captured.run(() => list(options));
return list(options);
}

describe("compact table (default)", () => {
Expand Down
13 changes: 4 additions & 9 deletions packages/cli-core/src/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { test, expect, describe, afterEach, mock, spyOn } from "bun:test";
import { AuthError } from "../../lib/errors.ts";
import { captureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts";
import { useCaptureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts";

const actualConstants = await import("../../lib/constants.ts");
const actualEnvironment = await import("../../lib/environment.ts");
Expand Down Expand Up @@ -94,15 +94,10 @@ const { login } = await import("./login.ts");
describe("login", () => {
let consoleSpy: ReturnType<typeof spyOn>;
let consoleErrorSpy: ReturnType<typeof spyOn>;
let captured: ReturnType<typeof captureLog>;
const captured = useCaptureLog();
const origSpawn = Bun.spawn;

beforeEach(() => {
captured = captureLog();
});

afterEach(() => {
captured.teardown();
mockGetValidToken.mockReset();
mockStoreToken.mockReset();
mockCreateOAuthSession.mockReset();
Expand All @@ -129,7 +124,7 @@ describe("login", () => {
});

function runLogin(options?: Parameters<typeof login>[0]) {
return captured.run(() => login(options));
return login(options);
}

function mockBunSpawn() {
Expand Down
13 changes: 4 additions & 9 deletions packages/cli-core/src/commands/auth/logout.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { captureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts";
import { test, expect, describe, afterEach, mock, spyOn } from "bun:test";
import { useCaptureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts";

const mockDeleteToken = mock();
const mockClearAuth = mock();
Expand All @@ -18,21 +18,16 @@ const { logout } = await import("./logout.ts");

describe("logout", () => {
let consoleSpy: ReturnType<typeof spyOn>;
let captured: ReturnType<typeof captureLog>;

beforeEach(() => {
captured = captureLog();
});
const captured = useCaptureLog();

afterEach(() => {
captured.teardown();
mockDeleteToken.mockReset();
mockClearAuth.mockReset();
consoleSpy?.mockRestore();
});

function runLogout() {
return captured.run(() => logout());
return logout();
}

test("deletes token and clears auth config", async () => {
Expand Down
Loading
Loading