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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/lib/env-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand All @@ -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",
Expand All @@ -45,25 +55,33 @@ 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 --
{
name: "SENTRY_ORG",
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 --
{
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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);
94 changes: 88 additions & 6 deletions src/lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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("");
Expand All @@ -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
Expand All @@ -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(),
};
}

/**
Expand Down Expand Up @@ -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 "";
Expand Down
46 changes: 45 additions & 1 deletion test/lib/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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);
}
});
});
Loading