diff --git a/src/main/handlers/github-cli.test.ts b/src/main/handlers/github-cli.test.ts new file mode 100644 index 000000000..d1c0b2484 --- /dev/null +++ b/src/main/handlers/github-cli.test.ts @@ -0,0 +1,115 @@ +import { delimiter } from 'node:path'; + +import { readGitHubCliToken } from './github-cli'; + +const execFileMock = vi.fn(); + +vi.mock('node:child_process', () => { + const execFile = (...args: unknown[]) => execFileMock(...args); + + // Node's real `execFile` carries this custom implementation, which is what + // makes `promisify(execFile)` resolve to `{ stdout, stderr }`. + (execFile as unknown as Record)[Symbol.for('nodejs.util.promisify.custom')] = ( + ...args: unknown[] + ) => execFileMock(...args); + + return { execFile }; +}); + +vi.mock('electron', () => ({ ipcMain: { handle: vi.fn() } })); + +describe('main/handlers/github-cli.ts', () => { + const inheritedEnv = { ...process.env }; + + beforeEach(() => { + execFileMock.mockReset(); + execFileMock.mockResolvedValue({ stdout: 'gho_token', stderr: '' }); + }); + + afterEach(() => { + process.env = { ...inheritedEnv }; + }); + + function spawnedEnv(): Record { + const options = execFileMock.mock.calls[0][2]; + return options.env; + } + + it('returns the token the CLI holds for the host', async () => { + execFileMock.mockResolvedValue({ stdout: 'gho_token\n', stderr: '' }); + + await expect(readGitHubCliToken('github.com')).resolves.toEqual({ token: 'gho_token' }); + expect(execFileMock).toHaveBeenCalledWith( + 'gh', + ['auth', 'token', '--hostname', 'github.com'], + expect.anything(), + ); + }); + + it('never leaves an empty PATH element, which would search the working directory', async () => { + delete process.env.PATH; + + await readGitHubCliToken('github.com'); + + expect(spawnedEnv().PATH?.split(delimiter)).not.toContain(''); + }); + + it("passes an ambient token through, since it can be the CLI's only credential", async () => { + process.env.GH_TOKEN = 'gho_from_shell'; + + await readGitHubCliToken('github.com'); + + expect(spawnedEnv().GH_TOKEN).toBe('gho_from_shell'); + }); + + it('reports a missing CLI', async () => { + execFileMock.mockRejectedValue(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })); + + await expect(readGitHubCliToken('github.com')).resolves.toEqual({ error: 'GH_NOT_FOUND' }); + }); + + it('reports a CLI with no token for the host', async () => { + execFileMock.mockRejectedValue( + Object.assign(new Error('exit 1'), { + code: 1, + stderr: 'no oauth token found for github.example.com\n', + }), + ); + + await expect(readGitHubCliToken('github.example.com')).resolves.toEqual({ + error: 'GH_NOT_AUTHENTICATED', + }); + }); + + it('treats empty CLI output as no token', async () => { + execFileMock.mockResolvedValue({ stdout: '\n', stderr: '' }); + + await expect(readGitHubCliToken('github.com')).resolves.toEqual({ + error: 'GH_NOT_AUTHENTICATED', + }); + }); + + it('reports a timed-out CLI separately, since a keychain prompt blocks it', async () => { + execFileMock.mockRejectedValue(Object.assign(new Error('killed'), { killed: true })); + + await expect(readGitHubCliToken('github.com')).resolves.toEqual({ error: 'GH_TIMED_OUT' }); + }); + + it("carries the CLI's own reason for an unclassified failure", async () => { + execFileMock.mockRejectedValue( + Object.assign(new Error('exit 1'), { code: 1, stderr: 'keyring is locked\nmore detail' }), + ); + + await expect(readGitHubCliToken('github.com')).resolves.toEqual({ + error: 'GH_FAILED', + detail: 'keyring is locked', + }); + }); + + it('never spawns the CLI for a hostname it cannot vouch for', async () => { + await expect(readGitHubCliToken('github.com; rm -rf /')).resolves.toEqual({ + error: 'GH_FAILED', + }); + expect(execFileMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/handlers/github-cli.ts b/src/main/handlers/github-cli.ts new file mode 100644 index 000000000..7498d6b80 --- /dev/null +++ b/src/main/handlers/github-cli.ts @@ -0,0 +1,107 @@ +import { execFile } from 'node:child_process'; +import { homedir } from 'node:os'; +import { delimiter } from 'node:path'; +import { promisify } from 'node:util'; + +import type { GitHubCliTokenError, IGitHubCliTokenResult } from '../../shared/events'; +import { EVENTS } from '../../shared/events'; +import { logError, toError } from '../../shared/logger'; +import { isWindows } from '../../shared/platform'; + +import { handleMainEvent } from '../events'; + +const execFileAsync = promisify(execFile); + +/** + * A GUI-launched app inherits the session launcher's minimal PATH, which omits + * the package-manager prefixes `gh` is usually installed under. Unix-only: the + * Windows installer puts `gh` on the inherited PATH itself. + */ +const EXTRA_PATH_ENTRIES = isWindows() + ? [] + : [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/home/linuxbrew/.linuxbrew/bin', + `${homedir()}/.local/bin`, + ]; + +const HOSTNAME_PATTERN = /^[a-z0-9][a-z0-9.-]*$/i; + +/** + * Ask the locally installed GitHub CLI for the token it holds for `hostname`. + * + * Whatever the CLI resolves is what Gitify uses, including a token it takes + * from `GH_TOKEN`/`GH_ENTERPRISE_TOKEN`: for some users that environment token + * is the only credential `gh` has. + * + * @param hostname - Host to read the token for (e.g. `github.com`). + * @returns The token, or the reason the CLI could not supply one. + */ +export async function readGitHubCliToken(hostname: string): Promise { + if (typeof hostname !== 'string' || !HOSTNAME_PATTERN.test(hostname)) { + return { error: 'GH_FAILED' }; + } + + const env: NodeJS.ProcessEnv = { ...process.env, PATH: buildPath() }; + + try { + const { stdout } = await execFileAsync('gh', ['auth', 'token', '--hostname', hostname], { + env, + timeout: 10_000, + }); + + const token = stdout.trim(); + + return token ? { token } : { error: 'GH_NOT_AUTHENTICATED' }; + } catch (err) { + const { error, detail } = classifyFailure(err); + + if (error === 'GH_FAILED') { + logError('main:github-cli-token', `Failed to read gh token for ${hostname}`, toError(err)); + } + + return { error, detail }; + } +} + +/** + * An empty PATH element means "the current directory" to `execvp`, so an unset + * PATH must not leave one behind. + */ +function buildPath(): string { + return [process.env.PATH, ...EXTRA_PATH_ENTRIES].filter(Boolean).join(delimiter); +} + +function classifyFailure(err: unknown): { + error: GitHubCliTokenError; + detail?: string; +} { + const { code, killed, stderr } = err as { + code?: string | number; + killed?: boolean; + stderr?: string; + }; + + if (code === 'ENOENT') { + return { error: 'GH_NOT_FOUND' }; + } + + if (killed) { + return { error: 'GH_TIMED_OUT' }; + } + + if (/no oauth token found|not logged in/i.test(stderr ?? '')) { + return { error: 'GH_NOT_AUTHENTICATED' }; + } + + return { error: 'GH_FAILED', detail: stderr?.trim().split('\n')[0] || undefined }; +} + +/** + * Register the IPC handler that resolves GitHub CLI tokens. Spawning is only + * possible from the main process, so the renderer asks for the token per host. + */ +export function registerGitHubCliHandlers(): void { + handleMainEvent(EVENTS.GITHUB_CLI_TOKEN, (_, hostname) => readGitHubCliToken(hostname)); +} diff --git a/src/main/handlers/index.ts b/src/main/handlers/index.ts index 29c36d0e5..7a2353259 100644 --- a/src/main/handlers/index.ts +++ b/src/main/handlers/index.ts @@ -1,4 +1,5 @@ export * from './app'; +export * from './github-cli'; export * from './storage'; export * from './system'; export * from './tray'; diff --git a/src/main/index.ts b/src/main/index.ts index 3d403932c..39017df15 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { menubar } from 'electron-menubar'; import { Paths, WindowConfig } from './config'; import { registerAppHandlers, + registerGitHubCliHandlers, registerStorageHandlers, registerSystemHandlers, registerTrayHandlers, @@ -63,6 +64,7 @@ app.whenReady().then(async () => { registerTrayHandlers(mb); registerSystemHandlers(mb); registerStorageHandlers(); + registerGitHubCliHandlers(); registerAppHandlers(mb); registerUpdaterHandlers(appUpdater); }); diff --git a/src/preload/index.ts b/src/preload/index.ts index c0bf146c7..51333a8b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -42,6 +42,14 @@ export const api = { */ decryptValue: (value: string) => invokeMainEvent(EVENTS.SAFE_STORAGE_DECRYPT, value), + /** + * Read the token the locally installed GitHub CLI holds for a host. + * + * @param hostname - Host to read the token for (e.g. `github.com`). + * @returns The token, or the reason the CLI could not supply one. + */ + githubCliToken: (hostname: string) => invokeMainEvent(EVENTS.GITHUB_CLI_TOKEN, hostname), + /** * Enable or disable launching the application at system login. * diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 20c48bc4a..1054836a7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -11,6 +11,7 @@ import { BitbucketLoginWithPersonalAccessTokenRoute } from './routes/bitbucket/L import { FiltersRoute } from './routes/Filters'; import { GiteaLoginWithPersonalAccessTokenRoute } from './routes/gitea/LoginWithPersonalAccessToken'; import { GitHubLoginWithDeviceFlowRoute } from './routes/github/LoginWithDeviceFlow'; +import { GitHubLoginWithCLIRoute } from './routes/github/LoginWithGitHubCLI'; import { GitHubLoginWithOAuthAppRoute } from './routes/github/LoginWithOAuthApp'; import { GitHubLoginWithPersonalAccessTokenRoute } from './routes/github/LoginWithPersonalAccessToken'; import { GitLabLoginWithPersonalAccessTokenRoute } from './routes/gitlab/LoginWithPersonalAccessToken'; @@ -101,6 +102,7 @@ export const App = () => { element={} path="/login/github/device-flow" /> + } path="/login/github/cli" /> } path="/login/github/personal-access-token" diff --git a/src/renderer/__helpers__/hook-mocks.ts b/src/renderer/__helpers__/hook-mocks.ts index 631795783..b2f10de15 100644 --- a/src/renderer/__helpers__/hook-mocks.ts +++ b/src/renderer/__helpers__/hook-mocks.ts @@ -51,6 +51,7 @@ function buildLoginsDefaults(): LoginsState { loginWithDeviceFlowStart: vi.fn(), loginWithDeviceFlowPoll: vi.fn(), loginWithDeviceFlowComplete: vi.fn(), + loginWithCli: vi.fn(), loginWithOAuthApp: vi.fn(), loginWithPersonalAccessToken: vi.fn(), logoutFromAccount: vi.fn(), diff --git a/src/renderer/__helpers__/test-utils.tsx b/src/renderer/__helpers__/test-utils.tsx index 2735311c8..0455dbbb1 100644 --- a/src/renderer/__helpers__/test-utils.tsx +++ b/src/renderer/__helpers__/test-utils.tsx @@ -41,6 +41,7 @@ const LOGIN_KEYS = [ 'loginWithDeviceFlowStart', 'loginWithDeviceFlowPoll', 'loginWithDeviceFlowComplete', + 'loginWithCli', 'loginWithOAuthApp', 'loginWithPersonalAccessToken', 'logoutFromAccount', diff --git a/src/renderer/__helpers__/visual.setup.ts b/src/renderer/__helpers__/visual.setup.ts index 3e05ceb49..67af5be5a 100644 --- a/src/renderer/__helpers__/visual.setup.ts +++ b/src/renderer/__helpers__/visual.setup.ts @@ -86,6 +86,7 @@ function createGitifyBridgeApi(): Window['gitify'] { openExternalLink: vi.fn(), decryptValue: vi.fn().mockResolvedValue({ token: 'decrypted' }), encryptValue: vi.fn().mockResolvedValue('encrypted'), + githubCliToken: vi.fn().mockResolvedValue({ token: 'gh-cli-token' }), setWindowVibrancy: vi.fn().mockResolvedValue(undefined), setNativeTheme: vi.fn().mockResolvedValue(undefined), platform: { diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index b172ce9d5..20b14e639 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -84,6 +84,7 @@ function createGitifyBridgeApi(): Window['gitify'] { openExternalLink: vi.fn(), decryptValue: vi.fn().mockResolvedValue({ token: 'decrypted' }), encryptValue: vi.fn().mockResolvedValue('encrypted'), + githubCliToken: vi.fn().mockResolvedValue({ token: 'gh-cli-token' }), setWindowVibrancy: vi.fn().mockResolvedValue(undefined), setNativeTheme: vi.fn().mockResolvedValue(undefined), platform: { diff --git a/src/renderer/__mocks__/account-mocks.ts b/src/renderer/__mocks__/account-mocks.ts index 01d00d629..5c87fd56c 100644 --- a/src/renderer/__mocks__/account-mocks.ts +++ b/src/renderer/__mocks__/account-mocks.ts @@ -25,6 +25,18 @@ export const mockPersonalAccessTokenAccount: Account = { scopes: getRecommendedScopeNames(), }; +export const mockGitHubCliAccount: Account = { + forge: 'github', + platform: 'GitHub Cloud', + method: 'GitHub CLI', + // CLI accounts carry no credential of their own; the CLI is read per request. + token: '' as Token, + hostname: Constants.GITHUB_HOSTNAME, + user: mockGitifyUser, + // The scope set the GitHub CLI's own OAuth app is granted. + scopes: ['gist', 'read:org', 'repo', 'workflow'], +}; + export const mockOAuthAccount: Account = { forge: 'github', platform: 'GitHub Enterprise Server', diff --git a/src/renderer/hooks/useLogins.ts b/src/renderer/hooks/useLogins.ts index 76f510678..4cba250eb 100644 --- a/src/renderer/hooks/useLogins.ts +++ b/src/renderer/hooks/useLogins.ts @@ -24,6 +24,7 @@ interface LoginsState { ) => Promise; loginWithDeviceFlowPoll: (forge: Forge, session: DeviceFlowSession) => Promise; loginWithDeviceFlowComplete: (forge: Forge, token: Token, hostname: Hostname) => Promise; + loginWithCli: (forge: Forge, hostname: Hostname) => Promise; loginWithOAuthApp: (forge: Forge, data: LoginOAuthWebOptions) => Promise; loginWithPersonalAccessToken: (data: LoginPersonalAccessTokenOptions) => Promise; logoutFromAccount: (account: Account) => Promise; @@ -104,6 +105,34 @@ export const useLogins = (): LoginsState => { [accounts, createAccount, removeAccountNotifications], ); + /** + * Login with the token held by a locally installed forge CLI. + * + * The token is resolved here only to fail fast while the login screen is + * still up. Nothing persists it: the CLI is re-read for every API client, so + * the account carries no credential of its own. + */ + const loginWithCli = useCallback( + async (forge: Forge, hostname: Hostname) => { + const { cliAuth } = getAdapter(forge); + if (!cliAuth) { + throw new Error(`CLI login is not supported for forge "${forge}".`); + } + + await cliAuth.resolveToken(hostname); + + const existingAccount = accounts.find( + (a) => a.hostname === hostname && a.method === cliAuth.authMethod, + ); + if (existingAccount) { + await removeAccountNotifications(existingAccount); + } + + await createAccount(cliAuth.authMethod, '' as Token, hostname, forge); + }, + [accounts, createAccount, removeAccountNotifications], + ); + /** * Login with a custom OAuth app on the given forge. */ @@ -171,6 +200,7 @@ export const useLogins = (): LoginsState => { loginWithDeviceFlowStart, loginWithDeviceFlowPoll, loginWithDeviceFlowComplete, + loginWithCli, loginWithOAuthApp, loginWithPersonalAccessToken, logoutFromAccount, diff --git a/src/renderer/routes/AccountScopes.test.tsx b/src/renderer/routes/AccountScopes.test.tsx index e96e13181..9a0677bed 100644 --- a/src/renderer/routes/AccountScopes.test.tsx +++ b/src/renderer/routes/AccountScopes.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../__helpers__/test-utils'; import { mockGitHubAppAccount, + mockGitHubCliAccount, mockOAuthAccount, mockPersonalAccessTokenAccount, } from '../__mocks__/account-mocks'; @@ -69,6 +70,19 @@ describe('renderer/routes/AccountScopes.tsx', () => { expect(rows[1]).toHaveTextContent('read:user'); }); + it('explains scopes another tool owns instead of listing rows the user cannot grant', async () => { + mockLocationAccount = mockGitHubCliAccount; + + await act(async () => { + renderWithProviders(); + }); + + expect(screen.getByTestId('account-scopes-managed-elsewhere')).toHaveTextContent( + 'gh auth refresh -s notifications', + ); + expect(screen.queryAllByTestId('account-scopes-required-scope')).toHaveLength(0); + }); + it('should show Detailed Notifications section with repo and public_repo rows', async () => { await act(async () => { renderWithProviders(); diff --git a/src/renderer/routes/AccountScopes.tsx b/src/renderer/routes/AccountScopes.tsx index af965674f..6ff34991b 100644 --- a/src/renderer/routes/AccountScopes.tsx +++ b/src/renderer/routes/AccountScopes.tsx @@ -15,6 +15,7 @@ import { Header } from '../components/primitives/Header'; import type { Account } from '../types'; import { + externallyManagedScopes, getAlternateScopeNames, getRecommendedScopeNames, getRequiredScopeNames, @@ -38,6 +39,8 @@ export const AccountScopesRoute: FC = () => { const publicRepoGranted = scopes.includes(OAUTH_SCOPE.PUBLIC_REPO.name); const hasDetailedNotifications = scopesLoaded && (repoGranted || publicRepoGranted); + const managedElsewhere = externallyManagedScopes(account); + // Scopes that don't belong to any known tier const allKnownNames = new Set([ ...getRequiredScopeNames(), @@ -77,26 +80,42 @@ export const AccountScopesRoute: FC = () => { Required - {Constants.OAUTH_SCOPES.REQUIRED.map(({ name, description }) => { - const granted = scopes.includes(name); - return ( - - - {name} - {description} + {managedElsewhere ? ( + + {managedElsewhere.label} + + {managedElsewhere.detail} Run{' '} + {managedElsewhere.command} to widen them. + + + ) : ( + Constants.OAUTH_SCOPES.REQUIRED.map(({ name, description }) => { + const granted = scopes.includes(name); + return ( + + + {name} + {description} + + - - - ); - })} + ); + }) + )} diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-dark-classic-chromium-linux.png index 5b8ff3fe0..caea7edd6 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-dark-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-dark-classic-chromium-linux.png new file mode 100644 index 000000000..cd61114b5 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-light-classic-chromium-linux.png new file mode 100644 index 000000000..b1f51d313 Binary files /dev/null and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-github-cli-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-light-classic-chromium-linux.png index e7ff1f9b3..cb7b1528c 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-light-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/login-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__snapshots__/Login.test.tsx.snap b/src/renderer/routes/__snapshots__/Login.test.tsx.snap index b15b96fbe..e4af27471 100644 --- a/src/renderer/routes/__snapshots__/Login.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Login.test.tsx.snap @@ -313,6 +313,51 @@ exports[`renderer/routes/Login.tsx > should render itself & its children 1`] = ` + + + + ); +}; diff --git a/src/renderer/routes/github/__snapshots__/LoginWithGitHubCLI.test.tsx.snap b/src/renderer/routes/github/__snapshots__/LoginWithGitHubCLI.test.tsx.snap new file mode 100644 index 000000000..d2586058d --- /dev/null +++ b/src/renderer/routes/github/__snapshots__/LoginWithGitHubCLI.test.tsx.snap @@ -0,0 +1,279 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderer/routes/github/LoginWithGitHubCLI.tsx > renders correctly 1`] = ` +
+
+
+
+ + +
+
+ +

+ Login with GitHub CLI +

+
+
+
+
+
+
+
+ + Uses the token your local + + gh + + install already holds, so no token has to be created or pasted. Gitify reads it from the CLI on every refresh, which means + + + gh auth login + + and + + gh auth refresh + + keep working without re-authenticating here. + + + The scopes come from the GitHub CLI and cannot be changed from Gitify. Its + + + repo + + scope covers notification access; run + + + gh auth refresh -s notifications + + to grant that scope explicitly. + +
+ + + + + + + Change only if you are using GitHub Enterprise Server + + +
+
+
+ +
+
+`; diff --git a/src/renderer/routes/routes.visual.test.tsx b/src/renderer/routes/routes.visual.test.tsx index 6c1a54b52..1b5afe38e 100644 --- a/src/renderer/routes/routes.visual.test.tsx +++ b/src/renderer/routes/routes.visual.test.tsx @@ -18,6 +18,7 @@ import { BitbucketLoginWithPersonalAccessTokenRoute } from './bitbucket/LoginWit import { FiltersRoute } from './Filters'; import { GiteaLoginWithPersonalAccessTokenRoute } from './gitea/LoginWithPersonalAccessToken'; import { GitHubLoginWithDeviceFlowRoute } from './github/LoginWithDeviceFlow'; +import { GitHubLoginWithCLIRoute } from './github/LoginWithGitHubCLI'; import { GitHubLoginWithOAuthAppRoute } from './github/LoginWithOAuthApp'; import { GitHubLoginWithPersonalAccessTokenRoute } from './github/LoginWithPersonalAccessToken'; import { GitLabLoginWithPersonalAccessTokenRoute } from './gitlab/LoginWithPersonalAccessToken'; @@ -77,6 +78,11 @@ const ROUTES: RouteCase[] = [ element: , state: { initialEntries: ['/login/github/device-flow'] }, }, + { + name: 'login-github-cli', + element: , + state: { initialEntries: ['/login/github/cli'] }, + }, { name: 'login-github-personal-access-token', element: , diff --git a/src/renderer/stores/useAccountsStore.ts b/src/renderer/stores/useAccountsStore.ts index af3e87f20..936e200c4 100644 --- a/src/renderer/stores/useAccountsStore.ts +++ b/src/renderer/stores/useAccountsStore.ts @@ -115,8 +115,10 @@ const useAccountsStore = create()( // Drop any forge-specific HTTP client state for the removed account. getAccountAdapter(account).onAccountTokenChange?.(); + const removedUUID = getAccountUUID(account); + set((state) => ({ - accounts: state.accounts.filter((a) => a.token !== account.token), + accounts: state.accounts.filter((a) => getAccountUUID(a) !== removedUUID), })); }, diff --git a/src/renderer/utils/auth/scopes.ts b/src/renderer/utils/auth/scopes.ts index 2d7a18c6b..b05947a9d 100644 --- a/src/renderer/utils/auth/scopes.ts +++ b/src/renderer/utils/auth/scopes.ts @@ -1,6 +1,7 @@ import { Constants } from '../../constants'; import type { Account } from '../../types'; +import type { ExternallyManagedScopes } from '../forges/types'; import { getAccountAdapter } from '../forges/registry'; @@ -34,6 +35,16 @@ export function hasAlternateScopes(account: Account): boolean { return getAccountAdapter(account).oauthScopes?.hasAlternate() ?? true; } +/** + * Return how to explain the account's scopes when another tool owns the + * credential, or `undefined` when Gitify can change them itself. + * + * @param account - The account whose scopes to describe. + */ +export function externallyManagedScopes(account: Account): ExternallyManagedScopes | undefined { + return getAccountAdapter(account).oauthScopes?.externallyManaged(); +} + /** * Return the list of required OAuth scope names. * diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index 9e7a4f594..60654acc2 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -1,6 +1,6 @@ import type { AuthCode, ClientID, ClientSecret, Forge, Hostname, Token } from '../../types'; -export type AuthMethod = 'GitHub App' | 'Personal Access Token' | 'OAuth App'; +export type AuthMethod = 'GitHub App' | 'GitHub CLI' | 'Personal Access Token' | 'OAuth App'; export type PlatformType = | 'Bitbucket Cloud' diff --git a/src/renderer/utils/forges/github/adapter.test.ts b/src/renderer/utils/forges/github/adapter.test.ts index 446b9e008..49e64516b 100644 --- a/src/renderer/utils/forges/github/adapter.test.ts +++ b/src/renderer/utils/forges/github/adapter.test.ts @@ -1,6 +1,6 @@ import { AppsIcon, KeyIcon, PersonIcon } from '@primer/octicons-react'; -import { mockGitHubCloudAccount } from '../../../__mocks__/account-mocks'; +import { mockGitHubCliAccount, mockGitHubCloudAccount } from '../../../__mocks__/account-mocks'; import type { GitifyNotificationUser, Hostname, Link, Token } from '../../../types'; @@ -15,9 +15,9 @@ describe('renderer/utils/forges/github/adapter.ts', () => { expect(githubAdapter.displayName).toBe('GitHub'); }); - it('exposes the device-flow / PAT / OAuth login methods', () => { + it('exposes the device-flow / PAT / CLI / OAuth login methods', () => { const ids = githubAdapter.loginMethods.map((m) => m.testId); - expect(ids).toEqual(['login-github', 'login-pat', 'login-oauth-app']); + expect(ids).toEqual(['login-github', 'login-pat', 'login-github-cli', 'login-oauth-app']); }); it('defaults the PAT hostname to github.com', () => { @@ -275,6 +275,36 @@ describe('renderer/utils/forges/github/adapter.ts', () => { ), ).toBe(true); }); + + it('judges GitHub CLI accounts on notification access, not PAT scope names', () => { + const oauthScopes = githubAdapter.accountOps.oauthScopes!; + const withCliScopes = (scopes: string[]) => ({ ...mockGitHubCliAccount, scopes }); + + // The scope set `gh` actually carries: no notifications, no read:user. + expect(oauthScopes.hasRequired(mockGitHubCliAccount)).toBe(true); + expect(oauthScopes.hasRecommended(mockGitHubCliAccount)).toBe(true); + + expect(oauthScopes.hasRequired(withCliScopes(['notifications']))).toBe(true); + expect(oauthScopes.hasRequired(withCliScopes(['gist', 'public_repo']))).toBe(false); + + // `gh auth token` can return an exported GH_TOKEN, which may be any PAT. + expect(oauthScopes.hasRecommended(withCliScopes(['notifications', 'public_repo']))).toBe( + false, + ); + expect(oauthScopes.hasAlternate(withCliScopes(['notifications', 'public_repo']))).toBe(true); + expect(oauthScopes.hasAlternate(withCliScopes(['notifications']))).toBe(false); + }); + + it('reports CLI scopes as owned by the CLI, and PAT scopes as Gitify-owned', () => { + const oauthScopes = githubAdapter.accountOps.oauthScopes!; + + expect(oauthScopes.externallyManaged(mockGitHubCliAccount)).toEqual({ + label: 'Managed by the GitHub CLI', + detail: 'The repo scope grants notification access.', + command: 'gh auth refresh -s notifications', + }); + expect(oauthScopes.externallyManaged(mockGitHubCloudAccount)).toBeUndefined(); + }); }); describe('followUrl', () => { diff --git a/src/renderer/utils/forges/github/adapter.ts b/src/renderer/utils/forges/github/adapter.ts index deead393c..dc7231c74 100644 --- a/src/renderer/utils/forges/github/adapter.ts +++ b/src/renderer/utils/forges/github/adapter.ts @@ -1,6 +1,12 @@ -import { AppsIcon, KeyIcon, MarkGithubIcon, PersonIcon } from '@primer/octicons-react'; +import { + AppsIcon, + KeyIcon, + MarkGithubIcon, + PersonIcon, + TerminalIcon, +} from '@primer/octicons-react'; -import { Constants } from '../../../constants'; +import { Constants, OAUTH_SCOPE } from '../../../constants'; import type { Account, Link, RawGitifyNotification } from '../../../types'; import type { AuthMethod } from '../../auth/types'; @@ -15,6 +21,7 @@ import { isValidToken, } from './auth'; import { githubCapabilities } from './capabilities'; +import { forgetGitHubCliToken, resolveGitHubCliToken } from './cli'; import { fetchAuthenticatedUserDetails, ignoreNotificationThreadSubscription, @@ -36,6 +43,12 @@ import { transformNotifications } from './transform'; import { formatGitHubNotificationUser } from './users'; async function fetchAuthenticatedUser(account: Account): Promise { + if (account.method === 'GitHub CLI') { + // Re-read the CLI so a `gh auth refresh` that widened the token's scopes is + // reflected here; a merely rotated token is handled per request. + forgetGitHubCliToken(account.hostname); + } + const response = await fetchAuthenticatedUserDetails(account); const user = response.data; const headers = response.headers as Record; @@ -109,6 +122,13 @@ export const githubAdapter: ForgeAdapter = { route: '/login/github/personal-access-token', authMethod: 'Personal Access Token', }, + { + testId: 'login-github-cli', + icon: TerminalIcon, + label: 'GitHub CLI', + route: '/login/github/cli', + authMethod: 'GitHub CLI', + }, { testId: 'login-oauth-app', icon: PersonIcon, @@ -133,11 +153,19 @@ export const githubAdapter: ForgeAdapter = { getNewOAuthAppUrl: getNewOAuthAppURL, }, + cliAuth: { + authMethod: 'GitHub CLI', + resolveToken: resolveGitHubCliToken, + }, + accountOps: { capabilities: githubCapabilities, formatNotificationUser: formatGitHubNotificationUser, fetchAuthenticatedUser, - onAccountTokenChange: clearOctokitClientCacheForAccount, + onAccountTokenChange: (account) => { + clearOctokitClientCacheForAccount(account); + forgetGitHubCliToken(account.hostname); + }, listNotifications, markThreadAsRead: async (account, threadId) => { await markNotificationThreadAsRead(account, threadId); @@ -157,6 +185,14 @@ export const githubAdapter: ForgeAdapter = { hasRequired: (account) => accountHasScopes(account, 'REQUIRED'), hasRecommended: (account) => accountHasScopes(account, 'RECOMMENDED'), hasAlternate: (account) => accountHasScopes(account, 'ALTERNATE'), + externallyManaged: (account) => + account.method === 'GitHub CLI' + ? { + label: 'Managed by the GitHub CLI', + detail: 'The repo scope grants notification access.', + command: 'gh auth refresh -s notifications', + } + : undefined, }, }, }; @@ -165,13 +201,37 @@ function accountHasScopes( account: Account, group: 'REQUIRED' | 'RECOMMENDED' | 'ALTERNATE', ): boolean { - return Constants.OAUTH_SCOPES[group].every(({ name }) => (account.scopes ?? []).includes(name)); + const scopes = account.scopes ?? []; + + if (account.method === 'GitHub CLI') { + // `gh` issues a fixed scope set Gitify cannot widen, and its `repo` scope + // already grants the notifications API, so judge these accounts on + // notification access rather than the scope names a PAT would be asked + // for. `gh auth token` can also return a `GH_TOKEN` the user exported, + // which may be any PAT, so the narrower tiers stay meaningful. + const canReadNotifications = + scopes.includes(OAUTH_SCOPE.NOTIFICATIONS.name) || scopes.includes(OAUTH_SCOPE.REPO.name); + + if (group === 'RECOMMENDED') { + return scopes.includes(OAUTH_SCOPE.REPO.name); + } + + if (group === 'ALTERNATE') { + return canReadNotifications && scopes.includes(OAUTH_SCOPE.PUBLIC_REPO.name); + } + + return canReadNotifications; + } + + return Constants.OAUTH_SCOPES[group].every(({ name }) => scopes.includes(name)); } function githubAuthMethodIcon(method: AuthMethod) { switch (method) { case 'GitHub App': return AppsIcon; + case 'GitHub CLI': + return TerminalIcon; case 'OAuth App': return PersonIcon; default: diff --git a/src/renderer/utils/forges/github/auth.ts b/src/renderer/utils/forges/github/auth.ts index 1f64405cc..99915c750 100644 --- a/src/renderer/utils/forges/github/auth.ts +++ b/src/renderer/utils/forges/github/auth.ts @@ -63,6 +63,7 @@ export function getGitHubAuthBaseUrl(hostname: Hostname): URL { * Return the GitHub developer settings URL appropriate for the account's auth method. * * - GitHub App → application connections page + * - GitHub CLI → authorized applications page * - OAuth App → developer settings page * - Personal Access Token → tokens settings page * @@ -76,6 +77,9 @@ export function getDeveloperSettingsURL(account: Account): Link { case 'GitHub App': settingsURL.pathname = `/settings/connections/applications/${Constants.OAUTH_DEVICE_FLOW_CLIENT_ID}`; break; + case 'GitHub CLI': + settingsURL.pathname = '/settings/applications'; + break; case 'OAuth App': settingsURL.pathname = '/settings/developers'; break; diff --git a/src/renderer/utils/forges/github/cli.test.ts b/src/renderer/utils/forges/github/cli.test.ts new file mode 100644 index 000000000..cd63aa6fa --- /dev/null +++ b/src/renderer/utils/forges/github/cli.test.ts @@ -0,0 +1,88 @@ +import type { Hostname } from '../../../types'; + +import * as comms from '../../system/comms'; +import { forgetGitHubCliToken, resolveGitHubCliToken } from './cli'; + +const GITHUB = 'github.com' as Hostname; +const ENTERPRISE = 'github.example.com' as Hostname; + +describe('renderer/utils/forges/github/cli.ts', () => { + const readToken = vi.spyOn(comms, 'readGitHubCliToken'); + + beforeEach(() => { + forgetGitHubCliToken(GITHUB); + forgetGitHubCliToken(ENTERPRISE); + readToken.mockReset(); + }); + + it('collapses concurrent resolutions onto one CLI read', async () => { + readToken.mockResolvedValue({ token: 'gho_token' }); + + const [first, second] = await Promise.all([ + resolveGitHubCliToken(GITHUB), + resolveGitHubCliToken(GITHUB), + ]); + + expect([first, second]).toEqual(['gho_token', 'gho_token']); + expect(readToken).toHaveBeenCalledTimes(1); + }); + + it('re-reads the CLI once the host is forgotten', async () => { + readToken.mockResolvedValueOnce({ token: 'gho_stale' }); + readToken.mockResolvedValueOnce({ token: 'gho_rotated' }); + + await expect(resolveGitHubCliToken(GITHUB)).resolves.toBe('gho_stale'); + forgetGitHubCliToken(GITHUB); + + await expect(resolveGitHubCliToken(GITHUB)).resolves.toBe('gho_rotated'); + }); + + it('resolves each host against its own CLI entry', async () => { + readToken.mockImplementation(async (hostname) => + hostname === GITHUB ? { token: 'gho_cloud' } : { token: 'gho_enterprise' }, + ); + + await expect(resolveGitHubCliToken(GITHUB)).resolves.toBe('gho_cloud'); + await expect(resolveGitHubCliToken(ENTERPRISE)).resolves.toBe('gho_enterprise'); + }); + + it('does not cache a failed read', async () => { + readToken.mockResolvedValueOnce({ error: 'GH_FAILED' }); + readToken.mockResolvedValueOnce({ token: 'gho_token' }); + + await expect(resolveGitHubCliToken(GITHUB)).rejects.toThrow(); + await expect(resolveGitHubCliToken(GITHUB)).resolves.toBe('gho_token'); + }); + + it('tells the user how to install a CLI it could not find', async () => { + readToken.mockResolvedValue({ error: 'GH_NOT_FOUND' }); + + await expect(resolveGitHubCliToken(GITHUB)).rejects.toThrow( + 'GitHub CLI (gh) was not found. Install it from https://cli.github.com, log in, and try again.', + ); + }); + + it('tells the user how to log the CLI in to the host', async () => { + readToken.mockResolvedValue({ error: 'GH_NOT_AUTHENTICATED' }); + + await expect(resolveGitHubCliToken(ENTERPRISE)).rejects.toThrow( + 'GitHub CLI has no token for github.example.com. Run `gh auth login --hostname github.example.com` and try again.', + ); + }); + + it('names the keychain prompt when the CLI does not respond', async () => { + readToken.mockResolvedValue({ error: 'GH_TIMED_OUT' }); + + await expect(resolveGitHubCliToken(GITHUB)).rejects.toThrow( + 'GitHub CLI did not respond within 10 seconds for github.com. It may be waiting on a keychain prompt.', + ); + }); + + it("passes through the CLI's own reason for an unclassified failure", async () => { + readToken.mockResolvedValue({ error: 'GH_FAILED', detail: 'keyring is locked' }); + + await expect(resolveGitHubCliToken(GITHUB)).rejects.toThrow( + 'GitHub CLI could not provide a token for github.com: keyring is locked', + ); + }); +}); diff --git a/src/renderer/utils/forges/github/cli.ts b/src/renderer/utils/forges/github/cli.ts new file mode 100644 index 000000000..e80783fd0 --- /dev/null +++ b/src/renderer/utils/forges/github/cli.ts @@ -0,0 +1,70 @@ +import type { GitHubCliTokenError } from '../../../../shared/events'; + +import type { Hostname, Token } from '../../../types'; + +import { readGitHubCliToken } from '../../system/comms'; + +/** + * In-flight and resolved CLI reads, per host. + * + * The CLI keychain is the source of truth for `GitHub CLI` accounts — nothing + * here is persisted. Reading it spawns a process and touches the OS keychain, + * so the promise is shared: concurrent requests collapse onto one read, and a + * resolved token is reused until it is forgotten. + */ +const cliTokens = new Map>(); + +/** + * Resolve the GitHub CLI token for `hostname`. + * + * @param hostname - Host to resolve the token for. + * @returns The token the CLI holds for that host. + * @throws If the CLI is missing, not logged in to the host, or failed. + */ +export function resolveGitHubCliToken(hostname: Hostname): Promise { + const inFlight = cliTokens.get(hostname); + if (inFlight) { + return inFlight; + } + + const read = readCliToken(hostname); + cliTokens.set(hostname, read); + + return read.catch((err) => { + cliTokens.delete(hostname); + throw err; + }); +} + +export function forgetGitHubCliToken(hostname: Hostname): void { + cliTokens.delete(hostname); +} + +async function readCliToken(hostname: Hostname): Promise { + const result = await readGitHubCliToken(hostname); + + if (result.error) { + throw new Error(describeFailure(result.error, result.detail, hostname)); + } + + return result.token as Token; +} + +function describeFailure( + error: GitHubCliTokenError, + detail: string | undefined, + hostname: Hostname, +): string { + switch (error) { + case 'GH_NOT_FOUND': + return 'GitHub CLI (gh) was not found. Install it from https://cli.github.com, log in, and try again.'; + case 'GH_NOT_AUTHENTICATED': + return `GitHub CLI has no token for ${hostname}. Run \`gh auth login --hostname ${hostname}\` and try again.`; + case 'GH_TIMED_OUT': + return `GitHub CLI did not respond within 10 seconds for ${hostname}. It may be waiting on a keychain prompt.`; + default: + return detail + ? `GitHub CLI could not provide a token for ${hostname}: ${detail}` + : `GitHub CLI could not provide a token for ${hostname}.`; + } +} diff --git a/src/renderer/utils/forges/github/octokit.test.ts b/src/renderer/utils/forges/github/octokit.test.ts index d8e62b39b..51a6111f4 100644 --- a/src/renderer/utils/forges/github/octokit.test.ts +++ b/src/renderer/utils/forges/github/octokit.test.ts @@ -1,10 +1,14 @@ import { mockGitHubAppAccount, + mockGitHubCliAccount, mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount, } from '../../../__mocks__/account-mocks'; +import type { Token } from '../../../types'; + import * as comms from '../../system/comms'; +import * as cli from './cli'; import { clearOctokitClientCache, createOctokitClient, @@ -135,4 +139,74 @@ describe('renderer/utils/forges/github/octokit.ts', () => { expect(mockDecryptValue).toHaveBeenCalledTimes(2); }); }); + + describe('GitHub CLI accounts', () => { + function stubFetch(statuses: number[]) { + const authorizations: string[] = []; + + const fetchMock = vi.fn( + async (_url: string, options: { headers: Record }) => { + authorizations.push(options.headers.authorization); + const status = statuses.shift() ?? 200; + + return new Response( + status === 200 ? '{"login":"octocat"}' : '{"message":"Bad credentials"}', + { + status, + headers: { 'content-type': 'application/json' }, + }, + ); + }, + ); + + vi.stubGlobal('fetch', fetchMock); + + return { authorizations, fetchMock }; + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('authenticates each request from the CLI rather than the stored token', async () => { + const resolveSpy = vi + .spyOn(cli, 'resolveGitHubCliToken') + .mockResolvedValue('gho_live' as Token); + const { authorizations } = stubFetch([200]); + + const octokit = await createOctokitClientUncached(mockGitHubCliAccount, 'rest'); + await octokit.request('GET /user'); + + expect(authorizations).toEqual(['token gho_live']); + expect(resolveSpy).toHaveBeenCalledWith(mockGitHubCliAccount.hostname); + expect(mockDecryptValue).not.toHaveBeenCalled(); + }); + + it('retries a rejected request against a re-read CLI credential', async () => { + const tokens = ['gho_stale', 'gho_rotated']; + vi.spyOn(cli, 'resolveGitHubCliToken').mockImplementation( + async () => tokens.shift() as Token, + ); + const forgetSpy = vi.spyOn(cli, 'forgetGitHubCliToken'); + const { authorizations } = stubFetch([401, 200]); + + const octokit = await createOctokitClientUncached(mockGitHubCliAccount, 'rest'); + const response = await octokit.request('GET /user'); + + expect(response.status).toBe(200); + expect(authorizations).toEqual(['token gho_stale', 'token gho_rotated']); + // Without this the memo would keep serving the stale token. + expect(forgetSpy).toHaveBeenCalledWith(mockGitHubCliAccount.hostname); + }); + + it('gives up when the re-read credential is rejected too', async () => { + vi.spyOn(cli, 'resolveGitHubCliToken').mockResolvedValue('gho_stale' as Token); + const { fetchMock } = stubFetch([401, 401]); + + const octokit = await createOctokitClientUncached(mockGitHubCliAccount, 'rest'); + + await expect(octokit.request('GET /user')).rejects.toThrow('Bad credentials'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/src/renderer/utils/forges/github/octokit.ts b/src/renderer/utils/forges/github/octokit.ts index 4ff69c056..3c4788fdf 100644 --- a/src/renderer/utils/forges/github/octokit.ts +++ b/src/renderer/utils/forges/github/octokit.ts @@ -4,11 +4,12 @@ import { restEndpointMethods } from '@octokit/plugin-rest-endpoint-methods'; import { APPLICATION } from '../../../../shared/constants'; -import type { Account } from '../../../types'; +import type { Account, Hostname } from '../../../types'; import type { APIClientType } from './types'; import { getAccountUUID } from '../../auth/utils'; import { decryptValue, getAppVersion } from '../../system/comms'; +import { forgetGitHubCliToken, resolveGitHubCliToken } from './cli'; import { getGitHubAPIBaseUrl } from './utils'; // Create the Octokit type with plugins @@ -75,21 +76,61 @@ export async function createOctokitClientUncached( account: Account, type: APIClientType, ): Promise { - const { token: decryptedToken } = await decryptValue(account.token); + const isCliAccount = account.method === 'GitHub CLI'; const version = await getAppVersion(); const userAgent = `${APPLICATION.NAME}/${version}`; const baseUrl = getGitHubAPIBaseUrl(account.hostname, type).toString().replace(/\/$/, ''); - return new OctokitWithPlugins({ - auth: decryptedToken, + const client = new OctokitWithPlugins({ + auth: isCliAccount ? undefined : (await decryptValue(account.token)).token, baseUrl: baseUrl, userAgent: userAgent, retry: { retries: 1, }, }); + + if (isCliAccount) { + authenticateFromCli(client, account.hostname); + } + + return client; +} + +/** + * Authenticate every request from the GitHub CLI rather than from a token + * baked in at construction, so a token the CLI rotates (`gh auth login`, + * `gh auth refresh`) is picked up on the next request instead of stranding + * this client until the account is refreshed. + */ +function authenticateFromCli(client: OctokitClient, hostname: Hostname): void { + client.hook.wrap('request', async (request, options) => { + const authorize = async () => ({ + ...options, + headers: { + ...options.headers, + authorization: `token ${await resolveGitHubCliToken(hostname)}`, + }, + }); + + try { + return await request(await authorize()); + } catch (err) { + if (!isUnauthorized(err)) { + throw err; + } + + forgetGitHubCliToken(hostname); + + return await request(await authorize()); + } + }); +} + +function isUnauthorized(err: unknown): boolean { + return typeof err === 'object' && err !== null && 'status' in err && err.status === 401; } /** diff --git a/src/renderer/utils/forges/registry.ts b/src/renderer/utils/forges/registry.ts index eee3f7999..d13cb76cb 100644 --- a/src/renderer/utils/forges/registry.ts +++ b/src/renderer/utils/forges/registry.ts @@ -86,6 +86,7 @@ export function getAccountAdapter(account: Account): ForgeAccountAdapter { hasRequired: () => oauthScopes.hasRequired(account), hasRecommended: () => oauthScopes.hasRecommended(account), hasAlternate: () => oauthScopes.hasAlternate(account), + externallyManaged: () => oauthScopes.externallyManaged(account), } : undefined, }; diff --git a/src/renderer/utils/forges/types.ts b/src/renderer/utils/forges/types.ts index 89170ef5e..3aa2f0274 100644 --- a/src/renderer/utils/forges/types.ts +++ b/src/renderer/utils/forges/types.ts @@ -179,6 +179,13 @@ export interface ForgeAdapter { * omit this bundle entirely — callers gate the OAuth-app UI on its presence. */ oauthWebApp?: OAuthWebAppSupport; + + /** + * Reuse of a locally installed forge CLI's credential. Forges whose CLI + * Gitify cannot read a token from omit this bundle entirely — callers gate + * the CLI login UI on its presence. + */ + cliAuth?: CliAuthSupport; } /** @@ -253,6 +260,12 @@ export interface ForgeAccountAdapter { hasRecommended(): boolean; /** Whether the account holds the alternate (legacy) scope set. */ hasAlternate(): boolean; + /** + * Set when another tool owns the credential and Gitify cannot widen its + * scopes, so a scopes UI should explain that instead of listing rows the + * user cannot act on. + */ + externallyManaged(): ExternallyManagedScopes | undefined; }; } @@ -282,6 +295,7 @@ export interface ForgeAccountOperations { hasRequired(account: Account): boolean; hasRecommended(account: Account): boolean; hasAlternate(account: Account): boolean; + externallyManaged(account: Account): ExternallyManagedScopes | undefined; }; } @@ -291,6 +305,16 @@ export interface ForgeAccountOperations { */ export type OAuthScopesSupport = NonNullable; +/** How to explain a scope set Gitify cannot change. */ +export interface ExternallyManagedScopes { + /** Who owns the scopes, e.g. `Managed by the GitHub CLI`. */ + label: string; + /** Why the account still works, in one sentence. */ + detail: string; + /** Command that widens the scopes outside Gitify. */ + command: string; +} + /** * Custom-OAuth-app web flow capability bundle. Present only on forges that * support browser-redirect OAuth with user-supplied client credentials @@ -321,3 +345,19 @@ export interface DeviceFlowSupport { /** URL the user visits to revoke Gitify's access on this forge. */ getRevokeAccessUrl(hostname: Hostname): Link; } + +/** + * Local-CLI credential capability bundle. Present only on forges whose + * official CLI stores a token Gitify can reuse (GitHub's `gh` today). + */ +export interface CliAuthSupport { + /** Auth method recorded on accounts created from the CLI's credential. */ + authMethod: AuthMethod; + /** + * Read the token the CLI holds for `hostname`. + * + * @throws With a user-facing message when the CLI is absent, is not logged + * in to the host, or failed. + */ + resolveToken(hostname: Hostname): Promise; +} diff --git a/src/renderer/utils/system/comms.ts b/src/renderer/utils/system/comms.ts index 688944218..a613846be 100644 --- a/src/renderer/utils/system/comms.ts +++ b/src/renderer/utils/system/comms.ts @@ -1,4 +1,4 @@ -import type { ISafeStorageDecryptResult } from '../../../shared/events'; +import type { IGitHubCliTokenResult, ISafeStorageDecryptResult } from '../../../shared/events'; import { useSettingsStore } from '../../stores'; @@ -52,6 +52,16 @@ export async function decryptValue(value: string): Promise { + return await window.gitify.githubCliToken(hostname); +} + /** * Quit the application. */ diff --git a/src/shared/events.ts b/src/shared/events.ts index dc74cd571..5e29cdc3c 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -23,6 +23,7 @@ export const EVENTS = { SET_NATIVE_THEME: `${P}set-native-theme`, SAFE_STORAGE_ENCRYPT: `${P}safe-storage-encrypt`, SAFE_STORAGE_DECRYPT: `${P}safe-storage-decrypt`, + GITHUB_CLI_TOKEN: `${P}github-cli-token`, NOTIFICATION_SOUND_PATH: `${P}notification-sound-path`, OPEN_EXTERNAL: `${P}open-external`, RESET_APP: `${P}reset-app`, @@ -78,6 +79,21 @@ export interface ISafeStorageDecryptResult { reEncryptedToken?: string; } +/** Why the GitHub CLI could not provide a token. */ +export type GitHubCliTokenError = + | 'GH_NOT_FOUND' + | 'GH_NOT_AUTHENTICATED' + | 'GH_TIMED_OUT' + | 'GH_FAILED'; + +/** + * Result of asking the locally installed GitHub CLI for the token it holds for + * a host. `detail` carries the CLI's own first line of stderr, when it has one. + */ +export type IGitHubCliTokenResult = + | { token: string; error?: never; detail?: never } + | { token?: never; error: GitHubCliTokenError; detail?: string }; + /** Shape of a single event contract: a request payload and a response payload. */ type Contract = { request: unknown; response: unknown }; @@ -128,6 +144,10 @@ export type EventContracts = AssertEventCoverage<{ request: string; response: ISafeStorageDecryptResult; }; + [EVENTS.GITHUB_CLI_TOKEN]: { + request: string; + response: IGitHubCliTokenResult; + }; [EVENTS.NOTIFICATION_SOUND_PATH]: { request: undefined; response: string }; [EVENTS.OPEN_EXTERNAL]: { request: IOpenExternal; response: undefined }; [EVENTS.RESET_APP]: { request: undefined; response: undefined };