From 5fc06b9edcb718e7b1146f220eb4e460c115ec54 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Mar 2026 19:35:51 +0000 Subject: [PATCH 01/28] feat: add --dry-run flag to `sentry api` and `sentry project create` Preview what would happen without executing mutating operations. ## sentry api --dry-run Shows the fully resolved request without sending it: - Method, full URL (with resolved base + query params) - Headers and body - Supports --json for machine-readable output - Short alias: -n ## sentry project create --dry-run Validates inputs and shows what would be created: - Organization (resolved from args/config/DSN) - Team (auto-selected/created as normal) - Name, slug, platform - Does NOT call createProject or fetch DSN - Still validates platform and resolves org/team Also adds output: "json" to the api command for --json/--fields support (previously had no structured output mode). Fixes #349 --- src/commands/api.ts | 118 ++++++++++++++++++- src/commands/project/create.ts | 41 ++++++- test/commands/api.property.test.ts | 126 ++++++++++++++++++++ test/commands/api.test.ts | 167 +++++++++++++++++++++++++++ test/commands/project/create.test.ts | 89 ++++++++++++++ 5 files changed, 539 insertions(+), 2 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index ecb26171..3dbb02c5 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -6,10 +6,13 @@ */ import type { SentryContext } from "../context.js"; -import { rawApiRequest } from "../lib/api-client.js"; +import { buildSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; import { ValidationError } from "../lib/errors.js"; +import { muted } from "../lib/formatters/colors.js"; +import { writeJson } from "../lib/formatters/json.js"; import { validateEndpoint } from "../lib/input-validation.js"; +import { getApiBaseUrl } from "../lib/sentry-client.js"; import type { Writer } from "../types/index.js"; type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; @@ -24,6 +27,11 @@ type ApiFlags = { readonly include: boolean; readonly silent: boolean; readonly verbose: boolean; + readonly "dry-run": boolean; + /** Injected by buildCommand via output: "json" */ + readonly json: boolean; + /** Injected by buildCommand via output: "json" */ + readonly fields?: string[]; }; // Request Parsing @@ -908,6 +916,89 @@ export function writeVerboseResponse( stdout.write("<\n"); } +/** + * Resolve the full URL that rawApiRequest would use for a request. + * + * Mirrors the URL construction in rawApiRequest: + * `${baseUrl}/api/0/${endpoint}?${queryString}` + * @internal Exported for testing + */ +export function resolveRequestUrl( + endpoint: string, + params?: Record +): string { + const baseUrl = getApiBaseUrl(); + const normalizedEndpoint = endpoint.startsWith("/") + ? endpoint.slice(1) + : endpoint; + const searchParams = buildSearchParams(params); + const queryString = searchParams ? `?${searchParams.toString()}` : ""; + return `${baseUrl}/api/0/${normalizedEndpoint}${queryString}`; +} + +/** + * Dry-run request details — everything that would be sent without actually sending. + */ +type DryRunRequest = { + method: string; + url: string; + headers: Record; + body: unknown; +}; + +/** Components needed to build a dry-run request preview */ +type DryRunRequestInput = { + method: string; + endpoint: string; + params?: Record; + headers?: Record; + body?: unknown; +}; + +/** + * Build a DryRunRequest from the resolved request components. + * @internal Exported for testing + */ +export function buildDryRunRequest(input: DryRunRequestInput): DryRunRequest { + return { + method: input.method, + url: resolveRequestUrl(input.endpoint, input.params), + headers: input.headers ?? {}, + body: input.body ?? null, + }; +} + +/** + * Write dry-run output in human-readable format. + * @internal Exported for testing + */ +export function writeDryRunHuman(stdout: Writer, request: DryRunRequest): void { + stdout.write(`${muted("Dry run — no request sent.")}\n\n`); + stdout.write(` Method: ${request.method}\n`); + stdout.write(` URL: ${request.url}\n`); + + const headerEntries = Object.entries(request.headers); + if (headerEntries.length > 0) { + const [first, ...rest] = headerEntries; + if (first) { + stdout.write(` Headers: ${first[0]}: ${first[1]}\n`); + for (const [key, value] of rest) { + stdout.write(` ${key}: ${value}\n`); + } + } + } + + if (request.body !== null) { + const bodyStr = + typeof request.body === "string" + ? request.body + : JSON.stringify(request.body, null, 2); + stdout.write(` Body: ${bodyStr}\n`); + } + + stdout.write("\n"); +} + /** * Handle response output based on flags * @internal Exported for testing @@ -1066,6 +1157,7 @@ export async function resolveBody( // Command Definition export const apiCommand = buildCommand({ + output: "json", docs: { brief: "Make an authenticated API request", fullDescription: @@ -1158,6 +1250,11 @@ export const apiCommand = buildCommand({ brief: "Include full HTTP request and response in the output", default: false, }, + "dry-run": { + kind: "boolean", + brief: "Show the resolved request without sending it", + default: false, + }, }, aliases: { X: "method", @@ -1166,6 +1263,7 @@ export const apiCommand = buildCommand({ f: "raw-field", H: "header", i: "include", + n: "dry-run", }, }, async func( @@ -1186,6 +1284,24 @@ export const apiCommand = buildCommand({ ? parseHeaders(flags.header) : undefined; + // Dry-run mode: show the resolved request without sending it + if (flags["dry-run"]) { + const request = buildDryRunRequest({ + method: flags.method, + endpoint: normalizedEndpoint, + params, + headers, + body, + }); + + if (flags.json) { + writeJson(stdout, request, flags.fields); + } else { + writeDryRunHuman(stdout, request); + } + return; + } + // Verbose mode: show request details (unless silent) if (flags.verbose && !flags.silent) { writeVerboseRequest(stdout, flags.method, normalizedEndpoint, headers); diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index ec7c9dbd..f3637fc2 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -30,10 +30,12 @@ import { ContextError, withAuthGuard, } from "../../lib/errors.js"; +import { muted } from "../../lib/formatters/colors.js"; import { formatProjectCreated, type ProjectCreatedResult, } from "../../lib/formatters/human.js"; +import { writeJson } from "../../lib/formatters/json.js"; import { isPlainOutput } from "../../lib/formatters/markdown.js"; import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js"; import { renderTextTable } from "../../lib/formatters/text-table.js"; @@ -60,6 +62,7 @@ const USAGE_HINT = "sentry project create / "; type CreateFlags = { readonly team?: string; + readonly "dry-run": boolean; readonly json: boolean; readonly fields?: string[]; }; @@ -308,8 +311,14 @@ export const createCommand = buildCommand({ brief: "Team to create the project under", optional: true, }, + "dry-run": { + kind: "boolean", + brief: + "Validate inputs and show what would be created without creating it", + default: false, + }, }, - aliases: { t: "team" }, + aliases: { t: "team", n: "dry-run" }, }, async func( this: SentryContext, @@ -381,6 +390,36 @@ export const createCommand = buildCommand({ autoCreateSlug: slugify(name), }); + // Dry-run mode: show what would be created without creating it + if (flags["dry-run"]) { + const { stdout } = this; + const dryRunData = { + organization: orgSlug, + team: team.slug, + teamSource: team.source, + name, + slug: slugify(name), + platform, + }; + + if (flags.json) { + writeJson(stdout, dryRunData, flags.fields); + } else { + stdout.write(`${muted("Dry run — no project created.")}\n\n`); + stdout.write(` Organization: ${orgSlug}\n`); + stdout.write(` Team: ${team.slug}`); + if (team.source !== "explicit") { + stdout.write(` (${team.source})`); + } + stdout.write("\n"); + stdout.write(` Name: ${name}\n`); + stdout.write(` Slug: ${slugify(name)}\n`); + stdout.write(` Platform: ${platform}\n`); + stdout.write("\n"); + } + return; + } + // Create the project const project = await createProjectWithErrors({ orgSlug, diff --git a/test/commands/api.property.test.ts b/test/commands/api.property.test.ts index d15f83fa..66479e28 100644 --- a/test/commands/api.property.test.ts +++ b/test/commands/api.property.test.ts @@ -21,6 +21,7 @@ import { uniqueArray, } from "fast-check"; import { + buildDryRunRequest, buildFromFields, extractJsonBody, normalizeEndpoint, @@ -30,6 +31,7 @@ import { parseFieldValue, parseMethod, resolveBody, + resolveRequestUrl, setNestedValue, } from "../../src/commands/api.js"; import { ValidationError } from "../../src/lib/errors.js"; @@ -788,3 +790,127 @@ describe("property: resolveBody", () => { ); }); }); + +// Dry-run property tests + +/** Arbitrary for HTTP methods */ +const httpMethodArb = constantFrom("GET", "POST", "PUT", "DELETE", "PATCH"); + +/** Arbitrary for clean API endpoint paths (no query string, for dry-run tests) */ +const dryRunEndpointArb = stringMatching( + /^[a-z][a-z0-9-]*\/[a-z0-9-]*\/$/ +).filter((s) => s.length > 3 && s.length < 80); + +/** Arbitrary for query param values */ +const paramValueArb = stringMatching(/^[a-zA-Z0-9_-]+$/).filter( + (s) => s.length > 0 && s.length < 40 +); + +/** Arbitrary for query param maps */ +const paramsArb = dictionary( + stringMatching(/^[a-zA-Z][a-zA-Z0-9_]*$/).filter( + (s) => s.length > 0 && s.length < 20 + ), + paramValueArb, + { minKeys: 0, maxKeys: 3 } +); + +describe("property: resolveRequestUrl", () => { + test("always contains /api/0/ prefix", () => { + fcAssert( + property(dryRunEndpointArb, (endpoint) => { + const url = resolveRequestUrl(endpoint); + expect(url).toContain("/api/0/"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always contains the endpoint path", () => { + fcAssert( + property(dryRunEndpointArb, (endpoint) => { + const url = resolveRequestUrl(endpoint); + const normalized = endpoint.startsWith("/") + ? endpoint.slice(1) + : endpoint; + expect(url).toContain(normalized); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("includes query string when params provided", () => { + fcAssert( + property(dryRunEndpointArb, paramsArb, (endpoint, params) => { + const url = resolveRequestUrl(endpoint, params); + if (Object.keys(params).length > 0) { + expect(url).toContain("?"); + for (const key of Object.keys(params)) { + expect(url).toContain(key); + } + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: buildDryRunRequest", () => { + test("method is preserved exactly", () => { + fcAssert( + property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { + const request = buildDryRunRequest({ method, endpoint }); + expect(request.method).toBe(method); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("URL contains the endpoint", () => { + fcAssert( + property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { + const request = buildDryRunRequest({ method, endpoint }); + const normalized = endpoint.startsWith("/") + ? endpoint.slice(1) + : endpoint; + expect(request.url).toContain(normalized); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("headers default to empty object when undefined", () => { + fcAssert( + property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { + const request = buildDryRunRequest({ method, endpoint }); + expect(request.headers).toEqual({}); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("body defaults to null when undefined", () => { + fcAssert( + property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { + const request = buildDryRunRequest({ method, endpoint }); + expect(request.body).toBeNull(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("provided body is preserved", () => { + fcAssert( + property( + httpMethodArb, + dryRunEndpointArb, + jsonValue(), + (method, endpoint, body) => { + const request = buildDryRunRequest({ method, endpoint, body }); + expect(request.body).toEqual(body); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index c27a399c..6c39cf58 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -10,6 +10,7 @@ import { Readable } from "node:stream"; import { buildBodyFromFields, buildBodyFromInput, + buildDryRunRequest, buildFromFields, buildQueryParams, buildQueryParamsFromFields, @@ -26,7 +27,9 @@ import { prepareRequestOptions, readStdin, resolveBody, + resolveRequestUrl, setNestedValue, + writeDryRunHuman, writeResponseBody, writeResponseHeaders, writeVerboseRequest, @@ -1730,3 +1733,167 @@ describe("dataToQueryParams", () => { ).toThrow(ValidationError); }); }); + +// Dry-run tests + +describe("resolveRequestUrl", () => { + test("builds URL with base URL and endpoint", () => { + const url = resolveRequestUrl("organizations/"); + expect(url).toMatch(/\/api\/0\/organizations\/$/); + }); + + test("strips leading slash from endpoint", () => { + const url = resolveRequestUrl("/organizations/"); + expect(url).toMatch(/\/api\/0\/organizations\/$/); + }); + + test("appends query params", () => { + const url = resolveRequestUrl("issues/", { status: "unresolved" }); + expect(url).toContain("?status=unresolved"); + expect(url).toContain("/api/0/issues/"); + }); + + test("handles array params", () => { + const url = resolveRequestUrl("events/", { + field: ["title", "timestamp"], + }); + expect(url).toContain("field=title"); + expect(url).toContain("field=timestamp"); + }); + + test("omits query string when no params", () => { + const url = resolveRequestUrl("projects/"); + expect(url).not.toContain("?"); + }); +}); + +describe("buildDryRunRequest", () => { + test("builds request with all fields", () => { + const request = buildDryRunRequest({ + method: "POST", + endpoint: "issues/123/", + params: { status: "resolved" }, + headers: { "Content-Type": "application/json" }, + body: { status: "resolved" }, + }); + + expect(request.method).toBe("POST"); + expect(request.url).toContain("/api/0/issues/123/"); + expect(request.url).toContain("status=resolved"); + expect(request.headers).toEqual({ + "Content-Type": "application/json", + }); + expect(request.body).toEqual({ status: "resolved" }); + }); + + test("defaults headers to empty object", () => { + const request = buildDryRunRequest({ + method: "GET", + endpoint: "organizations/", + }); + + expect(request.headers).toEqual({}); + }); + + test("defaults body to null", () => { + const request = buildDryRunRequest({ + method: "GET", + endpoint: "organizations/", + }); + + expect(request.body).toBeNull(); + }); + + test("preserves string body", () => { + const request = buildDryRunRequest({ + method: "POST", + endpoint: "issues/", + body: '{"raw":"string"}', + }); + + expect(request.body).toBe('{"raw":"string"}'); + }); +}); + +describe("writeDryRunHuman", () => { + test("writes method and URL", () => { + const writer = createMockWriter(); + writeDryRunHuman(writer, { + method: "GET", + url: "https://sentry.io/api/0/organizations/", + headers: {}, + body: null, + }); + + expect(writer.output).toContain("Method: GET"); + expect(writer.output).toContain( + "URL: https://sentry.io/api/0/organizations/" + ); + expect(writer.output).toContain("Dry run"); + }); + + test("writes headers", () => { + const writer = createMockWriter(); + writeDryRunHuman(writer, { + method: "POST", + url: "https://sentry.io/api/0/issues/", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer token", + }, + body: null, + }); + + expect(writer.output).toContain("Content-Type: application/json"); + expect(writer.output).toContain("Authorization: Bearer token"); + }); + + test("writes JSON body formatted", () => { + const writer = createMockWriter(); + writeDryRunHuman(writer, { + method: "PUT", + url: "https://sentry.io/api/0/issues/123/", + headers: {}, + body: { status: "resolved" }, + }); + + expect(writer.output).toContain("Body:"); + expect(writer.output).toContain('"status": "resolved"'); + }); + + test("writes string body as-is", () => { + const writer = createMockWriter(); + writeDryRunHuman(writer, { + method: "POST", + url: "https://sentry.io/api/0/events/", + headers: {}, + body: "raw-body-content", + }); + + expect(writer.output).toContain("Body: raw-body-content"); + }); + + test("omits body when null", () => { + const writer = createMockWriter(); + writeDryRunHuman(writer, { + method: "GET", + url: "https://sentry.io/api/0/organizations/", + headers: {}, + body: null, + }); + + expect(writer.output).not.toContain("Body:"); + }); + + test("omits headers section when empty", () => { + const writer = createMockWriter(); + writeDryRunHuman(writer, { + method: "GET", + url: "https://sentry.io/api/0/organizations/", + headers: {}, + body: null, + }); + + expect(writer.output).not.toContain("Headers:"); + }); +}); diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index fb4b27d7..6f3ed00f 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -626,4 +626,93 @@ describe("project create", () => { expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("Invalid platform 'python-django-rest'"); }); + + // --dry-run tests + + test("dry-run shows what would be created without API call", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { json: false, "dry-run": true }, + "my-app", + "node" + ); + + // Should NOT call createProject + expect(createProjectSpy).not.toHaveBeenCalled(); + // Should NOT fetch DSN + expect(tryGetPrimaryDsnSpy).not.toHaveBeenCalled(); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Dry run"); + expect(output).toContain("acme-corp"); + expect(output).toContain("engineering"); + expect(output).toContain("my-app"); + expect(output).toContain("node"); + }); + + test("dry-run still validates platform", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call( + context, + { json: false, "dry-run": true }, + "my-app", + "invalid-platform" + ) + .catch((e: Error) => e); + expect(err).toBeInstanceOf(CliError); + expect(err.message).toContain("Invalid platform"); + }); + + test("dry-run still resolves org", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { json: false, "dry-run": true }, + "my-org/my-app", + "python" + ); + + expect(resolveOrgSpy).toHaveBeenCalledWith({ + org: "my-org", + cwd: "/tmp", + }); + }); + + test("dry-run outputs JSON when --json is set", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: true, "dry-run": true }, "my-app", "node"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.organization).toBe("acme-corp"); + expect(parsed.team).toBe("engineering"); + expect(parsed.name).toBe("my-app"); + expect(parsed.slug).toBe("my-app"); + expect(parsed.platform).toBe("node"); + + // Should NOT call createProject + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + + test("dry-run shows team source for auto-selected teams", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { json: false, "dry-run": true }, + "my-app", + "node" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + // Single team = auto-selected + expect(output).toContain("auto-selected"); + }); }); From 736860b5120a6bf35f1f64e3da48b5c8f99fc550 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Mar 2026 19:36:43 +0000 Subject: [PATCH 02/28] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4c3b4b6d..c8baff3e 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -163,6 +163,7 @@ Create a new project **Flags:** - `-t, --team - Team to create the project under` +- `-n, --dry-run - Validate inputs and show what would be created without creating it` - `--json - Output as JSON` - `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` @@ -398,6 +399,9 @@ Make an authenticated API request - `-i, --include - Include HTTP response status line and headers in the output` - `--silent - Do not print the response body` - `--verbose - Include full HTTP request and response in the output` +- `-n, --dry-run - Show the resolved request without sending it` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` **Examples:** From a6e26f50a2cdcd4203f51a5daf8e86be801f916e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Mar 2026 19:48:19 +0000 Subject: [PATCH 03/28] fix: address BugBot findings in dry-run implementation - Prevent team auto-creation side effect in dry-run mode by passing autoCreateSlug: undefined when --dry-run is set (BugBot HIGH) - Use getDefaultSdkConfig().baseUrl in resolveRequestUrl to match rawApiRequest URL normalization, preventing double-slash URLs when SENTRY_URL has trailing slash (BugBot MEDIUM) --- src/commands/api.ts | 6 ++++-- src/commands/project/create.ts | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 3dbb02c5..6eb6aa0c 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -12,7 +12,7 @@ import { ValidationError } from "../lib/errors.js"; import { muted } from "../lib/formatters/colors.js"; import { writeJson } from "../lib/formatters/json.js"; import { validateEndpoint } from "../lib/input-validation.js"; -import { getApiBaseUrl } from "../lib/sentry-client.js"; +import { getDefaultSdkConfig } from "../lib/sentry-client.js"; import type { Writer } from "../types/index.js"; type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; @@ -927,7 +927,9 @@ export function resolveRequestUrl( endpoint: string, params?: Record ): string { - const baseUrl = getApiBaseUrl(); + // Use getDefaultSdkConfig().baseUrl — same as rawApiRequest — to ensure + // trailing slashes are stripped and the URL matches what would be sent. + const { baseUrl } = getDefaultSdkConfig(); const normalizedEndpoint = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index f3637fc2..210e53cb 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -382,12 +382,12 @@ export const createCommand = buildCommand({ } const orgSlug = resolved.org; - // Resolve team — auto-creates a team if the org has none + // Resolve team — auto-creates a team if the org has none (skipped in dry-run) const team: ResolvedTeam = await resolveOrCreateTeam(orgSlug, { team: flags.team, detectedFrom: resolved.detectedFrom, usageHint: USAGE_HINT, - autoCreateSlug: slugify(name), + autoCreateSlug: flags["dry-run"] ? undefined : slugify(name), }); // Dry-run mode: show what would be created without creating it From 8f9b2e00fd29a9fdf32e8ede1d14997606fcd947 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Mar 2026 20:23:52 +0000 Subject: [PATCH 04/28] fix: align multiline JSON body indentation in dry-run output Continuation lines of JSON.stringify output are now indented to align with the first line after the 'Body: ' label prefix. Also uses getDefaultSdkConfig().baseUrl (with trailing-slash normalization) instead of raw getApiBaseUrl() in resolveRequestUrl to match the URL rawApiRequest would actually use. --- src/commands/api.ts | 4 +++- test/commands/api.test.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 6eb6aa0c..c7d745cc 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -995,7 +995,9 @@ export function writeDryRunHuman(stdout: Writer, request: DryRunRequest): void { typeof request.body === "string" ? request.body : JSON.stringify(request.body, null, 2); - stdout.write(` Body: ${bodyStr}\n`); + // Indent continuation lines to align with the first line after "Body: " + const indented = bodyStr.replace(/\n/g, "\n "); + stdout.write(` Body: ${indented}\n`); } stdout.write("\n"); diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 6c39cf58..dc13b5b0 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -1861,6 +1861,26 @@ describe("writeDryRunHuman", () => { expect(writer.output).toContain('"status": "resolved"'); }); + test("multiline JSON body has aligned indentation", () => { + const writer = createMockWriter(); + writeDryRunHuman(writer, { + method: "POST", + url: "https://sentry.io/api/0/issues/", + headers: {}, + body: { status: "resolved", assignedTo: "user:123" }, + }); + + // Each continuation line of the JSON body should be indented to align + // with the first line (12 spaces = " Body: " prefix width) + const lines = writer.output.split("\n"); + const bodyLineIdx = lines.findIndex((l) => l.includes("Body:")); + expect(bodyLineIdx).toBeGreaterThan(-1); + // The JSON is multiline, so check that the next line starts with spaces + const nextLine = lines[bodyLineIdx + 1]; + expect(nextLine).toBeDefined(); + expect(nextLine!.startsWith(" ")).toBe(true); + }); + test("writes string body as-is", () => { const writer = createMockWriter(); writeDryRunHuman(writer, { From ba7e070ebbc3d62c98cec5faf924b4fad61d8a88 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Mar 2026 20:28:48 +0000 Subject: [PATCH 05/28] fix: dry-run shows would-be auto-created team instead of erroring When an org has no teams, normal mode auto-creates a team. Dry-run now reflects this by returning { slug, source: 'auto-created' } without calling the createTeam API. Previously dry-run passed autoCreateSlug: undefined which caused a ContextError. Now passes dryRun: true to resolveOrCreateTeam which skips the mutation while preserving the preview. --- src/commands/project/create.ts | 5 ++-- src/lib/resolve-team.ts | 36 +++++++++++++++++++++------- test/commands/project/create.test.ts | 23 ++++++++++++++++++ 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 210e53cb..f6664089 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -382,12 +382,13 @@ export const createCommand = buildCommand({ } const orgSlug = resolved.org; - // Resolve team — auto-creates a team if the org has none (skipped in dry-run) + // Resolve team — auto-creates a team if the org has none const team: ResolvedTeam = await resolveOrCreateTeam(orgSlug, { team: flags.team, detectedFrom: resolved.detectedFrom, usageHint: USAGE_HINT, - autoCreateSlug: flags["dry-run"] ? undefined : slugify(name), + autoCreateSlug: slugify(name), + dryRun: flags["dry-run"], }); // Dry-run mode: show what would be created without creating it diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts index 52830623..ab4b4e2d 100644 --- a/src/lib/resolve-team.ts +++ b/src/lib/resolve-team.ts @@ -65,6 +65,12 @@ export type ResolveTeamOptions = { * If not provided and the org has zero teams, an error is thrown instead. */ autoCreateSlug?: string; + /** + * When true, skip the actual team creation API call and return what + * would be created. The returned ResolvedTeam has source "auto-created" + * with the autoCreateSlug value. + */ + dryRun?: boolean; }; /** Result of team resolution, including how the team was determined */ @@ -116,14 +122,7 @@ export async function resolveOrCreateTeam( // No teams — auto-create one if a slug was provided if (teams.length === 0) { - if (options.autoCreateSlug) { - return await autoCreateTeam(orgSlug, options.autoCreateSlug); - } - const teamsUrl = `${getSentryBaseUrl()}/settings/${orgSlug}/teams/`; - throw new ContextError("Team", `${options.usageHint} --team `, [ - `No teams found in org '${orgSlug}'`, - `Create a team at ${teamsUrl}`, - ]); + return resolveEmptyTeams(orgSlug, options); } // Single team — auto-select @@ -155,6 +154,27 @@ export async function resolveOrCreateTeam( ); } +/** + * Handle the case when an org has zero teams. + * Either auto-creates a team, returns a dry-run preview, or throws. + */ +function resolveEmptyTeams( + orgSlug: string, + options: ResolveTeamOptions +): Promise | ResolvedTeam { + if (!options.autoCreateSlug) { + const teamsUrl = `${getSentryBaseUrl()}/settings/${orgSlug}/teams/`; + throw new ContextError("Team", `${options.usageHint} --team `, [ + `No teams found in org '${orgSlug}'`, + `Create a team at ${teamsUrl}`, + ]); + } + if (options.dryRun) { + return { slug: options.autoCreateSlug, source: "auto-created" }; + } + return autoCreateTeam(orgSlug, options.autoCreateSlug); +} + /** * Auto-create a team in an org that has no teams. * Uses the provided slug as the team name. diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 6f3ed00f..658f20e4 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -715,4 +715,27 @@ describe("project create", () => { // Single team = auto-selected expect(output).toContain("auto-selected"); }); + + test("dry-run with no teams shows auto-created team without creating it", async () => { + listTeamsSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { json: false, "dry-run": true }, + "my-app", + "node" + ); + + // Should NOT call createTeam + expect(createTeamSpy).not.toHaveBeenCalled(); + // Should NOT call createProject + expect(createProjectSpy).not.toHaveBeenCalled(); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Dry run"); + expect(output).toContain("my-app"); + expect(output).toContain("auto-created"); + }); }); From 6c4a191cb0b06d670fbb718f22491d9d60074bb4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Mar 2026 20:35:49 +0000 Subject: [PATCH 06/28] fix: remove output: json from api command, add explicit --json flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The api command is a raw API proxy — the response is already JSON from the Sentry API. Having output: 'json' from buildCommand injected --json and --fields flags that only worked in --dry-run mode, which was confusing. Now --json is an explicit flag documented as applying to dry-run preview output only. Removed --fields since it doesn't apply. --- src/commands/api.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index c7d745cc..df178ab9 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -28,10 +28,8 @@ type ApiFlags = { readonly silent: boolean; readonly verbose: boolean; readonly "dry-run": boolean; - /** Injected by buildCommand via output: "json" */ + /** Output dry-run preview as JSON instead of human-readable */ readonly json: boolean; - /** Injected by buildCommand via output: "json" */ - readonly fields?: string[]; }; // Request Parsing @@ -1161,7 +1159,6 @@ export async function resolveBody( // Command Definition export const apiCommand = buildCommand({ - output: "json", docs: { brief: "Make an authenticated API request", fullDescription: @@ -1259,6 +1256,11 @@ export const apiCommand = buildCommand({ brief: "Show the resolved request without sending it", default: false, }, + json: { + kind: "boolean", + brief: "Output dry-run preview as machine-readable JSON", + default: false, + }, }, aliases: { X: "method", @@ -1299,7 +1301,7 @@ export const apiCommand = buildCommand({ }); if (flags.json) { - writeJson(stdout, request, flags.fields); + writeJson(stdout, request); } else { writeDryRunHuman(stdout, request); } From 6bd2e8e2d30b337c6a4c9df1d459ae6d4d6664fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Mar 2026 20:36:20 +0000 Subject: [PATCH 07/28] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index c8baff3e..c6b14038 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -400,8 +400,7 @@ Make an authenticated API request - `--silent - Do not print the response body` - `--verbose - Include full HTTP request and response in the output` - `-n, --dry-run - Show the resolved request without sending it` -- `--json - Output as JSON` -- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` +- `--json - Output dry-run preview as machine-readable JSON` **Examples:** From d02f40b0dbcb6c2498d870cd334bce3f0a6fd3e2 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Mar 2026 20:49:17 +0000 Subject: [PATCH 08/28] fix: restore output: json and support --fields on api response --fields is useful for filtering both the raw API response and dry-run output. Restored output: 'json' on apiCommand so buildCommand injects --json/--fields flags. handleResponse now applies --fields filtering to the response body when provided. --- src/commands/api.ts | 27 +++++++++++++++++---------- test/commands/api.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index df178ab9..c3e83355 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -28,8 +28,10 @@ type ApiFlags = { readonly silent: boolean; readonly verbose: boolean; readonly "dry-run": boolean; - /** Output dry-run preview as JSON instead of human-readable */ + /** Injected by buildCommand via output: "json" */ readonly json: boolean; + /** Injected by buildCommand via output: "json" */ + readonly fields?: string[]; }; // Request Parsing @@ -1008,7 +1010,12 @@ export function writeDryRunHuman(stdout: Writer, request: DryRunRequest): void { export function handleResponse( stdout: Writer, response: { status: number; headers: Headers; body: unknown }, - flags: { silent: boolean; verbose: boolean; include: boolean } + flags: { + silent: boolean; + verbose: boolean; + include: boolean; + fields?: string[]; + } ): void { const isError = response.status >= 400; @@ -1027,8 +1034,12 @@ export function handleResponse( writeResponseHeaders(stdout, response.status, response.headers); } - // Output body - writeResponseBody(stdout, response.body); + // Output body — apply --fields filtering when requested + if (flags.fields && flags.fields.length > 0) { + writeJson(stdout, response.body, flags.fields); + } else { + writeResponseBody(stdout, response.body); + } // Exit with error code for error responses if (isError) { @@ -1159,6 +1170,7 @@ export async function resolveBody( // Command Definition export const apiCommand = buildCommand({ + output: "json", docs: { brief: "Make an authenticated API request", fullDescription: @@ -1256,11 +1268,6 @@ export const apiCommand = buildCommand({ brief: "Show the resolved request without sending it", default: false, }, - json: { - kind: "boolean", - brief: "Output dry-run preview as machine-readable JSON", - default: false, - }, }, aliases: { X: "method", @@ -1301,7 +1308,7 @@ export const apiCommand = buildCommand({ }); if (flags.json) { - writeJson(stdout, request); + writeJson(stdout, request, flags.fields); } else { writeDryRunHuman(stdout, request); } diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index dc13b5b0..47884c8f 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -1280,6 +1280,30 @@ describe("handleResponse", () => { process.exit = originalExit; } }); + + test("filters response body with --fields", () => { + const writer = createMockWriter(); + const response = { + status: 200, + headers: new Headers(), + body: { + id: "123", + name: "my-project", + slug: "my-project", + platform: "node", + }, + }; + + handleResponse(writer, response, { + silent: false, + verbose: false, + include: false, + fields: ["id", "name"], + }); + + const parsed = JSON.parse(writer.output); + expect(parsed).toEqual({ id: "123", name: "my-project" }); + }); }); // --data/-d and JSON auto-detection (CLI-AF) From 597404a2d09262d4a196c2bfb051918dcdb1c876 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Mar 2026 20:50:07 +0000 Subject: [PATCH 09/28] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index c6b14038..c8baff3e 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -400,7 +400,8 @@ Make an authenticated API request - `--silent - Do not print the response body` - `--verbose - Include full HTTP request and response in the output` - `-n, --dry-run - Show the resolved request without sending it` -- `--json - Output dry-run preview as machine-readable JSON` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` **Examples:** From 2d891cbf63a56eb89d45a03d0962ce12824a13bf Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Mar 2026 21:24:02 +0000 Subject: [PATCH 10/28] refactor: use writeOutput for dry-run in project create Replace manual flags.json/writeJson branching with writeOutput(), which is the shared utility that handles --json/--fields/human formatting. Extract formatDryRun as a pure function for the human-readable dry-run preview. --- src/commands/project/create.ts | 48 ++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index f6664089..1033d8a1 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -35,7 +35,32 @@ import { formatProjectCreated, type ProjectCreatedResult, } from "../../lib/formatters/human.js"; -import { writeJson } from "../../lib/formatters/json.js"; +import { writeOutput } from "../../lib/formatters/output.js"; + +type DryRunData = { + organization: string; + team: string; + teamSource: string; + name: string; + slug: string; + platform: string; +}; + +/** Format dry-run preview as human-readable text */ +function formatDryRun(data: DryRunData): string { + const lines: string[] = []; + lines.push(muted("Dry run — no project created.")); + lines.push(""); + lines.push(` Organization: ${data.organization}`); + const teamSuffix = + data.teamSource !== "explicit" ? ` (${data.teamSource})` : ""; + lines.push(` Team: ${data.team}${teamSuffix}`); + lines.push(` Name: ${data.name}`); + lines.push(` Slug: ${data.slug}`); + lines.push(` Platform: ${data.platform}`); + return lines.join("\n"); +} + import { isPlainOutput } from "../../lib/formatters/markdown.js"; import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js"; import { renderTextTable } from "../../lib/formatters/text-table.js"; @@ -393,7 +418,6 @@ export const createCommand = buildCommand({ // Dry-run mode: show what would be created without creating it if (flags["dry-run"]) { - const { stdout } = this; const dryRunData = { organization: orgSlug, team: team.slug, @@ -403,21 +427,11 @@ export const createCommand = buildCommand({ platform, }; - if (flags.json) { - writeJson(stdout, dryRunData, flags.fields); - } else { - stdout.write(`${muted("Dry run — no project created.")}\n\n`); - stdout.write(` Organization: ${orgSlug}\n`); - stdout.write(` Team: ${team.slug}`); - if (team.source !== "explicit") { - stdout.write(` (${team.source})`); - } - stdout.write("\n"); - stdout.write(` Name: ${name}\n`); - stdout.write(` Slug: ${slugify(name)}\n`); - stdout.write(` Platform: ${platform}\n`); - stdout.write("\n"); - } + writeOutput(this.stdout, dryRunData, { + json: flags.json, + fields: flags.fields, + formatHuman: formatDryRun, + }); return; } From 7f8df475d80284a23b9a884d4f8e66560926126a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Mar 2026 21:33:08 +0000 Subject: [PATCH 11/28] fix: dry-run includes auto-inferred Content-Type header Mirror rawApiRequest behavior: when body is an object and no Content-Type header was explicitly provided, auto-add Content-Type: application/json in buildDryRunRequest. --- src/commands/api.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index c3e83355..d549a60d 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -962,10 +962,23 @@ type DryRunRequestInput = { * @internal Exported for testing */ export function buildDryRunRequest(input: DryRunRequestInput): DryRunRequest { + const headers = { ...(input.headers ?? {}) }; + + // Mirror rawApiRequest: auto-add Content-Type for object bodies + // when no Content-Type was explicitly provided + if ( + input.body !== undefined && + input.body !== null && + typeof input.body !== "string" && + !Object.keys(headers).some((k) => k.toLowerCase() === "content-type") + ) { + headers["Content-Type"] = "application/json"; + } + return { method: input.method, url: resolveRequestUrl(input.endpoint, input.params), - headers: input.headers ?? {}, + headers, body: input.body ?? null, }; } From 1e91caa7b6d63f1afac3eb334b9ddc924ebbe1cf Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Mar 2026 21:40:55 +0000 Subject: [PATCH 12/28] fix: move DryRunData type and formatDryRun after all imports Biome's auto-fix placed the type and function between import statements. Move them after all imports to maintain standard module structure. --- src/commands/project/create.ts | 47 +++++++++++++++++----------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 1033d8a1..03654164 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -35,7 +35,30 @@ import { formatProjectCreated, type ProjectCreatedResult, } from "../../lib/formatters/human.js"; +import { isPlainOutput } from "../../lib/formatters/markdown.js"; import { writeOutput } from "../../lib/formatters/output.js"; +import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js"; +import { renderTextTable } from "../../lib/formatters/text-table.js"; +import { logger } from "../../lib/logger.js"; +import { + COMMON_PLATFORMS, + isValidPlatform, + suggestPlatform, +} from "../../lib/platforms.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { + buildOrgNotFoundError, + type ResolvedTeam, + resolveOrCreateTeam, +} from "../../lib/resolve-team.js"; +import { buildProjectUrl } from "../../lib/sentry-urls.js"; +import { slugify } from "../../lib/utils.js"; +import type { SentryProject } from "../../types/index.js"; + +const log = logger.withTag("project.create"); + +/** Usage hint template — base command without positionals */ +const USAGE_HINT = "sentry project create / "; type DryRunData = { organization: string; @@ -61,30 +84,6 @@ function formatDryRun(data: DryRunData): string { return lines.join("\n"); } -import { isPlainOutput } from "../../lib/formatters/markdown.js"; -import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js"; -import { renderTextTable } from "../../lib/formatters/text-table.js"; -import { logger } from "../../lib/logger.js"; -import { - COMMON_PLATFORMS, - isValidPlatform, - suggestPlatform, -} from "../../lib/platforms.js"; -import { resolveOrg } from "../../lib/resolve-target.js"; -import { - buildOrgNotFoundError, - type ResolvedTeam, - resolveOrCreateTeam, -} from "../../lib/resolve-team.js"; -import { buildProjectUrl } from "../../lib/sentry-urls.js"; -import { slugify } from "../../lib/utils.js"; -import type { SentryProject } from "../../types/index.js"; - -const log = logger.withTag("project.create"); - -/** Usage hint template — base command without positionals */ -const USAGE_HINT = "sentry project create / "; - type CreateFlags = { readonly team?: string; readonly "dry-run": boolean; From 849aad2816c6b478dea0e970fe966ea73b3aeaf2 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Mar 2026 21:44:36 +0000 Subject: [PATCH 13/28] refactor: use mdKvTable for dry-run output in project create Standardize dry-run human output to use the same markdown KV table pattern as formatProjectCreated. Uses ## heading, > blockquote notes for team source, and mdKvTable for key-value data. Removes custom manual alignment formatting. Also fixes interleaved imports: DryRunData type and formatDryRun function are now placed after all import statements. --- src/commands/project/create.ts | 49 +++++++++++++++++++++------- test/commands/project/create.test.ts | 6 ++-- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 03654164..4b8c91eb 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -30,12 +30,17 @@ import { ContextError, withAuthGuard, } from "../../lib/errors.js"; -import { muted } from "../../lib/formatters/colors.js"; import { formatProjectCreated, type ProjectCreatedResult, } from "../../lib/formatters/human.js"; -import { isPlainOutput } from "../../lib/formatters/markdown.js"; +import { + escapeMarkdownInline, + isPlainOutput, + mdKvTable, + renderMarkdown, + safeCodeSpan, +} from "../../lib/formatters/markdown.js"; import { writeOutput } from "../../lib/formatters/output.js"; import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js"; import { renderTextTable } from "../../lib/formatters/text-table.js"; @@ -69,19 +74,39 @@ type DryRunData = { platform: string; }; -/** Format dry-run preview as human-readable text */ +/** Format dry-run preview as human-readable markdown */ function formatDryRun(data: DryRunData): string { const lines: string[] = []; - lines.push(muted("Dry run — no project created.")); + + lines.push( + `## Dry run — project '${escapeMarkdownInline(data.name)}' in ${escapeMarkdownInline(data.organization)}` + ); lines.push(""); - lines.push(` Organization: ${data.organization}`); - const teamSuffix = - data.teamSource !== "explicit" ? ` (${data.teamSource})` : ""; - lines.push(` Team: ${data.team}${teamSuffix}`); - lines.push(` Name: ${data.name}`); - lines.push(` Slug: ${data.slug}`); - lines.push(` Platform: ${data.platform}`); - return lines.join("\n"); + + // Team source notes (same pattern as formatProjectCreated) + if (data.teamSource === "auto-created") { + lines.push( + `> **Note:** Would create team '${escapeMarkdownInline(data.team)}' (org has no teams).` + ); + lines.push(""); + } else if (data.teamSource === "auto-selected") { + lines.push( + `> **Note:** Would use team '${escapeMarkdownInline(data.team)}'. See all teams: \`sentry team list\`` + ); + lines.push(""); + } + + const kvRows: [string, string][] = [ + ["Name", escapeMarkdownInline(data.name)], + ["Slug", safeCodeSpan(data.slug)], + ["Org", safeCodeSpan(data.organization)], + ["Team", safeCodeSpan(data.team)], + ["Platform", data.platform], + ]; + + lines.push(mdKvTable(kvRows)); + + return renderMarkdown(lines.join("\n")); } type CreateFlags = { diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 658f20e4..545092aa 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -712,8 +712,8 @@ describe("project create", () => { ); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); - // Single team = auto-selected - expect(output).toContain("auto-selected"); + // Single team = auto-selected → note about team usage + expect(output).toContain("Would use team"); }); test("dry-run with no teams shows auto-created team without creating it", async () => { @@ -736,6 +736,6 @@ describe("project create", () => { const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Dry run"); expect(output).toContain("my-app"); - expect(output).toContain("auto-created"); + expect(output).toContain("Would create team"); }); }); From d4a7de3555350b7ae4638282c44e105461e3c08c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 09:33:30 +0000 Subject: [PATCH 14/28] refactor: unify dry-run and normal output in project create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both paths now construct a ProjectCreatedResult and return { data } through buildCommand's output wrapper. The single formatProjectCreated formatter handles both modes — dry-run adds a dryRun flag that adjusts the heading and team source note wording. Removes DryRunData type, formatDryRun function, and writeOutput import. Net -32 lines. --- src/commands/project/create.ts | 81 +++++----------------------- src/lib/formatters/human.ts | 44 ++++++++++----- test/commands/project/create.test.ts | 13 +++-- 3 files changed, 53 insertions(+), 85 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 4b8c91eb..19632ec6 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -34,14 +34,7 @@ import { formatProjectCreated, type ProjectCreatedResult, } from "../../lib/formatters/human.js"; -import { - escapeMarkdownInline, - isPlainOutput, - mdKvTable, - renderMarkdown, - safeCodeSpan, -} from "../../lib/formatters/markdown.js"; -import { writeOutput } from "../../lib/formatters/output.js"; +import { isPlainOutput } from "../../lib/formatters/markdown.js"; import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js"; import { renderTextTable } from "../../lib/formatters/text-table.js"; import { logger } from "../../lib/logger.js"; @@ -65,50 +58,6 @@ const log = logger.withTag("project.create"); /** Usage hint template — base command without positionals */ const USAGE_HINT = "sentry project create / "; -type DryRunData = { - organization: string; - team: string; - teamSource: string; - name: string; - slug: string; - platform: string; -}; - -/** Format dry-run preview as human-readable markdown */ -function formatDryRun(data: DryRunData): string { - const lines: string[] = []; - - lines.push( - `## Dry run — project '${escapeMarkdownInline(data.name)}' in ${escapeMarkdownInline(data.organization)}` - ); - lines.push(""); - - // Team source notes (same pattern as formatProjectCreated) - if (data.teamSource === "auto-created") { - lines.push( - `> **Note:** Would create team '${escapeMarkdownInline(data.team)}' (org has no teams).` - ); - lines.push(""); - } else if (data.teamSource === "auto-selected") { - lines.push( - `> **Note:** Would use team '${escapeMarkdownInline(data.team)}'. See all teams: \`sentry team list\`` - ); - lines.push(""); - } - - const kvRows: [string, string][] = [ - ["Name", escapeMarkdownInline(data.name)], - ["Slug", safeCodeSpan(data.slug)], - ["Org", safeCodeSpan(data.organization)], - ["Team", safeCodeSpan(data.team)], - ["Platform", data.platform], - ]; - - lines.push(mdKvTable(kvRows)); - - return renderMarkdown(lines.join("\n")); -} - type CreateFlags = { readonly team?: string; readonly "dry-run": boolean; @@ -440,23 +389,23 @@ export const createCommand = buildCommand({ dryRun: flags["dry-run"], }); + const expectedSlug = slugify(name); + // Dry-run mode: show what would be created without creating it if (flags["dry-run"]) { - const dryRunData = { - organization: orgSlug, - team: team.slug, + const result: ProjectCreatedResult = { + project: { id: "", slug: expectedSlug, name, platform }, + orgSlug, + teamSlug: team.slug, teamSource: team.source, - name, - slug: slugify(name), - platform, + requestedPlatform: platform, + dsn: null, + url: "", + slugDiverged: false, + expectedSlug, + dryRun: true, }; - - writeOutput(this.stdout, dryRunData, { - json: flags.json, - fields: flags.fields, - formatHuman: formatDryRun, - }); - return; + return { data: result }; } // Create the project @@ -471,8 +420,6 @@ export const createCommand = buildCommand({ // Fetch DSN (best-effort) const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); - const expectedSlug = slugify(name); - const result: ProjectCreatedResult = { project, orgSlug, diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 4979c10b..910084c8 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1631,6 +1631,8 @@ export type ProjectCreatedResult = { slugDiverged: boolean; /** The slug the user expected (derived from the project name) */ expectedSlug: string; + /** When true, nothing was actually created — output uses tentative wording */ + dryRun?: boolean; }; /** @@ -1644,13 +1646,19 @@ export type ProjectCreatedResult = { */ export function formatProjectCreated(result: ProjectCreatedResult): string { const lines: string[] = []; + const dry = result.dryRun === true; + const nameEsc = escapeMarkdownInline(result.project.name); + const orgEsc = escapeMarkdownInline(result.orgSlug); - lines.push( - `## Created project '${escapeMarkdownInline(result.project.name)}' in ${escapeMarkdownInline(result.orgSlug)}` - ); + // Heading + if (dry) { + lines.push(`## Dry run — project '${nameEsc}' in ${orgEsc}`); + } else { + lines.push(`## Created project '${nameEsc}' in ${orgEsc}`); + } lines.push(""); - // Slug divergence note + // Slug divergence note (never applies in dry-run — we can't predict server renames) if (result.slugDiverged) { lines.push( `> **Note:** Slug \`${result.project.slug}\` was assigned because \`${result.expectedSlug}\` is already taken.` @@ -1658,21 +1666,25 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { lines.push(""); } - // Team source notes + // Team source notes — tentative wording in dry-run if (result.teamSource === "auto-created") { lines.push( - `> **Note:** Created team '${escapeMarkdownInline(result.teamSlug)}' (org had no teams).` + dry + ? `> **Note:** Would create team '${escapeMarkdownInline(result.teamSlug)}' (org has no teams).` + : `> **Note:** Created team '${escapeMarkdownInline(result.teamSlug)}' (org had no teams).` ); lines.push(""); } else if (result.teamSource === "auto-selected") { lines.push( - `> **Note:** Using team '${escapeMarkdownInline(result.teamSlug)}'. See all teams: \`sentry team list\`` + dry + ? `> **Note:** Would use team '${escapeMarkdownInline(result.teamSlug)}'. See all teams: \`sentry team list\`` + : `> **Note:** Using team '${escapeMarkdownInline(result.teamSlug)}'. See all teams: \`sentry team list\`` ); lines.push(""); } const kvRows: [string, string][] = [ - ["Project", escapeMarkdownInline(result.project.name)], + ["Project", nameEsc], ["Slug", safeCodeSpan(result.project.slug)], ["Org", safeCodeSpan(result.orgSlug)], ["Team", safeCodeSpan(result.teamSlug)], @@ -1681,13 +1693,19 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { if (result.dsn) { kvRows.push(["DSN", safeCodeSpan(result.dsn)]); } - kvRows.push(["URL", result.url]); + if (result.url) { + kvRows.push(["URL", result.url]); + } lines.push(mdKvTable(kvRows)); - lines.push(""); - lines.push( - `*Tip: Use \`sentry project view ${result.orgSlug}/${result.project.slug}\` for details*` - ); + + // Tip footer — only when a real project exists to view + if (!dry) { + lines.push(""); + lines.push( + `*Tip: Use \`sentry project view ${result.orgSlug}/${result.project.slug}\` for details*` + ); + } return renderMarkdown(lines.join("\n")); } diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 545092aa..2671e26f 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -691,11 +691,14 @@ describe("project create", () => { const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); - expect(parsed.organization).toBe("acme-corp"); - expect(parsed.team).toBe("engineering"); - expect(parsed.name).toBe("my-app"); - expect(parsed.slug).toBe("my-app"); - expect(parsed.platform).toBe("node"); + // Same ProjectCreatedResult shape as normal path + expect(parsed.orgSlug).toBe("acme-corp"); + expect(parsed.teamSlug).toBe("engineering"); + expect(parsed.project.name).toBe("my-app"); + expect(parsed.project.slug).toBe("my-app"); + expect(parsed.project.platform).toBe("node"); + expect(parsed.dsn).toBeNull(); + expect(parsed.dryRun).toBe(true); // Should NOT call createProject expect(createProjectSpy).not.toHaveBeenCalled(); From 1617ab312b511bd14b759f2b311d42379ae7f662 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 09:44:57 +0000 Subject: [PATCH 15/28] refactor: api dry-run returns { data } through output wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade output: 'json' → { json: true, human: formatDryRunRequest } so dry-run returns { data } like project create (normal path returns void, which the wrapper silently ignores) - Convert writeDryRunHuman to formatDryRunRequest: pure function returning rendered markdown using mdKvTable (same pattern as other commands), replaces imperative stdout.write calls - Simplify buildDryRunRequest signature: (method, endpoint, options) instead of single object — mirrors how rawApiRequest is called - Remove muted import (now uses color tag in markdown) --- src/commands/api.ts | 113 ++++++++++----------- test/commands/api.property.test.ts | 10 +- test/commands/api.test.ts | 152 +++++++++++------------------ 3 files changed, 119 insertions(+), 156 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index d549a60d..31f6b68a 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -9,8 +9,13 @@ import type { SentryContext } from "../context.js"; import { buildSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; import { ValidationError } from "../lib/errors.js"; -import { muted } from "../lib/formatters/colors.js"; import { writeJson } from "../lib/formatters/json.js"; +import { + escapeMarkdownInline, + mdKvTable, + renderMarkdown, + safeCodeSpan, +} from "../lib/formatters/markdown.js"; import { validateEndpoint } from "../lib/input-validation.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; import type { Writer } from "../types/index.js"; @@ -941,66 +946,76 @@ export function resolveRequestUrl( /** * Dry-run request details — everything that would be sent without actually sending. */ -type DryRunRequest = { +export type DryRunRequest = { method: string; url: string; headers: Record; body: unknown; }; -/** Components needed to build a dry-run request preview */ -type DryRunRequestInput = { - method: string; - endpoint: string; - params?: Record; - headers?: Record; - body?: unknown; -}; - /** * Build a DryRunRequest from the resolved request components. + * + * Mirrors the URL and header logic from rawApiRequest so the + * preview matches what the real request would look like. + * * @internal Exported for testing */ -export function buildDryRunRequest(input: DryRunRequestInput): DryRunRequest { - const headers = { ...(input.headers ?? {}) }; +export function buildDryRunRequest( + method: string, + endpoint: string, + options: { + params?: Record; + headers?: Record; + body?: unknown; + } +): DryRunRequest { + const headers = { ...(options.headers ?? {}) }; // Mirror rawApiRequest: auto-add Content-Type for object bodies // when no Content-Type was explicitly provided if ( - input.body !== undefined && - input.body !== null && - typeof input.body !== "string" && + options.body !== undefined && + options.body !== null && + typeof options.body !== "string" && !Object.keys(headers).some((k) => k.toLowerCase() === "content-type") ) { headers["Content-Type"] = "application/json"; } return { - method: input.method, - url: resolveRequestUrl(input.endpoint, input.params), + method, + url: resolveRequestUrl(endpoint, options.params), headers, - body: input.body ?? null, + body: options.body ?? null, }; } /** - * Write dry-run output in human-readable format. + * Format a dry-run request preview as rendered markdown. + * + * Uses the standard mdKvTable layout for consistency with other commands. + * * @internal Exported for testing */ -export function writeDryRunHuman(stdout: Writer, request: DryRunRequest): void { - stdout.write(`${muted("Dry run — no request sent.")}\n\n`); - stdout.write(` Method: ${request.method}\n`); - stdout.write(` URL: ${request.url}\n`); +export function formatDryRunRequest(request: DryRunRequest): string { + const lines: string[] = []; + lines.push( + `## Dry run — ${escapeMarkdownInline(request.method)} ${escapeMarkdownInline(request.url)}` + ); + lines.push(""); + + const kvRows: [string, string][] = [ + ["Method", request.method], + ["URL", request.url], + ]; const headerEntries = Object.entries(request.headers); if (headerEntries.length > 0) { - const [first, ...rest] = headerEntries; - if (first) { - stdout.write(` Headers: ${first[0]}: ${first[1]}\n`); - for (const [key, value] of rest) { - stdout.write(` ${key}: ${value}\n`); - } - } + kvRows.push([ + "Headers", + headerEntries.map(([k, v]) => `${k}: ${v}`).join(", "), + ]); } if (request.body !== null) { @@ -1008,12 +1023,11 @@ export function writeDryRunHuman(stdout: Writer, request: DryRunRequest): void { typeof request.body === "string" ? request.body : JSON.stringify(request.body, null, 2); - // Indent continuation lines to align with the first line after "Body: " - const indented = bodyStr.replace(/\n/g, "\n "); - stdout.write(` Body: ${indented}\n`); + kvRows.push(["Body", safeCodeSpan(bodyStr)]); } - stdout.write("\n"); + lines.push(mdKvTable(kvRows)); + return renderMarkdown(lines.join("\n")); } /** @@ -1183,7 +1197,7 @@ export async function resolveBody( // Command Definition export const apiCommand = buildCommand({ - output: "json", + output: { json: true, human: formatDryRunRequest }, docs: { brief: "Make an authenticated API request", fullDescription: @@ -1292,11 +1306,7 @@ export const apiCommand = buildCommand({ n: "dry-run", }, }, - async func( - this: SentryContext, - flags: ApiFlags, - endpoint: string - ): Promise { + async func(this: SentryContext, flags: ApiFlags, endpoint: string) { const { stdout, stderr, stdin } = this; // Normalize endpoint to ensure trailing slash (Sentry API requirement) @@ -1312,20 +1322,13 @@ export const apiCommand = buildCommand({ // Dry-run mode: show the resolved request without sending it if (flags["dry-run"]) { - const request = buildDryRunRequest({ - method: flags.method, - endpoint: normalizedEndpoint, - params, - headers, - body, - }); - - if (flags.json) { - writeJson(stdout, request, flags.fields); - } else { - writeDryRunHuman(stdout, request); - } - return; + return { + data: buildDryRunRequest(flags.method, normalizedEndpoint, { + params, + headers, + body, + }), + }; } // Verbose mode: show request details (unless silent) diff --git a/test/commands/api.property.test.ts b/test/commands/api.property.test.ts index 66479e28..5eed256a 100644 --- a/test/commands/api.property.test.ts +++ b/test/commands/api.property.test.ts @@ -859,7 +859,7 @@ describe("property: buildDryRunRequest", () => { test("method is preserved exactly", () => { fcAssert( property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { - const request = buildDryRunRequest({ method, endpoint }); + const request = buildDryRunRequest(method, endpoint, {}); expect(request.method).toBe(method); }), { numRuns: DEFAULT_NUM_RUNS } @@ -869,7 +869,7 @@ describe("property: buildDryRunRequest", () => { test("URL contains the endpoint", () => { fcAssert( property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { - const request = buildDryRunRequest({ method, endpoint }); + const request = buildDryRunRequest(method, endpoint, {}); const normalized = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; @@ -882,7 +882,7 @@ describe("property: buildDryRunRequest", () => { test("headers default to empty object when undefined", () => { fcAssert( property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { - const request = buildDryRunRequest({ method, endpoint }); + const request = buildDryRunRequest(method, endpoint, {}); expect(request.headers).toEqual({}); }), { numRuns: DEFAULT_NUM_RUNS } @@ -892,7 +892,7 @@ describe("property: buildDryRunRequest", () => { test("body defaults to null when undefined", () => { fcAssert( property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { - const request = buildDryRunRequest({ method, endpoint }); + const request = buildDryRunRequest(method, endpoint, {}); expect(request.body).toBeNull(); }), { numRuns: DEFAULT_NUM_RUNS } @@ -906,7 +906,7 @@ describe("property: buildDryRunRequest", () => { dryRunEndpointArb, jsonValue(), (method, endpoint, body) => { - const request = buildDryRunRequest({ method, endpoint, body }); + const request = buildDryRunRequest(method, endpoint, { body }); expect(request.body).toEqual(body); } ), diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 47884c8f..0b39b124 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -15,8 +15,10 @@ import { buildQueryParams, buildQueryParamsFromFields, buildRawQueryParams, + type DryRunRequest, dataToQueryParams, extractJsonBody, + formatDryRunRequest, handleResponse, normalizeEndpoint, normalizeFields, @@ -29,7 +31,6 @@ import { resolveBody, resolveRequestUrl, setNestedValue, - writeDryRunHuman, writeResponseBody, writeResponseHeaders, writeVerboseRequest, @@ -1793,9 +1794,7 @@ describe("resolveRequestUrl", () => { describe("buildDryRunRequest", () => { test("builds request with all fields", () => { - const request = buildDryRunRequest({ - method: "POST", - endpoint: "issues/123/", + const request = buildDryRunRequest("POST", "issues/123/", { params: { status: "resolved" }, headers: { "Content-Type": "application/json" }, body: { status: "resolved" }, @@ -1811,27 +1810,19 @@ describe("buildDryRunRequest", () => { }); test("defaults headers to empty object", () => { - const request = buildDryRunRequest({ - method: "GET", - endpoint: "organizations/", - }); + const request = buildDryRunRequest("GET", "organizations/", {}); expect(request.headers).toEqual({}); }); test("defaults body to null", () => { - const request = buildDryRunRequest({ - method: "GET", - endpoint: "organizations/", - }); + const request = buildDryRunRequest("GET", "organizations/", {}); expect(request.body).toBeNull(); }); test("preserves string body", () => { - const request = buildDryRunRequest({ - method: "POST", - endpoint: "issues/", + const request = buildDryRunRequest("POST", "issues/", { body: '{"raw":"string"}', }); @@ -1839,105 +1830,74 @@ describe("buildDryRunRequest", () => { }); }); -describe("writeDryRunHuman", () => { - test("writes method and URL", () => { - const writer = createMockWriter(); - writeDryRunHuman(writer, { - method: "GET", - url: "https://sentry.io/api/0/organizations/", - headers: {}, - body: null, - }); - - expect(writer.output).toContain("Method: GET"); - expect(writer.output).toContain( - "URL: https://sentry.io/api/0/organizations/" - ); - expect(writer.output).toContain("Dry run"); +describe("formatDryRunRequest", () => { + const req = (overrides: Partial = {}): DryRunRequest => ({ + method: "GET", + url: "https://sentry.io/api/0/organizations/", + headers: {}, + body: null, + ...overrides, }); - test("writes headers", () => { - const writer = createMockWriter(); - writeDryRunHuman(writer, { - method: "POST", - url: "https://sentry.io/api/0/issues/", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer token", - }, - body: null, - }); + test("includes method and URL", () => { + const output = formatDryRunRequest(req()); - expect(writer.output).toContain("Content-Type: application/json"); - expect(writer.output).toContain("Authorization: Bearer token"); + expect(output).toContain("GET"); + expect(output).toContain("https://sentry.io/api/0/organizations/"); + expect(output).toContain("Dry run"); }); - test("writes JSON body formatted", () => { - const writer = createMockWriter(); - writeDryRunHuman(writer, { - method: "PUT", - url: "https://sentry.io/api/0/issues/123/", - headers: {}, - body: { status: "resolved" }, - }); + test("includes headers", () => { + const output = formatDryRunRequest( + req({ + method: "POST", + url: "https://sentry.io/api/0/issues/", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer token", + }, + }) + ); - expect(writer.output).toContain("Body:"); - expect(writer.output).toContain('"status": "resolved"'); + expect(output).toContain("Content-Type: application/json"); + expect(output).toContain("Authorization: Bearer token"); }); - test("multiline JSON body has aligned indentation", () => { - const writer = createMockWriter(); - writeDryRunHuman(writer, { - method: "POST", - url: "https://sentry.io/api/0/issues/", - headers: {}, - body: { status: "resolved", assignedTo: "user:123" }, - }); + test("includes JSON body", () => { + const output = formatDryRunRequest( + req({ + method: "PUT", + url: "https://sentry.io/api/0/issues/123/", + body: { status: "resolved" }, + }) + ); - // Each continuation line of the JSON body should be indented to align - // with the first line (12 spaces = " Body: " prefix width) - const lines = writer.output.split("\n"); - const bodyLineIdx = lines.findIndex((l) => l.includes("Body:")); - expect(bodyLineIdx).toBeGreaterThan(-1); - // The JSON is multiline, so check that the next line starts with spaces - const nextLine = lines[bodyLineIdx + 1]; - expect(nextLine).toBeDefined(); - expect(nextLine!.startsWith(" ")).toBe(true); + expect(output).toContain("Body"); + expect(output).toContain('"status": "resolved"'); }); - test("writes string body as-is", () => { - const writer = createMockWriter(); - writeDryRunHuman(writer, { - method: "POST", - url: "https://sentry.io/api/0/events/", - headers: {}, - body: "raw-body-content", - }); + test("includes string body as-is", () => { + const output = formatDryRunRequest( + req({ + method: "POST", + url: "https://sentry.io/api/0/events/", + body: "raw-body-content", + }) + ); - expect(writer.output).toContain("Body: raw-body-content"); + expect(output).toContain("Body"); + expect(output).toContain("raw-body-content"); }); test("omits body when null", () => { - const writer = createMockWriter(); - writeDryRunHuman(writer, { - method: "GET", - url: "https://sentry.io/api/0/organizations/", - headers: {}, - body: null, - }); + const output = formatDryRunRequest(req()); - expect(writer.output).not.toContain("Body:"); + expect(output).not.toContain("Body"); }); - test("omits headers section when empty", () => { - const writer = createMockWriter(); - writeDryRunHuman(writer, { - method: "GET", - url: "https://sentry.io/api/0/organizations/", - headers: {}, - body: null, - }); + test("omits headers row when empty", () => { + const output = formatDryRunRequest(req()); - expect(writer.output).not.toContain("Headers:"); + expect(output).not.toContain("Headers"); }); }); From 70e7f2dcab92ba750f87d26d9724a4e00593e1bd Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 09:59:30 +0000 Subject: [PATCH 16/28] refactor: api command uses JSON-only output for both normal and dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The api command is a JSON proxy — its output should always be JSON, not conditionally switch to human-readable markdown in dry-run mode. - Revert output config to 'json' (flag-only, no human formatter) - Dry-run writes JSON directly via writeJson (imperative, since flag-only mode doesn't intercept returns) - Remove formatDryRunRequest and its markdown imports entirely - Remove formatDryRunRequest test suite (6 tests) --- src/commands/api.ts | 61 +++++--------------------------- test/commands/api.test.ts | 74 --------------------------------------- 2 files changed, 8 insertions(+), 127 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 31f6b68a..16298f24 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -10,12 +10,6 @@ import { buildSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; import { ValidationError } from "../lib/errors.js"; import { writeJson } from "../lib/formatters/json.js"; -import { - escapeMarkdownInline, - mdKvTable, - renderMarkdown, - safeCodeSpan, -} from "../lib/formatters/markdown.js"; import { validateEndpoint } from "../lib/input-validation.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; import type { Writer } from "../types/index.js"; @@ -991,45 +985,6 @@ export function buildDryRunRequest( }; } -/** - * Format a dry-run request preview as rendered markdown. - * - * Uses the standard mdKvTable layout for consistency with other commands. - * - * @internal Exported for testing - */ -export function formatDryRunRequest(request: DryRunRequest): string { - const lines: string[] = []; - lines.push( - `## Dry run — ${escapeMarkdownInline(request.method)} ${escapeMarkdownInline(request.url)}` - ); - lines.push(""); - - const kvRows: [string, string][] = [ - ["Method", request.method], - ["URL", request.url], - ]; - - const headerEntries = Object.entries(request.headers); - if (headerEntries.length > 0) { - kvRows.push([ - "Headers", - headerEntries.map(([k, v]) => `${k}: ${v}`).join(", "), - ]); - } - - if (request.body !== null) { - const bodyStr = - typeof request.body === "string" - ? request.body - : JSON.stringify(request.body, null, 2); - kvRows.push(["Body", safeCodeSpan(bodyStr)]); - } - - lines.push(mdKvTable(kvRows)); - return renderMarkdown(lines.join("\n")); -} - /** * Handle response output based on flags * @internal Exported for testing @@ -1197,7 +1152,7 @@ export async function resolveBody( // Command Definition export const apiCommand = buildCommand({ - output: { json: true, human: formatDryRunRequest }, + output: "json", docs: { brief: "Make an authenticated API request", fullDescription: @@ -1322,13 +1277,13 @@ export const apiCommand = buildCommand({ // Dry-run mode: show the resolved request without sending it if (flags["dry-run"]) { - return { - data: buildDryRunRequest(flags.method, normalizedEndpoint, { - params, - headers, - body, - }), - }; + const request = buildDryRunRequest(flags.method, normalizedEndpoint, { + params, + headers, + body, + }); + writeJson(stdout, request, flags.fields); + return; } // Verbose mode: show request details (unless silent) diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 0b39b124..ec5d1758 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -15,10 +15,8 @@ import { buildQueryParams, buildQueryParamsFromFields, buildRawQueryParams, - type DryRunRequest, dataToQueryParams, extractJsonBody, - formatDryRunRequest, handleResponse, normalizeEndpoint, normalizeFields, @@ -1829,75 +1827,3 @@ describe("buildDryRunRequest", () => { expect(request.body).toBe('{"raw":"string"}'); }); }); - -describe("formatDryRunRequest", () => { - const req = (overrides: Partial = {}): DryRunRequest => ({ - method: "GET", - url: "https://sentry.io/api/0/organizations/", - headers: {}, - body: null, - ...overrides, - }); - - test("includes method and URL", () => { - const output = formatDryRunRequest(req()); - - expect(output).toContain("GET"); - expect(output).toContain("https://sentry.io/api/0/organizations/"); - expect(output).toContain("Dry run"); - }); - - test("includes headers", () => { - const output = formatDryRunRequest( - req({ - method: "POST", - url: "https://sentry.io/api/0/issues/", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer token", - }, - }) - ); - - expect(output).toContain("Content-Type: application/json"); - expect(output).toContain("Authorization: Bearer token"); - }); - - test("includes JSON body", () => { - const output = formatDryRunRequest( - req({ - method: "PUT", - url: "https://sentry.io/api/0/issues/123/", - body: { status: "resolved" }, - }) - ); - - expect(output).toContain("Body"); - expect(output).toContain('"status": "resolved"'); - }); - - test("includes string body as-is", () => { - const output = formatDryRunRequest( - req({ - method: "POST", - url: "https://sentry.io/api/0/events/", - body: "raw-body-content", - }) - ); - - expect(output).toContain("Body"); - expect(output).toContain("raw-body-content"); - }); - - test("omits body when null", () => { - const output = formatDryRunRequest(req()); - - expect(output).not.toContain("Body"); - }); - - test("omits headers row when empty", () => { - const output = formatDryRunRequest(req()); - - expect(output).not.toContain("Headers"); - }); -}); From 6980508886621fcbb4da62f05944a3500a3d8796 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 11:10:48 +0000 Subject: [PATCH 17/28] refactor: api command uses return-based output, remove buildDryRunRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both code paths now return { data } through the standard output system: - Dry-run: returns { data: { method, url, headers, body } } preview - Normal: returns { data: response.body } after side-effect writes (verbose/include headers, error exit code) Key changes: - output: 'json' → { json: true } (JSON-only config, no human formatter) - Make OutputConfig.human optional — when absent, renderCommandOutput always serializes as JSON regardless of --json flag - Remove buildDryRunRequest — inline request preview construction using resolveRequestUrl + new resolveEffectiveHeaders helper - Remove handleResponse — its behaviors are inlined in func: verbose/include headers as pre-return side effects, silent mode returns void, error uses process.exitCode (not process.exit) so the output wrapper can render before the process exits - Remove DryRunRequest type, writeJson import Tests: replace handleResponse tests (8) and buildDryRunRequest tests (4+5 property) with resolveEffectiveHeaders tests (6+4 property). 288 tests pass across 3 files. --- src/commands/api.ts | 142 ++++++------------ src/lib/formatters/output.ts | 18 ++- test/commands/api.property.test.ts | 76 +++++----- test/commands/api.test.ts | 233 ++++------------------------- 4 files changed, 133 insertions(+), 336 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 16298f24..dccff0bf 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -9,7 +9,6 @@ import type { SentryContext } from "../context.js"; import { buildSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; import { ValidationError } from "../lib/errors.js"; -import { writeJson } from "../lib/formatters/json.js"; import { validateEndpoint } from "../lib/input-validation.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; import type { Writer } from "../types/index.js"; @@ -27,9 +26,9 @@ type ApiFlags = { readonly silent: boolean; readonly verbose: boolean; readonly "dry-run": boolean; - /** Injected by buildCommand via output: "json" */ + /** Injected by buildCommand via output: { json: true } */ readonly json: boolean; - /** Injected by buildCommand via output: "json" */ + /** Injected by buildCommand via output: { json: true } */ readonly fields?: string[]; }; @@ -938,95 +937,27 @@ export function resolveRequestUrl( } /** - * Dry-run request details — everything that would be sent without actually sending. - */ -export type DryRunRequest = { - method: string; - url: string; - headers: Record; - body: unknown; -}; - -/** - * Build a DryRunRequest from the resolved request components. + * Resolve effective request headers, mirroring rawApiRequest logic. * - * Mirrors the URL and header logic from rawApiRequest so the - * preview matches what the real request would look like. + * Auto-adds Content-Type: application/json for non-string object bodies + * when no Content-Type was explicitly provided. * * @internal Exported for testing */ -export function buildDryRunRequest( - method: string, - endpoint: string, - options: { - params?: Record; - headers?: Record; - body?: unknown; - } -): DryRunRequest { - const headers = { ...(options.headers ?? {}) }; - - // Mirror rawApiRequest: auto-add Content-Type for object bodies - // when no Content-Type was explicitly provided +export function resolveEffectiveHeaders( + customHeaders: Record | undefined, + body: unknown +): Record { + const headers = { ...(customHeaders ?? {}) }; if ( - options.body !== undefined && - options.body !== null && - typeof options.body !== "string" && + body !== undefined && + body !== null && + typeof body !== "string" && !Object.keys(headers).some((k) => k.toLowerCase() === "content-type") ) { headers["Content-Type"] = "application/json"; } - - return { - method, - url: resolveRequestUrl(endpoint, options.params), - headers, - body: options.body ?? null, - }; -} - -/** - * Handle response output based on flags - * @internal Exported for testing - */ -export function handleResponse( - stdout: Writer, - response: { status: number; headers: Headers; body: unknown }, - flags: { - silent: boolean; - verbose: boolean; - include: boolean; - fields?: string[]; - } -): void { - const isError = response.status >= 400; - - // Silent mode - only set exit code - if (flags.silent) { - if (isError) { - process.exit(1); - } - return; - } - - // Output headers (verbose or include mode) - if (flags.verbose) { - writeVerboseResponse(stdout, response.status, response.headers); - } else if (flags.include) { - writeResponseHeaders(stdout, response.status, response.headers); - } - - // Output body — apply --fields filtering when requested - if (flags.fields && flags.fields.length > 0) { - writeJson(stdout, response.body, flags.fields); - } else { - writeResponseBody(stdout, response.body); - } - - // Exit with error code for error responses - if (isError) { - process.exit(1); - } + return headers; } /** @@ -1152,7 +1083,7 @@ export async function resolveBody( // Command Definition export const apiCommand = buildCommand({ - output: "json", + output: { json: true }, docs: { brief: "Make an authenticated API request", fullDescription: @@ -1275,18 +1206,19 @@ export const apiCommand = buildCommand({ ? parseHeaders(flags.header) : undefined; - // Dry-run mode: show the resolved request without sending it + // Dry-run mode: preview the request that would be sent if (flags["dry-run"]) { - const request = buildDryRunRequest(flags.method, normalizedEndpoint, { - params, - headers, - body, - }); - writeJson(stdout, request, flags.fields); - return; + return { + data: { + method: flags.method, + url: resolveRequestUrl(normalizedEndpoint, params), + headers: resolveEffectiveHeaders(headers, body), + body: body ?? null, + }, + }; } - // Verbose mode: show request details (unless silent) + // Verbose mode: show request details before the response if (flags.verbose && !flags.silent) { writeVerboseRequest(stdout, flags.method, normalizedEndpoint, headers); } @@ -1298,6 +1230,28 @@ export const apiCommand = buildCommand({ headers, }); - handleResponse(stdout, response, flags); + const isError = response.status >= 400; + + // Silent mode — only set exit code, no output + if (flags.silent) { + if (isError) { + process.exit(1); + } + return; + } + + // Output response headers when requested + if (flags.verbose) { + writeVerboseResponse(stdout, response.status, response.headers); + } else if (flags.include) { + writeResponseHeaders(stdout, response.status, response.headers); + } + + // Set error exit code (before return so the process exits after output) + if (isError) { + process.exitCode = 1; + } + + return { data: response.body }; }, }); diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index 7dae3a5f..327bd682 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -60,7 +60,7 @@ type WriteOutputOptions = { /** * Output configuration declared on `buildCommand` for automatic rendering. * - * Two forms: + * Three forms: * * 1. **Flag-only** — `output: "json"` — injects `--json` and `--fields` flags * but does not intercept returns. Commands handle their own output. @@ -69,14 +69,23 @@ type WriteOutputOptions = { * AND auto-renders the command's return value. Commands return * `{ data }` or `{ data, hint }` objects. * + * 3. **JSON-only config** — `output: { json: true }` — like full config but + * without a `human` formatter. Data is always serialized as JSON. + * * @typeParam T - Type of data the command returns (used by `human` formatter * and serialized as-is to JSON) */ export type OutputConfig = { /** Enable `--json` and `--fields` flag injection */ json: true; - /** Format data as a human-readable string for terminal output */ - human: (data: T) => string; + /** + * Format data as a human-readable string for terminal output. + * + * When omitted the command is **JSON-only**: data is always serialized + * as JSON regardless of whether `--json` was passed. The `--json` and + * `--fields` flags are still injected for consistency. + */ + human?: (data: T) => string; /** * Top-level keys to strip from JSON output. * @@ -136,7 +145,8 @@ export function renderCommandOutput( config: OutputConfig, ctx: RenderContext ): void { - if (ctx.json) { + // JSON mode: explicit --json flag, or no human formatter (JSON-only command) + if (ctx.json || !config.human) { let jsonData = data; if ( config.jsonExclude && diff --git a/test/commands/api.property.test.ts b/test/commands/api.property.test.ts index 5eed256a..135ed63a 100644 --- a/test/commands/api.property.test.ts +++ b/test/commands/api.property.test.ts @@ -16,12 +16,12 @@ import { oneof, property, record, + string, stringMatching, tuple, uniqueArray, } from "fast-check"; import { - buildDryRunRequest, buildFromFields, extractJsonBody, normalizeEndpoint, @@ -31,6 +31,7 @@ import { parseFieldValue, parseMethod, resolveBody, + resolveEffectiveHeaders, resolveRequestUrl, setNestedValue, } from "../../src/commands/api.js"; @@ -794,7 +795,6 @@ describe("property: resolveBody", () => { // Dry-run property tests /** Arbitrary for HTTP methods */ -const httpMethodArb = constantFrom("GET", "POST", "PUT", "DELETE", "PATCH"); /** Arbitrary for clean API endpoint paths (no query string, for dry-run tests) */ const dryRunEndpointArb = stringMatching( @@ -855,61 +855,65 @@ describe("property: resolveRequestUrl", () => { }); }); -describe("property: buildDryRunRequest", () => { - test("method is preserved exactly", () => { - fcAssert( - property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { - const request = buildDryRunRequest(method, endpoint, {}); - expect(request.method).toBe(method); - }), - { numRuns: DEFAULT_NUM_RUNS } - ); +describe("property: resolveEffectiveHeaders", () => { + /** Arbitrary for custom header entries (non-content-type keys) */ + const headerKeyArb = stringMatching(/^X-[A-Za-z]{1,10}$/); + const headerValueArb = string({ minLength: 1, maxLength: 20 }); + const customHeadersArb = dictionary(headerKeyArb, headerValueArb, { + minKeys: 0, + maxKeys: 3, }); - test("URL contains the endpoint", () => { + test("preserves all custom headers", () => { fcAssert( - property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { - const request = buildDryRunRequest(method, endpoint, {}); - const normalized = endpoint.startsWith("/") - ? endpoint.slice(1) - : endpoint; - expect(request.url).toContain(normalized); + property(customHeadersArb, (custom) => { + const result = resolveEffectiveHeaders(custom, undefined); + for (const [key, value] of Object.entries(custom)) { + expect(result[key]).toBe(value); + } }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("headers default to empty object when undefined", () => { + test("auto-adds Content-Type for object bodies without it", () => { fcAssert( - property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { - const request = buildDryRunRequest(method, endpoint, {}); - expect(request.headers).toEqual({}); + property(customHeadersArb, jsonValue(), (custom, body) => { + // Ensure body is a non-null object (not string/number/boolean/null) + if (body === null || body === undefined || typeof body !== "object") { + return; + } + const result = resolveEffectiveHeaders(custom, body); + expect(result["Content-Type"]).toBe("application/json"); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("body defaults to null when undefined", () => { + test("never adds Content-Type for string bodies", () => { fcAssert( - property(httpMethodArb, dryRunEndpointArb, (method, endpoint) => { - const request = buildDryRunRequest(method, endpoint, {}); - expect(request.body).toBeNull(); + property(customHeadersArb, string(), (custom, body) => { + const result = resolveEffectiveHeaders(custom, body); + // Should not have auto-added Content-Type (only custom headers) + if (!custom["Content-Type"]) { + expect(result["Content-Type"]).toBeUndefined(); + } }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("provided body is preserved", () => { + test("does not override explicit Content-Type", () => { + const contentTypeArb = constantFrom( + "text/plain", + "text/xml", + "application/x-www-form-urlencoded" + ); fcAssert( - property( - httpMethodArb, - dryRunEndpointArb, - jsonValue(), - (method, endpoint, body) => { - const request = buildDryRunRequest(method, endpoint, { body }); - expect(request.body).toEqual(body); - } - ), + property(contentTypeArb, jsonValue(), (ct, body) => { + const result = resolveEffectiveHeaders({ "Content-Type": ct }, body); + expect(result["Content-Type"]).toBe(ct); + }), { numRuns: DEFAULT_NUM_RUNS } ); }); diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index ec5d1758..4f23aa13 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -10,14 +10,12 @@ import { Readable } from "node:stream"; import { buildBodyFromFields, buildBodyFromInput, - buildDryRunRequest, buildFromFields, buildQueryParams, buildQueryParamsFromFields, buildRawQueryParams, dataToQueryParams, extractJsonBody, - handleResponse, normalizeEndpoint, normalizeFields, parseDataBody, @@ -27,6 +25,7 @@ import { prepareRequestOptions, readStdin, resolveBody, + resolveEffectiveHeaders, resolveRequestUrl, setNestedValue, writeResponseBody, @@ -1130,178 +1129,46 @@ describe("buildBodyFromInput", () => { }); }); -describe("handleResponse", () => { - // Mock process.exit for tests - const originalExit = process.exit; - - test("outputs body for successful response", () => { - const writer = createMockWriter(); - const response = { - status: 200, - headers: new Headers(), - body: { success: true }, - }; - - handleResponse(writer, response, { - silent: false, - verbose: false, - include: false, - }); - - expect(writer.output).toContain('"success": true'); +describe("resolveEffectiveHeaders", () => { + test("auto-adds Content-Type for object bodies", () => { + const headers = resolveEffectiveHeaders(undefined, { key: "value" }); + expect(headers["Content-Type"]).toBe("application/json"); }); - test("outputs headers with --include flag", () => { - const writer = createMockWriter(); - const response = { - status: 200, - headers: new Headers({ "Content-Type": "application/json" }), - body: { data: "test" }, - }; - - handleResponse(writer, response, { - silent: false, - verbose: false, - include: true, - }); - - expect(writer.output).toMatch(/^HTTP 200\n/); - expect(writer.output).toMatch(/content-type:/i); + test("does not add Content-Type for string bodies", () => { + const headers = resolveEffectiveHeaders(undefined, "raw-string"); + expect(headers["Content-Type"]).toBeUndefined(); }); - test("outputs verbose format with --verbose flag", () => { - const writer = createMockWriter(); - const response = { - status: 200, - headers: new Headers({ "Content-Type": "application/json" }), - body: { data: "test" }, - }; - - handleResponse(writer, response, { - silent: false, - verbose: true, - include: false, - }); - - expect(writer.output).toMatch(/^< HTTP 200\n/); - expect(writer.output).toMatch(/< content-type:/i); - }); - - test("verbose takes precedence over include", () => { - const writer = createMockWriter(); - const response = { - status: 200, - headers: new Headers(), - body: "test", - }; - - handleResponse(writer, response, { - silent: false, - verbose: true, - include: true, - }); - - // Should use verbose format (< prefix), not include format - expect(writer.output).toMatch(/^< HTTP/); - }); - - test("silent mode produces no output for success", () => { - const writer = createMockWriter(); - const response = { - status: 200, - headers: new Headers(), - body: { data: "test" }, - }; - - handleResponse(writer, response, { - silent: true, - verbose: false, - include: false, - }); - - expect(writer.output).toBe(""); + test("does not override explicit Content-Type", () => { + const headers = resolveEffectiveHeaders( + { "Content-Type": "text/plain" }, + { key: "value" } + ); + expect(headers["Content-Type"]).toBe("text/plain"); }); - test("silent mode with error calls process.exit(1)", () => { - const writer = createMockWriter(); - const response = { - status: 500, - headers: new Headers(), - body: { error: "Internal Server Error" }, - }; - - let exitCode: number | undefined; - process.exit = ((code?: number) => { - exitCode = code; - throw new Error("process.exit called"); - }) as typeof process.exit; - - try { - expect(() => - handleResponse(writer, response, { - silent: true, - verbose: false, - include: false, - }) - ).toThrow("process.exit called"); - expect(exitCode).toBe(1); - } finally { - process.exit = originalExit; - } + test("case-insensitive Content-Type check", () => { + const headers = resolveEffectiveHeaders( + { "content-type": "text/xml" }, + { key: "value" } + ); + expect(headers["content-type"]).toBe("text/xml"); + expect(headers["Content-Type"]).toBeUndefined(); }); - test("error response calls process.exit(1) after output", () => { - const writer = createMockWriter(); - const response = { - status: 404, - headers: new Headers(), - body: { detail: "Not found" }, - }; - - let exitCode: number | undefined; - process.exit = ((code?: number) => { - exitCode = code; - throw new Error("process.exit called"); - }) as typeof process.exit; - - try { - expect(() => - handleResponse(writer, response, { - silent: false, - verbose: false, - include: false, - }) - ).toThrow("process.exit called"); - expect(exitCode).toBe(1); - // Should have output the body before exiting - expect(writer.output).toContain("Not found"); - } finally { - process.exit = originalExit; - } + test("preserves custom headers", () => { + const headers = resolveEffectiveHeaders( + { Authorization: "Bearer token", "X-Custom": "value" }, + undefined + ); + expect(headers.Authorization).toBe("Bearer token"); + expect(headers["X-Custom"]).toBe("value"); }); - test("filters response body with --fields", () => { - const writer = createMockWriter(); - const response = { - status: 200, - headers: new Headers(), - body: { - id: "123", - name: "my-project", - slug: "my-project", - platform: "node", - }, - }; - - handleResponse(writer, response, { - silent: false, - verbose: false, - include: false, - fields: ["id", "name"], - }); - - const parsed = JSON.parse(writer.output); - expect(parsed).toEqual({ id: "123", name: "my-project" }); + test("defaults to empty object when no headers provided", () => { + const headers = resolveEffectiveHeaders(undefined, undefined); + expect(headers).toEqual({}); }); }); @@ -1789,41 +1656,3 @@ describe("resolveRequestUrl", () => { expect(url).not.toContain("?"); }); }); - -describe("buildDryRunRequest", () => { - test("builds request with all fields", () => { - const request = buildDryRunRequest("POST", "issues/123/", { - params: { status: "resolved" }, - headers: { "Content-Type": "application/json" }, - body: { status: "resolved" }, - }); - - expect(request.method).toBe("POST"); - expect(request.url).toContain("/api/0/issues/123/"); - expect(request.url).toContain("status=resolved"); - expect(request.headers).toEqual({ - "Content-Type": "application/json", - }); - expect(request.body).toEqual({ status: "resolved" }); - }); - - test("defaults headers to empty object", () => { - const request = buildDryRunRequest("GET", "organizations/", {}); - - expect(request.headers).toEqual({}); - }); - - test("defaults body to null", () => { - const request = buildDryRunRequest("GET", "organizations/", {}); - - expect(request.body).toBeNull(); - }); - - test("preserves string body", () => { - const request = buildDryRunRequest("POST", "issues/", { - body: '{"raw":"string"}', - }); - - expect(request.body).toBe('{"raw":"string"}'); - }); -}); From 042d2645d4d16e222ac8f3d02b8e4a5faaf9b769 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 11:12:02 +0000 Subject: [PATCH 18/28] remove dead writeResponseBody function and its tests --- src/commands/api.ts | 16 ----------- test/commands/api.test.ts | 59 --------------------------------------- 2 files changed, 75 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index dccff0bf..bb749c0a 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -863,22 +863,6 @@ export function writeResponseHeaders( stdout.write("\n"); } -/** - * Write response body to stdout - * @internal Exported for testing - */ -export function writeResponseBody(stdout: Writer, body: unknown): void { - if (body === null || body === undefined) { - return; - } - - if (typeof body === "object") { - stdout.write(`${JSON.stringify(body, null, 2)}\n`); - } else { - stdout.write(`${String(body)}\n`); - } -} - /** * Write verbose request output (curl-style format) * @internal Exported for testing diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 4f23aa13..409ea366 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -28,7 +28,6 @@ import { resolveEffectiveHeaders, resolveRequestUrl, setNestedValue, - writeResponseBody, writeResponseHeaders, writeVerboseRequest, writeVerboseResponse, @@ -913,64 +912,6 @@ describe("writeResponseHeaders", () => { }); }); -describe("writeResponseBody", () => { - test("writes JSON object with formatting", () => { - const writer = createMockWriter(); - - writeResponseBody(writer, { key: "value", num: 42 }); - - expect(writer.output).toBe('{\n "key": "value",\n "num": 42\n}\n'); - }); - - test("writes JSON array with formatting", () => { - const writer = createMockWriter(); - - writeResponseBody(writer, [1, 2, 3]); - - expect(writer.output).toBe("[\n 1,\n 2,\n 3\n]\n"); - }); - - test("writes string directly", () => { - const writer = createMockWriter(); - - writeResponseBody(writer, "plain text response"); - - expect(writer.output).toBe("plain text response\n"); - }); - - test("writes number as string", () => { - const writer = createMockWriter(); - - writeResponseBody(writer, 42); - - expect(writer.output).toBe("42\n"); - }); - - test("writes boolean as string", () => { - const writer = createMockWriter(); - - writeResponseBody(writer, true); - - expect(writer.output).toBe("true\n"); - }); - - test("does not write null", () => { - const writer = createMockWriter(); - - writeResponseBody(writer, null); - - expect(writer.output).toBe(""); - }); - - test("does not write undefined", () => { - const writer = createMockWriter(); - - writeResponseBody(writer, undefined); - - expect(writer.output).toBe(""); - }); -}); - describe("writeVerboseRequest", () => { test("writes method and endpoint", () => { const writer = createMockWriter(); From 997570c437d1c30071a172d0e612ba7eb1e2ade9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 11:19:36 +0000 Subject: [PATCH 19/28] fix: use process.exit(1) for api error responses Stricli overwrites process.exitCode after the command returns, so process.exitCode = 1 was silently reset to 0. For error responses, write JSON directly then process.exit(1). Success responses still use return-based { data } through the output system. --- src/commands/api.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index bb749c0a..e2dd3277 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -9,6 +9,7 @@ import type { SentryContext } from "../context.js"; import { buildSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; import { ValidationError } from "../lib/errors.js"; +import { writeJson } from "../lib/formatters/json.js"; import { validateEndpoint } from "../lib/input-validation.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; import type { Writer } from "../types/index.js"; @@ -1224,16 +1225,19 @@ export const apiCommand = buildCommand({ return; } - // Output response headers when requested + // Output response headers when requested (side-effect before body) if (flags.verbose) { writeVerboseResponse(stdout, response.status, response.headers); } else if (flags.include) { writeResponseHeaders(stdout, response.status, response.headers); } - // Set error exit code (before return so the process exits after output) + // Error responses: Stricli overwrites process.exitCode after the + // command returns, so we must use process.exit(1) directly. + // Return { data } for success so the output wrapper renders it. if (isError) { - process.exitCode = 1; + writeJson(stdout, response.body, flags.fields); + process.exit(1); } return { data: response.body }; From 922a650625448f701f17c5aa85494f44006855f6 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 11:31:56 +0000 Subject: [PATCH 20/28] fix: api command preserves raw string responses, fix null body header divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three bot review comments: 1. String response quoting regression (Seer + BugBot): the api command is a raw proxy — non-JSON responses (plain text, HTML error pages) must not be wrapped in JSON quotes. Revert to output: 'json' (flag-only) and write response body imperatively via writeResponseBody, which writes strings directly and JSON-formats objects with --fields. 2. Null body header divergence (BugBot): resolveEffectiveHeaders had an extra body !== null check that rawApiRequest doesn't. For --data "null", dry-run would omit Content-Type while the real request adds it. Aligned condition to match rawApiRequest exactly: !(isStringBody || hasContentType) && body !== undefined. 3. Stale comment about dry-run always outputting JSON (BugBot on old commit 70e7f2dc): the referenced code was completely rewritten in 69805088. No action needed — will reply to dismiss. Also reverts OutputConfig.human back to required (the JSON-only config form is no longer needed since the api command doesn't use it). --- src/commands/api.ts | 69 ++++++++++++++++++++++++++---------- src/lib/formatters/output.ts | 18 +++------- test/commands/api.test.ts | 59 ++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 33 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index e2dd3277..83b4fbc2 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -27,9 +27,9 @@ type ApiFlags = { readonly silent: boolean; readonly verbose: boolean; readonly "dry-run": boolean; - /** Injected by buildCommand via output: { json: true } */ + /** Injected by buildCommand via output: "json" */ readonly json: boolean; - /** Injected by buildCommand via output: { json: true } */ + /** Injected by buildCommand via output: "json" */ readonly fields?: string[]; }; @@ -864,6 +864,31 @@ export function writeResponseHeaders( stdout.write("\n"); } +/** + * Write API response body to stdout. + * + * Preserves raw strings (plain text, HTML error pages) without JSON quoting. + * Objects/arrays are JSON-formatted with optional `--fields` filtering. + * Null/undefined bodies produce no output. + * + * @internal Exported for testing + */ +export function writeResponseBody( + stdout: Writer, + body: unknown, + fields?: string[] +): void { + if (body === null || body === undefined) { + return; + } + + if (typeof body === "object") { + writeJson(stdout, body, fields); + } else { + stdout.write(`${String(body)}\n`); + } +} + /** * Write verbose request output (curl-style format) * @internal Exported for testing @@ -933,13 +958,14 @@ export function resolveEffectiveHeaders( customHeaders: Record | undefined, body: unknown ): Record { + // Mirror rawApiRequest exactly: auto-add Content-Type for any non-string, + // non-undefined body when no Content-Type was explicitly provided. + const isStringBody = typeof body === "string"; const headers = { ...(customHeaders ?? {}) }; - if ( - body !== undefined && - body !== null && - typeof body !== "string" && - !Object.keys(headers).some((k) => k.toLowerCase() === "content-type") - ) { + const hasContentType = Object.keys(headers).some( + (k) => k.toLowerCase() === "content-type" + ); + if (!(isStringBody || hasContentType) && body !== undefined) { headers["Content-Type"] = "application/json"; } return headers; @@ -1068,7 +1094,7 @@ export async function resolveBody( // Command Definition export const apiCommand = buildCommand({ - output: { json: true }, + output: "json", docs: { brief: "Make an authenticated API request", fullDescription: @@ -1191,16 +1217,20 @@ export const apiCommand = buildCommand({ ? parseHeaders(flags.header) : undefined; + // Dry-run mode: preview the request that would be sent // Dry-run mode: preview the request that would be sent if (flags["dry-run"]) { - return { - data: { + writeJson( + stdout, + { method: flags.method, url: resolveRequestUrl(normalizedEndpoint, params), headers: resolveEffectiveHeaders(headers, body), body: body ?? null, }, - }; + flags.fields + ); + return; } // Verbose mode: show request details before the response @@ -1225,21 +1255,22 @@ export const apiCommand = buildCommand({ return; } - // Output response headers when requested (side-effect before body) + // Output response headers when requested if (flags.verbose) { writeVerboseResponse(stdout, response.status, response.headers); } else if (flags.include) { writeResponseHeaders(stdout, response.status, response.headers); } - // Error responses: Stricli overwrites process.exitCode after the - // command returns, so we must use process.exit(1) directly. - // Return { data } for success so the output wrapper renders it. + // Write response body — preserve raw strings, JSON-format objects. + // The api command is a raw proxy; non-JSON responses (plain text, + // HTML error pages) must not be wrapped in JSON string quotes. + writeResponseBody(stdout, response.body, flags.fields); + + // Error exit: Stricli overwrites process.exitCode after the command + // returns, so we must call process.exit(1) directly. if (isError) { - writeJson(stdout, response.body, flags.fields); process.exit(1); } - - return { data: response.body }; }, }); diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index 327bd682..7dae3a5f 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -60,7 +60,7 @@ type WriteOutputOptions = { /** * Output configuration declared on `buildCommand` for automatic rendering. * - * Three forms: + * Two forms: * * 1. **Flag-only** — `output: "json"` — injects `--json` and `--fields` flags * but does not intercept returns. Commands handle their own output. @@ -69,23 +69,14 @@ type WriteOutputOptions = { * AND auto-renders the command's return value. Commands return * `{ data }` or `{ data, hint }` objects. * - * 3. **JSON-only config** — `output: { json: true }` — like full config but - * without a `human` formatter. Data is always serialized as JSON. - * * @typeParam T - Type of data the command returns (used by `human` formatter * and serialized as-is to JSON) */ export type OutputConfig = { /** Enable `--json` and `--fields` flag injection */ json: true; - /** - * Format data as a human-readable string for terminal output. - * - * When omitted the command is **JSON-only**: data is always serialized - * as JSON regardless of whether `--json` was passed. The `--json` and - * `--fields` flags are still injected for consistency. - */ - human?: (data: T) => string; + /** Format data as a human-readable string for terminal output */ + human: (data: T) => string; /** * Top-level keys to strip from JSON output. * @@ -145,8 +136,7 @@ export function renderCommandOutput( config: OutputConfig, ctx: RenderContext ): void { - // JSON mode: explicit --json flag, or no human formatter (JSON-only command) - if (ctx.json || !config.human) { + if (ctx.json) { let jsonData = data; if ( config.jsonExclude && diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 409ea366..ab0a7b3d 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -28,6 +28,7 @@ import { resolveEffectiveHeaders, resolveRequestUrl, setNestedValue, + writeResponseBody, writeResponseHeaders, writeVerboseRequest, writeVerboseResponse, @@ -912,6 +913,54 @@ describe("writeResponseHeaders", () => { }); }); +describe("writeResponseBody", () => { + test("writes JSON object with pretty-printing", () => { + const writer = createMockWriter(); + writeResponseBody(writer, { key: "value", num: 42 }); + expect(writer.output).toBe('{\n "key": "value",\n "num": 42\n}\n'); + }); + + test("writes JSON array with pretty-printing", () => { + const writer = createMockWriter(); + writeResponseBody(writer, [1, 2, 3]); + expect(writer.output).toBe("[\n 1,\n 2,\n 3\n]\n"); + }); + + test("writes string directly without JSON quoting", () => { + const writer = createMockWriter(); + writeResponseBody(writer, "plain text response"); + expect(writer.output).toBe("plain text response\n"); + }); + + test("writes number as string", () => { + const writer = createMockWriter(); + writeResponseBody(writer, 42); + expect(writer.output).toBe("42\n"); + }); + + test("does not write null", () => { + const writer = createMockWriter(); + writeResponseBody(writer, null); + expect(writer.output).toBe(""); + }); + + test("does not write undefined", () => { + const writer = createMockWriter(); + writeResponseBody(writer, undefined); + expect(writer.output).toBe(""); + }); + + test("applies --fields filtering to objects", () => { + const writer = createMockWriter(); + writeResponseBody(writer, { id: "1", name: "test", extra: "data" }, [ + "id", + "name", + ]); + const parsed = JSON.parse(writer.output); + expect(parsed).toEqual({ id: "1", name: "test" }); + }); +}); + describe("writeVerboseRequest", () => { test("writes method and endpoint", () => { const writer = createMockWriter(); @@ -1111,6 +1160,16 @@ describe("resolveEffectiveHeaders", () => { const headers = resolveEffectiveHeaders(undefined, undefined); expect(headers).toEqual({}); }); + + test("adds Content-Type for null body (matches rawApiRequest)", () => { + const headers = resolveEffectiveHeaders(undefined, null); + expect(headers["Content-Type"]).toBe("application/json"); + }); + + test("does not add Content-Type for undefined body", () => { + const headers = resolveEffectiveHeaders(undefined, undefined); + expect(headers["Content-Type"]).toBeUndefined(); + }); }); // --data/-d and JSON auto-detection (CLI-AF) From b307377cd089921485e1b6033a5045b7e5b6d10d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 11:39:06 +0000 Subject: [PATCH 21/28] fix: remove duplicate comment line in dry-run section --- src/commands/api.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 83b4fbc2..1f05f02a 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -1217,7 +1217,6 @@ export const apiCommand = buildCommand({ ? parseHeaders(flags.header) : undefined; - // Dry-run mode: preview the request that would be sent // Dry-run mode: preview the request that would be sent if (flags["dry-run"]) { writeJson( From 864f4d46f2ab4d07faa663869f292e3d73dac246 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 11:48:40 +0000 Subject: [PATCH 22/28] refactor: replace exitCode on CommandOutput with OutputError throw pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both dry-run and normal response paths now return { data } through the output system. No more imperative writeJson/writeResponseBody calls. Changes: - Add OutputError class (extends CliError) for commands that produce valid output but should exit non-zero. The buildCommand wrapper catches it, renders data through the output system, then calls process.exit(). This replaces the muddled exitCode field on CommandOutput. - Add formatApiResponse human formatter — preserves raw strings (plain text, HTML error pages) without JSON quoting, JSON-formats objects. Does NOT add trailing newline (renderCommandOutput appends it). - Api command uses output: { json: true, human: formatApiResponse } instead of flag-only output: "json". Error responses throw OutputError(response.body) instead of returning { exitCode: 1 }. - Remove writeResponseBody function and writeJson import from api.ts. - Separate stdout/stderr in test context to prevent Stricli error messages from polluting JSON output assertions. --- src/commands/api.ts | 56 ++++++++---------- src/lib/command.ts | 41 +++++++++++--- src/lib/errors.ts | 20 +++++++ test/commands/api.test.ts | 54 ++++++------------ test/lib/command.test.ts | 116 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 203 insertions(+), 84 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 1f05f02a..43aa322c 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -8,8 +8,7 @@ import type { SentryContext } from "../context.js"; import { buildSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; -import { ValidationError } from "../lib/errors.js"; -import { writeJson } from "../lib/formatters/json.js"; +import { OutputError, ValidationError } from "../lib/errors.js"; import { validateEndpoint } from "../lib/input-validation.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; import type { Writer } from "../types/index.js"; @@ -27,9 +26,9 @@ type ApiFlags = { readonly silent: boolean; readonly verbose: boolean; readonly "dry-run": boolean; - /** Injected by buildCommand via output: "json" */ + /** Injected by buildCommand via output config */ readonly json: boolean; - /** Injected by buildCommand via output: "json" */ + /** Injected by buildCommand via output config */ readonly fields?: string[]; }; @@ -865,28 +864,23 @@ export function writeResponseHeaders( } /** - * Write API response body to stdout. + * Format an API response body for human-readable output. * - * Preserves raw strings (plain text, HTML error pages) without JSON quoting. - * Objects/arrays are JSON-formatted with optional `--fields` filtering. - * Null/undefined bodies produce no output. + * The api command is a raw proxy — the response body is the output. + * Objects/arrays are JSON-formatted; strings (plain text, HTML error + * pages) pass through without JSON quoting; null/undefined produce + * no output. * * @internal Exported for testing */ -export function writeResponseBody( - stdout: Writer, - body: unknown, - fields?: string[] -): void { +export function formatApiResponse(body: unknown): string { if (body === null || body === undefined) { - return; + return ""; } - if (typeof body === "object") { - writeJson(stdout, body, fields); - } else { - stdout.write(`${String(body)}\n`); + return JSON.stringify(body, null, 2); } + return String(body); } /** @@ -1094,7 +1088,7 @@ export async function resolveBody( // Command Definition export const apiCommand = buildCommand({ - output: "json", + output: { json: true, human: formatApiResponse }, docs: { brief: "Make an authenticated API request", fullDescription: @@ -1219,17 +1213,14 @@ export const apiCommand = buildCommand({ // Dry-run mode: preview the request that would be sent if (flags["dry-run"]) { - writeJson( - stdout, - { + return { + data: { method: flags.method, url: resolveRequestUrl(normalizedEndpoint, params), headers: resolveEffectiveHeaders(headers, body), body: body ?? null, }, - flags.fields - ); - return; + }; } // Verbose mode: show request details before the response @@ -1254,22 +1245,19 @@ export const apiCommand = buildCommand({ return; } - // Output response headers when requested + // Output response headers when requested (side-effect before body) if (flags.verbose) { writeVerboseResponse(stdout, response.status, response.headers); } else if (flags.include) { writeResponseHeaders(stdout, response.status, response.headers); } - // Write response body — preserve raw strings, JSON-format objects. - // The api command is a raw proxy; non-JSON responses (plain text, - // HTML error pages) must not be wrapped in JSON string quotes. - writeResponseBody(stdout, response.body, flags.fields); - - // Error exit: Stricli overwrites process.exitCode after the command - // returns, so we must call process.exit(1) directly. + // Error responses: throw so the wrapper renders the body then exits 1. + // The body is still useful (API error details), just indicates failure. if (isError) { - process.exit(1); + throw new OutputError(response.body); } + + return { data: response.body }; }, }); diff --git a/src/lib/command.ts b/src/lib/command.ts index cb759019..1f5bc175 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -36,6 +36,7 @@ import { buildCommand as stricliCommand, numberParser as stricliNumberParser, } from "@stricli/core"; +import { OutputError } from "./errors.js"; import { parseFieldsList } from "./formatters/json.js"; import { type CommandOutput, @@ -379,19 +380,43 @@ export function buildCommand< setArgsContext(args); } + // OutputError handler: render data through the output system, then + // exit with the error's code. Stricli overwrites process.exitCode = 0 + // after successful returns, so process.exit() is the only way to + // preserve a non-zero code. This lives in the framework — commands + // simply `throw new OutputError(data)`. + const handleOutputError = (err: unknown): never => { + if (err instanceof OutputError && outputConfig) { + handleReturnValue( + this, + { data: err.data } as CommandOutput, + cleanFlags + ); + process.exit(err.exitCode); + } + throw err; + }; + // Call original and intercept data returns. // Commands with output config return { data, hint? }; // the wrapper renders automatically. Void returns are ignored. - const result = originalFunc.call( - this, - cleanFlags as FLAGS, - ...(args as unknown as ARGS) - ); + let result: ReturnType; + try { + result = originalFunc.call( + this, + cleanFlags as FLAGS, + ...(args as unknown as ARGS) + ); + } catch (err) { + handleOutputError(err); + } if (result instanceof Promise) { - return result.then((resolved) => { - handleReturnValue(this, resolved, cleanFlags); - }) as ReturnType; + return result + .then((resolved) => { + handleReturnValue(this, resolved, cleanFlags); + }) + .catch(handleOutputError) as ReturnType; } handleReturnValue(this, result, cleanFlags); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 811bc257..0edc59f1 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -130,6 +130,26 @@ export class ConfigError extends CliError { } } +/** + * Thrown when a command produces valid output but should exit non-zero. + * + * Unlike other errors, the output data is rendered to stdout (not stderr) + * through the normal output system — the `buildCommand` wrapper catches + * this before it reaches the global error handler. Think "HTTP 404 body": + * useful data, but the operation itself failed. + * + * @param data - The output data to render (same type as CommandOutput.data) + */ +export class OutputError extends CliError { + readonly data: unknown; + + constructor(data: unknown) { + super("", 1); + this.name = "OutputError"; + this.data = data; + } +} + const DEFAULT_CONTEXT_ALTERNATIVES = [ "Run from a directory with a Sentry-configured project", "Set SENTRY_ORG and SENTRY_PROJECT (or SENTRY_DSN) environment variables", diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index ab0a7b3d..19853ec1 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -16,6 +16,7 @@ import { buildRawQueryParams, dataToQueryParams, extractJsonBody, + formatApiResponse, normalizeEndpoint, normalizeFields, parseDataBody, @@ -28,7 +29,6 @@ import { resolveEffectiveHeaders, resolveRequestUrl, setNestedValue, - writeResponseBody, writeResponseHeaders, writeVerboseRequest, writeVerboseResponse, @@ -913,51 +913,33 @@ describe("writeResponseHeaders", () => { }); }); -describe("writeResponseBody", () => { - test("writes JSON object with pretty-printing", () => { - const writer = createMockWriter(); - writeResponseBody(writer, { key: "value", num: 42 }); - expect(writer.output).toBe('{\n "key": "value",\n "num": 42\n}\n'); - }); - - test("writes JSON array with pretty-printing", () => { - const writer = createMockWriter(); - writeResponseBody(writer, [1, 2, 3]); - expect(writer.output).toBe("[\n 1,\n 2,\n 3\n]\n"); +describe("formatApiResponse", () => { + test("formats JSON object with pretty-printing", () => { + expect(formatApiResponse({ key: "value", num: 42 })).toBe( + '{\n "key": "value",\n "num": 42\n}' + ); }); - test("writes string directly without JSON quoting", () => { - const writer = createMockWriter(); - writeResponseBody(writer, "plain text response"); - expect(writer.output).toBe("plain text response\n"); + test("formats JSON array with pretty-printing", () => { + expect(formatApiResponse([1, 2, 3])).toBe("[\n 1,\n 2,\n 3\n]"); }); - test("writes number as string", () => { - const writer = createMockWriter(); - writeResponseBody(writer, 42); - expect(writer.output).toBe("42\n"); + test("formats string directly without JSON quoting", () => { + expect(formatApiResponse("plain text response")).toBe( + "plain text response" + ); }); - test("does not write null", () => { - const writer = createMockWriter(); - writeResponseBody(writer, null); - expect(writer.output).toBe(""); + test("formats number as string", () => { + expect(formatApiResponse(42)).toBe("42"); }); - test("does not write undefined", () => { - const writer = createMockWriter(); - writeResponseBody(writer, undefined); - expect(writer.output).toBe(""); + test("returns empty string for null", () => { + expect(formatApiResponse(null)).toBe(""); }); - test("applies --fields filtering to objects", () => { - const writer = createMockWriter(); - writeResponseBody(writer, { id: "1", name: "test", extra: "data" }, [ - "id", - "name", - ]); - const parsed = JSON.parse(writer.output); - expect(parsed).toEqual({ id: "1", name: "test" }); + test("returns empty string for undefined", () => { + expect(formatApiResponse(undefined)).toBe(""); }); }); diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index 80e0a151..df689020 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -24,6 +24,7 @@ import { numberParser, VERBOSE_FLAG, } from "../../src/lib/command.js"; +import { OutputError } from "../../src/lib/errors.js"; import { LOG_LEVEL_NAMES, logger, setLogLevel } from "../../src/lib/logger.js"; /** Minimal context for test commands */ @@ -42,10 +43,11 @@ type TestContext = CommandContext & { * Access collected output via `ctx.output`. */ function createTestContext() { - const collected: string[] = []; + const stdoutCollected: string[] = []; + const stderrCollected: string[] = []; const stdoutWriter = { write: (s: string) => { - collected.push(s); + stdoutCollected.push(s); return true; }, }; @@ -54,15 +56,17 @@ function createTestContext() { stdout: stdoutWriter, stderr: { write: (s: string) => { - collected.push(s); + stderrCollected.push(s); return true; }, }, }, /** stdout on context — used by buildCommand's return-based output handler */ stdout: stdoutWriter, - /** All collected output chunks */ - output: collected, + /** stdout output chunks only */ + output: stdoutCollected, + /** stderr output chunks only */ + errors: stderrCollected, }; } @@ -804,7 +808,7 @@ describe("buildCommand output: json", () => { expect(funcCalled).toBe(false); expect( - ctx.output.some((s) => s.includes("No flag registered for --json")) + ctx.errors.some((s) => s.includes("No flag registered for --json")) ).toBe(true); }); @@ -1236,4 +1240,104 @@ describe("buildCommand return-based output", () => { expect(jsonRaw).not.toContain("Detected from"); expect(JSON.parse(jsonRaw)).toEqual({ org: "sentry" }); }); + + test("OutputError renders data and exits with error code", async () => { + let exitCalledWith: number | undefined; + const originalExit = process.exit; + + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + docs: { brief: "Test" }, + output: { + json: true, + human: (d: { error: string }) => `Error: ${d.error}`, + }, + parameters: {}, + async func(this: TestContext) { + throw new OutputError({ error: "not found" }); + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + // Mock process.exit — must throw to prevent fall-through since + // the real process.exit() is typed as `never` + class MockExit extends Error { + code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } + } + process.exit = ((code?: number) => { + exitCalledWith = code; + throw new MockExit(code ?? 0); + }) as typeof process.exit; + + try { + await run(app, ["test"], ctx as TestContext); + } finally { + process.exit = originalExit; + } + expect(exitCalledWith).toBe(1); + // Output was rendered BEFORE exit + expect(ctx.output.join("")).toContain("Error: not found"); + }); + + test("OutputError renders JSON in --json mode", async () => { + let exitCalledWith: number | undefined; + const originalExit = process.exit; + + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + docs: { brief: "Test" }, + output: { + json: true, + human: (d: { error: string }) => `Error: ${d.error}`, + }, + parameters: {}, + async func(this: TestContext) { + throw new OutputError({ error: "not found" }); + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + class MockExit extends Error { + code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } + } + process.exit = ((code?: number) => { + exitCalledWith = code; + throw new MockExit(code ?? 0); + }) as typeof process.exit; + + try { + await run(app, ["test", "--json"], ctx as TestContext); + } finally { + process.exit = originalExit; + } + expect(exitCalledWith).toBe(1); + const jsonOutput = JSON.parse(ctx.output.join("")); + expect(jsonOutput).toEqual({ error: "not found" }); + }); }); From 68758b6f3f5a0afbf21ef4cb19ee0e6327c01ae6 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 13:00:49 +0000 Subject: [PATCH 23/28] refactor: eliminate all direct stdout writes from api command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace imperative writeVerboseRequest, writeVerboseResponse, and writeResponseHeaders functions with return-based output: - Default: return raw response.body - --include: return { status, headers, body } envelope - --verbose: return { request, status, headers, body } full envelope The human formatter (formatApiResponse) handles all three shapes: - Raw body → formatBody (JSON-format objects, passthrough strings) - --include envelope → HTTP status + headers + body - --verbose envelope → '> ' request block + '< ' response block + body JSON mode gets the same structured data — --include/--verbose now produce structured JSON output instead of mixed text. Also: --silent + error uses OutputError(null) instead of bare process.exit(1), keeping the framework in control of exit codes. Delete dead functions: writeResponseHeaders, writeVerboseRequest, writeVerboseResponse. --- src/commands/api.ts | 166 ++++++++++++++++++------------- test/commands/api.test.ts | 204 +++++++++++++++++++++++--------------- 2 files changed, 224 insertions(+), 146 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 43aa322c..73333b1b 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -848,32 +848,10 @@ export function buildBodyFromFields( // Response Output /** - * Write response headers to stdout (standard format) + * Format a raw response body value for human-readable output. * @internal Exported for testing */ -export function writeResponseHeaders( - stdout: Writer, - status: number, - headers: Headers -): void { - stdout.write(`HTTP ${status}\n`); - headers.forEach((value, key) => { - stdout.write(`${key}: ${value}\n`); - }); - stdout.write("\n"); -} - -/** - * Format an API response body for human-readable output. - * - * The api command is a raw proxy — the response body is the output. - * Objects/arrays are JSON-formatted; strings (plain text, HTML error - * pages) pass through without JSON quoting; null/undefined produce - * no output. - * - * @internal Exported for testing - */ -export function formatApiResponse(body: unknown): string { +export function formatBody(body: unknown): string { if (body === null || body === undefined) { return ""; } @@ -884,38 +862,75 @@ export function formatApiResponse(body: unknown): string { } /** - * Write verbose request output (curl-style format) - * @internal Exported for testing + * Shape returned when --include or --verbose wraps the response body + * with HTTP metadata. When neither flag is set, data is the raw body. */ -export function writeVerboseRequest( - stdout: Writer, - method: string, - endpoint: string, - headers: Record | undefined -): void { - stdout.write(`> ${method} /api/0/${endpoint}\n`); - if (headers) { - for (const [key, value] of Object.entries(headers)) { - stdout.write(`> ${key}: ${value}\n`); - } - } - stdout.write(">\n"); +type ApiResponseEnvelope = { + status: number; + headers: Record; + body: unknown; + /** Present only in --verbose mode */ + request?: { + method: string; + endpoint: string; + headers?: Record; + }; +}; + +/** Type guard: does the data carry HTTP metadata? */ +function isEnvelope(data: unknown): data is ApiResponseEnvelope { + return ( + typeof data === "object" && + data !== null && + "status" in data && + "headers" in data && + "body" in data + ); } /** - * Write verbose response output (curl-style format) + * Format an API response for human-readable output. + * + * Handles two shapes: + * - Raw body (default) — formatted via formatBody + * - Envelope (--include/--verbose) — renders HTTP header block + * before the body, using curl-style `< ` prefixes in verbose mode. + * * @internal Exported for testing */ -export function writeVerboseResponse( - stdout: Writer, - status: number, - headers: Headers -): void { - stdout.write(`< HTTP ${status}\n`); - headers.forEach((value, key) => { - stdout.write(`< ${key}: ${value}\n`); - }); - stdout.write("<\n"); +export function formatApiResponse(data: unknown): string { + if (!isEnvelope(data)) { + return formatBody(data); + } + + const lines: string[] = []; + const prefix = data.request ? "< " : ""; + + // Verbose: request block first + if (data.request) { + lines.push(`> ${data.request.method} /api/0/${data.request.endpoint}`); + if (data.request.headers) { + for (const [key, value] of Object.entries(data.request.headers)) { + lines.push(`> ${key}: ${value}`); + } + } + lines.push(">"); + } + + // Response status + headers + lines.push(`${prefix}HTTP ${data.status}`); + for (const [key, value] of Object.entries(data.headers)) { + lines.push(`${prefix}${key}: ${value}`); + } + lines.push(prefix.trimEnd()); + + // Body + const bodyStr = formatBody(data.body); + if (bodyStr) { + lines.push(bodyStr); + } + + return lines.join("\n"); } /** @@ -1198,12 +1213,9 @@ export const apiCommand = buildCommand({ }, }, async func(this: SentryContext, flags: ApiFlags, endpoint: string) { - const { stdout, stderr, stdin } = this; + const { stderr, stdin } = this; - // Normalize endpoint to ensure trailing slash (Sentry API requirement) const normalizedEndpoint = normalizeEndpoint(endpoint); - - // Resolve body and query params from flags (--data, --input, or fields) const { body, params } = await resolveBody(flags, stdin, stderr); const headers = @@ -1223,11 +1235,6 @@ export const apiCommand = buildCommand({ }; } - // Verbose mode: show request details before the response - if (flags.verbose && !flags.silent) { - writeVerboseRequest(stdout, flags.method, normalizedEndpoint, headers); - } - const response = await rawApiRequest(normalizedEndpoint, { method: flags.method, body, @@ -1237,27 +1244,50 @@ export const apiCommand = buildCommand({ const isError = response.status >= 400; - // Silent mode — only set exit code, no output + // Silent mode — no output, just exit code if (flags.silent) { if (isError) { - process.exit(1); + throw new OutputError(null); } return; } - // Output response headers when requested (side-effect before body) + // Convert Headers to plain object for serialization + const headersToObject = (h: Headers): Record => { + const obj: Record = {}; + h.forEach((value, key) => { + obj[key] = value; + }); + return obj; + }; + + // Build response data — shape varies by flags + let responseData: unknown; if (flags.verbose) { - writeVerboseResponse(stdout, response.status, response.headers); + responseData = { + request: { + method: flags.method, + endpoint: normalizedEndpoint, + headers, + }, + status: response.status, + headers: headersToObject(response.headers), + body: response.body, + }; } else if (flags.include) { - writeResponseHeaders(stdout, response.status, response.headers); + responseData = { + status: response.status, + headers: headersToObject(response.headers), + body: response.body, + }; + } else { + responseData = response.body; } - // Error responses: throw so the wrapper renders the body then exits 1. - // The body is still useful (API error details), just indicates failure. if (isError) { - throw new OutputError(response.body); + throw new OutputError(responseData); } - return { data: response.body }; + return { data: responseData }; }, }); diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 19853ec1..2e4af810 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -17,6 +17,7 @@ import { dataToQueryParams, extractJsonBody, formatApiResponse, + formatBody, normalizeEndpoint, normalizeFields, parseDataBody, @@ -29,9 +30,6 @@ import { resolveEffectiveHeaders, resolveRequestUrl, setNestedValue, - writeResponseHeaders, - writeVerboseRequest, - writeVerboseResponse, } from "../../src/commands/api.js"; import { ValidationError } from "../../src/lib/errors.js"; import type { Writer } from "../../src/types/index.js"; @@ -878,135 +876,185 @@ describe("buildBodyFromFields", () => { }); }); -describe("writeResponseHeaders", () => { - test("writes status and headers", () => { - const writer = createMockWriter(); - const headers = new Headers({ - "Content-Type": "application/json", - "X-Custom": "value", +describe("formatApiResponse with --include envelope", () => { + test("renders status and headers", () => { + const output = formatApiResponse({ + status: 200, + headers: { + "content-type": "application/json", + "x-custom": "value", + }, + body: null, }); - writeResponseHeaders(writer, 200, headers); - - expect(writer.output).toMatch(/^HTTP 200\n/); - expect(writer.output).toMatch(/content-type: application\/json/i); - expect(writer.output).toMatch(/x-custom: value/i); - expect(writer.output).toMatch(/\n$/); + expect(output).toMatch(/^HTTP 200\n/); + expect(output).toContain("content-type: application/json"); + expect(output).toContain("x-custom: value"); }); test("handles different status codes", () => { - const writer = createMockWriter(); - const headers = new Headers(); - - writeResponseHeaders(writer, 404, headers); + const output = formatApiResponse({ + status: 404, + headers: {}, + body: null, + }); - expect(writer.output).toMatch(/^HTTP 404\n/); + expect(output).toContain("HTTP 404"); }); test("handles empty headers", () => { - const writer = createMockWriter(); - const headers = new Headers(); + const output = formatApiResponse({ + status: 200, + headers: {}, + body: null, + }); - writeResponseHeaders(writer, 200, headers); + expect(output).toContain("HTTP 200"); + }); - expect(writer.output).toBe("HTTP 200\n\n"); + test("includes body after headers", () => { + const output = formatApiResponse({ + status: 200, + headers: { "content-type": "application/json" }, + body: { key: "value" }, + }); + + expect(output).toContain("HTTP 200"); + expect(output).toContain("content-type: application/json"); + expect(output).toContain('"key": "value"'); }); }); -describe("formatApiResponse", () => { +describe("formatBody", () => { test("formats JSON object with pretty-printing", () => { - expect(formatApiResponse({ key: "value", num: 42 })).toBe( + expect(formatBody({ key: "value", num: 42 })).toBe( '{\n "key": "value",\n "num": 42\n}' ); }); test("formats JSON array with pretty-printing", () => { - expect(formatApiResponse([1, 2, 3])).toBe("[\n 1,\n 2,\n 3\n]"); + expect(formatBody([1, 2, 3])).toBe("[\n 1,\n 2,\n 3\n]"); }); test("formats string directly without JSON quoting", () => { - expect(formatApiResponse("plain text response")).toBe( - "plain text response" - ); + expect(formatBody("plain text response")).toBe("plain text response"); }); test("formats number as string", () => { - expect(formatApiResponse(42)).toBe("42"); + expect(formatBody(42)).toBe("42"); }); test("returns empty string for null", () => { - expect(formatApiResponse(null)).toBe(""); + expect(formatBody(null)).toBe(""); }); test("returns empty string for undefined", () => { - expect(formatApiResponse(undefined)).toBe(""); + expect(formatBody(undefined)).toBe(""); }); }); -describe("writeVerboseRequest", () => { - test("writes method and endpoint", () => { - const writer = createMockWriter(); - - writeVerboseRequest(writer, "GET", "organizations/", undefined); - - expect(writer.output).toBe("> GET /api/0/organizations/\n>\n"); +describe("formatApiResponse with raw body (no envelope)", () => { + test("delegates to formatBody for non-envelope data", () => { + expect(formatApiResponse({ key: "value" })).toBe('{\n "key": "value"\n}'); + expect(formatApiResponse("plain text")).toBe("plain text"); + expect(formatApiResponse(null)).toBe(""); + expect(formatApiResponse(undefined)).toBe(""); }); +}); - test("writes headers when provided", () => { - const writer = createMockWriter(); - - writeVerboseRequest(writer, "POST", "issues/", { - "Content-Type": "application/json", - "X-Custom": "value", +describe("formatApiResponse with --verbose envelope", () => { + test("renders request method and endpoint", () => { + const output = formatApiResponse({ + request: { + method: "GET", + endpoint: "organizations/", + }, + status: 200, + headers: {}, + body: null, }); - expect(writer.output).toMatch(/^> POST \/api\/0\/issues\/\n/); - expect(writer.output).toMatch(/> Content-Type: application\/json\n/); - expect(writer.output).toMatch(/> X-Custom: value\n/); - expect(writer.output).toMatch(/>\n$/); + expect(output).toContain("> GET /api/0/organizations/"); + expect(output).toContain(">"); }); - test("handles empty headers object", () => { - const writer = createMockWriter(); - - writeVerboseRequest(writer, "DELETE", "issues/123/", {}); + test("renders request headers when provided", () => { + const output = formatApiResponse({ + request: { + method: "POST", + endpoint: "issues/", + headers: { + "Content-Type": "application/json", + "X-Custom": "value", + }, + }, + status: 200, + headers: {}, + body: null, + }); - expect(writer.output).toBe("> DELETE /api/0/issues/123/\n>\n"); + expect(output).toContain("> POST /api/0/issues/"); + expect(output).toContain("> Content-Type: application/json"); + expect(output).toContain("> X-Custom: value"); }); -}); -describe("writeVerboseResponse", () => { - test("writes status and headers with < prefix", () => { - const writer = createMockWriter(); - const headers = new Headers({ - "Content-Type": "application/json", - "X-Request-Id": "abc123", + test("renders response status and headers with < prefix", () => { + const output = formatApiResponse({ + request: { + method: "GET", + endpoint: "organizations/", + }, + status: 200, + headers: { + "content-type": "application/json", + "x-request-id": "abc123", + }, + body: null, }); - writeVerboseResponse(writer, 200, headers); - - expect(writer.output).toMatch(/^< HTTP 200\n/); - expect(writer.output).toMatch(/< content-type: application\/json/i); - expect(writer.output).toMatch(/< x-request-id: abc123/i); - expect(writer.output).toMatch(/<\n$/); + expect(output).toContain("< HTTP 200"); + expect(output).toContain("< content-type: application/json"); + expect(output).toContain("< x-request-id: abc123"); }); test("handles error status codes", () => { - const writer = createMockWriter(); - const headers = new Headers(); - - writeVerboseResponse(writer, 500, headers); + const output = formatApiResponse({ + request: { method: "GET", endpoint: "issues/" }, + status: 500, + headers: {}, + body: null, + }); - expect(writer.output).toMatch(/^< HTTP 500\n/); + expect(output).toContain("< HTTP 500"); }); - test("handles empty headers", () => { - const writer = createMockWriter(); - const headers = new Headers(); + test("handles empty request headers", () => { + const output = formatApiResponse({ + request: { + method: "DELETE", + endpoint: "issues/123/", + }, + status: 204, + headers: {}, + body: null, + }); + + expect(output).toContain("> DELETE /api/0/issues/123/"); + expect(output).toContain("< HTTP 204"); + }); - writeVerboseResponse(writer, 204, headers); + test("includes body after response headers", () => { + const output = formatApiResponse({ + request: { method: "GET", endpoint: "issues/" }, + status: 200, + headers: { "content-type": "application/json" }, + body: { id: 123, title: "Bug" }, + }); - expect(writer.output).toBe("< HTTP 204\n<\n"); + expect(output).toContain("> GET /api/0/issues/"); + expect(output).toContain("< HTTP 200"); + expect(output).toContain('"id": 123'); + expect(output).toContain('"title": "Bug"'); }); }); From 6448bd9619a410cb7a4dcce7f110399c07c02200 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 13:21:43 +0000 Subject: [PATCH 24/28] refactor: remove --include, move --verbose to logger.debug() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --include wraps the body in { status, headers, body } which breaks --fields filtering (users would need --fields body.name instead of --fields name). Remove it entirely. --verbose request/response metadata is diagnostic info that belongs on stderr, not in the return data. Move to logger.debug() calls via consola — the framework already sets log level to debug when --verbose is passed, so the log lines appear automatically. The api command now always returns raw response.body. No envelopes, no shape changes per flags. --fields works directly on API response fields. Deleted: ApiResponseEnvelope type, isEnvelope() guard, formatBody() (renamed back to formatApiResponse), envelope rendering logic, headersToObject helper, --include flag + -i alias. --- src/commands/api.ts | 150 +++++++-------------------------- test/commands/api.test.ts | 173 +++----------------------------------- 2 files changed, 44 insertions(+), 279 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 73333b1b..6c06550d 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -10,6 +10,7 @@ import { buildSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; import { OutputError, ValidationError } from "../lib/errors.js"; import { validateEndpoint } from "../lib/input-validation.js"; +import { logger } from "../lib/logger.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; import type { Writer } from "../types/index.js"; @@ -22,7 +23,6 @@ type ApiFlags = { readonly "raw-field"?: string[]; readonly header?: string[]; readonly input?: string; - readonly include: boolean; readonly silent: boolean; readonly verbose: boolean; readonly "dry-run": boolean; @@ -849,88 +849,17 @@ export function buildBodyFromFields( /** * Format a raw response body value for human-readable output. - * @internal Exported for testing - */ -export function formatBody(body: unknown): string { - if (body === null || body === undefined) { - return ""; - } - if (typeof body === "object") { - return JSON.stringify(body, null, 2); - } - return String(body); -} - -/** - * Shape returned when --include or --verbose wraps the response body - * with HTTP metadata. When neither flag is set, data is the raw body. - */ -type ApiResponseEnvelope = { - status: number; - headers: Record; - body: unknown; - /** Present only in --verbose mode */ - request?: { - method: string; - endpoint: string; - headers?: Record; - }; -}; - -/** Type guard: does the data carry HTTP metadata? */ -function isEnvelope(data: unknown): data is ApiResponseEnvelope { - return ( - typeof data === "object" && - data !== null && - "status" in data && - "headers" in data && - "body" in data - ); -} - -/** - * Format an API response for human-readable output. - * - * Handles two shapes: - * - Raw body (default) — formatted via formatBody - * - Envelope (--include/--verbose) — renders HTTP header block - * before the body, using curl-style `< ` prefixes in verbose mode. - * + * Objects are pretty-printed as JSON, strings pass through, null/undefined → empty. * @internal Exported for testing */ export function formatApiResponse(data: unknown): string { - if (!isEnvelope(data)) { - return formatBody(data); - } - - const lines: string[] = []; - const prefix = data.request ? "< " : ""; - - // Verbose: request block first - if (data.request) { - lines.push(`> ${data.request.method} /api/0/${data.request.endpoint}`); - if (data.request.headers) { - for (const [key, value] of Object.entries(data.request.headers)) { - lines.push(`> ${key}: ${value}`); - } - } - lines.push(">"); - } - - // Response status + headers - lines.push(`${prefix}HTTP ${data.status}`); - for (const [key, value] of Object.entries(data.headers)) { - lines.push(`${prefix}${key}: ${value}`); + if (data === null || data === undefined) { + return ""; } - lines.push(prefix.trimEnd()); - - // Body - const bodyStr = formatBody(data.body); - if (bodyStr) { - lines.push(bodyStr); + if (typeof data === "object") { + return JSON.stringify(data, null, 2); } - - return lines.join("\n"); + return String(data); } /** @@ -1102,6 +1031,8 @@ export async function resolveBody( // Command Definition +const log = logger.withTag("api"); + export const apiCommand = buildCommand({ output: { json: true, human: formatApiResponse }, docs: { @@ -1181,11 +1112,6 @@ export const apiCommand = buildCommand({ optional: true, placeholder: "file", }, - include: { - kind: "boolean", - brief: "Include HTTP response status line and headers in the output", - default: false, - }, silent: { kind: "boolean", brief: "Do not print the response body", @@ -1208,7 +1134,6 @@ export const apiCommand = buildCommand({ F: "field", f: "raw-field", H: "header", - i: "include", n: "dry-run", }, }, @@ -1235,6 +1160,17 @@ export const apiCommand = buildCommand({ }; } + // Verbose: log request details to stderr before making the call + if (flags.verbose) { + log.debug(`> ${flags.method} /api/0/${normalizedEndpoint}`); + if (headers) { + for (const [key, value] of Object.entries(headers)) { + log.debug(`> ${key}: ${value}`); + } + } + log.debug(">"); + } + const response = await rawApiRequest(normalizedEndpoint, { method: flags.method, body, @@ -1244,6 +1180,15 @@ export const apiCommand = buildCommand({ const isError = response.status >= 400; + // Verbose: log response metadata to stderr + if (flags.verbose) { + log.debug(`< HTTP ${response.status}`); + response.headers.forEach((value, key) => { + log.debug(`< ${key}: ${value}`); + }); + log.debug("<"); + } + // Silent mode — no output, just exit code if (flags.silent) { if (isError) { @@ -1252,42 +1197,11 @@ export const apiCommand = buildCommand({ return; } - // Convert Headers to plain object for serialization - const headersToObject = (h: Headers): Record => { - const obj: Record = {}; - h.forEach((value, key) => { - obj[key] = value; - }); - return obj; - }; - - // Build response data — shape varies by flags - let responseData: unknown; - if (flags.verbose) { - responseData = { - request: { - method: flags.method, - endpoint: normalizedEndpoint, - headers, - }, - status: response.status, - headers: headersToObject(response.headers), - body: response.body, - }; - } else if (flags.include) { - responseData = { - status: response.status, - headers: headersToObject(response.headers), - body: response.body, - }; - } else { - responseData = response.body; - } - + // Always return raw body — --fields filters it directly if (isError) { - throw new OutputError(responseData); + throw new OutputError(response.body); } - return { data: responseData }; + return { data: response.body }; }, }); diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 2e4af810..455d3fba 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -17,7 +17,6 @@ import { dataToQueryParams, extractJsonBody, formatApiResponse, - formatBody, normalizeEndpoint, normalizeFields, parseDataBody, @@ -876,185 +875,37 @@ describe("buildBodyFromFields", () => { }); }); -describe("formatApiResponse with --include envelope", () => { - test("renders status and headers", () => { - const output = formatApiResponse({ - status: 200, - headers: { - "content-type": "application/json", - "x-custom": "value", - }, - body: null, - }); - - expect(output).toMatch(/^HTTP 200\n/); - expect(output).toContain("content-type: application/json"); - expect(output).toContain("x-custom: value"); - }); - - test("handles different status codes", () => { - const output = formatApiResponse({ - status: 404, - headers: {}, - body: null, - }); - - expect(output).toContain("HTTP 404"); - }); - - test("handles empty headers", () => { - const output = formatApiResponse({ - status: 200, - headers: {}, - body: null, - }); - - expect(output).toContain("HTTP 200"); - }); - - test("includes body after headers", () => { - const output = formatApiResponse({ - status: 200, - headers: { "content-type": "application/json" }, - body: { key: "value" }, - }); - - expect(output).toContain("HTTP 200"); - expect(output).toContain("content-type: application/json"); - expect(output).toContain('"key": "value"'); - }); -}); - -describe("formatBody", () => { +describe("formatApiResponse", () => { test("formats JSON object with pretty-printing", () => { - expect(formatBody({ key: "value", num: 42 })).toBe( + expect(formatApiResponse({ key: "value", num: 42 })).toBe( '{\n "key": "value",\n "num": 42\n}' ); }); test("formats JSON array with pretty-printing", () => { - expect(formatBody([1, 2, 3])).toBe("[\n 1,\n 2,\n 3\n]"); + expect(formatApiResponse([1, 2, 3])).toBe("[\n 1,\n 2,\n 3\n]"); }); test("formats string directly without JSON quoting", () => { - expect(formatBody("plain text response")).toBe("plain text response"); + expect(formatApiResponse("plain text response")).toBe( + "plain text response" + ); }); test("formats number as string", () => { - expect(formatBody(42)).toBe("42"); - }); - - test("returns empty string for null", () => { - expect(formatBody(null)).toBe(""); + expect(formatApiResponse(42)).toBe("42"); }); - test("returns empty string for undefined", () => { - expect(formatBody(undefined)).toBe(""); + test("formats boolean as string", () => { + expect(formatApiResponse(true)).toBe("true"); }); -}); -describe("formatApiResponse with raw body (no envelope)", () => { - test("delegates to formatBody for non-envelope data", () => { - expect(formatApiResponse({ key: "value" })).toBe('{\n "key": "value"\n}'); - expect(formatApiResponse("plain text")).toBe("plain text"); + test("returns empty string for null", () => { expect(formatApiResponse(null)).toBe(""); - expect(formatApiResponse(undefined)).toBe(""); }); -}); -describe("formatApiResponse with --verbose envelope", () => { - test("renders request method and endpoint", () => { - const output = formatApiResponse({ - request: { - method: "GET", - endpoint: "organizations/", - }, - status: 200, - headers: {}, - body: null, - }); - - expect(output).toContain("> GET /api/0/organizations/"); - expect(output).toContain(">"); - }); - - test("renders request headers when provided", () => { - const output = formatApiResponse({ - request: { - method: "POST", - endpoint: "issues/", - headers: { - "Content-Type": "application/json", - "X-Custom": "value", - }, - }, - status: 200, - headers: {}, - body: null, - }); - - expect(output).toContain("> POST /api/0/issues/"); - expect(output).toContain("> Content-Type: application/json"); - expect(output).toContain("> X-Custom: value"); - }); - - test("renders response status and headers with < prefix", () => { - const output = formatApiResponse({ - request: { - method: "GET", - endpoint: "organizations/", - }, - status: 200, - headers: { - "content-type": "application/json", - "x-request-id": "abc123", - }, - body: null, - }); - - expect(output).toContain("< HTTP 200"); - expect(output).toContain("< content-type: application/json"); - expect(output).toContain("< x-request-id: abc123"); - }); - - test("handles error status codes", () => { - const output = formatApiResponse({ - request: { method: "GET", endpoint: "issues/" }, - status: 500, - headers: {}, - body: null, - }); - - expect(output).toContain("< HTTP 500"); - }); - - test("handles empty request headers", () => { - const output = formatApiResponse({ - request: { - method: "DELETE", - endpoint: "issues/123/", - }, - status: 204, - headers: {}, - body: null, - }); - - expect(output).toContain("> DELETE /api/0/issues/123/"); - expect(output).toContain("< HTTP 204"); - }); - - test("includes body after response headers", () => { - const output = formatApiResponse({ - request: { method: "GET", endpoint: "issues/" }, - status: 200, - headers: { "content-type": "application/json" }, - body: { id: 123, title: "Bug" }, - }); - - expect(output).toContain("> GET /api/0/issues/"); - expect(output).toContain("< HTTP 200"); - expect(output).toContain('"id": 123'); - expect(output).toContain('"title": "Bug"'); + test("returns empty string for undefined", () => { + expect(formatApiResponse(undefined)).toBe(""); }); }); From c73b1dd6bf0acb6c5838431c0d0bc0060ecd4bc9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 13:22:14 +0000 Subject: [PATCH 25/28] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index c8baff3e..114f9140 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -396,7 +396,6 @@ Make an authenticated API request - `-f, --raw-field ... - Add a string parameter without JSON parsing` - `-H, --header ... - Add a HTTP request header in key:value format` - `--input - The file to use as body for the HTTP request (use "-" to read from standard input)` -- `-i, --include - Include HTTP response status line and headers in the output` - `--silent - Do not print the response body` - `--verbose - Include full HTTP request and response in the output` - `-n, --dry-run - Show the resolved request without sending it` From f3886160617db460cc2c13fcc6e9b4fc82e135c7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 14:37:22 +0000 Subject: [PATCH 26/28] fix: E2E failures from --include removal, --verbose to stderr, null output bug - Delete E2E tests for removed --include/-i flag - Fix --verbose E2E test: check stderr (logger.debug) not stdout - Fix renderCommandOutput null data bug: skip rendering when OutputError.data is null/undefined instead of writing spurious \n - Update api docs: replace --include example with --verbose section - Regenerate SKILL.md to remove stale --include references --- docs/src/content/docs/commands/api.md | 15 ++++--- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 2 +- src/lib/command.ts | 13 +++--- test/e2e/api.test.ts | 42 ++++--------------- 4 files changed, 26 insertions(+), 46 deletions(-) diff --git a/docs/src/content/docs/commands/api.md b/docs/src/content/docs/commands/api.md index 3fe0aa92..2330669c 100644 --- a/docs/src/content/docs/commands/api.md +++ b/docs/src/content/docs/commands/api.md @@ -85,17 +85,20 @@ sentry api /organizations/ \ --header "X-Custom-Header:value" ``` -### Show Response Headers +### Verbose Mode ```bash -sentry api /organizations/ --include +sentry api /organizations/ --verbose ``` -``` -HTTP/2 200 -content-type: application/json -x-sentry-rate-limit-remaining: 95 +Request and response metadata is logged to stderr: +``` +> GET /api/0/organizations/ +> +< HTTP 200 +< content-type: application/json +< [{"slug": "my-org", ...}] ``` diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 114f9140..c8b3d215 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -439,7 +439,7 @@ sentry api /projects/my-org/my-project/ \ sentry api /organizations/ \ --header "X-Custom-Header:value" -sentry api /organizations/ --include +sentry api /organizations/ --verbose # Get all issues (automatically follows pagination) sentry api /projects/my-org/my-project/issues/ --paginate diff --git a/src/lib/command.ts b/src/lib/command.ts index 1f5bc175..c4707ba9 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -387,11 +387,14 @@ export function buildCommand< // simply `throw new OutputError(data)`. const handleOutputError = (err: unknown): never => { if (err instanceof OutputError && outputConfig) { - handleReturnValue( - this, - { data: err.data } as CommandOutput, - cleanFlags - ); + // Only render if there's actual data to show + if (err.data !== null && err.data !== undefined) { + handleReturnValue( + this, + { data: err.data } as CommandOutput, + cleanFlags + ); + } process.exit(err.exitCode); } throw err; diff --git a/test/e2e/api.test.ts b/test/e2e/api.test.ts index 9c30bae5..5cc43aff 100644 --- a/test/e2e/api.test.ts +++ b/test/e2e/api.test.ts @@ -66,21 +66,6 @@ describe("sentry api", () => { { timeout: 15_000 } ); - test( - "--include flag shows response headers", - async () => { - await ctx.setAuthToken(TEST_TOKEN); - - const result = await ctx.run(["api", "organizations/", "--include"]); - - expect(result.exitCode).toBe(0); - // Should include HTTP status and headers before JSON body - expect(result.stdout).toMatch(/^HTTP \d{3}/); - expect(result.stdout).toMatch(/content-type:/i); - }, - { timeout: 15_000 } - ); - test( "invalid endpoint returns non-zero exit code", async () => { @@ -179,19 +164,6 @@ describe("sentry api", () => { { timeout: 15_000 } ); - test( - "-i alias for --include works", - async () => { - await ctx.setAuthToken(TEST_TOKEN); - - const result = await ctx.run(["api", "organizations/", "-i"]); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/^HTTP \d{3}/); - }, - { timeout: 15_000 } - ); - test( "-H alias for --header works", async () => { @@ -225,12 +197,14 @@ describe("sentry api", () => { const result = await ctx.run(["api", "organizations/", "--verbose"]); expect(result.exitCode).toBe(0); - // Should show request line with > prefix - expect(result.stdout).toMatch(/^> GET \/api\/0\/organizations\//m); - // Should show response status with < prefix - expect(result.stdout).toMatch(/^< HTTP \d{3}/m); - // Should show response headers with < prefix - expect(result.stdout).toMatch(/^< content-type:/im); + // Verbose output goes to stderr via logger.debug() + // consola formats as: [debug] [api] > GET /api/0/organizations/ + expect(result.stderr).toMatch(/> GET \/api\/0\/organizations\//); + expect(result.stderr).toMatch(/< HTTP \d{3}/); + expect(result.stderr).toMatch(/< content-type:/i); + // stdout should still contain the response body + const data = JSON.parse(result.stdout); + expect(Array.isArray(data)).toBe(true); }, { timeout: 15_000 } ); From 0e7fc3065f8b1267107f68b3625c8083235cddf1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 14:47:57 +0000 Subject: [PATCH 27/28] fix: consola routes debug/info to stdout in non-TTY mode Consola's BasicReporter (used when stderr is piped, e.g. in CI or Bun.spawn) routes debug and info messages to stdout instead of stderr. This contaminated command output when --verbose was used in non-TTY contexts. Fix: set stdout: process.stderr in createConsola() so ALL diagnostic log output goes to stderr regardless of reporter or TTY mode. --- src/lib/logger.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/logger.ts b/src/lib/logger.ts index c6b60bca..ec936d52 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -154,11 +154,11 @@ const scopedLoggers: ConsolaInstance[] = []; */ export const logger = createConsola({ level: DEFAULT_LOG_LEVEL, - // stderr is the correct stream for diagnostic/log output in CLIs — - // stdout is reserved for command output (data, JSON, tables). + // All diagnostic/log output goes to stderr — stdout is reserved for + // command output (data, JSON, tables). Both streams must be set because + // consola's BasicReporter (non-TTY) routes debug/info to stdout by default. + stdout: process.stderr, stderr: process.stderr, - // FancyReporter is included by default for TTY, BasicReporter for CI/non-TTY. - // Sentry reporter is added after Sentry.init() via attachSentryReporter(). }); /** From 14905d3d8261a6a43adba82ae6649532de25e816 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 11 Mar 2026 14:55:39 +0000 Subject: [PATCH 28/28] fix: --silent suppresses --verbose output, extract log helpers - Guard verbose logging with !flags.silent to prevent leak when --verbose and --silent are combined (BugBot finding) - Extract logRequest() and logResponse() helpers to reduce func cognitive complexity from 17 to within limit --- src/commands/api.ts | 46 +++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 6c06550d..43867168 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -1033,6 +1033,30 @@ export async function resolveBody( const log = logger.withTag("api"); +/** Log outgoing request details in `> ` curl-verbose style. */ +function logRequest( + method: string, + endpoint: string, + headers: Record | undefined +): void { + log.debug(`> ${method} /api/0/${endpoint}`); + if (headers) { + for (const [key, value] of Object.entries(headers)) { + log.debug(`> ${key}: ${value}`); + } + } + log.debug(">"); +} + +/** Log incoming response details in `< ` curl-verbose style. */ +function logResponse(response: { status: number; headers: Headers }): void { + log.debug(`< HTTP ${response.status}`); + response.headers.forEach((value, key) => { + log.debug(`< ${key}: ${value}`); + }); + log.debug("<"); +} + export const apiCommand = buildCommand({ output: { json: true, human: formatApiResponse }, docs: { @@ -1160,15 +1184,10 @@ export const apiCommand = buildCommand({ }; } - // Verbose: log request details to stderr before making the call - if (flags.verbose) { - log.debug(`> ${flags.method} /api/0/${normalizedEndpoint}`); - if (headers) { - for (const [key, value] of Object.entries(headers)) { - log.debug(`> ${key}: ${value}`); - } - } - log.debug(">"); + const verbose = flags.verbose && !flags.silent; + + if (verbose) { + logRequest(flags.method, normalizedEndpoint, headers); } const response = await rawApiRequest(normalizedEndpoint, { @@ -1180,13 +1199,8 @@ export const apiCommand = buildCommand({ const isError = response.status >= 400; - // Verbose: log response metadata to stderr - if (flags.verbose) { - log.debug(`< HTTP ${response.status}`); - response.headers.forEach((value, key) => { - log.debug(`< ${key}: ${value}`); - }); - log.debug("<"); + if (verbose) { + logResponse(response); } // Silent mode — no output, just exit code