Skip to content

Commit d934972

Browse files
committed
feat(cli): add sentry cli defaults command for persistent settings
Implement a command to view and manage persistent CLI defaults: organization, project, telemetry preference, and Sentry URL (for self-hosted instances). - Consolidate storage from the unused `defaults` single-row table to the `metadata` KV table (migration 13 copies any data, then drops the table) - Add `parseBoolValue()` utility for human-friendly boolean parsing (on/off, yes/no, true/false, 1/0) adapted from Sentry JS SDK's `envToBool` - Add persistent telemetry opt-out with priority chain: SENTRY_CLI_NO_TELEMETRY > DO_NOT_TRACK > SQLite preference > default (on) - Add persistent URL default applied in `preloadProjectContext()` after `.sentryclirc` env shim, using the same `env.SENTRY_URL` mechanism - Property-based tests for parseBoolValue, unit tests for defaults storage and isTelemetryEnabled priority chain Closes #304
1 parent ec08c24 commit d934972

19 files changed

Lines changed: 1611 additions & 168 deletions

File tree

AGENTS.md

Lines changed: 52 additions & 60 deletions
Large diffs are not rendered by default.

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ Make an authenticated API request
322322

323323
CLI-related commands
324324

325+
- `sentry cli defaults <key value...>` — View and manage default settings
325326
- `sentry cli feedback <message...>` — Send feedback about the CLI
326327
- `sentry cli fix` — Diagnose and repair CLI database issues
327328
- `sentry cli setup` — Configure shell integration

plugins/sentry-cli/skills/sentry-cli/references/cli.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ requires:
1111

1212
CLI-related commands
1313

14+
### `sentry cli defaults <key value...>`
15+
16+
View and manage default settings
17+
18+
**Flags:**
19+
- `--clear - Clear the specified default, or all defaults if no key is given`
20+
- `-y, --yes - Skip confirmation prompt`
21+
- `-f, --force - Force the operation without confirmation`
22+
1423
### `sentry cli feedback <message...>`
1524

1625
Send feedback about the CLI

src/cli.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@ async function preloadProjectContext(cwd: string): Promise<void> {
3535
// Apply .sentryclirc env shim (token, URL) — sentryclirc cache was
3636
// populated as a side effect of findProjectRoot's walk
3737
await applySentryCliRcEnvShim(cwd);
38+
39+
// Apply persistent URL default (lower priority than env vars and .sentryclirc).
40+
// Same mechanism as .sentryclirc — writes to env.SENTRY_URL so all downstream
41+
// URL resolution code picks it up automatically.
42+
const env = getEnv();
43+
if (!(env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim())) {
44+
try {
45+
const { getDefaultUrl } = await import("./lib/db/defaults.js");
46+
const url = getDefaultUrl();
47+
if (url) {
48+
env.SENTRY_URL = url;
49+
}
50+
} catch {
51+
// DB not available — skip
52+
}
53+
}
3854
}
3955

