Skip to content

Commit fed87ea

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 862ba00 commit fed87ea

8 files changed

Lines changed: 2241 additions & 4 deletions

File tree

src/cli.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,112 @@ 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+
async function tryRcImport(): Promise<"imported" | "declined" | "skip"> {
229+
const {
230+
discoverRcFiles,
231+
buildImportPlan,
232+
executeImport,
233+
isImportNeededAsync,
234+
markImportDeclined,
235+
} = await import("./lib/sentryclirc-import.js");
236+
237+
if (!(await isImportNeededAsync())) {
238+
return "skip";
239+
}
240+
241+
const files = await discoverRcFiles(process.cwd());
242+
const eligible = files.filter((f) => f.location !== "project-local");
243+
if (eligible.length === 0 || !eligible.some((f) => f.token)) {
244+
return "skip";
245+
}
246+
247+
const plan = buildImportPlan(eligible);
248+
if (
249+
!(
250+
plan.trusted &&
251+
plan.effective.token &&
252+
plan.newFields.includes("token")
253+
)
254+
) {
255+
return "skip";
256+
}
257+
258+
const source = plan.sources.find((s) => s.token)?.path ?? "~/.sentryclirc";
259+
process.stderr.write(
260+
`\nFound auth token in ${source}\n` +
261+
"Import settings to the new CLI? This stores your token with proper host scoping.\n\n"
262+
);
263+
264+
const { logger: logModule } = await import("./lib/logger.js");
265+
const confirmed = await logModule
266+
.withTag("import")
267+
.prompt("Import from .sentryclirc?", { type: "confirm", initial: true });
268+
269+
if (confirmed !== true) {
270+
markImportDeclined();
271+
return "declined";
272+
}
273+
274+
const result = await executeImport(plan, { validateToken: true });
275+
return result.imported && result.tokenValid !== false ? "imported" : "skip";
276+
}
277+
278+
/** Log import middleware errors at an appropriate level */
279+
async function logImportError(importErr: unknown): Promise<void> {
280+
const { logger: logModule } = await import("./lib/logger.js");
281+
const { HostScopeError: HSE } = await import("./lib/errors.js");
282+
const importLog = logModule.withTag("import");
283+
if (importErr instanceof HSE) {
284+
importLog.warn("Import middleware error", importErr);
285+
} else {
286+
importLog.debug("Import middleware error", importErr);
287+
}
288+
}
289+
290+
/**
291+
* `.sentryclirc` import middleware.
292+
*
293+
* When a command fails with `not_authenticated` and a non-project-local
294+
* `.sentryclirc` file has a token that passes the same-file trust gate,
295+
* offers to import it into the new CLI's SQLite store. On success, retries
296+
* the command. On decline, marks as declined (never asks again) and
297+
* re-throws so the auto-auth middleware can offer OAuth login instead.
298+
*
299+
* Only fires in interactive TTYs (disabled in CI). Project-local files
300+
* are excluded to avoid prompting in every cloned repo.
301+
*/
302+
const rcImportMiddleware: ErrorMiddleware = async (next, argv) => {
303+
try {
304+
await next(argv);
305+
} catch (err) {
306+
if (
307+
err instanceof AuthError &&
308+
err.reason === "not_authenticated" &&
309+
!err.skipAutoAuth &&
310+
isatty(0)
311+
) {
312+
try {
313+
const outcome = await tryRcImport();
314+
if (outcome === "imported") {
315+
process.stderr.write("Import successful! Retrying command...\n\n");
316+
await next(argv);
317+
return;
318+
}
319+
} catch (importErr) {
320+
await logImportError(importErr);
321+
}
322+
}
323+
throw err;
324+
}
325+
};
326+
221327
/**
222328
* Auto-authentication middleware.
223329
*
@@ -269,6 +375,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
269375
*/
270376
const errorMiddlewares: ErrorMiddleware[] = [
271377
seerTrialMiddleware,
378+
rcImportMiddleware,
272379
autoAuthMiddleware,
273380
];
274381

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)