-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathcli.ts
More file actions
637 lines (585 loc) · 22.2 KB
/
Copy pathcli.ts
File metadata and controls
637 lines (585 loc) · 22.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
/**
* CLI runner with fast-path dispatch.
*
* Shell completion (`__complete`) is dispatched before any heavy imports
* to avoid loading `@sentry/node-core` (~280ms). All other commands go through
* the full CLI with telemetry, middleware, and error recovery.
*
* Extracted from `bin.ts` so the logic is testable and reusable without
* top-level side effects. `bin.ts` remains a thin wrapper that registers
* stream error handlers and calls `startCli()`.
*/
import { getEnv } from "./lib/env.js";
import { captureEnvTokenHost } from "./lib/env-token-host.js";
import { CliError } from "./lib/errors.js";
import { applySentryCliRcEnvShim } from "./lib/sentryclirc.js";
/**
* Preload project context: walk up from `cwd` once, finding both the
* project root (for DSN detection) and `.sentryclirc` config (for
* org/project defaults and env shim). Caches both results so later calls
* to `findProjectRoot` and `loadSentryCliRc` are cache hits.
*/
async function preloadProjectContext(cwd: string): Promise<void> {
// Snapshot env-token host BEFORE anything mutates env.SENTRY_HOST/URL
// (the .sentryclirc shim or the default-URL fallback below). Pins the
// env-token's trust scope to the user's shell, not a repo-local file.
captureEnvTokenHost();
// Dynamic import keeps the heavy DSN/DB modules out of the completion fast-path
const [{ findProjectRoot }, { setCachedProjectRoot }] = await Promise.all([
import("./lib/dsn/project-root.js"),
import("./lib/db/project-root-cache.js"),
]);
const result = await findProjectRoot(cwd);
await setCachedProjectRoot(cwd, {
projectRoot: result.projectRoot,
reason: result.reason,
});
// Apply .sentryclirc env shim (token + URL). The URL trust check is
// deferred to buildCommand's wrapper where commands can opt out via
// skipRcUrlCheck (used by auth login/logout).
await applySentryCliRcEnvShim(cwd);
// Apply persistent URL default (lower priority than env vars and .sentryclirc).
const env = getEnv();
if (!(env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim())) {
try {
const { getDefaultUrl } = await import("./lib/db/defaults.js");
const url = getDefaultUrl();
if (url) {
env.SENTRY_URL = url;
}
} catch {
// DB not available — skip
}
}
}
/**
* Fast-path: shell completion.
*
* Dispatched before importing the full CLI to avoid loading @sentry/node-core,
* @stricli/core, and other heavy dependencies. Only loads the lightweight
* completion engine and SQLite cache modules.
*/
export async function runCompletion(completionArgs: string[]): Promise<void> {
// Disable telemetry so db/index.ts skips the @sentry/node-core lazy-require (~280ms)
getEnv().SENTRY_CLI_NO_TELEMETRY = "1";
const { handleComplete } = await import("./lib/complete.js");
handleComplete(completionArgs);
}
/**
* Flags whose values must never be sent to telemetry.
* Superset of `SENSITIVE_FLAGS` in `telemetry.ts` — includes `auth-token`
* because raw argv may use either form before Stricli parses to camelCase.
*/
const SENSITIVE_ARGV_FLAGS = new Set(["token", "auth-token"]);
/**
* Check whether an argv token is a sensitive flag that needs value redaction.
* Returns `"eq"` for `--flag=value` form, `"next"` for `--flag <value>` form,
* or `null` if the token is not sensitive.
*/
function sensitiveArgvFlag(token: string): "eq" | "next" | null {
if (!token.startsWith("--")) {
return null;
}
const eqIdx = token.indexOf("=");
if (eqIdx !== -1) {
const name = token.slice(2, eqIdx).toLowerCase();
return SENSITIVE_ARGV_FLAGS.has(name) ? "eq" : null;
}
const name = token.slice(2).toLowerCase();
return SENSITIVE_ARGV_FLAGS.has(name) ? "next" : null;
}
/**
* Redact sensitive values from argv before sending to telemetry.
*
* When a token matches `--<flag>` or `--<flag>=value` for a sensitive
* flag name, the value (next positional token or `=value` portion) is
* replaced with `[REDACTED]`.
*/
function redactArgv(argv: string[]): string[] {
const redacted: string[] = [];
let skipNext = false;
for (const token of argv) {
if (skipNext) {
redacted.push("[REDACTED]");
skipNext = false;
continue;
}
const kind = sensitiveArgvFlag(token);
if (kind === "eq") {
const eqIdx = token.indexOf("=");
redacted.push(`${token.slice(0, eqIdx + 1)}[REDACTED]`);
} else if (kind === "next") {
redacted.push(token);
skipNext = true;
} else {
redacted.push(token);
}
}
return redacted;
}
/**
* Error-recovery middleware for the CLI.
*
* Each middleware wraps command execution and may intercept specific errors
* to perform recovery actions (e.g., login, start trial) then retry.
*
* Middlewares are applied innermost-first: the last middleware in the array
* wraps the outermost layer, so it gets first crack at errors. This means
* auth recovery (outermost) can catch errors from both the command AND
* the trial prompt retry.
*
* @param next - The next function in the chain (command or inner middleware)
* @param args - CLI arguments for retry
* @returns A function with the same signature, with error recovery added
*/
type ErrorMiddleware = (
proceed: (cmdInput: string[]) => Promise<void>,
retryArgs: string[]
) => Promise<void>;
/**
* Full CLI execution with telemetry, middleware, and error recovery.
*
* All heavy imports are loaded here (not at module top level) so the
* `__complete` fast-path can skip them entirely.
*/
export async function runCli(cliArgs: string[]): Promise<void> {
const { isatty } = await import("node:tty");
const { ExitCode, run } = await import("@stricli/core");
const { app } = await import("./app.js");
const { hoistGlobalFlags } = await import("./lib/argv-hoist.js");
const { buildContext } = await import("./context.js");
const { AuthError, OutputError, formatError, getExitCode } = await import(
"./lib/errors.js"
);
const { error, warning } = await import("./lib/formatters/colors.js");
const { runInteractiveLogin } = await import("./lib/interactive-login.js");
const { getEnvLogLevel, setLogLevel } = await import("./lib/logger.js");
const { isTrialEligible, promptAndStartTrial } = await import(
"./lib/seer-trial.js"
);
const { withTelemetry } = await import("./lib/telemetry.js");
const { startCleanupOldBinary } = await import("./lib/upgrade.js");
const {
abortPendingVersionCheck,
getErrorUpdateNotification,
getUpdateNotification,
maybeCheckForUpdateInBackground,
shouldSuppressNotification,
} = await import("./lib/version-check.js");
// Move global flags (--verbose, -v, --log-level, --json, --fields) from any
// position to the end of argv, where Stricli's leaf-command parser can
// find them. This allows `sentry --verbose issue list` to work.
// The original cliArgs are kept for post-run checks (e.g., help recovery)
// that rely on the original token positions.
const hoistedArgs = hoistGlobalFlags(cliArgs);
// ---------------------------------------------------------------------------
// Error-recovery middleware
// ---------------------------------------------------------------------------
/**
* Seer trial prompt middleware.
*
* Catches trial-eligible SeerErrors and offers to start a free trial.
* On success, retries the original command. On failure/decline, re-throws
* so the outer error handler displays the full error with upgrade URL.
*/
const seerTrialMiddleware: ErrorMiddleware = async (next, argv) => {
try {
await next(argv);
} catch (err) {
if (isTrialEligible(err)) {
const started = await promptAndStartTrial(
// biome-ignore lint/style/noNonNullAssertion: isTrialEligible guarantees orgSlug is defined
err.orgSlug!,
err.reason
);
if (started) {
process.stderr.write("\nRetrying command...\n\n");
await next(argv);
return;
}
}
throw err;
}
};
/**
* Attempt to import `.sentryclirc` settings when the user is unauthenticated.
*
* Returns `"imported"` if a trusted token was found, imported, and validated.
* Returns `"declined"` if the user said no (marked as declined).
* Returns `"skip"` if no eligible files, trust gate fails, or any error.
*/
/**
* Build a trusted import plan from non-project-local .sentryclirc files,
* or return null if no eligible import is available.
*/
async function buildEligibleImportPlan() {
const { discoverRcFiles, buildImportPlan, isImportNeededAsync } =
await import("./lib/sentryclirc-import.js");
if (!(await isImportNeededAsync())) {
return null;
}
const files = await discoverRcFiles(process.cwd());
const eligible = files.filter((f) => f.location !== "project-local");
if (eligible.length === 0 || !eligible.some((f) => f.token)) {
return null;
}
const plan = buildImportPlan(eligible);
if (
!(
plan.trusted &&
plan.effective.token &&
plan.newFields.includes("token")
)
) {
return null;
}
return plan;
}
async function tryRcImport(): Promise<"imported" | "declined" | "skip"> {
const plan = await buildEligibleImportPlan();
if (!plan) {
return "skip";
}
const source = plan.sources.find((s) => s.token)?.path ?? "~/.sentryclirc";
process.stderr.write(
`\nFound auth token in ${source}\n` +
"Import settings to the new CLI? This stores your token with proper host scoping.\n\n"
);
const consent = await promptImportConsent();
if (consent === "declined") {
const { markImportDeclined } = await import(
"./lib/sentryclirc-import.js"
);
markImportDeclined(plan.sources);
return "declined";
}
if (consent !== "accepted") {
return "skip";
}
const { executeImport } = await import("./lib/sentryclirc-import.js");
const result = await executeImport(plan, { validateToken: true });
return result.imported && result.tokenValid !== false ? "imported" : "skip";
}
/**
* Prompt the user to accept/decline the import.
* Returns "accepted", "declined" (explicit no), or "cancelled" (Ctrl+C).
* Only "declined" permanently suppresses future prompts.
*/
async function promptImportConsent(): Promise<
"accepted" | "declined" | "cancelled"
> {
const { logger: logModule } = await import("./lib/logger.js");
const confirmed = await logModule
.withTag("import")
.prompt("Import from .sentryclirc?", { type: "confirm", initial: true });
if (confirmed === true) {
return "accepted";
}
// false = explicit "no"; Symbol(clack:cancel) = Ctrl+C
return confirmed === false ? "declined" : "cancelled";
}
/** Log import middleware errors at an appropriate level */
async function logImportError(importErr: unknown): Promise<void> {
const { logger: logModule } = await import("./lib/logger.js");
const { HostScopeError: HSE } = await import("./lib/errors.js");
const importLog = logModule.withTag("import");
if (importErr instanceof HSE) {
importLog.warn("Import middleware error", importErr);
} else {
importLog.debug("Import middleware error", importErr);
}
}
/**
* `.sentryclirc` import middleware.
*
* When a command fails with `not_authenticated` and a non-project-local
* `.sentryclirc` file has a token that passes the same-file trust gate,
* offers to import it into the new CLI's SQLite store. On success, retries
* the command. On decline, marks as declined (never asks again) and
* re-throws so the auto-auth middleware can offer OAuth login instead.
*
* Only fires in interactive TTYs (disabled in CI). Project-local files
* are excluded to avoid prompting in every cloned repo.
*/
const rcImportMiddleware: ErrorMiddleware = async (next, argv) => {
try {
await next(argv);
} catch (err) {
let imported = false;
if (
err instanceof AuthError &&
err.reason === "not_authenticated" &&
!err.skipAutoAuth &&
isatty(0)
) {
try {
imported = (await tryRcImport()) === "imported";
} catch (importErr) {
await logImportError(importErr);
}
}
if (imported) {
// Retry outside the import try/catch so retry errors propagate
// naturally instead of being swallowed and re-throwing the
// original AuthError.
process.stderr.write("Import successful! Retrying command...\n\n");
await next(argv);
return;
}
throw err;
}
};
/**
* Auto-authentication middleware.
*
* Catches auth errors (not_authenticated, expired) in interactive TTYs
* and runs the login flow. On success, retries through the full middleware
* chain so inner middlewares (e.g., trial prompt) also apply to the retry.
*/
const autoAuthMiddleware: ErrorMiddleware = async (next, argv) => {
try {
await next(argv);
} catch (err) {
// Use isatty(0) for reliable stdin TTY detection (process.stdin.isTTY can be undefined in Bun)
// Errors can opt-out via skipAutoAuth (e.g., auth status command)
if (
err instanceof AuthError &&
(err.reason === "not_authenticated" || err.reason === "expired") &&
!err.skipAutoAuth &&
isatty(0)
) {
process.stderr.write(
err.reason === "expired"
? "Authentication expired. Starting login flow...\n\n"
: "Authentication required. Starting login flow...\n\n"
);
const loginSuccess = await runInteractiveLogin();
if (loginSuccess) {
process.stderr.write("\nRetrying command...\n\n");
await next(argv);
return;
}
// Login failed or was cancelled
process.exitCode = 1;
return;
}
throw err;
}
};
/**
* Error-recovery middlewares applied around command execution.
*
* Order matters: applied innermost-first, so the last entry wraps the
* outermost layer. Auth middleware is outermost so it catches errors
* from both the command and any inner middleware retries.
*/
const errorMiddlewares: ErrorMiddleware[] = [
seerTrialMiddleware,
rcImportMiddleware,
autoAuthMiddleware,
];
/** Run CLI command with telemetry wrapper */
async function runCommand(argv: string[]): Promise<void> {
await withTelemetry(async (span) => {
await run(app, argv, buildContext(process, span));
// Stricli handles unknown subcommands internally — it writes to
// stderr and sets exitCode without throwing. Report to Sentry so
// we can track typo/confusion patterns and improve suggestions.
if (
(process.exitCode === ExitCode.UnknownCommand ||
process.exitCode === (ExitCode.UnknownCommand + 256) % 256) &&
// Skip when the unknown token is "help" — the outer code in
// runCli recovers this by retrying as `sentry help <group...>`
argv.at(-1) !== "help"
) {
// Best-effort: telemetry must never crash the CLI
try {
await reportUnknownCommand(argv);
} catch {
// Silently ignore telemetry failures
}
}
});
}
/**
* Extract org/project from raw argv by scanning for `org/project` tokens.
*
* Commands accept `org/project` or `org/` as positional targets.
* Since Stricli failed at route resolution, args are unparsed — we
* scan for the first slash-containing, non-flag token.
*/
function extractOrgProjectFromArgv(argv: string[]): {
org?: string;
project?: string;
} {
for (const token of argv) {
if (token.startsWith("-") || !token.includes("/")) {
continue;
}
const slashIdx = token.indexOf("/");
const org = token.slice(0, slashIdx) || undefined;
const project = token.slice(slashIdx + 1) || undefined;
return { org, project };
}
return {};
}
/**
* Report an unknown subcommand to Sentry with rich context.
*
* Called when Stricli's route scanner rejects an unrecognized token
* (e.g., `sentry issue helpp`). Uses the introspection system to find
* fuzzy matches and extracts org/project context from the raw argv.
*/
async function reportUnknownCommand(argv: string[]): Promise<void> {
const Sentry = await import("@sentry/node-core/light");
const { resolveCommandPath } = await import("./lib/introspect.js");
const { routes } = await import("./app.js");
const { getDefaultOrganization } = await import("./lib/db/defaults.js");
// Strip flags so resolveCommandPath only sees command path segments
const pathSegments = argv.filter((t) => !t.startsWith("-"));
const resolved = resolveCommandPath(
routes as unknown as Parameters<typeof resolveCommandPath>[0],
pathSegments
);
const unknownToken =
resolved?.kind === "unresolved" ? resolved.input : (argv.at(-1) ?? "");
const suggestions =
resolved?.kind === "unresolved" ? resolved.suggestions : [];
// Extract org/project from argv, fall back to default org from SQLite
const fromArgv = extractOrgProjectFromArgv(argv);
const org = fromArgv.org ?? getDefaultOrganization() ?? undefined;
// Redact sensitive flags (e.g., --token) before sending to Sentry
const safeArgv = redactArgv(argv);
Sentry.setTag("command", "unknown");
if (org) {
Sentry.setTag("sentry.org", org);
}
if (fromArgv.project) {
Sentry.setTag("sentry.project", fromArgv.project);
}
Sentry.setContext("unknown_command", {
argv: safeArgv,
unknown_token: unknownToken,
suggestions,
arg_count: argv.length,
org: org ?? null,
project: fromArgv.project ?? null,
});
// Fixed message string so all unknown commands group into one Sentry
// issue. The per-event detail (which token, suggestions) lives in the
// structured context above and is queryable via Discover.
Sentry.captureMessage("unknown_command", "info");
}
/** Build the command executor by composing error-recovery middlewares. */
let executor = runCommand;
for (const mw of errorMiddlewares) {
const inner = executor;
executor = (argv) => mw(inner, argv);
}
// ---------------------------------------------------------------------------
// Main execution
// ---------------------------------------------------------------------------
// Clean up old binary from previous Windows upgrade (no-op if file doesn't exist)
startCleanupOldBinary();
// Apply SENTRY_LOG_LEVEL env var early (lazy read, not at module load time).
// CLI flags (--log-level, --verbose) are handled by Stricli via
// buildCommand and take priority when present.
const envLogLevel = getEnvLogLevel();
if (envLogLevel !== null) {
setLogLevel(envLogLevel);
}
// Use hoisted args so positional checks (e.g., args[0] === "cli") work
// even when global flags precede the subcommand in the original argv.
const suppressNotification = shouldSuppressNotification(hoistedArgs);
// Start background update check (non-blocking)
if (!suppressNotification) {
maybeCheckForUpdateInBackground();
}
try {
await executor(hoistedArgs);
// When Stricli can't match a subcommand in a route group (e.g.,
// `sentry dashboard help`), it writes "No command registered for `help`"
// to stderr and sets exitCode to UnknownCommand. If the unrecognized
// token was "help", retry as `sentry help <group...>` which routes to
// the custom help command with proper introspection output.
// Check both raw (-5) and unsigned (251) forms because Node.js keeps
// the raw value while Bun converts to unsigned byte.
// Uses original cliArgs (not hoisted) so the `at(-1) === "help"` check
// works when global flags were placed before "help".
if (
(process.exitCode === ExitCode.UnknownCommand ||
process.exitCode === (ExitCode.UnknownCommand + 256) % 256) &&
cliArgs.length >= 2 &&
cliArgs.at(-1) === "help" &&
cliArgs[0] !== "help"
) {
process.exitCode = 0;
const helpArgs = ["help", ...cliArgs.slice(0, -1)];
process.stderr.write(
warning(
`Tip: use --help for help (e.g., sentry ${cliArgs.slice(0, -1).join(" ")} --help)\n`
)
);
await executor(helpArgs);
}
} catch (err) {
// OutputError: data was already rendered to stdout by the command wrapper.
// Just set exitCode silently — no stderr message needed.
if (err instanceof OutputError) {
process.exitCode = err.exitCode;
return;
}
process.stderr.write(`${error("Error:")} ${formatError(err)}\n`);
process.exitCode = getExitCode(err);
const notification = getErrorUpdateNotification(err, hoistedArgs);
if (notification) {
process.stderr.write(notification);
}
return;
} finally {
// Abort any pending version check to allow clean exit
abortPendingVersionCheck();
}
// Show update notification after command completes
if (!suppressNotification) {
const notification = getUpdateNotification();
if (notification) {
process.stderr.write(notification);
}
}
}
/**
* Top-level CLI dispatch.
*
* Reads `process.argv`, dispatches to the completion fast-path or the full
* CLI runner, and handles fatal errors. Called from `bin.ts`.
*/
export async function startCli(): Promise<void> {
const args = process.argv.slice(2);
// Completions are a fast-path (~1ms) — skip .sentryclirc I/O.
if (args[0] === "__complete") {
return runCompletion(args.slice(1)).catch(() => {
// Completions should never crash — silently return no results
process.exitCode = 0;
});
}
// Walk up from CWD once to find project root AND .sentryclirc config.
// Caches both so later findProjectRoot / loadSentryCliRc calls are hits.
// Most failures here are non-fatal (unreadable rc file, missing project
// markers), but `CliError` from the rc shim's host-scoping check is an
// actionable rejection that must surface to the user.
try {
await preloadProjectContext(process.cwd());
} catch (err) {
if (err instanceof CliError) {
process.stderr.write(`${err.format()}\n`);
process.exitCode = err.exitCode;
return;
}
// Gracefully degrade: project context is optional.
}
return runCli(args).catch((err) => {
process.stderr.write(`Fatal: ${err}\n`);
process.exitCode = 1;
});
}