Skip to content

Commit 43c2e22

Browse files
committed
feat(cli): add sentry cli import for .sentryclirc migration (#975)
Add a one-time import path for users migrating from the old Rust-based sentry-cli. Two complementary features: 1. `sentry cli import` — explicit command that scans for .sentryclirc files, shows what was found, and imports settings into SQLite with proper host scoping. Supports --yes (CI), --dry-run, --url (trust override), and --skip-validation. 2. Auto-detect middleware — when any command hits AuthError and a .sentryclirc token passes the trust gate, prompts to import before falling back to the OAuth login flow. Security: content-based trust model (same-file rule). Token and URL must originate from the same file — no path is inherently trusted. SHA-256 file hashes stored at import time detect post-import tampering. Auto-prompt disabled in CI (isatty check); project-local files excluded. Also updates the login command's rcTokenHint to mention `sentry cli import` as an alternative to `--token`.
1 parent 426e9e1 commit 43c2e22

9 files changed

Lines changed: 2544 additions & 50 deletions

File tree

.lore.md

Lines changed: 16 additions & 46 deletions
Large diffs are not rendered by default.

src/cli.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,143 @@ export async function runCli(cliArgs: string[]): Promise<void> {
218218
}
219219
};
220220

221+
/**
222+
* Attempt to import `.sentryclirc` settings when the user is unauthenticated.
223+
*
224+
* Returns `"imported"` if a trusted token was found, imported, and validated.
225+
* Returns `"declined"` if the user said no (marked as declined).
226+
* Returns `"skip"` if no eligible files, trust gate fails, or any error.
227+
*/
228+
/**
229+
* Build a trusted import plan from non-project-local .sentryclirc files,
230+
* or return null if no eligible import is available.
231+
*/
232+
async function buildEligibleImportPlan() {
233+
const { discoverRcFiles, buildImportPlan, isImportNeededAsync } =
234+
await import("./lib/sentryclirc-import.js");
235+
236+
if (!(await isImportNeededAsync())) {
237+
return null;
238+
}
239+
const files = await discoverRcFiles(process.cwd());
240+
const eligible = files.filter((f) => f.location !== "project-local");
241+
if (eligible.length === 0 || !eligible.some((f) => f.token)) {
242+
return null;
243+
}
244+
const plan = buildImportPlan(eligible);
245+
if (
246+
!(
247+
plan.trusted &&
248+
plan.effective.token &&
249+
plan.newFields.includes("token")
250+
)
251+
) {
252+
return null;
253+
}
254+
return plan;
255+
}
256+
257+
async function tryRcImport(): Promise<"imported" | "declined" | "skip"> {
258+
const plan = await buildEligibleImportPlan();
259+
if (!plan) {
260+
return "skip";
261+
}
262+
263+
const source = plan.sources.find((s) => s.token)?.path ?? "~/.sentryclirc";
264+
process.stderr.write(
265+
`\nFound auth token in ${source}\n` +
266+
"Import settings to the new CLI? This stores your token with proper host scoping.\n\n"
267+
);
268+
269+
const consent = await promptImportConsent();
270+
if (consent === "declined") {
271+
const { markImportDeclined } = await import(
272+
"./lib/sentryclirc-import.js"
273+
);
274+
markImportDeclined(plan.sources);
275+
return "declined";
276+
}
277+
if (consent !== "accepted") {
278+
return "skip";
279+
}
280+
281+
const { executeImport } = await import("./lib/sentryclirc-import.js");
282+
const result = await executeImport(plan, { validateToken: true });
283+
return result.imported && result.tokenValid !== false ? "imported" : "skip";
284+
}
285+
286+
/**
287+
* Prompt the user to accept/decline the import.
288+
* Returns "accepted", "declined" (explicit no), or "cancelled" (Ctrl+C).
289+
* Only "declined" permanently suppresses future prompts.
290+
*/
291+
async function promptImportConsent(): Promise<
292+
"accepted" | "declined" | "cancelled"
293+
> {
294+
const { logger: logModule } = await import("./lib/logger.js");
295+
const confirmed = await logModule
296+
.withTag("import")
297+
.prompt("Import from .sentryclirc?", { type: "confirm", initial: true });
298+
if (confirmed === true) {
299+
return "accepted";
300+
}
301+
// false = explicit "no"; Symbol(clack:cancel) = Ctrl+C
302+
return confirmed === false ? "declined" : "cancelled";
303+
}
304+
305+
/** Log import middleware errors at an appropriate level */
306+
async function logImportError(importErr: unknown): Promise<void> {
307+
const { logger: logModule } = await import("./lib/logger.js");
308+
const { HostScopeError: HSE } = await import("./lib/errors.js");
309+
const importLog = logModule.withTag("import");
310+
if (importErr instanceof HSE) {
311+
importLog.warn("Import middleware error", importErr);
312+
} else {
313+
importLog.debug("Import middleware error", importErr);
314+
}
315+
}
316+
317+
/**
318+
* `.sentryclirc` import middleware.
319+
*
320+
* When a command fails with `not_authenticated` and a non-project-local
321+
* `.sentryclirc` file has a token that passes the same-file trust gate,
322+
* offers to import it into the new CLI's SQLite store. On success, retries
323+
* the command. On decline, marks as declined (never asks again) and
324+
* re-throws so the auto-auth middleware can offer OAuth login instead.
325+
*
326+
* Only fires in interactive TTYs (disabled in CI). Project-local files
327+
* are excluded to avoid prompting in every cloned repo.
328+
*/
329+
const rcImportMiddleware: ErrorMiddleware = async (next, argv) => {
330+
try {
331+
await next(argv);
332+
} catch (err) {
333+
let imported = false;
334+
if (
335+
err instanceof AuthError &&
336+
err.reason === "not_authenticated" &&
337+
!err.skipAutoAuth &&
338+
isatty(0)
339+
) {
340+
try {
341+
imported = (await tryRcImport()) === "imported";
342+
} catch (importErr) {
343+
await logImportError(importErr);
344+
}
345+
}
346+
if (imported) {
347+
// Retry outside the import try/catch so retry errors propagate
348+
// naturally instead of being swallowed and re-throwing the
349+
// original AuthError.
350+
process.stderr.write("Import successful! Retrying command...\n\n");
351+
await next(argv);
352+
return;
353+
}
354+
throw err;
355+
}
356+
};
357+
221358
/**
222359
* Auto-authentication middleware.
223360
*
@@ -269,6 +406,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
269406
*/
270407
const errorMiddlewares: ErrorMiddleware[] = [
271408
seerTrialMiddleware,
409+
rcImportMiddleware,
272410
autoAuthMiddleware,
273411
];
274412

src/commands/auth/login.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ export function rcTokenHint(
207207
: ` --url ${effectiveHost}`;
208208
return (
209209
`Found a token in .sentryclirc (${rcConfig.sources.token}). ` +
210-
`To skip OAuth next time: sentry auth login --token <token>${urlHint}`
210+
"To import it: sentry cli import | " +
211+
`To pass it directly: sentry auth login --token <token>${urlHint}`
211212
);
212213
}
213214

@@ -374,6 +375,7 @@ export const loginCommand = buildCommand({
374375

375376
refuseLoginToUntrustedHost(flags, effectiveHost, urlFromRc);
376377

378+
// Check if already authenticated and handle re-authentication
377379
if (isAuthenticated()) {
378380
const shouldProceed = await handleExistingAuth(flags.force);
379381
if (!shouldProceed) {

0 commit comments

Comments
 (0)