diff --git a/src/lib/env-registry.ts b/src/lib/env-registry.ts index 8740a2104..9082f4d7b 100644 --- a/src/lib/env-registry.ts +++ b/src/lib/env-registry.ts @@ -18,6 +18,14 @@ export type EnvVarEntry = { defaultValue?: string; /** Install-script-only variable (not used at runtime by the CLI binary). */ installOnly?: boolean; + /** + * Surface this variable in the branded `sentry --help` output (and in the + * `envVars` array of `sentry help --json`). Reserve for the highest-signal + * variables — the full list lives in `configuration.md`. + */ + topLevel?: boolean; + /** Short one-line description used in the branded help summary. Falls back to `description` when absent. */ + briefDescription?: string; }; /** @@ -32,8 +40,10 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ { name: "SENTRY_AUTH_TOKEN", description: - "Authentication token for the Sentry API. This is the primary way to authenticate in CI/CD pipelines and scripts where interactive login is not possible.\n\nYou can create auth tokens in your [Sentry account settings](https://sentry.io/settings/account/api/auth-tokens/). When set, this takes precedence over any stored OAuth token from `sentry auth login`.", + "Authentication token for the Sentry API. This is the primary way to authenticate in CI/CD pipelines and scripts where interactive login is not possible.\n\nYou can create auth tokens in your [Sentry account settings](https://sentry.io/settings/account/api/auth-tokens/). When a stored OAuth login from `sentry auth login` also exists, the stored login takes priority — set `SENTRY_FORCE_ENV_TOKEN=1` to override.", example: "sntrys_YOUR_TOKEN_HERE", + topLevel: true, + briefDescription: "Auth token used for API requests (CI, scripts).", }, { name: "SENTRY_TOKEN", @@ -45,6 +55,8 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ description: "When set, environment variable tokens (`SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN`) take precedence over the stored OAuth token from `sentry auth login`. By default, the stored OAuth token takes priority because it supports automatic refresh. Set this if you want to ensure the environment variable token is always used, which is useful for self-hosted setups or CI environments.", example: "1", + topLevel: true, + briefDescription: "Prefer the env-var token over a stored OAuth login.", }, // -- Targeting -- { @@ -52,18 +64,24 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ description: "Default organization slug. Skips organization auto-detection.", example: "my-org", + topLevel: true, + briefDescription: "Default organization slug.", }, { name: "SENTRY_PROJECT", description: "Default project slug. Can also include the org in `org/project` format.\n\nWhen using the `org/project` combo format, `SENTRY_ORG` is ignored.", example: "my-org/my-project", + topLevel: true, + briefDescription: "Default project slug (or `org/project`).", }, { name: "SENTRY_DSN", description: "Sentry DSN for project auto-detection. This is the same DSN you use in `Sentry.init()`. The CLI resolves it to determine your organization and project.\n\nThe CLI also detects DSNs from `.env` files and source code automatically — see [DSN Auto-Detection](./features/#dsn-auto-detection).", example: "https://key@o123.ingest.us.sentry.io/456", + topLevel: true, + briefDescription: "DSN used to auto-detect org + project.", }, // -- URL -- { @@ -72,6 +90,8 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ "Base URL of your Sentry instance. **Only needed for [self-hosted Sentry](./self-hosted/).** SaaS users (sentry.io) should not set this.\n\nWhen set, all API requests (including OAuth login) are directed to this URL instead of `https://sentry.io`. The CLI also sets this automatically when you pass a self-hosted Sentry URL as a command argument.\n\n`SENTRY_HOST` takes precedence over `SENTRY_URL`. Both work identically — use whichever you prefer.", example: "https://sentry.example.com", defaultValue: "https://sentry.io", + topLevel: true, + briefDescription: "Base URL of your Sentry instance (self-hosted).", }, { name: "SENTRY_URL", @@ -139,6 +159,8 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ description: "Standard convention to disable color output. See [no-color.org](https://no-color.org/). Respected when `SENTRY_PLAIN_OUTPUT` is not set.", example: "1", + topLevel: true, + briefDescription: "Disable colored output (no-color.org convention).", }, { name: "FORCE_COLOR", @@ -159,6 +181,8 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ "Controls the verbosity of diagnostic output. Defaults to `info`.\n\nValid values: `error`, `warn`, `log`, `info`, `debug`, `trace`\n\nEquivalent to passing `--log-level debug` on the command line. CLI flags take precedence over the environment variable.", example: "debug", defaultValue: "info", + topLevel: true, + briefDescription: "Log verbosity (error, warn, info, debug, trace).", }, { name: "SENTRY_CLI_NO_TELEMETRY", @@ -194,3 +218,12 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ example: "1", }, ]; + +/** + * Subset of env vars surfaced in the branded `sentry --help` output and in + * the `envVars` array of `sentry help --json`. + * + * Order is preserved from {@link ENV_VAR_REGISTRY}. + */ +export const TOP_LEVEL_ENV_VARS: readonly EnvVarEntry[] = + ENV_VAR_REGISTRY.filter((entry) => entry.topLevel); diff --git a/src/lib/help.ts b/src/lib/help.ts index e05e407c0..e7f74eb13 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -9,6 +9,7 @@ import { routes } from "../app.js"; import { formatBanner } from "./banner.js"; import { isAuthenticated } from "./db/auth.js"; +import { TOP_LEVEL_ENV_VARS } from "./env-registry.js"; import { cyan, magenta, muted } from "./formatters/colors.js"; import { type CommandInfo, @@ -99,6 +100,27 @@ function formatCommands(commands: HelpCommand[]): string { .join("\n"); } +/** + * Format the top-level environment variable list with aligned descriptions. + * + * Source of truth: `TOP_LEVEL_ENV_VARS` in `env-registry.ts`. Keep this + * short — full docs live under `configuration.md`. + */ +function formatEnvVars(): string { + if (TOP_LEVEL_ENV_VARS.length === 0) { + return ""; + } + const padding = 4; + const maxNameLength = Math.max( + ...TOP_LEVEL_ENV_VARS.map((v) => v.name.length) + ); + return TOP_LEVEL_ENV_VARS.map((v) => { + const namePadded = v.name.padEnd(maxNameLength + padding); + const desc = v.briefDescription ?? v.description.split("\n")[0] ?? ""; + return ` ${cyan(namePadded)}${muted(desc)}`; + }).join("\n"); +} + /** * Build the custom branded help output string. * Shows a contextual example based on authentication status. @@ -124,6 +146,14 @@ export function printCustomHelp(): string { lines.push(formatCommands(generateCommands())); lines.push(""); + // Environment variables (auto-generated from env-registry) + const envVars = formatEnvVars(); + if (envVars) { + lines.push(` ${muted("Environment Variables:")}`); + lines.push(envVars); + lines.push(""); + } + // Example lines.push(` ${muted("try:")} ${magenta(example)}`); lines.push(""); @@ -140,6 +170,23 @@ export function printCustomHelp(): string { // Introspection (for `sentry help --json` and human rendering) // --------------------------------------------------------------------------- +/** + * Metadata for a top-level environment variable as exposed to JSON callers + * of `sentry help --json`. + */ +export type HelpEnvVarInfo = { + /** Variable name (e.g. `SENTRY_AUTH_TOKEN`). */ + name: string; + /** Short one-line description suitable for list display. */ + brief: string; + /** Full markdown description from the env-var registry. */ + description: string; + /** Example value (when provided in the registry). */ + example?: string; + /** Default value (when provided in the registry). */ + defaultValue?: string; +}; + /** * Result of introspecting the CLI. * Yielded as CommandOutput — JSON mode serializes directly, human mode @@ -149,18 +196,42 @@ export function printCustomHelp(): string { * it's stripped from JSON output via `jsonExclude`. */ export type HelpJsonResult = - | ({ routes: RouteInfo[] } & { _banner?: string }) + | ({ routes: RouteInfo[]; envVars: HelpEnvVarInfo[] } & { _banner?: string }) | CommandInfo | RouteInfo | { error: string; suggestions?: string[] }; +/** + * Build the top-level env-var list for JSON output. + * + * Exposes exactly the entries marked `topLevel` in the registry so that + * consumers (AI agents, docs tooling) can surface them without having to + * re-parse the CLI's branded help. + */ +function buildTopLevelEnvVars(): HelpEnvVarInfo[] { + return TOP_LEVEL_ENV_VARS.map((v) => ({ + name: v.name, + brief: v.briefDescription ?? v.description.split("\n")[0] ?? "", + description: v.description, + example: v.example, + defaultValue: v.defaultValue, + })); +} + /** * Introspect the full command tree. - * Returns all visible routes with all flags included. + * Returns all visible routes with all flags included, plus the top-level + * environment variables recognized by the CLI. */ -export function introspectAllCommands(): { routes: RouteInfo[] } { +export function introspectAllCommands(): { + routes: RouteInfo[]; + envVars: HelpEnvVarInfo[]; +} { const routeMap = routes as unknown as RouteMap; - return { routes: extractAllRoutes(routeMap) }; + return { + routes: extractAllRoutes(routeMap), + envVars: buildTopLevelEnvVars(), + }; } /** @@ -341,9 +412,20 @@ export function formatHelpHuman(data: HelpJsonResult): string { return `Error: ${data.error}`; } - // Full tree without banner (shouldn't happen in practice) + // Full tree without banner (shouldn't happen in practice — the help + // command always attaches one). Keep the envVars in sync anyway. if ("routes" in data) { - return data.routes.map(formatGroupHuman).join("\n\n"); + const routesText = data.routes.map(formatGroupHuman).join("\n\n"); + if ("envVars" in data && data.envVars.length > 0) { + const lines = [ + routesText, + "", + "Environment Variables:", + ...data.envVars.map((v) => ` ${v.name} — ${v.brief}`), + ]; + return lines.join("\n"); + } + return routesText; } return ""; diff --git a/test/lib/help.test.ts b/test/lib/help.test.ts index a8d8668f6..6d1c61332 100644 --- a/test/lib/help.test.ts +++ b/test/lib/help.test.ts @@ -7,7 +7,7 @@ import { describe, expect, test } from "bun:test"; import { formatBanner } from "../../src/lib/banner.js"; -import { printCustomHelp } from "../../src/lib/help.js"; +import { introspectAllCommands, printCustomHelp } from "../../src/lib/help.js"; import { useTestConfigDir } from "../helpers.js"; /** Strip ANSI escape sequences for content assertions */ @@ -69,4 +69,48 @@ describe("printCustomHelp", () => { const output = stripAnsi(printCustomHelp()); expect(output).toContain("sentry auth login"); }); + + test("includes an Environment Variables section with top-level vars", () => { + const output = stripAnsi(printCustomHelp()); + expect(output).toContain("Environment Variables:"); + // Highest-signal vars from the feedback issue must be surfaced. + expect(output).toContain("SENTRY_AUTH_TOKEN"); + expect(output).toContain("SENTRY_FORCE_ENV_TOKEN"); + expect(output).toContain("SENTRY_ORG"); + expect(output).toContain("SENTRY_PROJECT"); + expect(output).toContain("SENTRY_DSN"); + expect(output).toContain("SENTRY_HOST"); + expect(output).toContain("SENTRY_LOG_LEVEL"); + expect(output).toContain("NO_COLOR"); + }); +}); + +describe("introspectAllCommands", () => { + useTestConfigDir("help-introspect-"); + + test("includes an envVars array with the top-level env vars", () => { + const result = introspectAllCommands(); + expect(Array.isArray(result.envVars)).toBe(true); + const names = result.envVars.map((v) => v.name); + expect(names).toContain("SENTRY_AUTH_TOKEN"); + expect(names).toContain("SENTRY_FORCE_ENV_TOKEN"); + expect(names).toContain("SENTRY_ORG"); + expect(names).toContain("SENTRY_PROJECT"); + expect(names).toContain("SENTRY_DSN"); + expect(names).toContain("SENTRY_HOST"); + expect(names).toContain("SENTRY_LOG_LEVEL"); + expect(names).toContain("NO_COLOR"); + }); + + test("each envVars entry has a brief and description", () => { + const { envVars } = introspectAllCommands(); + for (const v of envVars) { + expect(typeof v.name).toBe("string"); + expect(v.name.length).toBeGreaterThan(0); + expect(typeof v.brief).toBe("string"); + expect(v.brief.length).toBeGreaterThan(0); + expect(typeof v.description).toBe("string"); + expect(v.description.length).toBeGreaterThan(0); + } + }); });