Skip to content

Commit 9357c90

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 3d286b5 commit 9357c90

19 files changed

Lines changed: 1234 additions & 150 deletions

File tree

AGENTS.md

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

0 commit comments

Comments
 (0)