mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-29 12:03:09 +00:00
Add one-click install/sign-in to sentry-triage setup gate (#2831)
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 <select> dropdown that
rebuilds in place once org discovery completes post-signin,
without requiring a canvas reopen.
- README.md — documents the one-click flow; manual npm install /
sentry auth login kept as a fallback.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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' : 'Can’t 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 isn’t 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">You’re signed in, but this canvas couldn’t reach Sentry — usually a temporary network blip.</p>
|
||||
@@ -193,7 +197,7 @@ export function Page({
|
||||
<p class="gate-lead">You’re signed in, but this canvas couldn’t 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 isn’t 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 isn’t connected yet. Run `npx sentry auth login` from the extension folder, then re-open this canvas.'
|
||||
return 'Sentry isn’t 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 'Couldn’t 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;
|
||||
|
||||
Reference in New Issue
Block a user