chore: publish from main

This commit is contained in:
github-actions[bot]
2026-08-27 23:57:57 +00:00
parent 351b969d89
commit 774ee8c75e
14 changed files with 1108 additions and 124 deletions
+13 -32
View File
@@ -40,17 +40,11 @@ that drafts a fix pull request.
- **Node.js 22 or newer.**
- The GitHub Copilot app canvas / UI-extensions experiment enabled.
- A Sentry sign-in. This canvas reads issues through the
- **A Sentry sign-in.** This canvas reads issues through the
[Sentry CLI](https://cli.sentry.dev) in library mode — there is no MCP server
to configure. After installing the dependency (see **Install** below), sign in
once with the package-local CLI, run from the extension folder:
```sh
npx sentry auth login
```
This stores an OAuth credential the canvas auto-detects. For non-interactive
environments, export a token instead:
to configure. The setup gate walks you through installing the dependency and
signing in with one-click buttons (see **Install** below); no terminal
required. For non-interactive environments, you can instead export a token:
```sh
export SENTRY_AUTH_TOKEN=<your-token>
@@ -67,30 +61,17 @@ repository at `.github/extensions/sentry-triage/` for project scope.
This canvas depends on the [`sentry`](https://cli.sentry.dev) npm package at
runtime, which isn't bundled with the extension source. If it's missing, the canvas
still **opens** and shows a setup gate explaining what to do instead of crashing.
still **opens** and shows a setup gate — click its **Install dependencies** button
and the extension runs `npm install` in its own directory (wherever it's actually
installed, so there's no path to guess). Once installed, if you aren't signed in
yet the gate shows a **Sign in with Sentry** button that opens your browser to
approve access and returns automatically — no terminal step required. The gate
clears once both finish; no reload or Copilot involvement needed.
### Let Copilot set it up (recommended)
### Or set it up manually
Because the canvas runs inside GitHub Copilot, the agent can install the dependency
for you. Paste this into Copilot:
> Locate the loaded `sentry-triage` canvas extension folder — the directory that
> contains its `package.json` — run `npm install` there, then reload extensions.
(That folder is `~/.copilot/extensions/sentry-triage/` for user scope,
`.github/extensions/sentry-triage/` for project scope, or, if you installed the
published plugin, `com.github.copilot/extensions/sentry-triage` inside the
installed plugin.)
Then finish the one interactive step yourself — sign in so Copilot never handles a
raw secret. Run this from the same extension folder (`npx` resolves the CLI the
local `npm install` just placed in `node_modules`):
```sh
npx sentry auth login
```
### Or install it manually
If you'd rather not use the buttons (or `npm`/a browser isn't available to the
extension process), you can run the same steps yourself:
```sh
# User scope
+233 -14
View File
@@ -179,11 +179,15 @@ export function Page({
<h2 id="gate-title">${packageMissing ? 'Set up the Sentry CLI' : (signedOut ? 'Connect Sentry to start triaging' : 'Cant reach Sentry right now')}</h2>
<div class="gate-body gate-body-setup" id="gate-body-setup"${packageMissing ? '' : ' style="display:none;"'}>
<p class="gate-lead">This canvas reads your live Sentry issues through the Sentry CLI, but its <code>sentry</code> package isnt installed for this extension yet.</p>
<p class="gate-steps">Ask Copilot to install the sentry-triage dependencies and reload extensions,” then run <code>npx sentry auth login</code> from the extension folder to sign in and re-open this canvas.</p>
<p class="gate-steps">Click below to install it. You'll then be prompted to sign in with Sentry.</p>
<button id="install-deps-btn" class="gate-action-btn" type="button">📦 Install dependencies</button>
<p class="gate-install-status" id="gate-install-status" role="status" aria-live="polite" style="display:none;"></p>
</div>
<div class="gate-body gate-body-auth" id="gate-body-auth"${signedOut && !packageMissing ? '' : ' style="display:none;"'}>
<p class="gate-lead">This canvas reads your live Sentry issues through the Sentry CLI, but it isn't signed in yet.</p>
<p class="gate-steps">Run <code>npx sentry auth login</code> from the extension folder to sign in, then re-open this canvas.</p>
<p class="gate-steps">Sign in with Sentry — this opens your browser to approve access, then returns here automatically.</p>
<button id="auth-login-btn" class="gate-action-btn" type="button">🔑 Sign in with Sentry</button>
<p class="gate-install-status" id="gate-auth-status" role="status" aria-live="polite" style="display:none;"></p>
</div>
<div class="gate-body gate-body-conn" id="gate-body-conn"${signedOut || !transientConn || packageMissing ? ' style="display:none;"' : ''}>
<p class="gate-lead">Youre signed in, but this canvas couldnt reach Sentry — usually a temporary network blip.</p>
@@ -193,7 +197,7 @@ export function Page({
<p class="gate-lead">Youre signed in, but this canvas couldnt reach Sentry.</p>
<p class="gate-steps">See the details below, then re-open this canvas to try again.</p>
</div>
<p class="gate-error" id="gate-error" ${sentryReady || !gateError || packageMissing ? 'style="display:none;"' : ''}>${escapeHtml(gateError || '')}</p>
<p class="gate-error" id="gate-error" ${sentryReady || !gateError || packageMissing || signedOut ? 'style="display:none;"' : ''}>${escapeHtml(gateError || '')}</p>
</div>
</section>
@@ -489,27 +493,77 @@ export function Page({
syncScanButtonState();
const orgSelectEl = document.getElementById("org-select");
if (orgSelectEl) {
const orgSelectMirror = orgSelectEl ? wireOrgSelect(orgSelectEl) : null;
// (Re)wire an org <select>'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 <select> shows its first option as selected by default, but the
// input starts empty when there's no detected default — so Scan looks
// disabled and re-picking the already-shown org fires no change event.
// Seed the input from the select's current value on load so the visible
// selection is the effective one and Scan is enabled.
if (orgInputEl && !orgInputEl.value.trim() && orgSelectEl.value) mirrorOrgSelect();
if (orgInputEl && !orgInputEl.value.trim() && orgSelectEl.value) orgSelectMirror(false);
}
// Rebuild the setup screen's org control as a <select> 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 <select> already
// reflects the same options, so it's safe to call on every SSE update.
function renderSetupOrgSelect() {
const orgField = document.getElementById("org-input");
if (!orgField) return; // already a <select>, 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 <select> below (msg.orgOptions has 2+ entries) — that
// selector's own mirror() already requests projects for the
// selected org, so firing here too would kick off a duplicate,
// serialized (and possibly slow, up to the paging budget) traversal.
const willRenderOrgSelect = Array.isArray(msg.orgOptions) && msg.orgOptions.filter(Boolean).length >= 2;
const justFilled = applyOrgDefault(msg.orgDefault);
if (justFilled && !willRenderOrgSelect && !document.getElementById("org-select") && lastAutoFetchedOrgDefault !== msg.orgDefault) {
lastAutoFetchedOrgDefault = msg.orgDefault;
requestProjectsForOrg(msg.orgDefault);
}
}
if ("project" in msg) {
const nextProject = msg.project || "";
@@ -1036,7 +1116,15 @@ export function Page({
}
}
}
if (Array.isArray(msg.orgOptions)) currentOrgOptions = msg.orgOptions;
if (Array.isArray(msg.orgOptions)) {
currentOrgOptions = msg.orgOptions;
// Post-load org discovery (e.g. signing in without reloading) can
// reveal 2+ orgs after the setup screen already rendered a plain
// text input. Rebuild it as a <select> now so the multi-org flow
// works without a reload. No-ops once already a <select>, and
// no-ops off the setup screen (no #org-input present there).
renderSetupOrgSelect();
}
if (typeof msg.savedDefaultOrg === "string" && msg.savedDefaultOrg !== currentSavedDefaultOrg) {
currentSavedDefaultOrg = msg.savedDefaultOrg;
refreshDefaultBtn();
@@ -1124,7 +1212,7 @@ export function Page({
const gateErrEl = document.getElementById("gate-error");
if (gateErrEl) {
const err = (c.sentry && c.sentry.error) || "";
if (gatedNow && err && !packageMissing) {
if (gatedNow && err && !packageMissing && !signedOut) {
gateErrEl.textContent = err;
gateErrEl.style.display = "";
} else {
@@ -1141,6 +1229,20 @@ export function Page({
wasGatedPrev = gatedNow;
}
function focusConnectionGateLead(sentryConn) {
const panelId = sentryConn && sentryConn.transient ? "gate-body-conn" : "gate-body-unknown";
const connLead = document.querySelector("#" + panelId + " .gate-lead");
if (connLead) { connLead.setAttribute("tabindex", "-1"); connLead.focus(); }
}
function focusActiveOrgControl() {
const orgInput = document.getElementById("org-input");
const orgSelect = document.getElementById("org-select");
const orgSwitcher = document.getElementById("org-switcher");
const focusTarget = orgInput || orgSelect || orgSwitcher;
if (focusTarget) focusTarget.focus();
}
function enterTriageChrome(org) {
const picker = document.querySelector(".org-picker");
if (picker) picker.style.display = "none";
@@ -1887,6 +1989,123 @@ export function Page({
});
document.addEventListener("click", (e) => {
const installBtn = e.target.closest("#install-deps-btn");
if (installBtn) {
const statusEl = document.getElementById("gate-install-status");
installBtn.disabled = true;
installBtn.textContent = "Installing…";
if (statusEl) {
statusEl.style.display = "";
statusEl.textContent = "Running npm install — this can take a moment…";
}
fetch("/api/install-dependencies", { method: "POST" })
.then((res) => {
if (!res.ok) throw new Error("install-dependencies " + res.status);
return res.json();
})
.then((data) => {
if (data && data.connections) {
currentConnections = data.connections;
applyConnectionState();
}
const stillMissing = data && data.connections && data.connections.sentry && data.connections.sentry.setup === "package-missing";
if (stillMissing) {
if (statusEl) {
statusEl.textContent = "Install didn't finish — check that npm is available, then try again.";
}
installBtn.disabled = false;
installBtn.textContent = "📦 Install dependencies";
} else if (statusEl) {
// applyConnectionState() above already repainted the gate for
// the fresh connection state — a successful install doesn't
// always land on the sign-in panel: an existing stored
// credential can make it immediately reachable (clearing the
// gate entirely), or the re-probe can hit a transient network
// blip (landing on the connectivity panel) instead. Announce
// and focus based on what's actually visible now rather than
// assuming sign-in is next.
const sentryConn = (data.connections && data.connections.sentry) || {};
if (sentryConn.reachable) {
showToast("✅ Sentry connected — loading your issues");
focusActiveOrgControl();
} else if (sentryConn.configured) {
// configured (has/had a credential) but not reachable and
// not package-missing: connectivity panel is now showing.
statusEl.textContent = "Installed, but couldn't reach Sentry just now — see details below.";
focusConnectionGateLead(sentryConn);
} else {
statusEl.textContent = "Installed. Click below to sign in with Sentry.";
// The install panel (and this now-hidden live region) was
// just swapped for the sign-in panel, so focus is left on
// the now-hidden, disabled install button — silent for a
// screen reader user. Move focus to the sign-in button so
// they're notified of the next actionable step.
const nextBtn = document.getElementById("auth-login-btn");
if (nextBtn) nextBtn.focus();
}
}
})
.catch(() => {
if (statusEl) statusEl.textContent = "Install failed — the extension server may be unreachable. Please try again.";
installBtn.disabled = false;
installBtn.textContent = "📦 Install dependencies";
});
return;
}
const authBtn = e.target.closest("#auth-login-btn");
if (authBtn) {
const statusEl = document.getElementById("gate-auth-status");
authBtn.disabled = true;
authBtn.textContent = "Waiting for sign-in…";
if (statusEl) {
statusEl.style.display = "";
statusEl.textContent = "Opening your browser to sign in with Sentry — approve access there, then this will continue automatically.";
}
fetch("/api/auth-login", { method: "POST" })
.then((res) => res.json().then((data) => ({ res, data })))
.then(({ res, data }) => {
if (data && data.connections) {
currentConnections = data.connections;
applyConnectionState();
}
if (!res.ok || (data && data.ok === false)) {
if (statusEl) {
statusEl.textContent = (data && data.error) || "Sign-in didn't complete — please try again.";
}
authBtn.disabled = false;
authBtn.textContent = "🔑 Sign in with Sentry";
return;
}
const stillSignedOut = data && data.connections && data.connections.sentry && !data.connections.sentry.configured;
if (stillSignedOut) {
if (statusEl) statusEl.textContent = "Still not signed in — please try again.";
authBtn.disabled = false;
authBtn.textContent = "🔑 Sign in with Sentry";
} else {
// applyConnectionState() above just hid the entire auth gate
// (signed in now) — this live region's own node may no longer
// be in the accessibility tree, and focus is left on the
// now-hidden, disabled sign-in button. Announce via the
// always-visible toast instead, and move focus into whatever
// view is now actually showing (reachable: the org picker;
// otherwise: the connectivity gate that's left, if any).
const sentryConn = (data.connections && data.connections.sentry) || {};
if (sentryConn.reachable) {
showToast("✅ Signed in — loading your issues");
focusActiveOrgControl();
} else {
showToast("✅ Signed in — checking Sentry connectivity");
focusConnectionGateLead(sentryConn);
}
}
})
.catch(() => {
if (statusEl) statusEl.textContent = "Sign-in failed — the extension server may be unreachable. Please try again.";
authBtn.disabled = false;
authBtn.textContent = "🔑 Sign in with Sentry";
});
return;
}
const btn = e.target.closest("#refresh, #rescan");
if (!btn) return;
const subtitle = document.querySelector(".page-subtitle");
+52 -1
View File
@@ -3,7 +3,7 @@ import { execSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { startServer } from './server.mjs'
import { scanIssues, listOrgs, listProjects, findProject } from './sentry.mjs'
import { checkConnections, checkConnectionsOnce } from './preflight.mjs'
import { checkConnections, checkConnectionsOnce, installDependencies, authenticate } from './preflight.mjs'
import { sanitizeForPrompt } from './escape.mjs'
// A Sentry-derived URL is safe to pass through only if it PARSES as a real
@@ -1309,6 +1309,55 @@ async function onRecheckConnections(entry) {
return connections
}
// "Install dependencies" button handler on the package-missing setup gate.
// Delegates to preflight's installDependencies (npm install + re-probe, rooted
// at the extension's own directory) and publishes the fresh connection state so
// the gate updates live. On success (Sentry now reachable and an org already
// committed) kicks a scan, mirroring onRecheckConnections's post-recovery path.
async function onInstallDependencies(entry) {
if (entry.closed) return entry.state.getConnections?.() || { sentry: { reachable: false } }
const connections = await installDependencies()
if (entry.closed) return connections
entry.state.setConnections(connections)
entry.notifyClients()
if (connections.sentry.reachable && entry.state.getOrg()) {
triageSentry(entry).catch((err) => {
console.error('[sentry-triage] post-install scan failed:', err instanceof Error ? err.message : err)
})
} else if (connections.sentry.reachable) {
discoverOrgs(entry).catch((err) => {
console.error('[sentry-triage] post-install org discovery failed:', err instanceof Error ? err.message : err)
})
}
return connections
}
// "Sign in with Sentry" button handler on the not-authenticated setup gate
// (shown only once the package is installed — see components/page.mjs). Runs
// the SDK's OAuth device-code login (opens the user's browser directly, no
// terminal) and publishes the fresh connection state so the gate updates
// live, mirroring onInstallDependencies's post-recovery path. Unlike install,
// a failed/cancelled login is allowed to propagate so the server route can
// report the specific reason instead of a generic "still signed out".
async function onAuthenticate(entry) {
if (entry.closed) return entry.state.getConnections?.() || { sentry: { reachable: false } }
const connections = await authenticate()
if (entry.closed) return connections
entry.state.setConnections(connections)
entry.notifyClients()
if (connections.sentry.reachable && entry.state.getOrg()) {
triageSentry(entry).catch((err) => {
console.error('[sentry-triage] post-auth scan failed:', err instanceof Error ? err.message : err)
})
} else if (connections.sentry.reachable) {
discoverOrgs(entry).catch((err) => {
console.error('[sentry-triage] post-auth org discovery failed:', err instanceof Error ? err.message : err)
})
}
return connections
}
async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
if (entry.closed) return
const uniqueKeys = [...new Set(issueKeys)].filter(Boolean)
@@ -1982,6 +2031,8 @@ const session = await joinSession({
onRefresh: () => refreshAll(entry),
onWorkSelected: (keys, modelByKey, assignCopilot) => onWorkSelected(entry, keys, modelByKey, assignCopilot),
onRecheck: () => onRecheckConnections(entry),
onInstallDependencies: () => onInstallDependencies(entry),
onAuthenticate: () => onAuthenticate(entry),
onListProjects: (org) => discoverProjects(entry, org, { force: true }),
onResolveProject: (org, slug) => resolveProject(entry, org, slug),
onInvalidateEnrichment: () => {
+44 -3
View File
@@ -15,7 +15,7 @@
// buildConnections() is pure so it can be unit-tested offline; checkConnections()
// is the thin wrapper that talks to Sentry.
import { whoami, SentryError } from './sentryClient.mjs'
import { whoami, installPackage, login, SentryError } from './sentryClient.mjs'
// Shape the raw signals into the connection status the UI consumes. Pure.
export function buildConnections({
@@ -60,6 +60,12 @@ const msg = (err) => (err instanceof Error ? err.message : String(err))
// network blip and not an auth failure, so it gets its own gate branch.
const isPackageMissing = (err) => Boolean(err) && err.code === 'SENTRY_PACKAGE_MISSING'
// An active SENTRY_AUTH_TOKEN/SENTRY_TOKEN in the environment takes precedence
// over any OAuth login, so the "Sign in" button can't do anything useful while
// one is set — sentryClient's login() detects this up front and fails fast
// with this code instead of running an OAuth flow that can't take effect.
const isEnvTokenActive = (err) => Boolean(err) && err.code === 'SENTRY_ENV_TOKEN_ACTIVE'
// Text used for classification: the message plus any CLI stderr, since a rejected
// credential (HTTP 401/403) often surfaces its status in stderr rather than the
// Error message.
@@ -95,11 +101,14 @@ const humanizeSentryError = (err) => {
if (isPackageMissing(err)) {
return 'The Sentry CLI isnt installed for this canvas yet. Ask Copilot to “install the sentry-triage dependencies and reload extensions,” then run `npx sentry auth login` from the extension folder and re-open this canvas.'
}
if (isEnvTokenActive(err)) {
return t
}
if (isNotAuthenticated(err)) {
return 'Sentry isnt connected yet. Run `npx sentry auth login` from the extension folder, then re-open this canvas.'
return 'Sentry isnt connected yet.'
}
if (isAuthFailure(err)) {
return 'Sentry rejected your credential (expired or invalid). Run `npx sentry auth login` from the extension folder, then re-open this canvas.'
return 'Sentry rejected your credential (expired or invalid). Sign in again below.'
}
if (isTransient(err)) {
return 'Couldnt reach Sentry just now (network). It should recover on the next check.'
@@ -174,3 +183,35 @@ export async function checkConnections() {
}
return shape(result)
}
// One-click fix for the package-missing gate: run `npm install` in the
// extension's own directory (via sentryClient's installPackage, so the path is
// never guessed by an agent or user) and immediately re-probe. Returns the fresh
// connection state either way so the gate/setup UI can render the outcome —
// success clears the gate, and a failed install surfaces as a normal probe error
// (e.g. still package-missing, or an npm/network failure) rather than throwing.
export async function installDependencies() {
try {
await installPackage()
} catch (err) {
console.error('[sentry-triage] npm install failed:', err instanceof Error ? err.message : err)
}
const { connections } = await checkConnectionsOnce()
return connections
}
// One-click fix for the "not authenticated" setup gate: run the SDK's own
// OAuth device-code login (sentryClient's login(), the in-process equivalent
// of `sentry auth login`) and immediately re-probe. Only ever called for a
// package-present, not-signed-in state — the gate never shows this button
// while the package itself is missing (see components/page.mjs) — so unlike
// installDependencies() a thrown login error (user closed the browser tab,
// denied consent, or the device code expired) is left to propagate: the
// caller (extension.mjs onAuthenticate) surfaces it to the gate rather than
// silently falling back to a generic "still signed out" re-probe, since the
// specific reason (denied vs. expired vs. cancelled) is worth showing.
export async function authenticate() {
await login()
const { connections } = await checkConnectionsOnce()
return connections
}
+149 -11
View File
@@ -13,6 +13,12 @@
// canvas's internal issue model lives in sentry.mjs so this file stays a thin,
// swappable transport.
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
const execFileAsync = promisify(execFile)
// The heavyweight `sentry` CLI package is an OPTIONAL, lazily-loaded dependency.
// awesome-copilot ships extension *source* only (no node_modules), so an installed
// plugin may not have it. A top-level `import ... from 'sentry'` would throw
@@ -27,10 +33,11 @@
// SentryError class, so existing `instanceof` + `.exitCode` checks keep matching
// the errors the SDK actually throws.
let SentryError = class SentryError extends Error {
constructor(message, opts = {}) {
constructor(message, exitCode = 0, stderr = '') {
super(message)
this.name = 'SentryError'
if (opts.code) this.code = opts.code
this.exitCode = exitCode
this.stderr = stderr
}
}
@@ -45,26 +52,68 @@ const PACKAGE_MISSING_MESSAGE =
let sdk = null
let sdkFactory = null
let installPromise = null
let loginPromise = null
// One-click install for the missing `sentry` package, driven by the setup
// gate's "Install dependencies" button (see extension.mjs installDependencies /
// server.mjs POST /api/install-dependencies). Runs `npm install` rooted at THIS
// file's own directory — i.e. the extension's actual on-disk location, whatever
// that is (repo source, a user/project extensions folder, or an installed
// plugin's materialized copy) — so it never depends on a user or agent guessing
// the right path (the earlier manual "ask Copilot to install" flow's failure
// mode). Serialized on the same sdkQueue as every other SDK call so it can't run
// concurrently with an in-flight probe. Concurrent install clicks share one
// in-flight install instead of queueing redundant npm installs.
export function installPackage() {
if (installPromise) return installPromise
installPromise = runSerial(async () => {
// Windows can't exec the `npm.cmd` shim directly without a shell, and a raw
// `new URL('.', import.meta.url).pathname` yields a URL-style path like
// "/C:/..." rather than a native Windows path — fileURLToPath handles both
// platforms correctly.
const cwd = fileURLToPath(new URL('.', import.meta.url))
// Async execFile (not execFileSync): this module and its loopback HTTP/SSE
// servers are shared by every canvas instance in the process, so a
// synchronous, up-to-120s install would freeze all of them. runSerial's
// queue already prevents this from overlapping with other SDK/install
// calls, so there's no concurrency downside to awaiting it instead.
await execFileAsync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], {
cwd,
timeout: 120_000,
shell: process.platform === 'win32',
})
sdkFactory = null
sdk = null
}).finally(() => {
installPromise = null
})
return installPromise
}
// Import the optional `sentry` package exactly once. Throws a SentryError tagged
// SENTRY_PACKAGE_MISSING when it can't be resolved, and swaps in the SDK's real
// SentryError class (live ESM binding) when it can.
//
async function loadFactory() {
if (sdkFactory) return sdkFactory
let mod
try {
mod = await import('sentry')
} catch (err) {
// Only translate "the `sentry` package itself isn't installed" into the setup
// gate. Node reports that as ERR_MODULE_NOT_FOUND naming the `sentry` package.
// Any OTHER failure — a missing TRANSITIVE dependency, or the package's own
// entrypoint throwing at import — is a real defect we must surface, not mask
// behind a misleading "reinstall sentry" message. Rethrow those unchanged.
if (!(err && err.code === 'ERR_MODULE_NOT_FOUND')) throw err
// ERR_MODULE_NOT_FOUND is also what Node throws when `sentry` itself
// resolves fine but one of ITS OWN dependencies is missing (e.g. a
// corrupt or partial install) — that's a real defect, not a "not
// installed" state, and must not be masked as package-missing. Only the
// "Cannot find package/module 'sentry'" message means the bare specifier
// itself failed to resolve.
const message = String((err && err.message) || '')
const sentryPackageMissing =
err && err.code === 'ERR_MODULE_NOT_FOUND' && /Cannot find (?:package|module) 'sentry'/.test(message)
if (!sentryPackageMissing) throw err
throw new SentryError(PACKAGE_MISSING_MESSAGE, { code: 'SENTRY_PACKAGE_MISSING' })
const sentryItselfMissing = /Cannot find (?:package|module) 'sentry'/.test(message)
if (!sentryItselfMissing) throw err
const missing = new SentryError(PACKAGE_MISSING_MESSAGE, 0, '')
missing.code = 'SENTRY_PACKAGE_MISSING'
throw missing
}
if (mod.SentryError) SentryError = mod.SentryError
sdkFactory = mod.default
@@ -135,6 +184,95 @@ export async function whoami() {
return runSerial(async () => (await getSdk()).auth.whoami())
}
// One-click sign-in for the "not authenticated" setup gate, driven by the
// gate's "Sign in with Sentry" button (see preflight.mjs authenticate() /
// server.mjs POST /api/auth-login). Runs the SDK's own OAuth device-code
// flow — the exact same flow `npx sentry auth login` drives from a
// terminal — which opens the user's default browser and waits for them to
// approve. Serialized on the same sdkQueue as every other SDK call (this
// module's whole reason for existing single-flights everything): the CLI
// keeps global auth state, so a concurrent whoami()/scan while login() is
// mid-flow would race the same on-disk credential login() is about to write.
// Any caller queued behind this one simply waits for the user to finish
// approving in their browser, same as a real terminal `sentry auth login`
// would block the shell.
//
// A closed tab or an ignored prompt would otherwise wait forever (the SDK
// default timeout is 900s / 15 minutes) and wedge the sdkQueue for every
// other Sentry call behind it — so we bound it here to something the setup
// gate can reasonably ask a user to wait through. On timeout the device code
// is left stale server-side; the caller can just click "Sign in" again to
// mint a fresh one.
//
// NOTE: the SDK's `timeout` is in SECONDS, not milliseconds (see
// AuthLoginParams in the sentry package's type defs) — do not multiply by
// 1000 here.
//
// `force: true` because this button is also how a signed-in-but-invalid
// credential (expired/revoked token) re-authenticates: in this non-TTY
// extension process, auth.login() silently declines to replace an existing
// credential unless forced, which would otherwise leave "Sign in again"
// wired to a no-op. `readOnly: true` requests only the read-only OAuth
// scopes (project:read, org:read, event:read, member:read, team:read) since
// this canvas only ever reads Sentry data — no need for the default
// write/admin scopes.
const LOGIN_TIMEOUT_SECONDS = 120
export function login() {
if (loginPromise) return loginPromise
loginPromise = runSerial(async () => {
// SENTRY_AUTH_TOKEN / SENTRY_TOKEN take precedence over the stored OAuth
// credential (see the module header). If one is set, auth.login() can
// still "succeed" and write a fresh OAuth login, but every subsequent
// call (including the whoami() probe authenticate() runs right after)
// keeps using the env token instead — so an invalid/expired env token
// would make this button look like it worked while leaving the gate
// signed out. Fail fast with actionable guidance instead of running an
// OAuth flow that can't actually take effect.
const envToken = process.env.SENTRY_AUTH_TOKEN || process.env.SENTRY_TOKEN
if (envToken) {
const envVar = process.env.SENTRY_AUTH_TOKEN ? 'SENTRY_AUTH_TOKEN' : 'SENTRY_TOKEN'
const err = new SentryError(
`${envVar} is set in this environment and takes precedence over signing in here. Unset it (or replace it with a valid token) and try again.`,
0,
''
)
err.code = 'SENTRY_ENV_TOKEN_ACTIVE'
throw err
}
// sentry@0.42.2's login command sets the SHARED extension process's
// process.exitCode (not just its own in-process return value) when the
// device flow is denied, cancelled, or expires — a signal meant for a
// one-shot CLI process exiting non-zero, not for this long-lived host
// that keeps running other extensions after the call returns. Save and
// restore it around the call so a failed sign-in here doesn't leave the
// whole extension host marked to exit unsuccessfully.
const savedExitCode = process.exitCode
let result
try {
result = await (await getSdk()).auth.login({ timeout: LOGIN_TIMEOUT_SECONDS, force: true, readOnly: true })
} finally {
process.exitCode = savedExitCode
}
// sentry@0.42.2's login command does not reliably reject when the device
// flow is denied, cancelled, or expires — it can resolve with an empty/
// falsy result after only setting its own CLI exit code, which this SDK
// wrapper doesn't surface. Left unchecked, authenticate() would silently
// re-probe and the caller (the auth-login route) would report ok:true for
// what was actually a failed sign-in. Treat an empty result as a failure
// so the specific "still not signed in" path in the gate is reachable.
if (!result) {
const err = new SentryError('Sign-in was not completed (denied, cancelled, or the code expired).', 0, '')
err.code = 'SENTRY_LOGIN_INCOMPLETE'
throw err
}
return result
}).finally(() => {
loginPromise = null
})
return loginPromise
}
// All organizations the stored credential can see. Raw org objects (each has a
// `slug`).
export async function orgList(limit = 100) {
+44 -1
View File
@@ -77,7 +77,7 @@ function hasValidToken(req, token) {
return value === token
}
export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onListProjects, onResolveProject, onInvalidateEnrichment, defaults } = {}) {
export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onInstallDependencies, onAuthenticate, onListProjects, onResolveProject, onInvalidateEnrichment, defaults } = {}) {
// Per-instance state + SSE clients — never shared across canvas instances.
const state = createState()
state.applyRepoDefaults(defaults)
@@ -225,6 +225,49 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
return
}
// One-click install for the "package-missing" setup gate. Runs `npm install`
// in the extension's own directory (via onInstallDependencies) and returns
// the freshly re-probed connection state, so the gate updates without
// requiring the canvas to be reopened.
if (req.method === 'POST' && req.url === '/api/install-dependencies') {
Promise.resolve(onInstallDependencies ? onInstallDependencies() : null)
.then((connections) => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, connections: connections || state.getConnections() }))
})
.catch((err) => {
console.error('[sentry-triage] install-dependencies failed:', err instanceof Error ? err.message : err)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, connections: state.getConnections() }))
})
return
}
// One-click sign-in for the "not authenticated" setup gate (only shown once
// the package is installed — see components/page.mjs). Runs the SDK's own
// OAuth device-code login, which opens the user's browser directly, and
// returns the freshly re-probed connection state. Unlike install, a failed
// login (denied consent, expired code, closed tab) is reported back as
// `ok:false` with a message instead of silently falling back to the stale
// connection state — the specific reason is worth surfacing in the gate.
if (req.method === 'POST' && req.url === '/api/auth-login') {
Promise.resolve(onAuthenticate ? onAuthenticate() : null)
.then((connections) => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, connections: connections || state.getConnections() }))
})
.catch((err) => {
console.error('[sentry-triage] auth-login failed:', err instanceof Error ? err.message : err)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
ok: false,
error: err instanceof Error ? err.message : String(err),
connections: state.getConnections(),
}))
})
return
}
// Fetch the Sentry project list for an org so the project field can render
// as a dropdown. Fire-and-forget from the client's perspective: the fetched
// list is broadcast to all clients over SSE by the handler. Used when the
+19
View File
@@ -1033,6 +1033,25 @@ export function styles() {
padding: 8px 10px;
text-align: left;
}
.gate-action-btn {
margin-top: 4px;
padding: 6px 14px;
font-size: 12.5px;
font-weight: var(--font-weight-semibold, 600);
color: var(--text-color-default, #e6edf3);
background: var(--background-color-default, #21262d);
border: 1px solid var(--border-color-default, #30363d);
border-radius: 6px;
cursor: pointer;
}
.gate-action-btn:hover:not(:disabled) { border-color: var(--color-focus-outline, #58a6ff); }
.gate-action-btn:disabled { opacity: 0.6; cursor: default; }
.gate-install-status {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-color-muted, #8b949e);
}
.toast {
position: fixed;
@@ -40,17 +40,11 @@ that drafts a fix pull request.
- **Node.js 22 or newer.**
- The GitHub Copilot app canvas / UI-extensions experiment enabled.
- A Sentry sign-in. This canvas reads issues through the
- **A Sentry sign-in.** This canvas reads issues through the
[Sentry CLI](https://cli.sentry.dev) in library mode — there is no MCP server
to configure. After installing the dependency (see **Install** below), sign in
once with the package-local CLI, run from the extension folder:
```sh
npx sentry auth login
```
This stores an OAuth credential the canvas auto-detects. For non-interactive
environments, export a token instead:
to configure. The setup gate walks you through installing the dependency and
signing in with one-click buttons (see **Install** below); no terminal
required. For non-interactive environments, you can instead export a token:
```sh
export SENTRY_AUTH_TOKEN=<your-token>
@@ -67,30 +61,17 @@ repository at `.github/extensions/sentry-triage/` for project scope.
This canvas depends on the [`sentry`](https://cli.sentry.dev) npm package at
runtime, which isn't bundled with the extension source. If it's missing, the canvas
still **opens** and shows a setup gate explaining what to do instead of crashing.
still **opens** and shows a setup gate — click its **Install dependencies** button
and the extension runs `npm install` in its own directory (wherever it's actually
installed, so there's no path to guess). Once installed, if you aren't signed in
yet the gate shows a **Sign in with Sentry** button that opens your browser to
approve access and returns automatically — no terminal step required. The gate
clears once both finish; no reload or Copilot involvement needed.
### Let Copilot set it up (recommended)
### Or set it up manually
Because the canvas runs inside GitHub Copilot, the agent can install the dependency
for you. Paste this into Copilot:
> Locate the loaded `sentry-triage` canvas extension folder — the directory that
> contains its `package.json` — run `npm install` there, then reload extensions.
(That folder is `~/.copilot/extensions/sentry-triage/` for user scope,
`.github/extensions/sentry-triage/` for project scope, or, if you installed the
published plugin, `com.github.copilot/extensions/sentry-triage` inside the
installed plugin.)
Then finish the one interactive step yourself — sign in so Copilot never handles a
raw secret. Run this from the same extension folder (`npx` resolves the CLI the
local `npm install` just placed in `node_modules`):
```sh
npx sentry auth login
```
### Or install it manually
If you'd rather not use the buttons (or `npm`/a browser isn't available to the
extension process), you can run the same steps yourself:
```sh
# User scope
@@ -179,11 +179,15 @@ export function Page({
<h2 id="gate-title">${packageMissing ? 'Set up the Sentry CLI' : (signedOut ? 'Connect Sentry to start triaging' : 'Cant reach Sentry right now')}</h2>
<div class="gate-body gate-body-setup" id="gate-body-setup"${packageMissing ? '' : ' style="display:none;"'}>
<p class="gate-lead">This canvas reads your live Sentry issues through the Sentry CLI, but its <code>sentry</code> package isnt installed for this extension yet.</p>
<p class="gate-steps">Ask Copilot to install the sentry-triage dependencies and reload extensions, then run <code>npx sentry auth login</code> from the extension folder to sign in and re-open this canvas.</p>
<p class="gate-steps">Click below to install it. You'll then be prompted to sign in with Sentry.</p>
<button id="install-deps-btn" class="gate-action-btn" type="button">📦 Install dependencies</button>
<p class="gate-install-status" id="gate-install-status" role="status" aria-live="polite" style="display:none;"></p>
</div>
<div class="gate-body gate-body-auth" id="gate-body-auth"${signedOut && !packageMissing ? '' : ' style="display:none;"'}>
<p class="gate-lead">This canvas reads your live Sentry issues through the Sentry CLI, but it isn't signed in yet.</p>
<p class="gate-steps">Run <code>npx sentry auth login</code> from the extension folder to sign in, then re-open this canvas.</p>
<p class="gate-steps">Sign in with Sentry this opens your browser to approve access, then returns here automatically.</p>
<button id="auth-login-btn" class="gate-action-btn" type="button">🔑 Sign in with Sentry</button>
<p class="gate-install-status" id="gate-auth-status" role="status" aria-live="polite" style="display:none;"></p>
</div>
<div class="gate-body gate-body-conn" id="gate-body-conn"${signedOut || !transientConn || packageMissing ? ' style="display:none;"' : ''}>
<p class="gate-lead">Youre signed in, but this canvas couldnt reach Sentry usually a temporary network blip.</p>
@@ -193,7 +197,7 @@ export function Page({
<p class="gate-lead">Youre signed in, but this canvas couldnt reach Sentry.</p>
<p class="gate-steps">See the details below, then re-open this canvas to try again.</p>
</div>
<p class="gate-error" id="gate-error" ${sentryReady || !gateError || packageMissing ? 'style="display:none;"' : ''}>${escapeHtml(gateError || '')}</p>
<p class="gate-error" id="gate-error" ${sentryReady || !gateError || packageMissing || signedOut ? 'style="display:none;"' : ''}>${escapeHtml(gateError || '')}</p>
</div>
</section>
@@ -489,27 +493,77 @@ export function Page({
syncScanButtonState();
const orgSelectEl = document.getElementById("org-select");
if (orgSelectEl) {
const orgSelectMirror = orgSelectEl ? wireOrgSelect(orgSelectEl) : null;
// (Re)wire an org <select>'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 <select> shows its first option as selected by default, but the
// input starts empty when there's no detected default — so Scan looks
// disabled and re-picking the already-shown org fires no change event.
// Seed the input from the select's current value on load so the visible
// selection is the effective one and Scan is enabled.
if (orgInputEl && !orgInputEl.value.trim() && orgSelectEl.value) mirrorOrgSelect();
if (orgInputEl && !orgInputEl.value.trim() && orgSelectEl.value) orgSelectMirror(false);
}
// Rebuild the setup screen's org control as a <select> 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 <select> already
// reflects the same options, so it's safe to call on every SSE update.
function renderSetupOrgSelect() {
const orgField = document.getElementById("org-input");
if (!orgField) return; // already a <select>, 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 <select> below (msg.orgOptions has 2+ entries) — that
// selector's own mirror() already requests projects for the
// selected org, so firing here too would kick off a duplicate,
// serialized (and possibly slow, up to the paging budget) traversal.
const willRenderOrgSelect = Array.isArray(msg.orgOptions) && msg.orgOptions.filter(Boolean).length >= 2;
const justFilled = applyOrgDefault(msg.orgDefault);
if (justFilled && !willRenderOrgSelect && !document.getElementById("org-select") && lastAutoFetchedOrgDefault !== msg.orgDefault) {
lastAutoFetchedOrgDefault = msg.orgDefault;
requestProjectsForOrg(msg.orgDefault);
}
}
if ("project" in msg) {
const nextProject = msg.project || "";
@@ -1036,7 +1116,15 @@ export function Page({
}
}
}
if (Array.isArray(msg.orgOptions)) currentOrgOptions = msg.orgOptions;
if (Array.isArray(msg.orgOptions)) {
currentOrgOptions = msg.orgOptions;
// Post-load org discovery (e.g. signing in without reloading) can
// reveal 2+ orgs after the setup screen already rendered a plain
// text input. Rebuild it as a <select> now so the multi-org flow
// works without a reload. No-ops once already a <select>, and
// no-ops off the setup screen (no #org-input present there).
renderSetupOrgSelect();
}
if (typeof msg.savedDefaultOrg === "string" && msg.savedDefaultOrg !== currentSavedDefaultOrg) {
currentSavedDefaultOrg = msg.savedDefaultOrg;
refreshDefaultBtn();
@@ -1124,7 +1212,7 @@ export function Page({
const gateErrEl = document.getElementById("gate-error");
if (gateErrEl) {
const err = (c.sentry && c.sentry.error) || "";
if (gatedNow && err && !packageMissing) {
if (gatedNow && err && !packageMissing && !signedOut) {
gateErrEl.textContent = err;
gateErrEl.style.display = "";
} else {
@@ -1141,6 +1229,20 @@ export function Page({
wasGatedPrev = gatedNow;
}
function focusConnectionGateLead(sentryConn) {
const panelId = sentryConn && sentryConn.transient ? "gate-body-conn" : "gate-body-unknown";
const connLead = document.querySelector("#" + panelId + " .gate-lead");
if (connLead) { connLead.setAttribute("tabindex", "-1"); connLead.focus(); }
}
function focusActiveOrgControl() {
const orgInput = document.getElementById("org-input");
const orgSelect = document.getElementById("org-select");
const orgSwitcher = document.getElementById("org-switcher");
const focusTarget = orgInput || orgSelect || orgSwitcher;
if (focusTarget) focusTarget.focus();
}
function enterTriageChrome(org) {
const picker = document.querySelector(".org-picker");
if (picker) picker.style.display = "none";
@@ -1887,6 +1989,123 @@ export function Page({
});
document.addEventListener("click", (e) => {
const installBtn = e.target.closest("#install-deps-btn");
if (installBtn) {
const statusEl = document.getElementById("gate-install-status");
installBtn.disabled = true;
installBtn.textContent = "Installing…";
if (statusEl) {
statusEl.style.display = "";
statusEl.textContent = "Running npm install — this can take a moment…";
}
fetch("/api/install-dependencies", { method: "POST" })
.then((res) => {
if (!res.ok) throw new Error("install-dependencies " + res.status);
return res.json();
})
.then((data) => {
if (data && data.connections) {
currentConnections = data.connections;
applyConnectionState();
}
const stillMissing = data && data.connections && data.connections.sentry && data.connections.sentry.setup === "package-missing";
if (stillMissing) {
if (statusEl) {
statusEl.textContent = "Install didn't finish — check that npm is available, then try again.";
}
installBtn.disabled = false;
installBtn.textContent = "📦 Install dependencies";
} else if (statusEl) {
// applyConnectionState() above already repainted the gate for
// the fresh connection state — a successful install doesn't
// always land on the sign-in panel: an existing stored
// credential can make it immediately reachable (clearing the
// gate entirely), or the re-probe can hit a transient network
// blip (landing on the connectivity panel) instead. Announce
// and focus based on what's actually visible now rather than
// assuming sign-in is next.
const sentryConn = (data.connections && data.connections.sentry) || {};
if (sentryConn.reachable) {
showToast("✅ Sentry connected — loading your issues");
focusActiveOrgControl();
} else if (sentryConn.configured) {
// configured (has/had a credential) but not reachable and
// not package-missing: connectivity panel is now showing.
statusEl.textContent = "Installed, but couldn't reach Sentry just now — see details below.";
focusConnectionGateLead(sentryConn);
} else {
statusEl.textContent = "Installed. Click below to sign in with Sentry.";
// The install panel (and this now-hidden live region) was
// just swapped for the sign-in panel, so focus is left on
// the now-hidden, disabled install button — silent for a
// screen reader user. Move focus to the sign-in button so
// they're notified of the next actionable step.
const nextBtn = document.getElementById("auth-login-btn");
if (nextBtn) nextBtn.focus();
}
}
})
.catch(() => {
if (statusEl) statusEl.textContent = "Install failed — the extension server may be unreachable. Please try again.";
installBtn.disabled = false;
installBtn.textContent = "📦 Install dependencies";
});
return;
}
const authBtn = e.target.closest("#auth-login-btn");
if (authBtn) {
const statusEl = document.getElementById("gate-auth-status");
authBtn.disabled = true;
authBtn.textContent = "Waiting for sign-in…";
if (statusEl) {
statusEl.style.display = "";
statusEl.textContent = "Opening your browser to sign in with Sentry — approve access there, then this will continue automatically.";
}
fetch("/api/auth-login", { method: "POST" })
.then((res) => res.json().then((data) => ({ res, data })))
.then(({ res, data }) => {
if (data && data.connections) {
currentConnections = data.connections;
applyConnectionState();
}
if (!res.ok || (data && data.ok === false)) {
if (statusEl) {
statusEl.textContent = (data && data.error) || "Sign-in didn't complete — please try again.";
}
authBtn.disabled = false;
authBtn.textContent = "🔑 Sign in with Sentry";
return;
}
const stillSignedOut = data && data.connections && data.connections.sentry && !data.connections.sentry.configured;
if (stillSignedOut) {
if (statusEl) statusEl.textContent = "Still not signed in — please try again.";
authBtn.disabled = false;
authBtn.textContent = "🔑 Sign in with Sentry";
} else {
// applyConnectionState() above just hid the entire auth gate
// (signed in now) — this live region's own node may no longer
// be in the accessibility tree, and focus is left on the
// now-hidden, disabled sign-in button. Announce via the
// always-visible toast instead, and move focus into whatever
// view is now actually showing (reachable: the org picker;
// otherwise: the connectivity gate that's left, if any).
const sentryConn = (data.connections && data.connections.sentry) || {};
if (sentryConn.reachable) {
showToast("✅ Signed in — loading your issues");
focusActiveOrgControl();
} else {
showToast("✅ Signed in — checking Sentry connectivity");
focusConnectionGateLead(sentryConn);
}
}
})
.catch(() => {
if (statusEl) statusEl.textContent = "Sign-in failed — the extension server may be unreachable. Please try again.";
authBtn.disabled = false;
authBtn.textContent = "🔑 Sign in with Sentry";
});
return;
}
const btn = e.target.closest("#refresh, #rescan");
if (!btn) return;
const subtitle = document.querySelector(".page-subtitle");
@@ -3,7 +3,7 @@ import { execSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { startServer } from './server.mjs'
import { scanIssues, listOrgs, listProjects, findProject } from './sentry.mjs'
import { checkConnections, checkConnectionsOnce } from './preflight.mjs'
import { checkConnections, checkConnectionsOnce, installDependencies, authenticate } from './preflight.mjs'
import { sanitizeForPrompt } from './escape.mjs'
// A Sentry-derived URL is safe to pass through only if it PARSES as a real
@@ -1309,6 +1309,55 @@ async function onRecheckConnections(entry) {
return connections
}
// "Install dependencies" button handler on the package-missing setup gate.
// Delegates to preflight's installDependencies (npm install + re-probe, rooted
// at the extension's own directory) and publishes the fresh connection state so
// the gate updates live. On success (Sentry now reachable and an org already
// committed) kicks a scan, mirroring onRecheckConnections's post-recovery path.
async function onInstallDependencies(entry) {
if (entry.closed) return entry.state.getConnections?.() || { sentry: { reachable: false } }
const connections = await installDependencies()
if (entry.closed) return connections
entry.state.setConnections(connections)
entry.notifyClients()
if (connections.sentry.reachable && entry.state.getOrg()) {
triageSentry(entry).catch((err) => {
console.error('[sentry-triage] post-install scan failed:', err instanceof Error ? err.message : err)
})
} else if (connections.sentry.reachable) {
discoverOrgs(entry).catch((err) => {
console.error('[sentry-triage] post-install org discovery failed:', err instanceof Error ? err.message : err)
})
}
return connections
}
// "Sign in with Sentry" button handler on the not-authenticated setup gate
// (shown only once the package is installed — see components/page.mjs). Runs
// the SDK's OAuth device-code login (opens the user's browser directly, no
// terminal) and publishes the fresh connection state so the gate updates
// live, mirroring onInstallDependencies's post-recovery path. Unlike install,
// a failed/cancelled login is allowed to propagate so the server route can
// report the specific reason instead of a generic "still signed out".
async function onAuthenticate(entry) {
if (entry.closed) return entry.state.getConnections?.() || { sentry: { reachable: false } }
const connections = await authenticate()
if (entry.closed) return connections
entry.state.setConnections(connections)
entry.notifyClients()
if (connections.sentry.reachable && entry.state.getOrg()) {
triageSentry(entry).catch((err) => {
console.error('[sentry-triage] post-auth scan failed:', err instanceof Error ? err.message : err)
})
} else if (connections.sentry.reachable) {
discoverOrgs(entry).catch((err) => {
console.error('[sentry-triage] post-auth org discovery failed:', err instanceof Error ? err.message : err)
})
}
return connections
}
async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
if (entry.closed) return
const uniqueKeys = [...new Set(issueKeys)].filter(Boolean)
@@ -1982,6 +2031,8 @@ const session = await joinSession({
onRefresh: () => refreshAll(entry),
onWorkSelected: (keys, modelByKey, assignCopilot) => onWorkSelected(entry, keys, modelByKey, assignCopilot),
onRecheck: () => onRecheckConnections(entry),
onInstallDependencies: () => onInstallDependencies(entry),
onAuthenticate: () => onAuthenticate(entry),
onListProjects: (org) => discoverProjects(entry, org, { force: true }),
onResolveProject: (org, slug) => resolveProject(entry, org, slug),
onInvalidateEnrichment: () => {
@@ -15,7 +15,7 @@
// buildConnections() is pure so it can be unit-tested offline; checkConnections()
// is the thin wrapper that talks to Sentry.
import { whoami, SentryError } from './sentryClient.mjs'
import { whoami, installPackage, login, SentryError } from './sentryClient.mjs'
// Shape the raw signals into the connection status the UI consumes. Pure.
export function buildConnections({
@@ -60,6 +60,12 @@ const msg = (err) => (err instanceof Error ? err.message : String(err))
// network blip and not an auth failure, so it gets its own gate branch.
const isPackageMissing = (err) => Boolean(err) && err.code === 'SENTRY_PACKAGE_MISSING'
// An active SENTRY_AUTH_TOKEN/SENTRY_TOKEN in the environment takes precedence
// over any OAuth login, so the "Sign in" button can't do anything useful while
// one is set — sentryClient's login() detects this up front and fails fast
// with this code instead of running an OAuth flow that can't take effect.
const isEnvTokenActive = (err) => Boolean(err) && err.code === 'SENTRY_ENV_TOKEN_ACTIVE'
// Text used for classification: the message plus any CLI stderr, since a rejected
// credential (HTTP 401/403) often surfaces its status in stderr rather than the
// Error message.
@@ -95,11 +101,14 @@ const humanizeSentryError = (err) => {
if (isPackageMissing(err)) {
return 'The Sentry CLI isnt installed for this canvas yet. Ask Copilot to “install the sentry-triage dependencies and reload extensions,” then run `npx sentry auth login` from the extension folder and re-open this canvas.'
}
if (isEnvTokenActive(err)) {
return t
}
if (isNotAuthenticated(err)) {
return 'Sentry isnt connected yet. Run `npx sentry auth login` from the extension folder, then re-open this canvas.'
return 'Sentry isnt connected yet.'
}
if (isAuthFailure(err)) {
return 'Sentry rejected your credential (expired or invalid). Run `npx sentry auth login` from the extension folder, then re-open this canvas.'
return 'Sentry rejected your credential (expired or invalid). Sign in again below.'
}
if (isTransient(err)) {
return 'Couldnt reach Sentry just now (network). It should recover on the next check.'
@@ -174,3 +183,35 @@ export async function checkConnections() {
}
return shape(result)
}
// One-click fix for the package-missing gate: run `npm install` in the
// extension's own directory (via sentryClient's installPackage, so the path is
// never guessed by an agent or user) and immediately re-probe. Returns the fresh
// connection state either way so the gate/setup UI can render the outcome —
// success clears the gate, and a failed install surfaces as a normal probe error
// (e.g. still package-missing, or an npm/network failure) rather than throwing.
export async function installDependencies() {
try {
await installPackage()
} catch (err) {
console.error('[sentry-triage] npm install failed:', err instanceof Error ? err.message : err)
}
const { connections } = await checkConnectionsOnce()
return connections
}
// One-click fix for the "not authenticated" setup gate: run the SDK's own
// OAuth device-code login (sentryClient's login(), the in-process equivalent
// of `sentry auth login`) and immediately re-probe. Only ever called for a
// package-present, not-signed-in state — the gate never shows this button
// while the package itself is missing (see components/page.mjs) — so unlike
// installDependencies() a thrown login error (user closed the browser tab,
// denied consent, or the device code expired) is left to propagate: the
// caller (extension.mjs onAuthenticate) surfaces it to the gate rather than
// silently falling back to a generic "still signed out" re-probe, since the
// specific reason (denied vs. expired vs. cancelled) is worth showing.
export async function authenticate() {
await login()
const { connections } = await checkConnectionsOnce()
return connections
}
@@ -13,6 +13,12 @@
// canvas's internal issue model lives in sentry.mjs so this file stays a thin,
// swappable transport.
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
const execFileAsync = promisify(execFile)
// The heavyweight `sentry` CLI package is an OPTIONAL, lazily-loaded dependency.
// awesome-copilot ships extension *source* only (no node_modules), so an installed
// plugin may not have it. A top-level `import ... from 'sentry'` would throw
@@ -27,10 +33,11 @@
// SentryError class, so existing `instanceof` + `.exitCode` checks keep matching
// the errors the SDK actually throws.
let SentryError = class SentryError extends Error {
constructor(message, opts = {}) {
constructor(message, exitCode = 0, stderr = '') {
super(message)
this.name = 'SentryError'
if (opts.code) this.code = opts.code
this.exitCode = exitCode
this.stderr = stderr
}
}
@@ -45,26 +52,68 @@ const PACKAGE_MISSING_MESSAGE =
let sdk = null
let sdkFactory = null
let installPromise = null
let loginPromise = null
// One-click install for the missing `sentry` package, driven by the setup
// gate's "Install dependencies" button (see extension.mjs installDependencies /
// server.mjs POST /api/install-dependencies). Runs `npm install` rooted at THIS
// file's own directory — i.e. the extension's actual on-disk location, whatever
// that is (repo source, a user/project extensions folder, or an installed
// plugin's materialized copy) — so it never depends on a user or agent guessing
// the right path (the earlier manual "ask Copilot to install" flow's failure
// mode). Serialized on the same sdkQueue as every other SDK call so it can't run
// concurrently with an in-flight probe. Concurrent install clicks share one
// in-flight install instead of queueing redundant npm installs.
export function installPackage() {
if (installPromise) return installPromise
installPromise = runSerial(async () => {
// Windows can't exec the `npm.cmd` shim directly without a shell, and a raw
// `new URL('.', import.meta.url).pathname` yields a URL-style path like
// "/C:/..." rather than a native Windows path — fileURLToPath handles both
// platforms correctly.
const cwd = fileURLToPath(new URL('.', import.meta.url))
// Async execFile (not execFileSync): this module and its loopback HTTP/SSE
// servers are shared by every canvas instance in the process, so a
// synchronous, up-to-120s install would freeze all of them. runSerial's
// queue already prevents this from overlapping with other SDK/install
// calls, so there's no concurrency downside to awaiting it instead.
await execFileAsync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], {
cwd,
timeout: 120_000,
shell: process.platform === 'win32',
})
sdkFactory = null
sdk = null
}).finally(() => {
installPromise = null
})
return installPromise
}
// Import the optional `sentry` package exactly once. Throws a SentryError tagged
// SENTRY_PACKAGE_MISSING when it can't be resolved, and swaps in the SDK's real
// SentryError class (live ESM binding) when it can.
//
async function loadFactory() {
if (sdkFactory) return sdkFactory
let mod
try {
mod = await import('sentry')
} catch (err) {
// Only translate "the `sentry` package itself isn't installed" into the setup
// gate. Node reports that as ERR_MODULE_NOT_FOUND naming the `sentry` package.
// Any OTHER failure — a missing TRANSITIVE dependency, or the package's own
// entrypoint throwing at import — is a real defect we must surface, not mask
// behind a misleading "reinstall sentry" message. Rethrow those unchanged.
if (!(err && err.code === 'ERR_MODULE_NOT_FOUND')) throw err
// ERR_MODULE_NOT_FOUND is also what Node throws when `sentry` itself
// resolves fine but one of ITS OWN dependencies is missing (e.g. a
// corrupt or partial install) — that's a real defect, not a "not
// installed" state, and must not be masked as package-missing. Only the
// "Cannot find package/module 'sentry'" message means the bare specifier
// itself failed to resolve.
const message = String((err && err.message) || '')
const sentryPackageMissing =
err && err.code === 'ERR_MODULE_NOT_FOUND' && /Cannot find (?:package|module) 'sentry'/.test(message)
if (!sentryPackageMissing) throw err
throw new SentryError(PACKAGE_MISSING_MESSAGE, { code: 'SENTRY_PACKAGE_MISSING' })
const sentryItselfMissing = /Cannot find (?:package|module) 'sentry'/.test(message)
if (!sentryItselfMissing) throw err
const missing = new SentryError(PACKAGE_MISSING_MESSAGE, 0, '')
missing.code = 'SENTRY_PACKAGE_MISSING'
throw missing
}
if (mod.SentryError) SentryError = mod.SentryError
sdkFactory = mod.default
@@ -135,6 +184,95 @@ export async function whoami() {
return runSerial(async () => (await getSdk()).auth.whoami())
}
// One-click sign-in for the "not authenticated" setup gate, driven by the
// gate's "Sign in with Sentry" button (see preflight.mjs authenticate() /
// server.mjs POST /api/auth-login). Runs the SDK's own OAuth device-code
// flow — the exact same flow `npx sentry auth login` drives from a
// terminal — which opens the user's default browser and waits for them to
// approve. Serialized on the same sdkQueue as every other SDK call (this
// module's whole reason for existing single-flights everything): the CLI
// keeps global auth state, so a concurrent whoami()/scan while login() is
// mid-flow would race the same on-disk credential login() is about to write.
// Any caller queued behind this one simply waits for the user to finish
// approving in their browser, same as a real terminal `sentry auth login`
// would block the shell.
//
// A closed tab or an ignored prompt would otherwise wait forever (the SDK
// default timeout is 900s / 15 minutes) and wedge the sdkQueue for every
// other Sentry call behind it — so we bound it here to something the setup
// gate can reasonably ask a user to wait through. On timeout the device code
// is left stale server-side; the caller can just click "Sign in" again to
// mint a fresh one.
//
// NOTE: the SDK's `timeout` is in SECONDS, not milliseconds (see
// AuthLoginParams in the sentry package's type defs) — do not multiply by
// 1000 here.
//
// `force: true` because this button is also how a signed-in-but-invalid
// credential (expired/revoked token) re-authenticates: in this non-TTY
// extension process, auth.login() silently declines to replace an existing
// credential unless forced, which would otherwise leave "Sign in again"
// wired to a no-op. `readOnly: true` requests only the read-only OAuth
// scopes (project:read, org:read, event:read, member:read, team:read) since
// this canvas only ever reads Sentry data — no need for the default
// write/admin scopes.
const LOGIN_TIMEOUT_SECONDS = 120
export function login() {
if (loginPromise) return loginPromise
loginPromise = runSerial(async () => {
// SENTRY_AUTH_TOKEN / SENTRY_TOKEN take precedence over the stored OAuth
// credential (see the module header). If one is set, auth.login() can
// still "succeed" and write a fresh OAuth login, but every subsequent
// call (including the whoami() probe authenticate() runs right after)
// keeps using the env token instead — so an invalid/expired env token
// would make this button look like it worked while leaving the gate
// signed out. Fail fast with actionable guidance instead of running an
// OAuth flow that can't actually take effect.
const envToken = process.env.SENTRY_AUTH_TOKEN || process.env.SENTRY_TOKEN
if (envToken) {
const envVar = process.env.SENTRY_AUTH_TOKEN ? 'SENTRY_AUTH_TOKEN' : 'SENTRY_TOKEN'
const err = new SentryError(
`${envVar} is set in this environment and takes precedence over signing in here. Unset it (or replace it with a valid token) and try again.`,
0,
''
)
err.code = 'SENTRY_ENV_TOKEN_ACTIVE'
throw err
}
// sentry@0.42.2's login command sets the SHARED extension process's
// process.exitCode (not just its own in-process return value) when the
// device flow is denied, cancelled, or expires — a signal meant for a
// one-shot CLI process exiting non-zero, not for this long-lived host
// that keeps running other extensions after the call returns. Save and
// restore it around the call so a failed sign-in here doesn't leave the
// whole extension host marked to exit unsuccessfully.
const savedExitCode = process.exitCode
let result
try {
result = await (await getSdk()).auth.login({ timeout: LOGIN_TIMEOUT_SECONDS, force: true, readOnly: true })
} finally {
process.exitCode = savedExitCode
}
// sentry@0.42.2's login command does not reliably reject when the device
// flow is denied, cancelled, or expires — it can resolve with an empty/
// falsy result after only setting its own CLI exit code, which this SDK
// wrapper doesn't surface. Left unchecked, authenticate() would silently
// re-probe and the caller (the auth-login route) would report ok:true for
// what was actually a failed sign-in. Treat an empty result as a failure
// so the specific "still not signed in" path in the gate is reachable.
if (!result) {
const err = new SentryError('Sign-in was not completed (denied, cancelled, or the code expired).', 0, '')
err.code = 'SENTRY_LOGIN_INCOMPLETE'
throw err
}
return result
}).finally(() => {
loginPromise = null
})
return loginPromise
}
// All organizations the stored credential can see. Raw org objects (each has a
// `slug`).
export async function orgList(limit = 100) {
@@ -77,7 +77,7 @@ function hasValidToken(req, token) {
return value === token
}
export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onListProjects, onResolveProject, onInvalidateEnrichment, defaults } = {}) {
export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onInstallDependencies, onAuthenticate, onListProjects, onResolveProject, onInvalidateEnrichment, defaults } = {}) {
// Per-instance state + SSE clients — never shared across canvas instances.
const state = createState()
state.applyRepoDefaults(defaults)
@@ -225,6 +225,49 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
return
}
// One-click install for the "package-missing" setup gate. Runs `npm install`
// in the extension's own directory (via onInstallDependencies) and returns
// the freshly re-probed connection state, so the gate updates without
// requiring the canvas to be reopened.
if (req.method === 'POST' && req.url === '/api/install-dependencies') {
Promise.resolve(onInstallDependencies ? onInstallDependencies() : null)
.then((connections) => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, connections: connections || state.getConnections() }))
})
.catch((err) => {
console.error('[sentry-triage] install-dependencies failed:', err instanceof Error ? err.message : err)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, connections: state.getConnections() }))
})
return
}
// One-click sign-in for the "not authenticated" setup gate (only shown once
// the package is installed — see components/page.mjs). Runs the SDK's own
// OAuth device-code login, which opens the user's browser directly, and
// returns the freshly re-probed connection state. Unlike install, a failed
// login (denied consent, expired code, closed tab) is reported back as
// `ok:false` with a message instead of silently falling back to the stale
// connection state — the specific reason is worth surfacing in the gate.
if (req.method === 'POST' && req.url === '/api/auth-login') {
Promise.resolve(onAuthenticate ? onAuthenticate() : null)
.then((connections) => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, connections: connections || state.getConnections() }))
})
.catch((err) => {
console.error('[sentry-triage] auth-login failed:', err instanceof Error ? err.message : err)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
ok: false,
error: err instanceof Error ? err.message : String(err),
connections: state.getConnections(),
}))
})
return
}
// Fetch the Sentry project list for an org so the project field can render
// as a dropdown. Fire-and-forget from the client's perspective: the fetched
// list is broadcast to all clients over SSE by the handler. Used when the
@@ -1033,6 +1033,25 @@ export function styles() {
padding: 8px 10px;
text-align: left;
}
.gate-action-btn {
margin-top: 4px;
padding: 6px 14px;
font-size: 12.5px;
font-weight: var(--font-weight-semibold, 600);
color: var(--text-color-default, #e6edf3);
background: var(--background-color-default, #21262d);
border: 1px solid var(--border-color-default, #30363d);
border-radius: 6px;
cursor: pointer;
}
.gate-action-btn:hover:not(:disabled) { border-color: var(--color-focus-outline, #58a6ff); }
.gate-action-btn:disabled { opacity: 0.6; cursor: default; }
.gate-install-status {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-color-muted, #8b949e);
}
.toast {
position: fixed;