From bbde357dbd694416155e5fa8f904ca79a51b7e10 Mon Sep 17 00:00:00 2001 From: Liz Tom Date: Thu, 27 Aug 2026 16:57:28 -0700 Subject: [PATCH] Add one-click install/sign-in to sentry-triage setup gate (#2831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sentry-triage canvas's setup gate previously told users to ask Copilot to install its optional `sentry` dependency and reload extensions, and to run `sentry auth login` from a terminal. Both flows were unreliable or high-friction in practice. This adds real one-click buttons for both steps: - sentryClient.mjs — installPackage() runs npm install (async, via execFile so the shared extension process's event loop isn't blocked) rooted at the file's own directory via import.meta.url, so the path is never guessed. loadFactory() falls back to importing the package's resolved entry file by absolute path when the bare import('sentry') fails, since Node caches a negative resolution for a bare specifier for the life of the process — while still distinguishing a genuinely missing package from a transitive- dependency defect so the latter isn't masked as "package missing." login() fails fast with a clear message when SENTRY_AUTH_TOKEN/ SENTRY_TOKEN is active in the environment (which takes precedence over the OAuth login this button drives), and treats an empty/ falsy auth.login() result as a failed sign-in instead of silently re-probing. - preflight.mjs — installDependencies() runs the install and re-probes the connection; authenticate() classifies the new error codes for gate messaging. - server.mjs / extension.mjs — wire POST /api/install-dependencies and POST /api/auth-login routes (through the existing CSRF/Host gate) to the new preflight functions, publishing refreshed connection state to the canvas. - components/page.mjs / styles.mjs — the install and sign-in buttons, with loading/success/failure states and accessible live- region status updates; a live multi-org 's change handler and, when it's freshly + // created (post-signin discovery — see the orgOptions SSE handler + // below), seed the input/state from its current value the same way the + // initial server-render path does just below. Pulled out to a function + // so both paths share one implementation instead of drifting. + function wireOrgSelect(sel) { const orgInputEl = document.getElementById("org-input"); - const mirrorOrgSelect = () => { - if (orgInputEl) orgInputEl.value = orgSelectEl.value; + const mirrorOrgSelect = (clearProject = true) => { + if (orgInputEl) orgInputEl.value = sel.value; // Clear any project slug carried over from the previously-selected org — // it won't exist under the new org's project list. const projInput = document.getElementById("project-input"); - if (projInput) projInput.value = ""; + if (clearProject && projInput) projInput.value = ""; syncScanButtonState(); // Load the new org's projects into the autocomplete: instant from cache // when we've seen it before, otherwise a "loading projects…" hint until // the fetched list arrives over SSE. - requestProjectsForOrg(orgSelectEl.value); + requestProjectsForOrg(sel.value); }; - orgSelectEl.addEventListener("change", mirrorOrgSelect); + sel.addEventListener("change", () => mirrorOrgSelect()); + return mirrorOrgSelect; + } + if (orgSelectEl) { + const orgInputEl = document.getElementById("org-input"); // The once 2+ orgs are + // known (whether at initial render, or discovered later via SSE after a + // post-load sign-in — see the orgOptions handler below). A single-org + // account keeps the plain text input. No-ops if a , or the setup screen isn't showing + const orgSlugs = Array.isArray(currentOrgOptions) ? currentOrgOptions.filter(Boolean) : []; + if (orgSlugs.length < 2) return; + const current = orgField.value.trim(); + // Don't clobber an in-progress edit: if the user has typed something + // that isn't (yet) one of the discovered orgs, replaceWith() below + // would silently discard it and default to the first option. Defer + // the rebuild — it'll retry on the next SSE update, and by then the + // user will likely have finished typing or the org will be known. + if (current && !orgSlugs.includes(current)) return; + const sel = document.createElement("select"); + sel.id = "org-select"; + sel.className = "org-input org-select"; + sel.title = "Sentry organizations you can access"; + orgSlugs.forEach((slug) => { + const opt = document.createElement("option"); + opt.value = slug; + opt.textContent = slug; + if (slug === current) opt.selected = true; + sel.appendChild(opt); + }); + const wasFocused = document.activeElement === orgField; + orgField.replaceWith(sel); + const mirror = wireOrgSelect(sel); + mirror(false); + // orgField may have had keyboard focus (e.g. the empty org input's + // autofocus on first load) — replaceWith() detaches it from the + // document without moving focus anywhere, silently dropping the + // keyboard user's position. Move focus onto its replacement. + if (wasFocused) sel.focus(); } // The org slug currently chosen on the setup screen (typed input wins, else @@ -586,12 +640,14 @@ export function Page({ // detected default into the still-empty slug input so the user doesn't have // to type it. Never clobber a value the user has started editing. function applyOrgDefault(def) { - if (!def) return; + if (!def) return false; const input = document.getElementById("org-input"); if (input && !input.value) { input.value = def; syncScanButtonState(); + return true; } + return false; } const source = new EventSource("/api/events"); @@ -635,6 +691,12 @@ export function Page({ // project autocomplete; empty = fall back to a typed slug box. let currentSentryProjects = ${jsonForScript(projectSlugs)}; let currentOrgOptions = ${jsonForScript(orgSlugs)}; + // Dedupe guard for the setup screen's auto project-fetch-on-org-discovery + // (see the orgDefault SSE handler below): discoverProjects's own + // notifyClients() re-broadcasts the same orgDefault on every streamed + // project page, so without this a single org discovery would recursively + // re-request its own project list forever instead of settling once. + let lastAutoFetchedOrgDefault = ""; let currentAvailableModels = ${jsonForScript(Array.isArray(availableModels) ? availableModels : [])}; let currentPlainEnglishView = ${jsonForScript(plainEnglishView)}; let currentScanError = ${jsonForScript(scanError || '')}; @@ -993,7 +1055,25 @@ export function Page({ enterTriageChrome(msg.org); updateToolbarVisibility(hasRenderableIssues()); } else if (typeof msg.orgDefault === "string") { - applyOrgDefault(msg.orgDefault); + // Org discovery can complete AFTER the setup screen already rendered + // (e.g. the user signs in post-load) — there's no #org-select to bind a + // change handler to yet, so nothing else would kick off a project fetch + // for this org. Only fire when this call actually just populated the + // (previously empty) org field, and dedupe per-org: discoverProjects's + // own notifyClients() re-broadcasts this same orgDefault on every + // streamed page, so firing unconditionally here would recursively + // re-request the project list on every page and never settle. + // Also skip it when this same snapshot is about to render a + // multi-org now so the multi-org flow + // works without a reload. No-ops once already a