fix(gh-project): diagnose env-shadowed gh tokens in auth errors (#1478)
* fix(gh-project): diagnose env-shadowed gh tokens in auth errors `gh auth refresh -s project` silently no-ops when GITHUB_TOKEN/GH_TOKEN is exported in the user's shell — gh prefers env tokens and refuses to modify them, exiting 0. Users follow the canned remediation, see no error, retry, and stay stuck. Add a one-shot `gh auth status` probe (gh:diagnoseAuth IPC) that: - Detects env-shadowed credentials and rewrites the fix to `unset GITHUB_TOKEN` plus a grep to find where it's exported. - Detects missing gh install, plain missing-scope on a keyring login, and SAML SSO authorization. - Surfaces a tailored multi-button error UI in ProjectViewWrapper and ProjectPicker instead of one canned 'Copy command'. Co-authored-by: Orca <help@stably.ai> * fix(gh-project): address review feedback - Cross-platform shell guidance: PowerShell commands on Windows (Get-ChildItem Env:, Remove-Item Env:, [Environment]::SetEnvironmentVariable) via navigator.userAgent platform check. - Use `window.api.shell.openUrl` for the docs button instead of `window.open`, matching SidebarToolbar's external-URL pattern. - Tighten gh auth status parser: accept single-label hostnames and optional trailing colon; recover host from the inline 'Logged in to <host>' line so a missed section header never silently drops accounts. - Add tests for multi-host output and host-recovery fallback. - Drop dead command/copy locals in ProjectViewWrapper.ErrorState by short-circuiting the auth-error case before they're computed. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
8565f7f186
commit
e49d90bee4
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { parseAuthStatus } from './auth-diagnose'
|
||||
|
||||
describe('parseAuthStatus', () => {
|
||||
it('parses an env-shadowed login alongside a keyring login (real gh output)', () => {
|
||||
const text = `github.com
|
||||
✓ Logged in to github.com account nwparker (GITHUB_TOKEN)
|
||||
- Active account: true
|
||||
- Git operations protocol: https
|
||||
- Token: gho_************************************
|
||||
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
|
||||
|
||||
✓ Logged in to github.com account nwparker (keyring)
|
||||
- Active account: false
|
||||
- Git operations protocol: https
|
||||
- Token: gho_************************************
|
||||
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
|
||||
`
|
||||
const accounts = parseAuthStatus(text)
|
||||
expect(accounts).toHaveLength(2)
|
||||
expect(accounts[0]).toMatchObject({
|
||||
host: 'github.com',
|
||||
user: 'nwparker',
|
||||
active: true,
|
||||
envToken: 'GITHUB_TOKEN',
|
||||
source: 'env'
|
||||
})
|
||||
expect(accounts[0].scopes).toEqual(['gist', 'read:org', 'repo', 'workflow'])
|
||||
expect(accounts[1]).toMatchObject({
|
||||
active: false,
|
||||
envToken: null,
|
||||
source: 'keyring'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a single keyring login', () => {
|
||||
const text = `github.com
|
||||
✓ Logged in to github.com account alice (keyring)
|
||||
- Active account: true
|
||||
- Token scopes: 'project', 'read:org', 'repo'
|
||||
`
|
||||
const accounts = parseAuthStatus(text)
|
||||
expect(accounts).toHaveLength(1)
|
||||
expect(accounts[0]).toMatchObject({
|
||||
user: 'alice',
|
||||
active: true,
|
||||
envToken: null,
|
||||
source: 'keyring'
|
||||
})
|
||||
expect(accounts[0].scopes).toContain('project')
|
||||
})
|
||||
|
||||
it('detects GH_TOKEN env source', () => {
|
||||
const text = `github.com
|
||||
✓ Logged in to github.com account bot (GH_TOKEN)
|
||||
- Active account: true
|
||||
- Token scopes: 'repo'
|
||||
`
|
||||
const [acc] = parseAuthStatus(text)
|
||||
expect(acc.envToken).toBe('GH_TOKEN')
|
||||
expect(acc.source).toBe('env')
|
||||
})
|
||||
|
||||
it('returns empty array when nothing is logged in', () => {
|
||||
expect(parseAuthStatus('You are not logged into any GitHub hosts.')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses multiple hosts in one output (github.com + GHES)', () => {
|
||||
const text = `github.com
|
||||
✓ Logged in to github.com account alice (keyring)
|
||||
- Active account: true
|
||||
- Token scopes: 'read:org', 'repo'
|
||||
|
||||
ghe.acme.io
|
||||
✓ Logged in to ghe.acme.io account bob (keyring)
|
||||
- Active account: true
|
||||
- Token scopes: 'project', 'repo'
|
||||
`
|
||||
const accounts = parseAuthStatus(text)
|
||||
expect(accounts.map((a) => a.host)).toEqual(['github.com', 'ghe.acme.io'])
|
||||
expect(accounts.map((a) => a.user)).toEqual(['alice', 'bob'])
|
||||
expect(accounts[1].scopes).toContain('project')
|
||||
})
|
||||
|
||||
it('recovers host from the Logged-in line when the section header is missing', () => {
|
||||
// gh prints a colon after the host on some versions; we tolerate it,
|
||||
// but if the regex ever fails to match the header we still want
|
||||
// accounts attributed to the host from the inline message.
|
||||
const text = ` ✓ Logged in to github.acme.io account carol (keyring)
|
||||
- Active account: true
|
||||
- Token scopes: 'project'
|
||||
`
|
||||
const accounts = parseAuthStatus(text)
|
||||
expect(accounts).toHaveLength(1)
|
||||
expect(accounts[0].host).toBe('github.acme.io')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* gh CLI auth diagnostics.
|
||||
*
|
||||
* Why: when project queries fail with "missing scope", the canned
|
||||
* remediation `gh auth refresh -s project ...` silently no-ops if the user
|
||||
* has `GITHUB_TOKEN` (or `GH_TOKEN`) exported in their shell — gh prefers
|
||||
* env tokens over keyring credentials and refuses to refresh env-supplied
|
||||
* tokens. Users follow the instructions, see no error, retry, and stay
|
||||
* stuck. This probe makes that failure mode legible in the UI.
|
||||
*
|
||||
* Output is parsed from `gh auth status`, which prints free-form text but
|
||||
* uses stable field labels ("Token scopes:", "(GITHUB_TOKEN)", etc.).
|
||||
*/
|
||||
import { ghExecFileAsync } from '../git/runner'
|
||||
import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github-auth-types'
|
||||
|
||||
// Required scopes for ProjectV2 GraphQL access in Orca. `project` is the
|
||||
// scope that gates ProjectV2 reads/writes; the others are needed for the
|
||||
// surrounding repo/org queries we already run.
|
||||
const REQUIRED_SCOPES = ['project', 'read:org', 'repo'] as const
|
||||
|
||||
/**
|
||||
* Parse `gh auth status` stderr/stdout. gh writes to stderr by default but
|
||||
* has used stdout in some versions; we accept either. Format (per host):
|
||||
*
|
||||
* github.com
|
||||
* ✓ Logged in to github.com account NAME (GITHUB_TOKEN)
|
||||
* - Active account: true
|
||||
* - Token scopes: 'gist', 'read:org', 'repo'
|
||||
*/
|
||||
export function parseAuthStatus(text: string): GhAuthAccount[] {
|
||||
const accounts: GhAuthAccount[] = []
|
||||
let currentHost: string | null = null
|
||||
let current: GhAuthAccount | null = null
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const line = rawLine.replace(/\r$/, '')
|
||||
// Host header: a non-indented hostname token, with an optional
|
||||
// trailing colon some gh versions emit. Permits single-label hostnames
|
||||
// (internal GHES like `github` or `ghe-internal`); we also recover the
|
||||
// host from the `Logged in to <host>` line below if this header was
|
||||
// missed, so a parser miss never silently drops every account.
|
||||
const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*)\s*:?\s*$/i)
|
||||
if (hostMatch && !/^logged\b/i.test(line)) {
|
||||
currentHost = hostMatch[1]
|
||||
continue
|
||||
}
|
||||
const loggedIn = line.match(/Logged in to (\S+) account (\S+)(?:\s+\(([^)]+)\))?/i)
|
||||
if (loggedIn) {
|
||||
if (current) {
|
||||
accounts.push(current)
|
||||
}
|
||||
// Prefer the host from the `Logged in to <host>` line itself — it's
|
||||
// always present, whereas the section header above can be skipped
|
||||
// by the regex on unfamiliar gh output.
|
||||
const host = loggedIn[1] || currentHost || 'github.com'
|
||||
const sourceLabel = (loggedIn[3] ?? '').trim()
|
||||
// gh emits "(keyring)" for stored creds and "(GITHUB_TOKEN)" /
|
||||
// "(GH_TOKEN)" when an env var is shadowing the keyring.
|
||||
const envToken =
|
||||
sourceLabel === 'GITHUB_TOKEN' || sourceLabel === 'GH_TOKEN' ? sourceLabel : null
|
||||
current = {
|
||||
host,
|
||||
user: loggedIn[2],
|
||||
active: false,
|
||||
envToken,
|
||||
source: envToken ? 'env' : 'keyring',
|
||||
scopes: []
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
const activeMatch = line.match(/Active account:\s*(true|false)/i)
|
||||
if (activeMatch) {
|
||||
current.active = activeMatch[1].toLowerCase() === 'true'
|
||||
continue
|
||||
}
|
||||
const scopesMatch = line.match(/Token scopes:\s*(.+)$/i)
|
||||
if (scopesMatch) {
|
||||
current.scopes = scopesMatch[1]
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
accounts.push(current)
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
export async function diagnoseGhAuth(): Promise<GhAuthDiagnostic> {
|
||||
let raw = ''
|
||||
let ghAvailable = true
|
||||
try {
|
||||
// `gh auth status` exits non-zero when no host is logged in but still
|
||||
// prints the same diagnostic text we want, so capture both streams.
|
||||
const { stdout, stderr } = await ghExecFileAsync(['auth', 'status'])
|
||||
raw = `${stdout}\n${stderr}`
|
||||
} catch (err) {
|
||||
const stderr =
|
||||
err && typeof err === 'object' && 'stderr' in err
|
||||
? String((err as { stderr?: unknown }).stderr ?? '')
|
||||
: ''
|
||||
const stdout =
|
||||
err && typeof err === 'object' && 'stdout' in err
|
||||
? String((err as { stdout?: unknown }).stdout ?? '')
|
||||
: ''
|
||||
raw = `${stdout}\n${stderr}`
|
||||
if (!raw.trim()) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
// Most likely cause: gh CLI not installed or not on PATH.
|
||||
if (/ENOENT|not found|command not found/i.test(message)) {
|
||||
ghAvailable = false
|
||||
}
|
||||
raw = message
|
||||
}
|
||||
}
|
||||
const accounts = parseAuthStatus(raw)
|
||||
const active = accounts.find((a) => a.active) ?? accounts[0] ?? null
|
||||
const envTokenInProcess: 'GITHUB_TOKEN' | 'GH_TOKEN' | null = process.env.GH_TOKEN
|
||||
? 'GH_TOKEN'
|
||||
: process.env.GITHUB_TOKEN
|
||||
? 'GITHUB_TOKEN'
|
||||
: null
|
||||
const missingScopes = active
|
||||
? REQUIRED_SCOPES.filter((s) => !active.scopes.includes(s))
|
||||
: [...REQUIRED_SCOPES]
|
||||
// Is there a non-env (keyring) account we could fall back to by unsetting
|
||||
// the env var? Only meaningful if the active account is env-shadowed.
|
||||
const keyringFallback = accounts.find((a) => a.source === 'keyring') ?? null
|
||||
return {
|
||||
ghAvailable,
|
||||
activeAccount: active,
|
||||
accounts,
|
||||
envTokenInProcess,
|
||||
missingScopes,
|
||||
requiredScopes: [...REQUIRED_SCOPES],
|
||||
hasKeyringFallback: Boolean(keyringFallback && keyringFallback !== active)
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
} from '../github/client'
|
||||
import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details'
|
||||
import { getRateLimit } from '../github/rate-limit'
|
||||
import { diagnoseGhAuth } from '../github/auth-diagnose'
|
||||
import type { GitHubPRFile } from '../../shared/types'
|
||||
import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args'
|
||||
import {
|
||||
|
|
@ -406,6 +407,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
|||
getRateLimit(args?.force ? { force: true } : undefined)
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:diagnoseAuth', () => diagnoseGhAuth())
|
||||
|
||||
// ── GitHub ProjectV2 view handlers ─────────────────────────────────
|
||||
// Why: registered unconditionally so enabling the experimental flag at
|
||||
// runtime takes effect without a restart. The renderer gates entry points.
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ import type {
|
|||
ClaudeUsageSummary
|
||||
} from '../shared/claude-usage-types'
|
||||
import type { RateLimitState } from '../shared/rate-limit-types'
|
||||
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
|
||||
import type {
|
||||
SshConnectionState,
|
||||
SshTarget,
|
||||
|
|
@ -602,6 +603,14 @@ export type PreloadApi = {
|
|||
* `force: true` to bust after a known-expensive op.
|
||||
*/
|
||||
rateLimit: (args?: { force?: boolean }) => Promise<GetRateLimitResult>
|
||||
/**
|
||||
* Probe `gh auth status` and the Electron process env to explain
|
||||
* why ProjectV2 calls are failing with scope_missing. Surfaces the
|
||||
* common gotcha where `GITHUB_TOKEN` is exported in the user's
|
||||
* shell and silently shadows the keyring credential — in that case
|
||||
* `gh auth refresh` is a no-op and the UI must say so.
|
||||
*/
|
||||
diagnoseAuth: () => Promise<GhAuthDiagnostic>
|
||||
// ── ProjectV2 (GitHub Projects) ─────────────────────────────────
|
||||
listAccessibleProjects: () => Promise<ListAccessibleProjectsResult>
|
||||
resolveProjectRef: (args: ResolveProjectRefArgs) => Promise<ResolveProjectRefResult>
|
||||
|
|
@ -759,11 +768,7 @@ export type PreloadApi = {
|
|||
sidekick: {
|
||||
import: () => Promise<CustomSidekick | null>
|
||||
importPetBundle: () => Promise<CustomSidekick | null>
|
||||
read: (
|
||||
id: string,
|
||||
fileName: string,
|
||||
kind?: 'image' | 'bundle'
|
||||
) => Promise<ArrayBuffer | null>
|
||||
read: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise<ArrayBuffer | null>
|
||||
delete: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise<void>
|
||||
}
|
||||
browser: BrowserApi
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
} from '../shared/types'
|
||||
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types'
|
||||
import type { RateLimitState } from '../shared/rate-limit-types'
|
||||
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
|
||||
import type {
|
||||
AddIssueCommentBySlugArgs,
|
||||
ClearProjectItemFieldArgs,
|
||||
|
|
@ -669,6 +670,8 @@ const api = {
|
|||
rateLimit: (args?: { force?: boolean }): Promise<GetRateLimitResult> =>
|
||||
ipcRenderer.invoke('gh:rateLimit', args),
|
||||
|
||||
diagnoseAuth: (): Promise<GhAuthDiagnostic> => ipcRenderer.invoke('gh:diagnoseAuth'),
|
||||
|
||||
// ── ProjectV2 (GitHub Projects) ───────────────────────────────────
|
||||
listAccessibleProjects: (): Promise<ListAccessibleProjectsResult> =>
|
||||
ipcRenderer.invoke('gh:listAccessibleProjects'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Inline guidance for `auth_required` / `scope_missing` errors from gh.
|
||||
*
|
||||
* Why: the canned remediation `gh auth refresh -s project ...` silently
|
||||
* no-ops when GITHUB_TOKEN/GH_TOKEN is exported in the user's shell — gh
|
||||
* prefers env tokens and refuses to refresh them. Users follow the
|
||||
* instructions, see no error, retry, and stay stuck. This component runs
|
||||
* a one-shot diagnostic and rewrites the suggested fix to match what gh
|
||||
* is actually doing: env-shadow vs. plain missing-scope vs. not-installed.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Copy, ExternalLink } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { GitHubProjectViewError } from '@/../../shared/github-project-types'
|
||||
import type { GhAuthDiagnostic } from '@/../../shared/github-auth-types'
|
||||
|
||||
type AuthErrorKind = 'auth_required' | 'scope_missing'
|
||||
|
||||
const REFRESH_CMD = 'gh auth refresh -s project -s read:org -s repo'
|
||||
const LOGIN_CMD = 'gh auth login'
|
||||
|
||||
// AGENTS.md requires platform-specific shell guidance. The env-shadow
|
||||
// remediation needs different commands per host shell — bash/zsh on
|
||||
// macOS/Linux vs PowerShell on Windows.
|
||||
const IS_WINDOWS = typeof navigator !== 'undefined' && /Win(dows|32|64)/i.test(navigator.userAgent)
|
||||
|
||||
function findEnvVarCommand(varName: string): { label: string; command: string } {
|
||||
if (IS_WINDOWS) {
|
||||
return {
|
||||
label: 'Check if it’s set (PowerShell)',
|
||||
command: `Get-ChildItem Env:${varName}`
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: 'Find where it’s set',
|
||||
command: `grep -RIn '${varName}' ~/.zshrc ~/.zshenv ~/.bashrc ~/.bash_profile ~/.profile ~/.config 2>/dev/null`
|
||||
}
|
||||
}
|
||||
|
||||
function unsetEnvVarCommand(varName: string): { label: string; command: string } {
|
||||
if (IS_WINDOWS) {
|
||||
// Persistent removal at the user scope; the user still needs a fresh
|
||||
// shell/Orca relaunch for the change to take effect.
|
||||
return {
|
||||
label: 'Unset (PowerShell, persistent)',
|
||||
command: `Remove-Item Env:${varName}; [Environment]::SetEnvironmentVariable('${varName}', $null, 'User')`
|
||||
}
|
||||
}
|
||||
return { label: 'Unset for this shell', command: `unset ${varName}` }
|
||||
}
|
||||
|
||||
function openExternal(url: string): void {
|
||||
// In Electron renderers, raw `window.open` doesn't reliably route to the
|
||||
// user's default browser. Use the same shell IPC the rest of the app
|
||||
// uses (see SidebarToolbar.openExternalUrl).
|
||||
void window.api.shell.openUrl(url)
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(text)
|
||||
toast.success('Copied to clipboard')
|
||||
} catch {
|
||||
toast.error('Failed to copy')
|
||||
}
|
||||
}
|
||||
|
||||
type Remediation = {
|
||||
/** Short human-readable summary of why the error is happening. */
|
||||
summary: string
|
||||
/** Optional follow-up paragraph explaining the fix. */
|
||||
detail?: string
|
||||
/** Commands to surface as copyable buttons, in order. */
|
||||
commands: { label: string; command: string }[]
|
||||
/** Optional external doc link. */
|
||||
docsUrl?: string
|
||||
}
|
||||
|
||||
function buildRemediation(
|
||||
errorMessage: string,
|
||||
kind: AuthErrorKind,
|
||||
diag: GhAuthDiagnostic | null
|
||||
): Remediation {
|
||||
// Diagnostic still loading or unavailable — fall back to the canned advice
|
||||
// so the UI never gets worse than the pre-diagnosis behavior.
|
||||
if (!diag) {
|
||||
return {
|
||||
summary: errorMessage,
|
||||
commands: [
|
||||
{ label: 'Copy command', command: kind === 'auth_required' ? LOGIN_CMD : REFRESH_CMD }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (!diag.ghAvailable) {
|
||||
return {
|
||||
summary: 'GitHub CLI (`gh`) is not installed or not on PATH.',
|
||||
detail:
|
||||
'Orca uses `gh` to talk to GitHub Projects. Install it from cli.github.com, then sign in.',
|
||||
commands: [{ label: 'Copy login command', command: LOGIN_CMD }],
|
||||
docsUrl: 'https://cli.github.com/'
|
||||
}
|
||||
}
|
||||
|
||||
const active = diag.activeAccount
|
||||
// Most insidious failure mode: gh is using a token from the environment,
|
||||
// so `gh auth refresh` prints "GITHUB_TOKEN is being used... first clear
|
||||
// the value from the environment" and exits 0 without doing anything.
|
||||
if (active?.envToken) {
|
||||
const varName = active.envToken
|
||||
const fallback = diag.hasKeyringFallback
|
||||
? ' Your keyring already has a `gh` login that will take over once the env var is gone.'
|
||||
: ' After unsetting it, run `gh auth login` to sign in normally, then retry.'
|
||||
return {
|
||||
summary: `\`${varName}\` is set in your environment, so \`gh\` is using that token instead of your keyring login. \`gh auth refresh\` cannot modify env-supplied tokens — that's why running it didn't help.`,
|
||||
detail: IS_WINDOWS
|
||||
? `Find where \`${varName}\` is set (System or User environment variables, or your PowerShell profile), remove it, then restart Orca so the new environment is picked up.${fallback}`
|
||||
: `Find where \`${varName}\` is exported (commonly \`~/.zshrc\`, \`~/.zshenv\`, \`~/.bashrc\`, \`~/.profile\`, or your shell's secrets manager), remove it, then restart Orca so the new environment is picked up.${fallback}`,
|
||||
commands: [findEnvVarCommand(varName), unsetEnvVarCommand(varName)],
|
||||
docsUrl: 'https://cli.github.com/manual/gh_help_environment'
|
||||
}
|
||||
}
|
||||
|
||||
// gh is not the problem, but the Electron process inherited GITHUB_TOKEN
|
||||
// from the parent shell. Even after the user runs `gh auth refresh` in a
|
||||
// separate terminal, Orca's gh subprocess sees the env var and uses it.
|
||||
if (diag.envTokenInProcess && (!active || diag.missingScopes.length > 0)) {
|
||||
const varName = diag.envTokenInProcess
|
||||
return {
|
||||
summary: `Orca inherited \`${varName}\` from your shell, and \`gh\` is using that token. \`gh auth refresh\` doesn't apply to env-supplied tokens.`,
|
||||
detail: `Unset \`${varName}\` in the shell that launches Orca${
|
||||
IS_WINDOWS ? ' (or in your user environment variables)' : ' (or in your shell rc file)'
|
||||
}, then restart Orca.`,
|
||||
commands: [findEnvVarCommand(varName), unsetEnvVarCommand(varName)],
|
||||
docsUrl: 'https://cli.github.com/manual/gh_help_environment'
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === 'auth_required' || !active) {
|
||||
return {
|
||||
summary: 'You’re not signed in to GitHub via `gh`.',
|
||||
commands: [{ label: 'Copy login command', command: LOGIN_CMD }]
|
||||
}
|
||||
}
|
||||
|
||||
// Plain missing-scope case on a keyring login — refresh will work.
|
||||
if (diag.missingScopes.length > 0) {
|
||||
return {
|
||||
summary: `Your \`gh\` token is missing the ${diag.missingScopes
|
||||
.map((s) => `\`${s}\``)
|
||||
.join(
|
||||
', '
|
||||
)} scope${diag.missingScopes.length === 1 ? '' : 's'} needed for GitHub Projects.`,
|
||||
detail:
|
||||
'Run the refresh command in a terminal. It will open a browser to authorize the new scopes, then come back here and reload.',
|
||||
commands: [{ label: 'Copy refresh command', command: REFRESH_CMD }]
|
||||
}
|
||||
}
|
||||
|
||||
// Scopes look fine but GitHub still rejected us — likely SAML SSO not
|
||||
// authorized for this org's token, or the project is in an org the token
|
||||
// can't see. Surface the most likely fix.
|
||||
return {
|
||||
summary: errorMessage,
|
||||
detail:
|
||||
'Your token has the required scopes but GitHub still denied access. If the project is in an org with SAML SSO, you must authorize this token for the org under Settings → Developer settings → Personal access tokens → Configure SSO.',
|
||||
commands: [{ label: 'Copy refresh command', command: REFRESH_CMD }],
|
||||
docsUrl:
|
||||
'https://docs.github.com/en/enterprise-cloud@latest/authentication/authenticating-with-saml-single-sign-on/authorizing-a-personal-access-token-for-use-with-saml-single-sign-on'
|
||||
}
|
||||
}
|
||||
|
||||
export function GhAuthErrorHelp({
|
||||
error,
|
||||
variant = 'block'
|
||||
}: {
|
||||
error: GitHubProjectViewError & { type: AuthErrorKind }
|
||||
variant?: 'block' | 'banner'
|
||||
}): React.JSX.Element {
|
||||
const [diag, setDiag] = useState<GhAuthDiagnostic | null>(null)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
window.api.gh
|
||||
.diagnoseAuth()
|
||||
.then((d) => {
|
||||
if (!cancelled) {
|
||||
setDiag(d)
|
||||
}
|
||||
})
|
||||
// Diagnostic is best-effort; never block the error UI on it.
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remedy = buildRemediation(error.message, error.type, diag)
|
||||
const docsUrl = remedy.docsUrl
|
||||
|
||||
if (variant === 'banner') {
|
||||
return (
|
||||
<div className="border-b border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||
<div className="font-medium">{remedy.summary}</div>
|
||||
{remedy.detail ? <div className="mt-0.5 opacity-80">{remedy.detail}</div> : null}
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{remedy.commands.map((c) => (
|
||||
<button
|
||||
key={c.command}
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(c.command)}
|
||||
title={c.command}
|
||||
className="inline-flex items-center gap-1 rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[11px] hover:bg-amber-500/20"
|
||||
>
|
||||
<Copy className="size-3" /> {c.label}
|
||||
</button>
|
||||
))}
|
||||
{docsUrl ? (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded border border-amber-500/30 px-1.5 py-0.5 text-[11px] hover:bg-amber-500/20"
|
||||
>
|
||||
<ExternalLink className="size-3" /> Docs
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<div className="text-foreground">{remedy.summary}</div>
|
||||
{remedy.detail ? <div className="text-muted-foreground">{remedy.detail}</div> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{remedy.commands.map((c) => (
|
||||
<Button
|
||||
key={c.command}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title={c.command}
|
||||
onClick={() => copyToClipboard(c.command)}
|
||||
>
|
||||
<Copy className="mr-1 size-3.5" /> {c.label}
|
||||
</Button>
|
||||
))}
|
||||
{docsUrl ? (
|
||||
<Button size="sm" variant="outline" onClick={() => openExternal(docsUrl)}>
|
||||
<ExternalLink className="mr-1 size-3.5" /> Docs
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,8 +4,9 @@
|
|||
// from `listAccessibleProjects` and is cached for 5 minutes. Paste-to-add
|
||||
// accepts org/user project URLs and `owner/number` shorthand.
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertTriangle, ChevronDown, Copy, Loader, Pin, Search } from 'lucide-react'
|
||||
import { AlertTriangle, ChevronDown, Loader, Pin, Search } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { GhAuthErrorHelp } from '@/components/github-project/GhAuthErrorHelp'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
|
@ -642,31 +643,18 @@ function PartialFailuresBanner({
|
|||
}
|
||||
|
||||
function AuthErrorBanner({ error }: { error: GitHubProjectViewError }): React.JSX.Element {
|
||||
const command =
|
||||
error.type === 'auth_required'
|
||||
? 'gh auth login'
|
||||
: error.type === 'scope_missing'
|
||||
? 'gh auth refresh -s project -s read:org -s repo'
|
||||
: null
|
||||
if (error.type === 'auth_required' || error.type === 'scope_missing') {
|
||||
return (
|
||||
<GhAuthErrorHelp
|
||||
error={error as GitHubProjectViewError & { type: 'auth_required' | 'scope_missing' }}
|
||||
variant="banner"
|
||||
/>
|
||||
)
|
||||
}
|
||||
// Non-auth errors keep the legacy single-line banner.
|
||||
return (
|
||||
<div className="border-b border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||
<div>{error.message}</div>
|
||||
{command ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(command)
|
||||
toast.success('Command copied')
|
||||
} catch {
|
||||
toast.error('Failed to copy')
|
||||
}
|
||||
}}
|
||||
className="mt-1 inline-flex items-center gap-1 rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[11px] hover:bg-amber-500/20"
|
||||
>
|
||||
<Copy className="size-3" /> Copy command
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
// documented in the design doc.
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader,
|
||||
RefreshCw,
|
||||
|
|
@ -26,6 +25,7 @@ import {
|
|||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import GitHubItemDialog, { type GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog'
|
||||
import { GhAuthErrorHelp } from '@/components/github-project/GhAuthErrorHelp'
|
||||
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
|
||||
import { useRepoSlugIndex } from '@/lib/repo-slug-index'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -900,12 +900,21 @@ function ErrorState({
|
|||
totalCount?: number
|
||||
onOpenInGitHub: () => void
|
||||
}): React.JSX.Element {
|
||||
const command =
|
||||
error.type === 'auth_required'
|
||||
? 'gh auth login'
|
||||
: error.type === 'scope_missing'
|
||||
? 'gh auth refresh -s project -s read:org -s repo'
|
||||
: null
|
||||
// Auth/scope errors get a richer remediation UI driven by `gh auth
|
||||
// status`. Bail early so the generic `command`/`copy` block below is
|
||||
// only computed for non-auth error types.
|
||||
if (error.type === 'auth_required' || error.type === 'scope_missing') {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-start gap-3 p-6 text-sm">
|
||||
<GhAuthErrorHelp
|
||||
error={error as GitHubProjectViewError & { type: 'auth_required' | 'scope_missing' }}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={onOpenInGitHub}>
|
||||
<ExternalLink className="mr-1 size-3.5" /> Open in GitHub
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const copy =
|
||||
error.type === 'too_large'
|
||||
? `This view has ${totalCount ?? 'many'} items — too large to render in Orca. Narrow the view's filter on GitHub.`
|
||||
|
|
@ -920,22 +929,6 @@ function ErrorState({
|
|||
<div className="flex flex-1 flex-col items-start gap-3 p-6 text-sm">
|
||||
<div className="text-muted-foreground">{copy}</div>
|
||||
<div className="flex gap-2">
|
||||
{command ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(command)
|
||||
toast.success('Command copied')
|
||||
} catch {
|
||||
toast.error('Failed to copy')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-1 size-3.5" /> Copy command
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant="outline" onClick={onOpenInGitHub}>
|
||||
<ExternalLink className="mr-1 size-3.5" /> Open in GitHub
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Shared types for `gh auth status` diagnostics surfaced to the renderer.
|
||||
*/
|
||||
|
||||
export type GhAuthAccount = {
|
||||
host: string
|
||||
user: string
|
||||
/** True when this is the account gh would use for the next call. */
|
||||
active: boolean
|
||||
/**
|
||||
* If gh reports the credential came from an environment variable, the
|
||||
* variable's name. Null when the credential came from the keyring/file
|
||||
* config. Env-token accounts can't be refreshed by `gh auth refresh`.
|
||||
*/
|
||||
envToken: 'GITHUB_TOKEN' | 'GH_TOKEN' | null
|
||||
source: 'env' | 'keyring'
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
export type GhAuthDiagnostic = {
|
||||
/** False when gh CLI is not installed / not on PATH. */
|
||||
ghAvailable: boolean
|
||||
activeAccount: GhAuthAccount | null
|
||||
accounts: GhAuthAccount[]
|
||||
/**
|
||||
* Whether the Electron main process itself sees GITHUB_TOKEN/GH_TOKEN in
|
||||
* its environment. Distinct from `activeAccount.envToken` because gh may
|
||||
* report an env source even when the variable was set in a parent shell
|
||||
* that didn't propagate to Electron, and vice versa.
|
||||
*/
|
||||
envTokenInProcess: 'GITHUB_TOKEN' | 'GH_TOKEN' | null
|
||||
missingScopes: string[]
|
||||
requiredScopes: string[]
|
||||
/**
|
||||
* True when there's a non-env keyring account on the same/another host
|
||||
* that the user could fall back to by unsetting the env var.
|
||||
*/
|
||||
hasKeyringFallback: boolean
|
||||
}
|
||||
Loading…
Reference in New Issue