From dfa79b5b243b08910609fde37b29d07302e535ef Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:40:38 +0000 Subject: [PATCH 1/4] feat(cli): Add persistent tips default and SENTRY_DISABLE_TIPS override Let users hide the `Tip: ...` hints printed below command output, either persistently with `sentry cli defaults tips off` or per session with `SENTRY_DISABLE_TIPS=1` (the env var wins over the stored preference). Only tip text is removed: pagination, dry-run, cancellation and other notices that share the command hint footer are kept, as is the cache-age footer. No CLI flag is added. Ported from getsentry/cli#1414 with the review feedback applied. Closes #1479 Co-Authored-By: Burak Yigit Kaya --- apps/cli-docs/src/fragments/commands/cli.md | 3 + apps/cli-docs/src/fragments/configuration.md | 14 +- .../skills/sentry-cli/references/cli.md | 3 + packages/cli/src/commands/cli/defaults.ts | 30 ++++- packages/cli/src/commands/issue/list.ts | 3 +- packages/cli/src/lib/command.ts | 8 +- packages/cli/src/lib/db/defaults.ts | 38 ++++++ packages/cli/src/lib/env-registry.ts | 6 + packages/cli/src/lib/formatters/human.ts | 6 +- packages/cli/src/lib/tips.ts | 77 +++++++++++ .../cli/test/commands/cli/defaults.test.ts | 27 ++++ packages/cli/test/lib/command.test.ts | 40 ++++++ packages/cli/test/lib/tips.test.ts | 127 ++++++++++++++++++ 13 files changed, 377 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/lib/tips.ts create mode 100644 packages/cli/test/lib/tips.test.ts diff --git a/apps/cli-docs/src/fragments/commands/cli.md b/apps/cli-docs/src/fragments/commands/cli.md index 9053b9483..241d5ce57 100644 --- a/apps/cli-docs/src/fragments/commands/cli.md +++ b/apps/cli-docs/src/fragments/commands/cli.md @@ -85,6 +85,9 @@ sentry cli defaults ca-cert /path/to/ca.pem # Disable telemetry sentry cli defaults telemetry off +# Hide Tip: hints in command output +sentry cli defaults tips off + # Clear a single default sentry cli defaults org --clear diff --git a/apps/cli-docs/src/fragments/configuration.md b/apps/cli-docs/src/fragments/configuration.md index 4a4c51840..22ec4e241 100644 --- a/apps/cli-docs/src/fragments/configuration.md +++ b/apps/cli-docs/src/fragments/configuration.md @@ -66,19 +66,31 @@ If you previously used the legacy `sentry-cli` and have a `~/.sentryclirc` file, ## Persistent Defaults -Use `sentry cli defaults` to set persistent defaults for organization, project, URL, and telemetry. These are stored in the CLI's local database and apply to all commands. +Use `sentry cli defaults` to set persistent defaults for organization, project, URL, telemetry, and tips. These are stored in the CLI's local database and apply to all commands. ```bash sentry cli defaults org my-org # Set default organization sentry cli defaults project my-project # Set default project sentry cli defaults url https://... # Set Sentry URL (self-hosted) sentry cli defaults telemetry off # Disable telemetry +sentry cli defaults tips off # Hide Tip: hints in command output sentry cli defaults # Show all current defaults sentry cli defaults org --clear # Clear a specific default ``` See [`sentry cli defaults`](./commands/cli/#sentry-cli-defaults) for full usage. +### Disabling tips + +Some commands print a `Tip: ...` hint below their output. To hide these, store the preference or set an environment variable: + +```bash +sentry cli defaults tips off # persistent +export SENTRY_DISABLE_TIPS=1 # per-session, overrides the stored preference +``` + +Only the tips are removed. Pagination hints, dry-run and cancellation notices, and the cache-age footer (`cached · 3m ago · use -f to refresh`) are still shown. + ## Global Options These flags are accepted by every command. They are not shown in individual command `--help` output, but are always available. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md index 3025e135f..dc0ea1c94 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md @@ -60,6 +60,9 @@ sentry cli defaults ca-cert /path/to/ca.pem # Disable telemetry sentry cli defaults telemetry off +# Hide Tip: hints in command output +sentry cli defaults tips off + # Clear a single default sentry cli defaults org --clear diff --git a/packages/cli/src/commands/cli/defaults.ts b/packages/cli/src/commands/cli/defaults.ts index 38209644d..0730840a5 100644 --- a/packages/cli/src/commands/cli/defaults.ts +++ b/packages/cli/src/commands/cli/defaults.ts @@ -29,6 +29,7 @@ import { getDefaultUrl, getGraphicsPreference, getTelemetryPreference, + getTipsPreference, setAgentSkillsPreference, setDefaultCaCert, setDefaultHeaders, @@ -37,6 +38,7 @@ import { setDefaultUrl, setGraphicsPreference, setTelemetryPreference, + setTipsPreference, } from "../../lib/db/defaults.js"; import { ValidationError } from "../../lib/errors.js"; import { formatDefaultsResult } from "../../lib/formatters/human.js"; @@ -62,6 +64,7 @@ type DefaultKey = | "telemetry" | "agent-skills" | "graphics" + | "tips" | "url" | "headers" | "ca-cert"; @@ -166,6 +169,29 @@ const DEFAULTS_REGISTRY: Record = { }, clear: () => setGraphicsPreference(null), }, + tips: { + get: () => { + const pref = getTipsPreference(); + if (pref === true) { + return "on"; + } + if (pref === false) { + return "off"; + } + return null; + }, + set: (value) => { + const parsed = parseBoolValue(value); + if (parsed === null) { + throw new ValidationError( + `Invalid tips value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, + "tips", + ); + } + setTipsPreference(parsed); + }, + clear: () => setTipsPreference(null), + }, url: { get: getDefaultUrl, set: (value) => { @@ -281,6 +307,7 @@ export const defaultsCommand = buildCommand({ "sentry cli defaults telemetry off # Disable telemetry\n" + "sentry cli defaults agent-skills off # Stop installing agent skills on upgrade\n" + "sentry cli defaults graphics off # Disable inline terminal images\n" + + "sentry cli defaults tips off # Hide Tip: hints in command output\n" + "sentry cli defaults url https://... # Set Sentry URL (self-hosted)\n" + "sentry cli defaults headers 'X-IAP: t' # Set custom headers (self-hosted)\n" + "sentry cli defaults ca-cert /path/to/ca.pem # Trust a custom CA certificate\n" + @@ -295,6 +322,7 @@ export const defaultsCommand = buildCommand({ "| `telemetry` | Telemetry preference (on/off, yes/no, true/false, 1/0) |\n" + "| `agent-skills` | Install agent skills on setup/upgrade (on/off, yes/no, true/false, 1/0) |\n" + "| `graphics` | Render images inline on capable terminals (on/off, yes/no, true/false, 1/0) |\n" + + "| `tips` | Show `Tip:` hints in command output (on/off, yes/no, true/false, 1/0). `SENTRY_DISABLE_TIPS` overrides it at runtime |\n" + "| `url` | Sentry instance URL (for self-hosted installations) |\n" + "| `headers` | Custom HTTP headers for self-hosted proxies (semicolon-separated `Name: Value`) |\n" + "| `ca-cert` | Path to PEM file with custom CA certificates (for corporate proxies) |", @@ -351,7 +379,7 @@ export const defaultsCommand = buildCommand({ guardNonInteractive(flags); if (!isConfirmationBypassed(flags)) { const confirmed = await log.prompt( - "This will clear all defaults (organization, project, telemetry, URL, headers, ca-cert, agent-skills, graphics). Continue?", + "This will clear all defaults (organization, project, telemetry, URL, headers, ca-cert, agent-skills, graphics, tips). Continue?", { type: "confirm" }, ); if (confirmed !== true) { diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index 1069122c0..036ad407f 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -98,6 +98,7 @@ import { type TimeRange, timeRangeToApiParams, } from "../../lib/time-range.js"; +import { tipsSuppressed } from "../../lib/tips.js"; import { type SentryIssue, SentryIssueSchema, @@ -1482,7 +1483,7 @@ function formatIssueListHuman(result: IssueListResult): string { } // Footer tip (e.g. "Tip: Use 'sentry issue view ' ...") - if (result.footerMode) { + if (result.footerMode && !tipsSuppressed()) { parts.push(formatListFooter(result.footerMode)); } diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index e5ef0d76a..470748071 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -66,6 +66,7 @@ import { setLogLevel, } from "./logger.js"; import { setArgsContext, setFlagContext, withTracing } from "./telemetry.js"; +import { applyTipsPreference } from "./tips.js"; /** * Parse a string input as a number. @@ -819,7 +820,12 @@ export function buildCommand< // "cached · 3m ago · use -f to refresh" when data was cached. // Skip bare `return;` paths (e.g. `--web` which opens a browser // without yielding) — no rendered output should mean no footer. - const finalHint = returned ? appendCacheHint(returned.hint) : undefined; + // `Tip:` text is stripped first when tips are disabled + // (`sentry cli defaults tips off` / SENTRY_DISABLE_TIPS); other + // notices and the cache-age staleness indicator are kept. + const finalHint = returned + ? appendCacheHint(applyTipsPreference(returned.hint)) + : undefined; await withTracing("render", "cli.command.render", () => { writeFinalization(stdout, finalHint, cleanFlags.json, renderer); }); diff --git a/packages/cli/src/lib/db/defaults.ts b/packages/cli/src/lib/db/defaults.ts index ff633dd44..00cdf5080 100644 --- a/packages/cli/src/lib/db/defaults.ts +++ b/packages/cli/src/lib/db/defaults.ts @@ -8,6 +8,7 @@ * - `defaults.url` — Sentry instance URL (for self-hosted) * - `defaults.agent-skills` — agent skill install preference (`"on"` / `"off"`) * - `defaults.graphics` — inline terminal graphics preference (`"on"` / `"off"`) + * - `defaults.tips` — `Tip:` hint preference (`"on"` / `"off"`) */ import { getDatabase } from "./index.js"; @@ -21,6 +22,7 @@ const DEFAULTS_HEADERS = "defaults.headers"; const DEFAULTS_CA_CERT = "defaults.ca-cert"; const DEFAULTS_AGENT_SKILLS = "defaults.agent-skills"; const DEFAULTS_GRAPHICS = "defaults.graphics"; +const DEFAULTS_TIPS = "defaults.tips"; /** All metadata keys used for defaults (for bulk operations) */ const ALL_DEFAULTS_KEYS = [ @@ -32,6 +34,7 @@ const ALL_DEFAULTS_KEYS = [ DEFAULTS_CA_CERT, DEFAULTS_AGENT_SKILLS, DEFAULTS_GRAPHICS, + DEFAULTS_TIPS, ]; /** State of all persistent defaults */ @@ -52,6 +55,8 @@ export type DefaultsState = { "agent-skills": "on" | "off" | null; /** Inline terminal graphics preference: "on", "off", or null (= default enabled) */ graphics: "on" | "off" | null; + /** `Tip:` hint preference: "on", "off", or null (= default enabled) */ + tips: "on" | "off" | null; }; /** Parse a raw "on" / "off" metadata value to a typed "on" | "off" | null. */ @@ -140,6 +145,25 @@ export function getGraphicsPreference(): boolean | undefined { return; } +/** + * Get the persistent `Tip:` hint preference. + * + * @returns `true` if explicitly enabled, `false` if explicitly disabled, + * `undefined` if no preference is stored (callers should default to enabled) + */ +export function getTipsPreference(): boolean | undefined { + const db = getDatabase(); + const m = getMetadata(db, [DEFAULTS_TIPS]); + const val = m.get(DEFAULTS_TIPS); + if (val === "on") { + return true; + } + if (val === "off") { + return false; + } + return; +} + /** Get the default Sentry instance URL, or null if not set. */ export function getDefaultUrl(): string | null { const db = getDatabase(); @@ -183,6 +207,7 @@ export function getAllDefaults(): DefaultsState { "ca-cert": m.get(DEFAULTS_CA_CERT) ?? null, "agent-skills": parseOnOffValue(m.get(DEFAULTS_AGENT_SKILLS)), graphics: parseOnOffValue(m.get(DEFAULTS_GRAPHICS)), + tips: parseOnOffValue(m.get(DEFAULTS_TIPS)), }; } @@ -249,6 +274,19 @@ export function setGraphicsPreference(enabled: boolean | null): void { } } +/** + * Set or clear the persistent `Tip:` hint preference. + * Pass `null` to remove the preference (callers will default to enabled). + */ +export function setTipsPreference(enabled: boolean | null): void { + const db = getDatabase(); + if (enabled === null) { + clearMetadata(db, [DEFAULTS_TIPS]); + } else { + setMetadata(db, { [DEFAULTS_TIPS]: enabled ? "on" : "off" }); + } +} + /** Set or clear the default Sentry instance URL. Pass `null` to clear. */ export function setDefaultUrl(url: string | null): void { const db = getDatabase(); diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index e0983fa81..02ef0f324 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -206,6 +206,12 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ "Disable inline terminal graphics (kitty and sixel) on terminals that support them; the banner falls back to block art, and image attachments printed by `sentry api` are written as raw bytes instead of rendered inline. You can also set this persistently with `sentry cli defaults graphics off`.", example: "1", }, + { + name: "SENTRY_DISABLE_TIPS", + description: + "Hide `Tip: ...` hints in command output. Other notices (pagination, dry-run, cache age) are still shown. Overrides the persistent `sentry cli defaults tips` setting.", + example: "1", + }, { name: "SENTRY_NO_SIXEL", description: diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index c6038128e..f0bdb5ab2 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -31,6 +31,7 @@ import type { import { resolveOrgDisplayName } from "../api-client.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { withSerializeSpan } from "../telemetry.js"; +import { tipsSuppressed } from "../tips.js"; import { type FixabilityTier, muted } from "./colors.js"; import { colorTag, @@ -2032,7 +2033,7 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { lines.push(mdKvTable(kvRows)); // Tip footer — only when a real project exists to view - if (!dry) { + if (!dry && !tipsSuppressed()) { lines.push(""); lines.push( `*Tip: Use \`sentry project view ${result.orgSlug}/${result.project.slug}\` for details*`, @@ -2512,6 +2513,7 @@ const DEFAULT_LABELS: Record = { telemetry: "Telemetry", "agent-skills": "Agent Skills", graphics: "Graphics", + tips: "Tips", url: "URL", headers: "Headers", "ca-cert": "CA Certificate", @@ -2538,6 +2540,7 @@ function buildDefaultsShowRows(data: DefaultsResult): [string, string][] { const telLabel = d.telemetry ?? "on (default)"; const agentSkillsLabel = d["agent-skills"] ?? "on (default)"; const graphicsLabel = d.graphics ?? "on (default)"; + const tipsLabel = d.tips ?? "on (default)"; return [ ["Organization", d.organization ? safeCodeSpan(d.organization) : notSet], @@ -2548,6 +2551,7 @@ function buildDefaultsShowRows(data: DefaultsResult): [string, string][] { ], ["Agent Skills", escapeMarkdownInline(String(agentSkillsLabel))], ["Graphics", escapeMarkdownInline(String(graphicsLabel))], + ["Tips", escapeMarkdownInline(String(tipsLabel))], ["URL", d.url ? safeCodeSpan(d.url) : notSet], ["Headers", d.headers ? safeCodeSpan(d.headers) : notSet], ["CA Certificate", d["ca-cert"] ? safeCodeSpan(d["ca-cert"]) : notSet], diff --git a/packages/cli/src/lib/tips.ts b/packages/cli/src/lib/tips.ts new file mode 100644 index 000000000..82bfacc0d --- /dev/null +++ b/packages/cli/src/lib/tips.ts @@ -0,0 +1,77 @@ +/** + * `Tip:` hint suppression via a persistent default plus an env override. + * + * `sentry cli defaults tips off` stores the preference; `SENTRY_DISABLE_TIPS` + * overrides it at runtime (env wins). Only `Tip: ...` text is removed — + * pagination, dry-run, cancellation, and other operational notices that share + * the command footer channel are preserved, and so is the cache-age footer. + * + * getsentry/toolkit#1479 + * + * @module + */ + +import { getTipsPreference } from "./db/defaults.js"; +import { getEnv } from "./env.js"; +import { isTruthyEnv } from "./formatters/plain-detect.js"; + +/** Environment variable that disables `Tip:` hints at runtime. */ +export const TIPS_DISABLE_ENV_VAR = "SENTRY_DISABLE_TIPS"; + +/** + * Matches a `Tip: ...` sentence through the end of its line, either at the + * start of the line or after other text (e.g. `"Missing: a. Tip: Use ..."`). + */ +const TIP_PATTERN = /(^|\s+)Tip:.*$/; + +/** + * Decide whether `Tip:` hints should be suppressed. + * + * - A truthy `SENTRY_DISABLE_TIPS` suppresses tips and wins over the stored + * preference, using the shared truthy-env semantics (`"0"` / `"false"` / + * `""` are falsy and fall through to the preference). + * - Otherwise the stored `defaults.tips` preference applies (`off` suppresses). + * - Default: tips are shown. + */ +export function tipsSuppressed(): boolean { + const envVal = getEnv()[TIPS_DISABLE_ENV_VAR]; + if (envVal !== undefined && isTruthyEnv(envVal)) { + return true; + } + return getTipsPreference() === false; +} + +/** + * Remove `Tip: ...` text from a hint, keeping every other line intact. + * + * Lines that are entirely a tip are dropped; a tip trailing other text on the + * same line is cut off. Blank lines left at the edges are trimmed. + * + * @returns The remaining hint, or `undefined` when only tips were present + */ +export function stripTips(hint: string): string | undefined { + const kept: string[] = []; + for (const line of hint.split("\n")) { + const remainder = line.replace(TIP_PATTERN, ""); + if (remainder === line) { + kept.push(line); + } else if (remainder.trim()) { + kept.push(remainder.trimEnd()); + } + } + const result = kept.join("\n").replace(/^\n+|\n+$/g, ""); + return result.trim() ? result : undefined; +} + +/** + * Apply the user's tip preference to a command hint: strip `Tip:` text when + * tips are suppressed, otherwise return the hint unchanged. + */ +export function applyTipsPreference( + hint: string | undefined, +): string | undefined { + if (!hint) { + return hint; + } + return tipsSuppressed() ? stripTips(hint) : hint; +} diff --git a/packages/cli/test/commands/cli/defaults.test.ts b/packages/cli/test/commands/cli/defaults.test.ts index f6f973555..190e2f9af 100644 --- a/packages/cli/test/commands/cli/defaults.test.ts +++ b/packages/cli/test/commands/cli/defaults.test.ts @@ -16,11 +16,13 @@ import { getDefaultProject, getDefaultUrl, getTelemetryPreference, + getTipsPreference, setAgentSkillsPreference, setDefaultOrganization, setDefaultProject, setDefaultUrl, setTelemetryPreference, + setTipsPreference, } from "../../../src/lib/db/defaults.js"; import { getDatabase } from "../../../src/lib/db/index.js"; import { formatDefaultsResult } from "../../../src/lib/formatters/human.js"; @@ -109,6 +111,25 @@ describe("defaults storage", () => { expect(getAgentSkillsPreference()).toBeUndefined(); }); + test("getTipsPreference returns undefined when not set", () => { + expect(getTipsPreference()).toBeUndefined(); + }); + + test("setTipsPreference stores on/off", () => { + setTipsPreference(false); + expect(getTipsPreference()).toBe(false); + expect(getAllDefaults().tips).toBe("off"); + setTipsPreference(true); + expect(getTipsPreference()).toBe(true); + expect(getAllDefaults().tips).toBe("on"); + }); + + test("setTipsPreference(null) clears preference", () => { + setTipsPreference(false); + setTipsPreference(null); + expect(getTipsPreference()).toBeUndefined(); + }); + test("getDefaultUrl returns null when not set", () => { expect(getDefaultUrl()).toBeNull(); }); @@ -141,6 +162,7 @@ describe("defaults storage", () => { headers: null, "ca-cert": null, graphics: null, + tips: null, }); }); @@ -155,6 +177,7 @@ describe("defaults storage", () => { headers: null, "ca-cert": null, graphics: null, + tips: null, }); }); @@ -162,6 +185,7 @@ describe("defaults storage", () => { setDefaultOrganization("org"); setDefaultProject("proj"); setTelemetryPreference(true); + setTipsPreference(false); setDefaultUrl("https://example.com"); clearAllDefaults(); @@ -169,6 +193,7 @@ describe("defaults storage", () => { expect(getDefaultOrganization()).toBeNull(); expect(getDefaultProject()).toBeNull(); expect(getTelemetryPreference()).toBeUndefined(); + expect(getTipsPreference()).toBeUndefined(); expect(getDefaultUrl()).toBeNull(); expect(getDefaultHeaders()).toBeNull(); }); @@ -417,6 +442,7 @@ describe("formatDefaultsResult", () => { project: "my-proj", telemetry: "off", graphics: "off", + tips: "off", url: "https://sentry.example.com", }, telemetryEffective: { enabled: false, source: "preference" }, @@ -426,6 +452,7 @@ describe("formatDefaultsResult", () => { expect(plain).toContain("my-proj"); expect(plain).toContain("off"); expect(plain).toContain("Graphics"); + expect(plain).toContain("Tips"); expect(plain).toContain("sentry.example.com"); }); diff --git a/packages/cli/test/lib/command.test.ts b/packages/cli/test/lib/command.test.ts index 4b269c9e5..7283321fb 100644 --- a/packages/cli/test/lib/command.test.ts +++ b/packages/cli/test/lib/command.test.ts @@ -26,6 +26,7 @@ import { numberParser, VERBOSE_FLAG, } from "../../src/lib/command.js"; +import { setEnv } from "../../src/lib/env.js"; import { EXIT, OutputError } from "../../src/lib/errors.js"; import { CommandOutput } from "../../src/lib/formatters/output.js"; import { @@ -1281,6 +1282,45 @@ describe("buildCommand return-based output", () => { expect(jsonOutput).toEqual({ value: 42 }); }); + test("strips only Tip: lines from the hint when tips are disabled", async () => { + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { + human: (d: { value: number }) => `Value: ${d.value}`, + }, + parameters: {}, + async *func(this: TestContext) { + yield new CommandOutput({ value: 42 }); + return { + hint: "Showing 10 of 25 items\nTip: Use 'sentry thing view ' for details", + }; + }, + }); + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + setEnv({ ...process.env, SENTRY_DISABLE_TIPS: "1" }); + try { + await run(app, ["test"], ctx as TestContext); + } finally { + setEnv(process.env); + } + + const output = ctx.output.join(""); + expect(output).toContain("Value: 42"); + expect(output).toContain("Showing 10 of 25 items"); + expect(output).not.toContain("Tip:"); + }); + test("void return does nothing (no crash)", async () => { let executed = false; diff --git a/packages/cli/test/lib/tips.test.ts b/packages/cli/test/lib/tips.test.ts new file mode 100644 index 000000000..942d2e71b --- /dev/null +++ b/packages/cli/test/lib/tips.test.ts @@ -0,0 +1,127 @@ +/** + * Tip-hint suppression tests (getsentry/toolkit#1479). + * + * Covers `tipsSuppressed()` precedence between `SENTRY_DISABLE_TIPS` and the + * stored `defaults.tips` preference, and `stripTips()` keeping non-tip lines. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { setTipsPreference } from "../../src/lib/db/defaults.js"; +import { setEnv } from "../../src/lib/env.js"; +import { + applyTipsPreference, + stripTips, + tipsSuppressed, +} from "../../src/lib/tips.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("test-tips-"); + +/** Point getEnv() at process.env with SENTRY_DISABLE_TIPS set or removed. */ +function setTipsEnv(value?: string): void { + const env = { ...process.env }; + delete env.SENTRY_DISABLE_TIPS; + setEnv(value === undefined ? env : { ...env, SENTRY_DISABLE_TIPS: value }); +} + +describe("tipsSuppressed", () => { + beforeEach(() => { + setTipsEnv(); + }); + + afterEach(() => { + setEnv(process.env); + }); + + test("shows tips by default (no env, no preference)", () => { + expect(tipsSuppressed()).toBe(false); + }); + + test("suppresses tips when the stored preference is off", () => { + setTipsPreference(false); + expect(tipsSuppressed()).toBe(true); + }); + + test("shows tips when the stored preference is on", () => { + setTipsPreference(true); + expect(tipsSuppressed()).toBe(false); + }); + + test("truthy SENTRY_DISABLE_TIPS wins over the stored preference", () => { + setTipsPreference(true); + for (const val of ["1", "true", "yes"]) { + setTipsEnv(val); + expect(tipsSuppressed()).toBe(true); + } + }); + + test("falsy SENTRY_DISABLE_TIPS falls through to the stored preference", () => { + for (const val of ["0", "false", ""]) { + setTipsEnv(val); + setTipsPreference(null); + expect(tipsSuppressed()).toBe(false); + setTipsPreference(false); + expect(tipsSuppressed()).toBe(true); + } + }); +}); + +describe("stripTips", () => { + test("drops a hint that is only a tip", () => { + expect( + stripTips( + "Tip: Use 'sentry issue explain CLI-1' for AI root cause analysis", + ), + ).toBeUndefined(); + }); + + test("keeps non-tip lines", () => { + expect( + stripTips( + "Showing 10 of 25 organizations\nTip: Use 'sentry org view ' for details", + ), + ).toBe("Showing 10 of 25 organizations"); + }); + + test("cuts a tip that trails other text on the same line", () => { + expect( + stripTips( + "Missing: web, api. Tip: Use 'sentry project list acme/' to see all projects", + ), + ).toBe("Missing: web, api."); + }); + + test("leaves hints without tips unchanged", () => { + const hint = "No issues found.\n\nDetected from .env file"; + expect(stripTips(hint)).toBe(hint); + }); + + test("trims blank lines left at the edges", () => { + expect( + stripTips("No projects found.\n\nTip: Use 'sentry project list'"), + ).toBe("No projects found."); + }); +}); + +describe("applyTipsPreference", () => { + afterEach(() => { + setEnv(process.env); + }); + + test("returns the hint unchanged when tips are enabled", () => { + setTipsEnv(); + const hint = "Showing 5 items\nTip: Use 'sentry thing view '"; + expect(applyTipsPreference(hint)).toBe(hint); + }); + + test("strips tips when SENTRY_DISABLE_TIPS is set", () => { + setTipsEnv("1"); + expect( + applyTipsPreference("Showing 5 items\nTip: Use 'sentry thing view '"), + ).toBe("Showing 5 items"); + }); + + test("passes undefined through", () => { + expect(applyTipsPreference(undefined)).toBeUndefined(); + }); +}); From 8261bf6556bc8fea976dcafb664e03f5cdf51e7d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:13:19 +0000 Subject: [PATCH 2/4] ref(cli): Share on/off preference accessors and memoize tipsSuppressed - Add getOnOffPreference/setOnOffPreference so the telemetry, agent-skills, graphics and tips accessors stop duplicating the metadata read/write logic, and an onOffHandler factory for the matching `sentry cli defaults` handlers. - Build the clear-all confirmation key list from the defaults registry instead of a hand-maintained string. - Memoize tipsSuppressed() per env object (same pattern as getAuthToken), reset when the tips default changes and between tests. --- packages/cli/src/commands/cli/defaults.ts | 146 ++++++++-------------- packages/cli/src/lib/db/defaults.ts | 144 ++++++--------------- packages/cli/src/lib/tips.ts | 31 ++++- packages/cli/test/helpers.ts | 3 + packages/cli/test/lib/tips.test.ts | 11 ++ 5 files changed, 133 insertions(+), 202 deletions(-) diff --git a/packages/cli/src/commands/cli/defaults.ts b/packages/cli/src/commands/cli/defaults.ts index 0730840a5..66b46e88e 100644 --- a/packages/cli/src/commands/cli/defaults.ts +++ b/packages/cli/src/commands/cli/defaults.ts @@ -52,6 +52,7 @@ import { } from "../../lib/mutate-command.js"; import { parseBoolValue } from "../../lib/parse-bool.js"; import { computeTelemetryEffective } from "../../lib/telemetry.js"; +import { resetTipsCache } from "../../lib/tips.js"; // --------------------------------------------------------------------------- // Defaults registry — maps canonical keys to get/set/clear handlers @@ -88,110 +89,68 @@ function validateSlug(value: string, label: string): string { return trimmed; } -/** Registry of all supported defaults with their handlers */ -const DEFAULTS_REGISTRY: Record = { - organization: { - get: getDefaultOrganization, - set: (value) => setDefaultOrganization(validateSlug(value, "Organization")), - clear: () => setDefaultOrganization(null), - }, - project: { - get: getDefaultProject, - set: (value) => setDefaultProject(validateSlug(value, "Project")), - clear: () => setDefaultProject(null), - }, - telemetry: { - get: () => { - const pref = getTelemetryPreference(); - if (pref === true) { - return "on"; - } - if (pref === false) { - return "off"; - } - return null; - }, - set: (value) => { - const parsed = parseBoolValue(value); - if (parsed === null) { - throw new ValidationError( - `Invalid telemetry value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, - "telemetry", - ); - } - setTelemetryPreference(parsed); - }, - clear: () => setTelemetryPreference(null), - }, - "agent-skills": { - get: () => { - const pref = getAgentSkillsPreference(); - if (pref === true) { - return "on"; - } - if (pref === false) { - return "off"; - } - return null; - }, - set: (value) => { - const parsed = parseBoolValue(value); - if (parsed === null) { - throw new ValidationError( - `Invalid agent-skills value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, - "agent-skills", - ); - } - setAgentSkillsPreference(parsed); - }, - clear: () => setAgentSkillsPreference(null), - }, - graphics: { +/** + * Build a handler for an on/off preference. Accepts on/off, yes/no, + * true/false, or 1/0 and reports the stored value as "on", "off", or null. + */ +function onOffHandler( + key: string, + get: () => boolean | undefined, + set: (enabled: boolean | null) => void, +): DefaultHandler { + return { get: () => { - const pref = getGraphicsPreference(); - if (pref === true) { - return "on"; + const pref = get(); + if (pref === undefined) { + return null; } - if (pref === false) { - return "off"; - } - return null; + return pref ? "on" : "off"; }, set: (value) => { const parsed = parseBoolValue(value); if (parsed === null) { throw new ValidationError( - `Invalid graphics value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, - "graphics", + `Invalid ${key} value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, + key, ); } - setGraphicsPreference(parsed); + set(parsed); }, - clear: () => setGraphicsPreference(null), + clear: () => set(null), + }; +} + +/** Registry of all supported defaults with their handlers */ +const DEFAULTS_REGISTRY: Record = { + organization: { + get: getDefaultOrganization, + set: (value) => setDefaultOrganization(validateSlug(value, "Organization")), + clear: () => setDefaultOrganization(null), }, - tips: { - get: () => { - const pref = getTipsPreference(); - if (pref === true) { - return "on"; - } - if (pref === false) { - return "off"; - } - return null; - }, - set: (value) => { - const parsed = parseBoolValue(value); - if (parsed === null) { - throw new ValidationError( - `Invalid tips value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, - "tips", - ); - } - setTipsPreference(parsed); - }, - clear: () => setTipsPreference(null), + project: { + get: getDefaultProject, + set: (value) => setDefaultProject(validateSlug(value, "Project")), + clear: () => setDefaultProject(null), }, + telemetry: onOffHandler( + "telemetry", + getTelemetryPreference, + setTelemetryPreference, + ), + "agent-skills": onOffHandler( + "agent-skills", + getAgentSkillsPreference, + setAgentSkillsPreference, + ), + graphics: onOffHandler( + "graphics", + getGraphicsPreference, + setGraphicsPreference, + ), + tips: onOffHandler("tips", getTipsPreference, (enabled) => { + setTipsPreference(enabled); + resetTipsCache(); + }), url: { get: getDefaultUrl, set: (value) => { @@ -379,7 +338,7 @@ export const defaultsCommand = buildCommand({ guardNonInteractive(flags); if (!isConfirmationBypassed(flags)) { const confirmed = await log.prompt( - "This will clear all defaults (organization, project, telemetry, URL, headers, ca-cert, agent-skills, graphics, tips). Continue?", + `This will clear all defaults (${Object.keys(DEFAULTS_REGISTRY).join(", ")}). Continue?`, { type: "confirm" }, ); if (confirmed !== true) { @@ -387,6 +346,7 @@ export const defaultsCommand = buildCommand({ } } clearAllDefaults(); + resetTipsCache(); yield new CommandOutput({ action: "clear-all" as const, defaults: getAllDefaults(), diff --git a/packages/cli/src/lib/db/defaults.ts b/packages/cli/src/lib/db/defaults.ts index 00cdf5080..43ae2137c 100644 --- a/packages/cli/src/lib/db/defaults.ts +++ b/packages/cli/src/lib/db/defaults.ts @@ -70,6 +70,30 @@ function parseOnOffValue(val: string | undefined): "on" | "off" | null { return null; } +/** + * Read an on/off preference stored under `key`. + * + * @returns `true` if explicitly enabled, `false` if explicitly disabled, + * `undefined` if no preference is stored (callers should default to enabled) + */ +function getOnOffPreference(key: string): boolean | undefined { + const value = parseOnOffValue(getMetadata(getDatabase(), [key]).get(key)); + return value === null ? undefined : value === "on"; +} + +/** + * Store an on/off preference under `key`. + * Pass `null` to remove it (callers will default to enabled). + */ +function setOnOffPreference(key: string, enabled: boolean | null): void { + const db = getDatabase(); + if (enabled === null) { + clearMetadata(db, [key]); + } else { + setMetadata(db, { [key]: enabled ? "on" : "off" }); + } +} + // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- @@ -88,80 +112,24 @@ export function getDefaultProject(): string | null { return m.get(DEFAULTS_PROJECT) ?? null; } -/** - * Get the persistent telemetry preference. - * - * @returns `true` if explicitly enabled, `false` if explicitly disabled, - * `undefined` if no preference is stored (callers should default to enabled) - */ +/** Get the persistent telemetry preference (see {@link getOnOffPreference}). */ export function getTelemetryPreference(): boolean | undefined { - const db = getDatabase(); - const m = getMetadata(db, [DEFAULTS_TELEMETRY]); - const val = m.get(DEFAULTS_TELEMETRY); - if (val === "on") { - return true; - } - if (val === "off") { - return false; - } - return; + return getOnOffPreference(DEFAULTS_TELEMETRY); } -/** - * Get the persistent agent skill install preference. - * - * @returns `true` if explicitly enabled, `false` if explicitly disabled, - * `undefined` if no preference is stored (callers should default to enabled) - */ +/** Get the persistent agent skill install preference (see {@link getOnOffPreference}). */ export function getAgentSkillsPreference(): boolean | undefined { - const db = getDatabase(); - const m = getMetadata(db, [DEFAULTS_AGENT_SKILLS]); - const val = m.get(DEFAULTS_AGENT_SKILLS); - if (val === "on") { - return true; - } - if (val === "off") { - return false; - } - return; + return getOnOffPreference(DEFAULTS_AGENT_SKILLS); } -/** - * Get the persistent inline-graphics preference. - * - * @returns `true` if explicitly enabled, `false` if explicitly disabled, - * `undefined` if no preference is stored (callers should default to enabled) - */ +/** Get the persistent inline-graphics preference (see {@link getOnOffPreference}). */ export function getGraphicsPreference(): boolean | undefined { - const db = getDatabase(); - const m = getMetadata(db, [DEFAULTS_GRAPHICS]); - const val = m.get(DEFAULTS_GRAPHICS); - if (val === "on") { - return true; - } - if (val === "off") { - return false; - } - return; + return getOnOffPreference(DEFAULTS_GRAPHICS); } -/** - * Get the persistent `Tip:` hint preference. - * - * @returns `true` if explicitly enabled, `false` if explicitly disabled, - * `undefined` if no preference is stored (callers should default to enabled) - */ +/** Get the persistent `Tip:` hint preference (see {@link getOnOffPreference}). */ export function getTipsPreference(): boolean | undefined { - const db = getDatabase(); - const m = getMetadata(db, [DEFAULTS_TIPS]); - const val = m.get(DEFAULTS_TIPS); - if (val === "on") { - return true; - } - if (val === "off") { - return false; - } - return; + return getOnOffPreference(DEFAULTS_TIPS); } /** Get the default Sentry instance URL, or null if not set. */ @@ -235,56 +203,24 @@ export function setDefaultProject(value: string | null): void { } } -/** - * Set or clear the persistent telemetry preference. - * Pass `null` to remove the preference (callers will default to enabled). - */ +/** Set or clear the persistent telemetry preference (see {@link setOnOffPreference}). */ export function setTelemetryPreference(enabled: boolean | null): void { - const db = getDatabase(); - if (enabled === null) { - clearMetadata(db, [DEFAULTS_TELEMETRY]); - } else { - setMetadata(db, { [DEFAULTS_TELEMETRY]: enabled ? "on" : "off" }); - } + setOnOffPreference(DEFAULTS_TELEMETRY, enabled); } -/** - * Set or clear the persistent agent skill install preference. - * Pass `null` to remove the preference (callers will default to enabled). - */ +/** Set or clear the persistent agent skill install preference (see {@link setOnOffPreference}). */ export function setAgentSkillsPreference(enabled: boolean | null): void { - const db = getDatabase(); - if (enabled === null) { - clearMetadata(db, [DEFAULTS_AGENT_SKILLS]); - } else { - setMetadata(db, { [DEFAULTS_AGENT_SKILLS]: enabled ? "on" : "off" }); - } + setOnOffPreference(DEFAULTS_AGENT_SKILLS, enabled); } -/** - * Set or clear the persistent inline-graphics preference. - * Pass `null` to remove the preference (callers will default to enabled). - */ +/** Set or clear the persistent inline-graphics preference (see {@link setOnOffPreference}). */ export function setGraphicsPreference(enabled: boolean | null): void { - const db = getDatabase(); - if (enabled === null) { - clearMetadata(db, [DEFAULTS_GRAPHICS]); - } else { - setMetadata(db, { [DEFAULTS_GRAPHICS]: enabled ? "on" : "off" }); - } + setOnOffPreference(DEFAULTS_GRAPHICS, enabled); } -/** - * Set or clear the persistent `Tip:` hint preference. - * Pass `null` to remove the preference (callers will default to enabled). - */ +/** Set or clear the persistent `Tip:` hint preference (see {@link setOnOffPreference}). */ export function setTipsPreference(enabled: boolean | null): void { - const db = getDatabase(); - if (enabled === null) { - clearMetadata(db, [DEFAULTS_TIPS]); - } else { - setMetadata(db, { [DEFAULTS_TIPS]: enabled ? "on" : "off" }); - } + setOnOffPreference(DEFAULTS_TIPS, enabled); } /** Set or clear the default Sentry instance URL. Pass `null` to clear. */ diff --git a/packages/cli/src/lib/tips.ts b/packages/cli/src/lib/tips.ts index 82bfacc0d..34ac702b6 100644 --- a/packages/cli/src/lib/tips.ts +++ b/packages/cli/src/lib/tips.ts @@ -25,7 +25,14 @@ export const TIPS_DISABLE_ENV_VAR = "SENTRY_DISABLE_TIPS"; const TIP_PATTERN = /(^|\s+)Tip:.*$/; /** - * Decide whether `Tip:` hints should be suppressed. + * Memoized {@link tipsSuppressed} results, keyed by the active env object. + * The CLI has one env per process, so the DB is read at most once; SDK + * invocations isolated with `withEnv()` each get their own entry. + */ +const tipsCache = { byEnv: new WeakMap() }; + +/** + * Decide whether `Tip:` hints should be suppressed. Memoized per env. * * - A truthy `SENTRY_DISABLE_TIPS` suppresses tips and wins over the stored * preference, using the shared truthy-env semantics (`"0"` / `"false"` / @@ -34,11 +41,25 @@ const TIP_PATTERN = /(^|\s+)Tip:.*$/; * - Default: tips are shown. */ export function tipsSuppressed(): boolean { - const envVal = getEnv()[TIPS_DISABLE_ENV_VAR]; - if (envVal !== undefined && isTruthyEnv(envVal)) { - return true; + const env = getEnv(); + const cached = tipsCache.byEnv.get(env); + if (cached !== undefined) { + return cached; } - return getTipsPreference() === false; + const envVal = env[TIPS_DISABLE_ENV_VAR]; + const suppressed = + (envVal !== undefined && isTruthyEnv(envVal)) || + getTipsPreference() === false; + tipsCache.byEnv.set(env, suppressed); + return suppressed; +} + +/** + * Drop memoized {@link tipsSuppressed} results. Call after the stored tips + * preference changes (`sentry cli defaults tips ...`) and between tests. + */ +export function resetTipsCache(): void { + tipsCache.byEnv = new WeakMap(); } /** diff --git a/packages/cli/test/helpers.ts b/packages/cli/test/helpers.ts index d8a19c112..195f91e31 100644 --- a/packages/cli/test/helpers.ts +++ b/packages/cli/test/helpers.ts @@ -15,6 +15,7 @@ import { resetIdentityFingerprintCache, } from "../src/lib/db/auth.js"; import { CONFIG_DIR_ENV_VAR, closeDatabase } from "../src/lib/db/index.js"; +import { resetTipsCache } from "../src/lib/tips.js"; // re-exporting a single constant, not a barrel export { TEST_TMP_DIR } from "./constants.js"; @@ -116,6 +117,7 @@ export function useTestConfigDir( resetAuthRowCache(); resetHasStoredCredsCache(); resetIdentityFingerprintCache(); + resetTipsCache(); dir = await createTestConfigDir(prefix, options); process.env[CONFIG_DIR_ENV_VAR] = dir; }); @@ -126,6 +128,7 @@ export function useTestConfigDir( resetAuthRowCache(); resetHasStoredCredsCache(); resetIdentityFingerprintCache(); + resetTipsCache(); // Always restore the previous value — never delete. // Deleting process.env.SENTRY_CONFIG_DIR causes failures in test files // that load after this afterEach runs, because their module-level code diff --git a/packages/cli/test/lib/tips.test.ts b/packages/cli/test/lib/tips.test.ts index 942d2e71b..291d6c211 100644 --- a/packages/cli/test/lib/tips.test.ts +++ b/packages/cli/test/lib/tips.test.ts @@ -10,6 +10,7 @@ import { setTipsPreference } from "../../src/lib/db/defaults.js"; import { setEnv } from "../../src/lib/env.js"; import { applyTipsPreference, + resetTipsCache, stripTips, tipsSuppressed, } from "../../src/lib/tips.js"; @@ -47,6 +48,14 @@ describe("tipsSuppressed", () => { expect(tipsSuppressed()).toBe(false); }); + test("memoizes per env until the cache is reset", () => { + expect(tipsSuppressed()).toBe(false); + setTipsPreference(false); + expect(tipsSuppressed()).toBe(false); + resetTipsCache(); + expect(tipsSuppressed()).toBe(true); + }); + test("truthy SENTRY_DISABLE_TIPS wins over the stored preference", () => { setTipsPreference(true); for (const val of ["1", "true", "yes"]) { @@ -59,8 +68,10 @@ describe("tipsSuppressed", () => { for (const val of ["0", "false", ""]) { setTipsEnv(val); setTipsPreference(null); + resetTipsCache(); expect(tipsSuppressed()).toBe(false); setTipsPreference(false); + resetTipsCache(); expect(tipsSuppressed()).toBe(true); } }); From c565360c83e92a9aa9c275bfe964af7e0733a524 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:26:37 +0000 Subject: [PATCH 3/4] ref(cli): Drive on/off defaults from a single key map Replace the per-preference get/set wrappers (telemetry, agent-skills, graphics, tips) with exported getOnOffPreference(name) and setOnOffPreference(name, enabled) backed by one key map, and build the matching `sentry cli defaults` handlers from ON_OFF_PREFERENCES. The tips cache reset moves into a per-preference change hook. --- packages/cli/src/commands/cli/defaults.ts | 66 +++++------- packages/cli/src/commands/cli/setup.ts | 8 +- packages/cli/src/lib/db/defaults.ts | 69 +++++------- packages/cli/src/lib/sixel.ts | 8 +- packages/cli/src/lib/telemetry.ts | 4 +- packages/cli/src/lib/tips.ts | 4 +- .../cli/test/commands/cli/defaults.test.ts | 102 +++++++++--------- packages/cli/test/commands/cli/setup.test.ts | 12 +-- packages/cli/test/lib/tips.test.ts | 14 +-- 9 files changed, 123 insertions(+), 164 deletions(-) diff --git a/packages/cli/src/commands/cli/defaults.ts b/packages/cli/src/commands/cli/defaults.ts index 66b46e88e..990323d20 100644 --- a/packages/cli/src/commands/cli/defaults.ts +++ b/packages/cli/src/commands/cli/defaults.ts @@ -19,26 +19,22 @@ import { readCaCertFile } from "../../lib/custom-ca.js"; import { parseCustomHeaders } from "../../lib/custom-headers.js"; import { clearAllDefaults, + getOnOffPreference, + ON_OFF_PREFERENCES, + type OnOffPreference, + setOnOffPreference, type DefaultsState, - getAgentSkillsPreference, getAllDefaults, getDefaultCaCert, getDefaultHeaders, getDefaultOrganization, getDefaultProject, getDefaultUrl, - getGraphicsPreference, - getTelemetryPreference, - getTipsPreference, - setAgentSkillsPreference, setDefaultCaCert, setDefaultHeaders, setDefaultOrganization, setDefaultProject, setDefaultUrl, - setGraphicsPreference, - setTelemetryPreference, - setTipsPreference, } from "../../lib/db/defaults.js"; import { ValidationError } from "../../lib/errors.js"; import { formatDefaultsResult } from "../../lib/formatters/human.js"; @@ -62,10 +58,7 @@ import { resetTipsCache } from "../../lib/tips.js"; type DefaultKey = | "organization" | "project" - | "telemetry" - | "agent-skills" - | "graphics" - | "tips" + | OnOffPreference | "url" | "headers" | "ca-cert"; @@ -89,18 +82,23 @@ function validateSlug(value: string, label: string): string { return trimmed; } +/** Side effects to run after an on/off preference changes. */ +const ON_OFF_CHANGE_HOOKS: Partial void>> = { + tips: resetTipsCache, +}; + /** - * Build a handler for an on/off preference. Accepts on/off, yes/no, + * Build the handler for an on/off preference. Accepts on/off, yes/no, * true/false, or 1/0 and reports the stored value as "on", "off", or null. */ -function onOffHandler( - key: string, - get: () => boolean | undefined, - set: (enabled: boolean | null) => void, -): DefaultHandler { +function onOffHandler(name: OnOffPreference): DefaultHandler { + const store = (enabled: boolean | null) => { + setOnOffPreference(name, enabled); + ON_OFF_CHANGE_HOOKS[name]?.(); + }; return { get: () => { - const pref = get(); + const pref = getOnOffPreference(name); if (pref === undefined) { return null; } @@ -110,13 +108,13 @@ function onOffHandler( const parsed = parseBoolValue(value); if (parsed === null) { throw new ValidationError( - `Invalid ${key} value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, - key, + `Invalid ${name} value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, + name, ); } - set(parsed); + store(parsed); }, - clear: () => set(null), + clear: () => store(null), }; } @@ -132,25 +130,9 @@ const DEFAULTS_REGISTRY: Record = { set: (value) => setDefaultProject(validateSlug(value, "Project")), clear: () => setDefaultProject(null), }, - telemetry: onOffHandler( - "telemetry", - getTelemetryPreference, - setTelemetryPreference, - ), - "agent-skills": onOffHandler( - "agent-skills", - getAgentSkillsPreference, - setAgentSkillsPreference, - ), - graphics: onOffHandler( - "graphics", - getGraphicsPreference, - setGraphicsPreference, - ), - tips: onOffHandler("tips", getTipsPreference, (enabled) => { - setTipsPreference(enabled); - resetTipsCache(); - }), + ...(Object.fromEntries( + ON_OFF_PREFERENCES.map((name) => [name, onOffHandler(name)]), + ) as Record), url: { get: getDefaultUrl, set: (value) => { diff --git a/packages/cli/src/commands/cli/setup.ts b/packages/cli/src/commands/cli/setup.ts index fadcaf93b..e0168393f 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -31,8 +31,8 @@ import { import { CLI_VERSION } from "../../lib/constants.js"; import { getAuthConfig } from "../../lib/db/auth.js"; import { - getAgentSkillsPreference, - setAgentSkillsPreference, + getOnOffPreference, + setOnOffPreference, } from "../../lib/db/defaults.js"; import { closeDatabase, resolveXdgConfigDir } from "../../lib/db/index.js"; import { setInstallInfo } from "../../lib/db/install-info.js"; @@ -564,12 +564,12 @@ async function runConfigurationSteps(opts: ConfigStepOptions) { if (flags["no-agent-skills"]) { await bestEffort( "Recording agent skills preference", - () => setAgentSkillsPreference(false), + () => setOnOffPreference("agent-skills", false), warn, ); } const skipAgentSkills = - flags["no-agent-skills"] || getAgentSkillsPreference() === false; + flags["no-agent-skills"] || getOnOffPreference("agent-skills") === false; if (!skipAgentSkills) { await bestEffort( "Agent skills", diff --git a/packages/cli/src/lib/db/defaults.ts b/packages/cli/src/lib/db/defaults.ts index 43ae2137c..86e141e92 100644 --- a/packages/cli/src/lib/db/defaults.ts +++ b/packages/cli/src/lib/db/defaults.ts @@ -70,22 +70,43 @@ function parseOnOffValue(val: string | undefined): "on" | "off" | null { return null; } +/** Metadata keys for the on/off preferences, by `sentry cli defaults` key. */ +const ON_OFF_PREFERENCE_KEYS = { + telemetry: DEFAULTS_TELEMETRY, + "agent-skills": DEFAULTS_AGENT_SKILLS, + graphics: DEFAULTS_GRAPHICS, + tips: DEFAULTS_TIPS, +} as const; + +/** Name of an on/off preference (`telemetry`, `agent-skills`, ...). */ +export type OnOffPreference = keyof typeof ON_OFF_PREFERENCE_KEYS; + +/** All on/off preference names, in display order. */ +export const ON_OFF_PREFERENCES = Object.keys( + ON_OFF_PREFERENCE_KEYS, +) as OnOffPreference[]; + /** - * Read an on/off preference stored under `key`. + * Read a persistent on/off preference. * * @returns `true` if explicitly enabled, `false` if explicitly disabled, * `undefined` if no preference is stored (callers should default to enabled) */ -function getOnOffPreference(key: string): boolean | undefined { +export function getOnOffPreference(name: OnOffPreference): boolean | undefined { + const key = ON_OFF_PREFERENCE_KEYS[name]; const value = parseOnOffValue(getMetadata(getDatabase(), [key]).get(key)); return value === null ? undefined : value === "on"; } /** - * Store an on/off preference under `key`. + * Set or clear a persistent on/off preference. * Pass `null` to remove it (callers will default to enabled). */ -function setOnOffPreference(key: string, enabled: boolean | null): void { +export function setOnOffPreference( + name: OnOffPreference, + enabled: boolean | null, +): void { + const key = ON_OFF_PREFERENCE_KEYS[name]; const db = getDatabase(); if (enabled === null) { clearMetadata(db, [key]); @@ -112,26 +133,6 @@ export function getDefaultProject(): string | null { return m.get(DEFAULTS_PROJECT) ?? null; } -/** Get the persistent telemetry preference (see {@link getOnOffPreference}). */ -export function getTelemetryPreference(): boolean | undefined { - return getOnOffPreference(DEFAULTS_TELEMETRY); -} - -/** Get the persistent agent skill install preference (see {@link getOnOffPreference}). */ -export function getAgentSkillsPreference(): boolean | undefined { - return getOnOffPreference(DEFAULTS_AGENT_SKILLS); -} - -/** Get the persistent inline-graphics preference (see {@link getOnOffPreference}). */ -export function getGraphicsPreference(): boolean | undefined { - return getOnOffPreference(DEFAULTS_GRAPHICS); -} - -/** Get the persistent `Tip:` hint preference (see {@link getOnOffPreference}). */ -export function getTipsPreference(): boolean | undefined { - return getOnOffPreference(DEFAULTS_TIPS); -} - /** Get the default Sentry instance URL, or null if not set. */ export function getDefaultUrl(): string | null { const db = getDatabase(); @@ -203,26 +204,6 @@ export function setDefaultProject(value: string | null): void { } } -/** Set or clear the persistent telemetry preference (see {@link setOnOffPreference}). */ -export function setTelemetryPreference(enabled: boolean | null): void { - setOnOffPreference(DEFAULTS_TELEMETRY, enabled); -} - -/** Set or clear the persistent agent skill install preference (see {@link setOnOffPreference}). */ -export function setAgentSkillsPreference(enabled: boolean | null): void { - setOnOffPreference(DEFAULTS_AGENT_SKILLS, enabled); -} - -/** Set or clear the persistent inline-graphics preference (see {@link setOnOffPreference}). */ -export function setGraphicsPreference(enabled: boolean | null): void { - setOnOffPreference(DEFAULTS_GRAPHICS, enabled); -} - -/** Set or clear the persistent `Tip:` hint preference (see {@link setOnOffPreference}). */ -export function setTipsPreference(enabled: boolean | null): void { - setOnOffPreference(DEFAULTS_TIPS, enabled); -} - /** Set or clear the default Sentry instance URL. Pass `null` to clear. */ export function setDefaultUrl(url: string | null): void { const db = getDatabase(); diff --git a/packages/cli/src/lib/sixel.ts b/packages/cli/src/lib/sixel.ts index 659cdcc7f..2ba3f4094 100644 --- a/packages/cli/src/lib/sixel.ts +++ b/packages/cli/src/lib/sixel.ts @@ -19,7 +19,7 @@ import { execSync } from "node:child_process"; import { closeSync, openSync, readSync, writeSync } from "node:fs"; import { BANNER_SIXEL } from "../generated/banner-sixel.js"; -import { getGraphicsPreference } from "./db/defaults.js"; +import { getOnOffPreference } from "./db/defaults.js"; import { getEnv } from "./env.js"; import { isPlainOutput, isTruthyEnv } from "./formatters/plain-detect.js"; @@ -320,7 +320,7 @@ export function canRenderSixel(): boolean { if (optedOut()) { return false; } - if (getGraphicsPreference() === false) { + if (getOnOffPreference("graphics") === false) { return false; } return detectSixelCaps().supported; @@ -338,7 +338,7 @@ export function canRenderKitty(): boolean { if (optedOut()) { return false; } - if (getGraphicsPreference() === false) { + if (getOnOffPreference("graphics") === false) { return false; } return detectSixelCaps().kitty === true; @@ -400,7 +400,7 @@ export function sixelBanner( if (optedOut()) { return; } - if (getGraphicsPreference() === false) { + if (getOnOffPreference("graphics") === false) { return; } const caps = detectSixelCaps(); diff --git a/packages/cli/src/lib/telemetry.ts b/packages/cli/src/lib/telemetry.ts index 1364b35a2..4d3673629 100644 --- a/packages/cli/src/lib/telemetry.ts +++ b/packages/cli/src/lib/telemetry.ts @@ -24,7 +24,7 @@ import { SENTRY_CLI_DSN, } from "./constants.js"; import { getCustomCaCerts } from "./custom-ca.js"; -import { getTelemetryPreference } from "./db/defaults.js"; +import { getOnOffPreference } from "./db/defaults.js"; import { isReadonlyError, tryRepairAndRetry } from "./db/schema.js"; import { type AgentInfo, @@ -139,7 +139,7 @@ export function computeTelemetryEffective(): TelemetryEffective { // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { - const pref = getTelemetryPreference(); + const pref = getOnOffPreference("telemetry"); if (pref !== undefined) { return { enabled: pref, source: "preference" }; } diff --git a/packages/cli/src/lib/tips.ts b/packages/cli/src/lib/tips.ts index 34ac702b6..7397d13a1 100644 --- a/packages/cli/src/lib/tips.ts +++ b/packages/cli/src/lib/tips.ts @@ -11,7 +11,7 @@ * @module */ -import { getTipsPreference } from "./db/defaults.js"; +import { getOnOffPreference } from "./db/defaults.js"; import { getEnv } from "./env.js"; import { isTruthyEnv } from "./formatters/plain-detect.js"; @@ -49,7 +49,7 @@ export function tipsSuppressed(): boolean { const envVal = env[TIPS_DISABLE_ENV_VAR]; const suppressed = (envVal !== undefined && isTruthyEnv(envVal)) || - getTipsPreference() === false; + getOnOffPreference("tips") === false; tipsCache.byEnv.set(env, suppressed); return suppressed; } diff --git a/packages/cli/test/commands/cli/defaults.test.ts b/packages/cli/test/commands/cli/defaults.test.ts index 190e2f9af..03b8e2446 100644 --- a/packages/cli/test/commands/cli/defaults.test.ts +++ b/packages/cli/test/commands/cli/defaults.test.ts @@ -9,20 +9,16 @@ import chalk from "chalk"; import { describe, expect, test } from "vitest"; import { clearAllDefaults, - getAgentSkillsPreference, getAllDefaults, getDefaultHeaders, getDefaultOrganization, getDefaultProject, getDefaultUrl, - getTelemetryPreference, - getTipsPreference, - setAgentSkillsPreference, + getOnOffPreference, setDefaultOrganization, setDefaultProject, setDefaultUrl, - setTelemetryPreference, - setTipsPreference, + setOnOffPreference, } from "../../../src/lib/db/defaults.js"; import { getDatabase } from "../../../src/lib/db/index.js"; import { formatDefaultsResult } from "../../../src/lib/formatters/human.js"; @@ -75,59 +71,59 @@ describe("defaults storage", () => { expect(getDefaultProject()).toBe("proj1"); }); - test("getTelemetryPreference returns undefined when not set", () => { - expect(getTelemetryPreference()).toBeUndefined(); + test("getOnOffPreference(telemetry) returns undefined when not set", () => { + expect(getOnOffPreference("telemetry")).toBeUndefined(); }); - test("setTelemetryPreference stores on/off", () => { - setTelemetryPreference(false); - expect(getTelemetryPreference()).toBe(false); + test("setOnOffPreference(telemetry) stores on/off", () => { + setOnOffPreference("telemetry", false); + expect(getOnOffPreference("telemetry")).toBe(false); - setTelemetryPreference(true); - expect(getTelemetryPreference()).toBe(true); + setOnOffPreference("telemetry", true); + expect(getOnOffPreference("telemetry")).toBe(true); }); - test("setTelemetryPreference(null) clears preference", () => { - setTelemetryPreference(false); - setTelemetryPreference(null); - expect(getTelemetryPreference()).toBeUndefined(); + test("setOnOffPreference(telemetry, null) clears preference", () => { + setOnOffPreference("telemetry", false); + setOnOffPreference("telemetry", null); + expect(getOnOffPreference("telemetry")).toBeUndefined(); }); - test("getAgentSkillsPreference returns undefined when not set", () => { - expect(getAgentSkillsPreference()).toBeUndefined(); + test("getOnOffPreference(agent-skills) returns undefined when not set", () => { + expect(getOnOffPreference("agent-skills")).toBeUndefined(); }); - test("setAgentSkillsPreference stores on/off", () => { - setAgentSkillsPreference(false); - expect(getAgentSkillsPreference()).toBe(false); + test("setOnOffPreference(agent-skills) stores on/off", () => { + setOnOffPreference("agent-skills", false); + expect(getOnOffPreference("agent-skills")).toBe(false); - setAgentSkillsPreference(true); - expect(getAgentSkillsPreference()).toBe(true); + setOnOffPreference("agent-skills", true); + expect(getOnOffPreference("agent-skills")).toBe(true); }); - test("setAgentSkillsPreference(null) clears preference", () => { - setAgentSkillsPreference(false); - setAgentSkillsPreference(null); - expect(getAgentSkillsPreference()).toBeUndefined(); + test("setOnOffPreference(agent-skills, null) clears preference", () => { + setOnOffPreference("agent-skills", false); + setOnOffPreference("agent-skills", null); + expect(getOnOffPreference("agent-skills")).toBeUndefined(); }); - test("getTipsPreference returns undefined when not set", () => { - expect(getTipsPreference()).toBeUndefined(); + test("getOnOffPreference(tips) returns undefined when not set", () => { + expect(getOnOffPreference("tips")).toBeUndefined(); }); - test("setTipsPreference stores on/off", () => { - setTipsPreference(false); - expect(getTipsPreference()).toBe(false); + test("setOnOffPreference(tips) stores on/off", () => { + setOnOffPreference("tips", false); + expect(getOnOffPreference("tips")).toBe(false); expect(getAllDefaults().tips).toBe("off"); - setTipsPreference(true); - expect(getTipsPreference()).toBe(true); + setOnOffPreference("tips", true); + expect(getOnOffPreference("tips")).toBe(true); expect(getAllDefaults().tips).toBe("on"); }); - test("setTipsPreference(null) clears preference", () => { - setTipsPreference(false); - setTipsPreference(null); - expect(getTipsPreference()).toBeUndefined(); + test("setOnOffPreference(tips, null) clears preference", () => { + setOnOffPreference("tips", false); + setOnOffPreference("tips", null); + expect(getOnOffPreference("tips")).toBeUndefined(); }); test("getDefaultUrl returns null when not set", () => { @@ -148,8 +144,8 @@ describe("defaults storage", () => { test("getAllDefaults returns full state", () => { setDefaultOrganization("test-org"); setDefaultProject("test-project"); - setTelemetryPreference(false); - setAgentSkillsPreference(false); + setOnOffPreference("telemetry", false); + setOnOffPreference("agent-skills", false); setDefaultUrl("https://sentry.example.com"); const state = getAllDefaults(); @@ -184,16 +180,16 @@ describe("defaults storage", () => { test("clearAllDefaults removes everything", () => { setDefaultOrganization("org"); setDefaultProject("proj"); - setTelemetryPreference(true); - setTipsPreference(false); + setOnOffPreference("telemetry", true); + setOnOffPreference("tips", false); setDefaultUrl("https://example.com"); clearAllDefaults(); expect(getDefaultOrganization()).toBeNull(); expect(getDefaultProject()).toBeNull(); - expect(getTelemetryPreference()).toBeUndefined(); - expect(getTipsPreference()).toBeUndefined(); + expect(getOnOffPreference("telemetry")).toBeUndefined(); + expect(getOnOffPreference("tips")).toBeUndefined(); expect(getDefaultUrl()).toBeNull(); expect(getDefaultHeaders()).toBeNull(); }); @@ -259,7 +255,7 @@ describe("isTelemetryEnabled", () => { test("respects persistent preference: off", () => { setup(); try { - setTelemetryPreference(false); + setOnOffPreference("telemetry", false); expect(isTelemetryEnabled()).toBe(false); } finally { teardown(); @@ -269,7 +265,7 @@ describe("isTelemetryEnabled", () => { test("respects persistent preference: on", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); expect(isTelemetryEnabled()).toBe(true); } finally { teardown(); @@ -279,7 +275,7 @@ describe("isTelemetryEnabled", () => { test("SENTRY_CLI_NO_TELEMETRY=1 overrides preference", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); process.env.SENTRY_CLI_NO_TELEMETRY = "1"; expect(isTelemetryEnabled()).toBe(false); } finally { @@ -290,7 +286,7 @@ describe("isTelemetryEnabled", () => { test("DO_NOT_TRACK=1 overrides preference", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); process.env.DO_NOT_TRACK = "1"; expect(isTelemetryEnabled()).toBe(false); } finally { @@ -364,7 +360,7 @@ describe("computeTelemetryEffective", () => { test("returns preference source when set to off", () => { setup(); try { - setTelemetryPreference(false); + setOnOffPreference("telemetry", false); const result = computeTelemetryEffective(); expect(result).toEqual({ enabled: false, source: "preference" }); } finally { @@ -375,7 +371,7 @@ describe("computeTelemetryEffective", () => { test("returns preference source when set to on", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); const result = computeTelemetryEffective(); expect(result).toEqual({ enabled: true, source: "preference" }); } finally { @@ -386,7 +382,7 @@ describe("computeTelemetryEffective", () => { test("SENTRY_CLI_NO_TELEMETRY overrides preference", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); process.env.SENTRY_CLI_NO_TELEMETRY = "1"; const result = computeTelemetryEffective(); expect(result.enabled).toBe(false); @@ -399,7 +395,7 @@ describe("computeTelemetryEffective", () => { test("DO_NOT_TRACK overrides preference", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); process.env.DO_NOT_TRACK = "1"; const result = computeTelemetryEffective(); expect(result.enabled).toBe(false); diff --git a/packages/cli/test/commands/cli/setup.test.ts b/packages/cli/test/commands/cli/setup.test.ts index f8861b4fd..b09a8c431 100644 --- a/packages/cli/test/commands/cli/setup.test.ts +++ b/packages/cli/test/commands/cli/setup.test.ts @@ -45,8 +45,8 @@ import { app } from "../../../src/app.js"; import type { SentryContext } from "../../../src/context.js"; import { getAuthConfig, setAuthToken } from "../../../src/lib/db/auth.js"; import { - getAgentSkillsPreference, - setAgentSkillsPreference, + getOnOffPreference, + setOnOffPreference, } from "../../../src/lib/db/defaults.js"; import { clearInstallInfo, @@ -938,7 +938,7 @@ describe("sentry cli setup", () => { beforeEach(() => { // Reset the persisted preference — the config DB is shared across tests // in this file, so a prior --no-agent-skills run would otherwise leak. - setAgentSkillsPreference(null); + setOnOffPreference("agent-skills", null); originalFetch = globalThis.fetch; mockFetch( async () => @@ -948,7 +948,7 @@ describe("sentry cli setup", () => { afterEach(() => { globalThis.fetch = originalFetch; - setAgentSkillsPreference(null); + setOnOffPreference("agent-skills", null); }); test("installs agent skills when Claude Code is detected", async () => { @@ -1123,12 +1123,12 @@ describe("sentry cli setup", () => { context, ); - expect(getAgentSkillsPreference()).toBe(false); + expect(getOnOffPreference("agent-skills")).toBe(false); }); test("honors a persisted opt-out without the flag (upgrade scenario)", async () => { // Simulate a prior `--no-agent-skills` run having stored the preference. - setAgentSkillsPreference(false); + setOnOffPreference("agent-skills", false); mkdirSync(join(testDir, ".claude"), { recursive: true }); const { context, getOutput, restore } = createMockContext({ diff --git a/packages/cli/test/lib/tips.test.ts b/packages/cli/test/lib/tips.test.ts index 291d6c211..12584b99a 100644 --- a/packages/cli/test/lib/tips.test.ts +++ b/packages/cli/test/lib/tips.test.ts @@ -6,7 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { setTipsPreference } from "../../src/lib/db/defaults.js"; +import { setOnOffPreference } from "../../src/lib/db/defaults.js"; import { setEnv } from "../../src/lib/env.js"; import { applyTipsPreference, @@ -39,25 +39,25 @@ describe("tipsSuppressed", () => { }); test("suppresses tips when the stored preference is off", () => { - setTipsPreference(false); + setOnOffPreference("tips", false); expect(tipsSuppressed()).toBe(true); }); test("shows tips when the stored preference is on", () => { - setTipsPreference(true); + setOnOffPreference("tips", true); expect(tipsSuppressed()).toBe(false); }); test("memoizes per env until the cache is reset", () => { expect(tipsSuppressed()).toBe(false); - setTipsPreference(false); + setOnOffPreference("tips", false); expect(tipsSuppressed()).toBe(false); resetTipsCache(); expect(tipsSuppressed()).toBe(true); }); test("truthy SENTRY_DISABLE_TIPS wins over the stored preference", () => { - setTipsPreference(true); + setOnOffPreference("tips", true); for (const val of ["1", "true", "yes"]) { setTipsEnv(val); expect(tipsSuppressed()).toBe(true); @@ -67,10 +67,10 @@ describe("tipsSuppressed", () => { test("falsy SENTRY_DISABLE_TIPS falls through to the stored preference", () => { for (const val of ["0", "false", ""]) { setTipsEnv(val); - setTipsPreference(null); + setOnOffPreference("tips", null); resetTipsCache(); expect(tipsSuppressed()).toBe(false); - setTipsPreference(false); + setOnOffPreference("tips", false); resetTipsCache(); expect(tipsSuppressed()).toBe(true); } From 4401285b5786e4a7c01d704e6a830070fe07ec8b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:40:02 +0000 Subject: [PATCH 4/4] ref(cli): Type on/off preference names as a literal union Declare the on/off preference names as a const tuple so OnOffPreference is a restricted string union (no Object.keys cast), derive their metadata keys and DefaultsState fields from it, and collapse the per-preference storage tests into one parameterized block. --- packages/cli/src/lib/db/defaults.ts | 70 ++++++++----------- .../cli/test/commands/cli/defaults.test.ts | 65 ++++------------- 2 files changed, 43 insertions(+), 92 deletions(-) diff --git a/packages/cli/src/lib/db/defaults.ts b/packages/cli/src/lib/db/defaults.ts index 86e141e92..3619c0f96 100644 --- a/packages/cli/src/lib/db/defaults.ts +++ b/packages/cli/src/lib/db/defaults.ts @@ -16,48 +16,52 @@ import { clearMetadata, getMetadata, setMetadata } from "./utils.js"; const DEFAULTS_ORG = "defaults.org"; const DEFAULTS_PROJECT = "defaults.project"; -const DEFAULTS_TELEMETRY = "defaults.telemetry"; const DEFAULTS_URL = "defaults.url"; const DEFAULTS_HEADERS = "defaults.headers"; const DEFAULTS_CA_CERT = "defaults.ca-cert"; -const DEFAULTS_AGENT_SKILLS = "defaults.agent-skills"; -const DEFAULTS_GRAPHICS = "defaults.graphics"; -const DEFAULTS_TIPS = "defaults.tips"; + +/** Names of the on/off preferences, in display order. */ +export const ON_OFF_PREFERENCES = [ + "telemetry", + "agent-skills", + "graphics", + "tips", +] as const; + +/** Name of an on/off preference. */ +export type OnOffPreference = (typeof ON_OFF_PREFERENCES)[number]; + +/** Metadata key an on/off preference is stored under. */ +function onOffKey(name: OnOffPreference): `defaults.${OnOffPreference}` { + return `defaults.${name}`; +} /** All metadata keys used for defaults (for bulk operations) */ const ALL_DEFAULTS_KEYS = [ DEFAULTS_ORG, DEFAULTS_PROJECT, - DEFAULTS_TELEMETRY, DEFAULTS_URL, DEFAULTS_HEADERS, DEFAULTS_CA_CERT, - DEFAULTS_AGENT_SKILLS, - DEFAULTS_GRAPHICS, - DEFAULTS_TIPS, + ...ON_OFF_PREFERENCES.map(onOffKey), ]; -/** State of all persistent defaults */ +/** + * State of all persistent defaults. On/off preferences are "on", "off", or + * null (= default enabled). + */ export type DefaultsState = { /** Default organization slug, or null if unset */ organization: string | null; /** Default project slug, or null if unset */ project: string | null; - /** Telemetry preference: "on", "off", or null (= default enabled) */ - telemetry: "on" | "off" | null; /** Default Sentry instance URL, or null if unset */ url: string | null; /** Custom HTTP headers for self-hosted proxy auth, or null if unset */ headers: string | null; /** Path to a PEM file with custom CA certificates, or null if unset */ "ca-cert": string | null; - /** Agent skill install preference: "on", "off", or null (= default enabled) */ - "agent-skills": "on" | "off" | null; - /** Inline terminal graphics preference: "on", "off", or null (= default enabled) */ - graphics: "on" | "off" | null; - /** `Tip:` hint preference: "on", "off", or null (= default enabled) */ - tips: "on" | "off" | null; -}; +} & Record; /** Parse a raw "on" / "off" metadata value to a typed "on" | "off" | null. */ function parseOnOffValue(val: string | undefined): "on" | "off" | null { @@ -70,22 +74,6 @@ function parseOnOffValue(val: string | undefined): "on" | "off" | null { return null; } -/** Metadata keys for the on/off preferences, by `sentry cli defaults` key. */ -const ON_OFF_PREFERENCE_KEYS = { - telemetry: DEFAULTS_TELEMETRY, - "agent-skills": DEFAULTS_AGENT_SKILLS, - graphics: DEFAULTS_GRAPHICS, - tips: DEFAULTS_TIPS, -} as const; - -/** Name of an on/off preference (`telemetry`, `agent-skills`, ...). */ -export type OnOffPreference = keyof typeof ON_OFF_PREFERENCE_KEYS; - -/** All on/off preference names, in display order. */ -export const ON_OFF_PREFERENCES = Object.keys( - ON_OFF_PREFERENCE_KEYS, -) as OnOffPreference[]; - /** * Read a persistent on/off preference. * @@ -93,7 +81,7 @@ export const ON_OFF_PREFERENCES = Object.keys( * `undefined` if no preference is stored (callers should default to enabled) */ export function getOnOffPreference(name: OnOffPreference): boolean | undefined { - const key = ON_OFF_PREFERENCE_KEYS[name]; + const key = onOffKey(name); const value = parseOnOffValue(getMetadata(getDatabase(), [key]).get(key)); return value === null ? undefined : value === "on"; } @@ -106,7 +94,7 @@ export function setOnOffPreference( name: OnOffPreference, enabled: boolean | null, ): void { - const key = ON_OFF_PREFERENCE_KEYS[name]; + const key = onOffKey(name); const db = getDatabase(); if (enabled === null) { clearMetadata(db, [key]); @@ -170,13 +158,15 @@ export function getAllDefaults(): DefaultsState { return { organization: m.get(DEFAULTS_ORG) ?? null, project: m.get(DEFAULTS_PROJECT) ?? null, - telemetry: parseOnOffValue(m.get(DEFAULTS_TELEMETRY)), url: m.get(DEFAULTS_URL) ?? null, headers: m.get(DEFAULTS_HEADERS) ?? null, "ca-cert": m.get(DEFAULTS_CA_CERT) ?? null, - "agent-skills": parseOnOffValue(m.get(DEFAULTS_AGENT_SKILLS)), - graphics: parseOnOffValue(m.get(DEFAULTS_GRAPHICS)), - tips: parseOnOffValue(m.get(DEFAULTS_TIPS)), + ...(Object.fromEntries( + ON_OFF_PREFERENCES.map((name) => [ + name, + parseOnOffValue(m.get(onOffKey(name))), + ]), + ) as Record), }; } diff --git a/packages/cli/test/commands/cli/defaults.test.ts b/packages/cli/test/commands/cli/defaults.test.ts index 03b8e2446..7595a7903 100644 --- a/packages/cli/test/commands/cli/defaults.test.ts +++ b/packages/cli/test/commands/cli/defaults.test.ts @@ -15,6 +15,7 @@ import { getDefaultProject, getDefaultUrl, getOnOffPreference, + ON_OFF_PREFERENCES, setDefaultOrganization, setDefaultProject, setDefaultUrl, @@ -71,59 +72,19 @@ describe("defaults storage", () => { expect(getDefaultProject()).toBe("proj1"); }); - test("getOnOffPreference(telemetry) returns undefined when not set", () => { - expect(getOnOffPreference("telemetry")).toBeUndefined(); - }); - - test("setOnOffPreference(telemetry) stores on/off", () => { - setOnOffPreference("telemetry", false); - expect(getOnOffPreference("telemetry")).toBe(false); - - setOnOffPreference("telemetry", true); - expect(getOnOffPreference("telemetry")).toBe(true); - }); - - test("setOnOffPreference(telemetry, null) clears preference", () => { - setOnOffPreference("telemetry", false); - setOnOffPreference("telemetry", null); - expect(getOnOffPreference("telemetry")).toBeUndefined(); - }); - - test("getOnOffPreference(agent-skills) returns undefined when not set", () => { - expect(getOnOffPreference("agent-skills")).toBeUndefined(); - }); - - test("setOnOffPreference(agent-skills) stores on/off", () => { - setOnOffPreference("agent-skills", false); - expect(getOnOffPreference("agent-skills")).toBe(false); - - setOnOffPreference("agent-skills", true); - expect(getOnOffPreference("agent-skills")).toBe(true); - }); - - test("setOnOffPreference(agent-skills, null) clears preference", () => { - setOnOffPreference("agent-skills", false); - setOnOffPreference("agent-skills", null); - expect(getOnOffPreference("agent-skills")).toBeUndefined(); - }); - - test("getOnOffPreference(tips) returns undefined when not set", () => { - expect(getOnOffPreference("tips")).toBeUndefined(); - }); - - test("setOnOffPreference(tips) stores on/off", () => { - setOnOffPreference("tips", false); - expect(getOnOffPreference("tips")).toBe(false); - expect(getAllDefaults().tips).toBe("off"); - setOnOffPreference("tips", true); - expect(getOnOffPreference("tips")).toBe(true); - expect(getAllDefaults().tips).toBe("on"); - }); + describe.each(ON_OFF_PREFERENCES)("on/off preference %s", (name) => { + test("is undefined when not set", () => { + expect(getOnOffPreference(name)).toBeUndefined(); + }); - test("setOnOffPreference(tips, null) clears preference", () => { - setOnOffPreference("tips", false); - setOnOffPreference("tips", null); - expect(getOnOffPreference("tips")).toBeUndefined(); + test("stores on/off and clears with null", () => { + setOnOffPreference(name, false); + expect(getOnOffPreference(name)).toBe(false); + setOnOffPreference(name, true); + expect(getOnOffPreference(name)).toBe(true); + setOnOffPreference(name, null); + expect(getOnOffPreference(name)).toBeUndefined(); + }); }); test("getDefaultUrl returns null when not set", () => {