4056
/**

src/commands/cli/defaults.ts

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
/**
2+
* sentry cli defaults
3+
*
4+
* View and manage persistent CLI default settings.
5+
*
6+
* Supports four defaults:
7+
* - `org` / `organization` — default organization slug
8+
* - `project` — default project slug
9+
* - `telemetry` — telemetry preference (on/off)
10+
* - `url` — Sentry instance URL (for self-hosted)
11+
*/
12+
13+
import type { SentryContext } from "../../context.js";
14+
import { buildCommand } from "../../lib/command.js";
15+
import { normalizeUrl } from "../../lib/constants.js";
16+
import {
17+
clearAllDefaults,
18+
type DefaultsState,
19+
getAllDefaults,
20+
getDefaultOrganization,
21+
getDefaultProject,
22+
getDefaultUrl,
23+
getTelemetryPreference,
24+
setDefaultOrganization,
25+
setDefaultProject,
26+
setDefaultUrl,
27+
setTelemetryPreference,
28+
} from "../../lib/db/defaults.js";
29+
import { ValidationError } from "../../lib/errors.js";
30+
import { formatDefaultsResult } from "../../lib/formatters/human.js";
31+
import { CommandOutput } from "../../lib/formatters/output.js";
32+
import { logger } from "../../lib/logger.js";
33+
import {
34+
FORCE_FLAG,
35+
guardNonInteractive,
36+
isConfirmationBypassed,
37+
YES_FLAG,
38+
} from "../../lib/mutate-command.js";
39+
import { parseBoolValue } from "../../lib/parse-bool.js";
40+
import { computeTelemetryEffective } from "../../lib/telemetry.js";
41+
42+
// ---------------------------------------------------------------------------
43+
// Defaults registry — maps canonical keys to get/set/clear handlers
44+
// ---------------------------------------------------------------------------
45+
46+
/** Canonical key names matching DefaultsState fields */
47+
type DefaultKey = "organization" | "project" | "telemetry" | "url";
48+
49+
/** Handler for reading, writing, and clearing a single default */
50+
type DefaultHandler = {
51+
/** Get current value for display / change tracking */
52+
get: () => string | boolean | null;
53+
/** Validate and store a new value */
54+
set: (value: string) => void;
55+
/** Clear the stored value */
56+
clear: () => void;
57+
};
58+
59+
/** Validate that a slug value is non-empty after trimming whitespace. */
60+
function validateSlug(value: string, label: string): string {
61+
const trimmed = value.trim();
62+
if (!trimmed) {
63+
throw new ValidationError(`${label} cannot be empty.`, label.toLowerCase());
64+
}
65+
return trimmed;
66+
}
67+
68+
/** Registry of all supported defaults with their handlers */
69+
const DEFAULTS_REGISTRY: Record<DefaultKey, DefaultHandler> = {
70+
organization: {
71+
get: getDefaultOrganization,
72+
set: (value) => setDefaultOrganization(validateSlug(value, "Organization")),
73+
clear: () => setDefaultOrganization(null),
74+
},
75+
project: {
76+
get: getDefaultProject,
77+
set: (value) => setDefaultProject(validateSlug(value, "Project")),
78+
clear: () => setDefaultProject(null),
79+
},
80+
telemetry: {
81+
get: () => {
82+
const pref = getTelemetryPreference();
83+
if (pref === true) {
84+
return "on";
85+
}
86+
if (pref === false) {
87+
return "off";
88+
}
89+
return null;
90+
},
91+
set: (value) => {
92+
const parsed = parseBoolValue(value);
93+
if (parsed === null) {
94+
throw new ValidationError(
95+
`Invalid telemetry value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`,
96+
"telemetry"
97+
);
98+
}
99+
setTelemetryPreference(parsed);
100+
},
101+
clear: () => setTelemetryPreference(null),
102+
},
103+
url: {
104+
get: getDefaultUrl,
105+
set: (value) => {
106+
const normalized = normalizeUrl(value);
107+
if (!normalized) {
108+
throw new ValidationError("URL cannot be empty.", "url");
109+
}
110+
try {
111+
new URL(normalized);
112+
} catch {
113+
throw new ValidationError(
114+
`Invalid URL: '${value}'. Provide a valid URL (e.g., https://sentry.example.com).`,
115+
"url"
116+
);
117+
}
118+
setDefaultUrl(normalized);
119+
},
120+
clear: () => setDefaultUrl(null),
121+
},
122+
};
123+
124+
// ---------------------------------------------------------------------------
125+
// Key aliases — maps shorthand names to canonical DefaultKey
126+
// ---------------------------------------------------------------------------
127+
128+
/** Shorthand aliases for canonical keys (e.g., "org" → "organization") */
129+
const KEY_ALIASES: Partial<Record<string, DefaultKey>> = {
130+
org: "organization",
131+
};
132+
133+
/** Resolve a user-provided key string to a canonical key, or null if unknown */
134+
function normalizeKey(key: string): DefaultKey | null {
135+
const lower = key.toLowerCase();
136+
return (
137+
KEY_ALIASES[lower] ??
138+
(lower in DEFAULTS_REGISTRY ? (lower as DefaultKey) : null)
139+
);
140+
}
141+
142+
// ---------------------------------------------------------------------------
143+
// Result type + telemetry effective state
144+
// ---------------------------------------------------------------------------
145+
146+
/** Result data for the defaults command */
147+
export type DefaultsResult = {
148+
/** The operation performed */
149+
action: "show" | "set" | "clear" | "clear-all";
150+
/** Current state of all defaults after the operation */
151+
defaults: DefaultsState;
152+
/** Effective telemetry state considering env var overrides (display-only) */
153+
telemetryEffective?: {
154+
enabled: boolean;
155+
source: string;
156+
};
157+
/** What was changed (for set/clear actions) */
158+
changed?: {
159+
key: string;
160+
previousValue: string | boolean | null;
161+
newValue: string | boolean | null;
162+
};
163+
};
164+
165+
// ---------------------------------------------------------------------------
166+
// Command
167+
// ---------------------------------------------------------------------------
168+
169+
const log = logger.withTag("defaults");
170+
171+
export const defaultsCommand = buildCommand({
172+
auth: false,
173+
docs: {
174+
brief: "View and manage default settings",
175+
fullDescription:
176+
"View and manage persistent CLI default settings.\n\n" +
177+
"With no arguments, shows all current defaults. Pass a key and value\n" +
178+
"to set a default, or use `--clear` to remove defaults.\n\n" +
179+
"## Examples\n\n" +
180+
"```\n" +
181+
"sentry cli defaults # Show all defaults\n" +
182+
"sentry cli defaults org my-org # Set default organization\n" +
183+
"sentry cli defaults project my-proj # Set default project\n" +
184+
"sentry cli defaults telemetry off # Disable telemetry\n" +
185+
"sentry cli defaults url https://... # Set Sentry URL (self-hosted)\n" +
186+
"sentry cli defaults org --clear # Clear a specific default\n" +
187+
"sentry cli defaults --clear --yes # Clear all defaults\n" +
188+
"```\n\n" +
189+
"## Recognized keys\n\n" +
190+
"| Key | Description |\n" +
191+
"|-----|------------|\n" +
192+
"| `org` | Default organization slug |\n" +
193+
"| `project` | Default project slug |\n" +
194+
"| `telemetry` | Telemetry preference (on/off, yes/no, true/false, 1/0) |\n" +
195+
"| `url` | Sentry instance URL (for self-hosted installations) |",
196+
},
197+
output: {
198+
human: formatDefaultsResult,
199+
jsonExclude: ["telemetryEffective"],
200+
},
201+
parameters: {
202+
positional: {
203+
kind: "array",
204+
parameter: {
205+
brief: "Setting key and optional value",
206+
parse: String,
207+
placeholder: "key value",
208+
},
209+
},
210+
flags: {
211+
clear: {
212+
kind: "boolean",
213+
brief:
214+
"Clear the specified default, or all defaults if no key is given",
215+
default: false,
216+
},
217+
yes: YES_FLAG,
218+
force: FORCE_FLAG,
219+
},
220+
aliases: { y: "yes", f: "force" },
221+
},
222+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: sequential command dispatch
223+
async *func(
224+
this: SentryContext,
225+
flags: {
226+
readonly clear: boolean;
227+
readonly yes: boolean;
228+
readonly force: boolean;
229+
},
230+
...args: string[]
231+
) {
232+
const [keyArg, valueArg, ...rest] = args;
233+
234+
// Too many arguments
235+
if (rest.length > 0) {
236+
throw new ValidationError(
237+
"Too many arguments. Usage: sentry cli defaults [<key> [<value>]]",
238+
"args"
239+
);
240+
}
241+
242+
// No key specified — show all or clear all
243+
if (!keyArg) {
244+
if (flags.clear) {
245+
// Clear all defaults (with confirmation)
246+
guardNonInteractive(flags);
247+
if (!isConfirmationBypassed(flags)) {
248+
const confirmed = await log.prompt(
249+
"This will clear all defaults (organization, project, telemetry, URL). Continue?",
250+
{ type: "confirm" }
251+
);
252+
if (confirmed !== true) {
253+
return { hint: "Cancelled." };
254+
}
255+
}
256+
clearAllDefaults();
257+
yield new CommandOutput({
258+
action: "clear-all" as const,
259+
defaults: getAllDefaults(),
260+
});
261+
return { hint: "All defaults have been cleared." };
262+
}
263+
264+
// Show all defaults
265+
yield new CommandOutput({
266+
action: "show" as const,
267+
defaults: getAllDefaults(),
268+
telemetryEffective: computeTelemetryEffective(),
269+
});
270+
return;
271+
}
272+
273+
// Validate key
274+
const canonical = normalizeKey(keyArg);
275+
if (!canonical) {
276+
const validKeys = [
277+
...Object.keys(DEFAULTS_REGISTRY),
278+
...Object.keys(KEY_ALIASES),
279+
];
280+
throw new ValidationError(
281+
`Unknown default '${keyArg}'. Valid keys: ${validKeys.join(", ")}`,
282+
"key"
283+
);
284+
}
285+
286+
const handler = DEFAULTS_REGISTRY[canonical];
287+
288+
// Key + --clear → clear specific default
289+
if (flags.clear) {
290+
if (valueArg !== undefined) {
291+
throw new ValidationError(
292+
`Cannot use --clear with a value. Use either 'sentry cli defaults ${keyArg} --clear' or 'sentry cli defaults ${keyArg} <value>'.`,
293+
"args"
294+
);
295+
}
296+
const previous = handler.get();
297+
handler.clear();
298+
yield new CommandOutput({
299+
action: "clear" as const,
300+
defaults: getAllDefaults(),
301+
changed: { key: canonical, previousValue: previous, newValue: null },
302+
});
303+
return;
304+
}
305+
306+
// Key only, no value → show specific default
307+
if (valueArg === undefined) {
308+
yield new CommandOutput({
309+
action: "show" as const,
310+
defaults: getAllDefaults(),
311+
telemetryEffective:
312+
canonical === "telemetry" ? computeTelemetryEffective() : undefined,
313+
});
314+
return;
315+
}
316+
317+
// Key + value → set default
318+
const previous = handler.get();
319+
handler.set(valueArg);
320+
const newValue = handler.get();
321+
322+
yield new CommandOutput({
323+
action: "set" as const,
324+
defaults: getAllDefaults(),
325+
changed: { key: canonical, previousValue: previous, newValue },
326+
});
327+
328+
// Show telemetry override warning when setting telemetry preference
329+
if (canonical === "telemetry") {
330+
const effective = computeTelemetryEffective();
331+
if (
332+
effective?.source.startsWith("env:") &&
333+
effective.enabled !== (newValue === "on")
334+
) {
335+
log.warn(
336+
`Note: ${effective.source.slice(4)} environment variable overrides this preference.`
337+
);
338+
}
339+
}
340+
341+
return;
342+
},
343+
});

0 commit comments

Comments
 (0)