From ef08f2c5d2ee27c1b24edc7c8871436016b16725 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 3 Jul 2026 23:55:13 +0800 Subject: [PATCH 1/4] feat(cli): add `void github join` for org-shared installations (Task 11/13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the `void github join` command: an org member runs it to discover and join the GitHub App installations they can access, gaining a TTL-cached membership so `void github installations` lists them and `void github connect` becomes eligible. Mirrors `void github link`'s localhost + PKCE deferred-bind handshake against the platform JOIN routes (join-url → join-callback → join/complete), fails closed with a friendly message when org-sharing OAuth is not configured (503), and reports the number of installations joined. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/cli.ts | 2 +- packages/void/src/cli/client.ts | 39 ++++ packages/void/src/cli/github-args.ts | 11 +- packages/void/src/cli/github-cmd.ts | 68 ++++++ packages/void/src/cli/help.ts | 8 + packages/void/test/unit/github-args.test.ts | 20 +- packages/void/test/unit/github-client.test.ts | 74 +++++++ .../void/test/unit/github-join-cmd.test.ts | 204 ++++++++++++++++++ 8 files changed, 422 insertions(+), 4 deletions(-) create mode 100644 packages/void/test/unit/github-join-cmd.test.ts diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 3b0498946..cc39c4960 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -206,7 +206,7 @@ if (helpPath) { }, { name: 'github', - hint: 'install | installations | connect | update | link', + hint: 'install | installations | connect | update | link | join', description: 'Connect a GitHub repository for automatic deploys', }, { diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index d0dd8419e..4580a08c4 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -1071,4 +1071,43 @@ export class PlatformClient { return res.json(); } + + async getGithubJoinUrl(redirect: string, codeChallenge: string): Promise<{ url: string }> { + const params = new URLSearchParams({ + redirect, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + const res = await fetch(`${this.baseUrl}/github/join-url?${params}`, { + headers: { + ...this.authHeaders, + }, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to start GitHub join', res); + } + + return res.json(); + } + + async completeGithubJoin( + joinCode: string, + codeVerifier: string, + ): Promise<{ status: 'joined'; count: number }> { + const res = await fetch(`${this.baseUrl}/github/join/complete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.authHeaders, + }, + body: JSON.stringify({ join_code: joinCode, code_verifier: codeVerifier }), + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to complete GitHub join', res); + } + + return res.json(); + } } diff --git a/packages/void/src/cli/github-args.ts b/packages/void/src/cli/github-args.ts index 7ba44d092..f0eda0f35 100644 --- a/packages/void/src/cli/github-args.ts +++ b/packages/void/src/cli/github-args.ts @@ -14,12 +14,14 @@ export const githubSubcommands: Array = [ description: "Update an existing connection's branch, build executor, or deploy workflow", }, { name: 'link', description: 'Link your GitHub account to Void' }, + { name: 'join', description: "Join your organization's shared GitHub App installations" }, ]; export type GithubArgs = | { subcommand: 'install' } | { subcommand: 'installations' } | { subcommand: 'link' } + | { subcommand: 'join' } | { subcommand: 'connect'; projectName: string; @@ -61,6 +63,13 @@ export function parseGithubArgs(args: Array): GithubArgs { return { subcommand }; } + if (subcommand === 'join') { + if (rest.length > 0) { + throw new Error(`github: Unexpected argument '${rest[0]}'.`); + } + return { subcommand }; + } + if (subcommand === 'connect') { let projectName = ''; let installationId: string | undefined; @@ -209,5 +218,5 @@ export function parseGithubArgs(args: Array): GithubArgs { return { subcommand, projectName, branch, executor, workflowPath }; } - throw new Error('github: Usage: void github .'); + throw new Error('github: Usage: void github .'); } diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 7f00804a2..97a0b561a 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -134,6 +134,8 @@ export async function runGithubCommand(root: string, args: GithubArgs): Promise< return runGithubUpdate(root, client, args); case 'link': return runGithubLink(root, client); + case 'join': + return runGithubJoin(root, client); } } @@ -261,6 +263,72 @@ export async function runGithubLink(root: string, client: PlatformClient): Promi } } +// --------------------------------------------------------------------------- +// join +// --------------------------------------------------------------------------- + +/** + * Discover and join the shared GitHub App installations owned by the + * organizations the logged-in GitHub identity belongs to. Drives the same + * 3-endpoint localhost + PKCE handshake as {@link runGithubLink}, but against + * the platform's JOIN routes: membership upsert is idempotent, so unlike link + * there is no 409/already-linked conflict to map. The only special error is the + * fail-closed 503 raised when org-installation sharing is not configured on the + * instance. + */ +export async function runGithubJoin(root: string, client: PlatformClient): Promise { + void root; // Signature parity with runGithubLink; routing uses `client`. + + const verifier = createCodeVerifier(); + const challenge = createCodeChallenge(verifier); + const state = randomBytes(32).toString('hex'); + + // completeGithubJoin runs INSIDE the request handler (via finalize), so the + // browser only shows the success page after the join actually succeeds. + let count: number; + try { + ({ count } = await awaitBrowserCallback<{ status: 'joined'; count: number }>({ + state, + buildAuthUrl: async (callback) => { + callback.searchParams.set('state', state); + const { url } = await client.getGithubJoinUrl(callback.href, challenge); + return url; + }, + describeError: (p) => (p.get('error') ? 'GitHub authorization failed.' : null), + onUrl: (url) => + log.message( + `Open this URL to join your organization's shared GitHub App installations:\n${pc.cyan(url)}`, + ), + finalize: async (params) => { + const joinCode = params.get('join_code'); + if (!joinCode) { + throw new Error('github: join callback did not return a join code.'); + } + return client.completeGithubJoin(joinCode, verifier); + }, + })); + } catch (err) { + // Fail closed: org-installation sharing OAuth is not configured on this + // instance. The membership flow has no already-joined conflict to map. + if ( + err instanceof PlatformApiError && + err.status === 503 && + err.code === 'github_oauth_not_configured' + ) { + log.error('Organization installation sharing is not enabled on this Void instance.'); + process.exit(1); + } + throw err; + } + + if (count === 0) { + log.info('No new installations to join.'); + } else { + log.success(`Joined ${count} installation${count === 1 ? '' : 's'}.`); + log.message('Run `void github installations` to see them, then `void github connect`.'); + } +} + // --------------------------------------------------------------------------- // installations // --------------------------------------------------------------------------- diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 629eacb3f..734d8290d 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -205,6 +205,10 @@ const HELP_TREE: HelpTree = { description: "Update an existing connection's branch, build executor, or deploy workflow", }, { command: 'link', description: 'Link your GitHub account to Void' }, + { + command: 'join', + description: "Join your organization's shared GitHub App installations", + }, ], children: { install: basicSubcommand( @@ -216,6 +220,10 @@ const HELP_TREE: HelpTree = { 'void github installations', ), link: basicSubcommand('Link your GitHub account to Void.', 'void github link'), + join: basicSubcommand( + "Join your organization's shared GitHub App installations.", + 'void github join', + ), connect: { summary: 'Connect a repository to a project for automatic deploys.', usage: ['void github connect [project] [options]'], diff --git a/packages/void/test/unit/github-args.test.ts b/packages/void/test/unit/github-args.test.ts index df2be5438..c600639b0 100644 --- a/packages/void/test/unit/github-args.test.ts +++ b/packages/void/test/unit/github-args.test.ts @@ -481,16 +481,32 @@ describe('parseGithubArgs', () => { }); }); + describe('join', () => { + it('parses join with no args', () => { + expect(parseGithubArgs(['join'])).toEqual({ subcommand: 'join' }); + }); + + it('rejects extra positional', () => { + expect(() => parseGithubArgs(['join', 'x'])).toThrow("github: Unexpected argument 'x'."); + }); + + it('rejects a flag', () => { + expect(() => parseGithubArgs(['join', '--flag'])).toThrow( + "github: Unexpected argument '--flag'.", + ); + }); + }); + describe('unknown subcommand', () => { it('throws on empty args', () => { expect(() => parseGithubArgs([])).toThrow( - 'github: Usage: void github .', + 'github: Usage: void github .', ); }); it('throws on unknown subcommand', () => { expect(() => parseGithubArgs(['unknown'])).toThrow( - 'github: Usage: void github .', + 'github: Usage: void github .', ); }); }); diff --git a/packages/void/test/unit/github-client.test.ts b/packages/void/test/unit/github-client.test.ts index c1527753d..cf2eed84d 100644 --- a/packages/void/test/unit/github-client.test.ts +++ b/packages/void/test/unit/github-client.test.ts @@ -365,3 +365,77 @@ describe('PlatformClient.completeGithubLink', () => { }); }); }); + +describe('PlatformClient.getGithubJoinUrl', () => { + it('GETs join-url with redirect + S256 challenge query, auth headers, returns { url }', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ url: 'https://github.com/login/oauth/authorize?client_id=x' }), + ); + + const client = makeClient(); + const redirect = 'http://localhost:54321/callback?state=abc'; + const result = await client.getGithubJoinUrl(redirect, 'challenge-xyz'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe(`${apiUrl}/github/join-url`); + expect(parsed.searchParams.get('redirect')).toBe(redirect); + expect(parsed.searchParams.get('code_challenge')).toBe('challenge-xyz'); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + expect(init.method).toBeUndefined(); + const headers = init.headers as Record; + expect(headers.Authorization).toBe(`Bearer ${token}`); + expect(result).toEqual({ url: 'https://github.com/login/oauth/authorize?client_id=x' }); + }); + + it('throws PlatformApiError on non-2xx (503 github_oauth_not_configured)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'github_oauth_not_configured' }, 503)); + const client = makeClient(); + await expect(client.getGithubJoinUrl('http://localhost:1/callback', 'c')).rejects.toMatchObject( + { + name: 'PlatformApiError', + status: 503, + code: 'github_oauth_not_configured', + }, + ); + }); +}); + +describe('PlatformClient.completeGithubJoin', () => { + it('POSTs join/complete with exact JSON body + auth headers, returns { status, count }', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ status: 'joined', count: 3 })); + + const client = makeClient(); + const result = await client.completeGithubJoin('join-code-1', 'verifier-1'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${apiUrl}/github/join/complete`); + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + expect(headers.Authorization).toBe(`Bearer ${token}`); + expect(JSON.parse(init.body as string)).toEqual({ + join_code: 'join-code-1', + code_verifier: 'verifier-1', + }); + expect(result).toEqual({ status: 'joined', count: 3 }); + }); + + it('returns a zero count', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ status: 'joined', count: 0 })); + const client = makeClient(); + const result = await client.completeGithubJoin('join-code-2', 'verifier-2'); + expect(result).toEqual({ status: 'joined', count: 0 }); + }); + + it('throws PlatformApiError on non-2xx', async () => { + fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 })); + const client = makeClient(); + await expect(client.completeGithubJoin('join-code-3', 'verifier-3')).rejects.toMatchObject({ + name: 'PlatformApiError', + status: 500, + }); + }); +}); diff --git a/packages/void/test/unit/github-join-cmd.test.ts b/packages/void/test/unit/github-join-cmd.test.ts new file mode 100644 index 000000000..726db2dce --- /dev/null +++ b/packages/void/test/unit/github-join-cmd.test.ts @@ -0,0 +1,204 @@ +import { log } from '@clack/prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { PlatformApiError, type PlatformClient } from '../../src/cli/client.ts'; +import { runGithubJoin } from '../../src/cli/github-cmd.ts'; + +// Mock node:child_process so openBrowser never launches a real browser; the +// localhost callback server is driven directly by fetch in each test. +const mockExecFile = vi.fn(); +vi.mock('node:child_process', () => ({ + execFile: (...args: Array) => mockExecFile(...args), +})); + +const originalFetch = globalThis.fetch.bind(globalThis); + +type FakeClient = Pick; + +let exitSpy: ReturnType; + +beforeEach(() => { + mockExecFile.mockReset(); + // process.exit -> throw so we can assert the error-path exit without killing + // the test runner. + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); +}); + +afterEach(() => { + exitSpy.mockRestore(); + vi.unstubAllGlobals(); +}); + +/** + * Run runGithubJoin against a fake client, intercepting the authorize URL the + * command requests (via the fake getGithubJoinUrl) so the test can POST back to + * the localhost callback with whatever query it wants. + * + * `completeGithubJoin` and the callback query are configurable per test. + */ +function drive(opts: { + completeGithubJoin: FakeClient['completeGithubJoin']; + callbackQuery: (callbackUrl: URL) => URL; +}): Promise { + let resolveCallback!: (url: URL) => void; + const callbackReady = new Promise((resolve) => { + resolveCallback = resolve; + }); + + const client: FakeClient = { + getGithubJoinUrl: vi.fn(async (redirect: string) => { + // `redirect` is the localhost callback URL (with the command's state). + resolveCallback(new URL(redirect)); + return { url: 'https://github.com/login/oauth/authorize?client_id=x' }; + }), + completeGithubJoin: opts.completeGithubJoin, + }; + + const run = runGithubJoin('/project', client as unknown as PlatformClient); + + // Once the command has built the callback URL, fire the simulated browser + // redirect back at the localhost server. Swallow fetch errors: on the + // error paths the server is torn down mid-response (process.exit / throw), + // which can surface as ECONNRESET on the in-flight request — irrelevant to + // what we're asserting (the command's outcome). + const fired = callbackReady.then(async (callbackUrl) => { + const hit = opts.callbackQuery(callbackUrl); + await originalFetch(hit).catch(() => {}); + }); + + // Settle on the command's outcome, but make sure the fetch is also drained so + // no dangling promise leaks across tests. + return run.finally(() => fired.catch(() => {})); +} + +describe('runGithubJoin', () => { + it('completes the join on a successful callback (count > 0, plural)', async () => { + const successSpy = vi.spyOn(log, 'success').mockImplementation(() => {}); + const messageSpy = vi.spyOn(log, 'message').mockImplementation(() => {}); + const completeGithubJoin = vi.fn(async () => ({ status: 'joined' as const, count: 2 })); + + await drive({ + completeGithubJoin, + callbackQuery: (cb) => { + const hit = new URL(cb); // already carries the command's state + hit.searchParams.set('join_code', 'jc-123'); + return hit; + }, + }); + + expect(completeGithubJoin).toHaveBeenCalledTimes(1); + expect(completeGithubJoin).toHaveBeenCalledWith('jc-123', expect.any(String)); + expect(successSpy).toHaveBeenCalledWith('Joined 2 installations.'); + expect(messageSpy).toHaveBeenCalledWith( + 'Run `void github installations` to see them, then `void github connect`.', + ); + expect(exitSpy).not.toHaveBeenCalled(); + + successSpy.mockRestore(); + messageSpy.mockRestore(); + }); + + it('uses the singular form when exactly one installation is joined', async () => { + const successSpy = vi.spyOn(log, 'success').mockImplementation(() => {}); + const completeGithubJoin = vi.fn(async () => ({ status: 'joined' as const, count: 1 })); + + await drive({ + completeGithubJoin, + callbackQuery: (cb) => { + const hit = new URL(cb); + hit.searchParams.set('join_code', 'jc-one'); + return hit; + }, + }); + + expect(successSpy).toHaveBeenCalledWith('Joined 1 installation.'); + expect(exitSpy).not.toHaveBeenCalled(); + + successSpy.mockRestore(); + }); + + it('reports the no-op path when count === 0', async () => { + const infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {}); + const successSpy = vi.spyOn(log, 'success').mockImplementation(() => {}); + const completeGithubJoin = vi.fn(async () => ({ status: 'joined' as const, count: 0 })); + + await drive({ + completeGithubJoin, + callbackQuery: (cb) => { + const hit = new URL(cb); + hit.searchParams.set('join_code', 'jc-zero'); + return hit; + }, + }); + + expect(completeGithubJoin).toHaveBeenCalledTimes(1); + expect(infoSpy).toHaveBeenCalledWith('No new installations to join.'); + expect(successSpy).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + + infoSpy.mockRestore(); + successSpy.mockRestore(); + }); + + it('throws when the callback returns no join code', async () => { + const completeGithubJoin = vi.fn(async () => ({ status: 'joined' as const, count: 1 })); + + await expect( + drive({ + completeGithubJoin, + // valid state, but no join_code in the query + callbackQuery: (cb) => new URL(cb), + }), + ).rejects.toThrow('did not return a join code'); + + expect(completeGithubJoin).not.toHaveBeenCalled(); + }); + + it('exits(1) with a friendly message when org sharing is not configured (503)', async () => { + const errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); + // getGithubJoinUrl (buildAuthUrl) rejects fail-closed; awaitBrowserCallback + // tears down the localhost server and rejects WITHOUT firing a callback, so + // no simulated browser redirect is needed here. + const getGithubJoinUrl = vi.fn(async () => { + throw new PlatformApiError( + 'Failed to start GitHub join', + 503, + JSON.stringify({ error: 'github_oauth_not_configured' }), + ); + }); + const completeGithubJoin = vi.fn(async () => ({ status: 'joined' as const, count: 0 })); + const client: FakeClient = { getGithubJoinUrl, completeGithubJoin }; + + await expect(runGithubJoin('/project', client as unknown as PlatformClient)).rejects.toThrow( + 'process.exit(1)', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Organization installation sharing is not enabled on this Void instance.', + ); + expect(completeGithubJoin).not.toHaveBeenCalled(); + + errorSpy.mockRestore(); + }); + + it('re-throws non-503 PlatformApiErrors', async () => { + const completeGithubJoin = vi.fn(async () => { + throw new PlatformApiError('Failed to complete GitHub join', 500, 'boom'); + }); + + await expect( + drive({ + completeGithubJoin, + callbackQuery: (cb) => { + const hit = new URL(cb); + hit.searchParams.set('join_code', 'jc-500'); + return hit; + }, + }), + ).rejects.toMatchObject({ name: 'PlatformApiError', status: 500 }); + + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); From 50a1f827cb86a4487a860a4f907bf7ff23aebe9d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 4 Jul 2026 00:09:27 +0800 Subject: [PATCH 2/4] feat(cli): connect-authorization round-trip for non-owner members (Task 12/13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `void github connect` returns 403 connect_authorization_required (the caller is an org member, not the installation owner), run a per-repo authorize round-trip (getGithubConnectAuthorizeUrl → localhost callback carrying connect_grant) and retry the connect exactly once with the grant + PKCE verifier. The interception precedes the generic 403 handler so a member is never wrongly told "no access to this project"; a rejected grant (expired / identity mismatch) surfaces a clear retry message; the owner path is unchanged. repo_not_accessible is surfaced with a friendly per-repo message during the round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/client.ts | 35 ++ packages/void/src/cli/github-cmd.ts | 103 +++++- packages/void/test/unit/github-client.test.ts | 81 +++++ .../void/test/unit/github-connect-cmd.test.ts | 323 ++++++++++++++++++ 4 files changed, 536 insertions(+), 6 deletions(-) create mode 100644 packages/void/test/unit/github-connect-cmd.test.ts diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 4580a08c4..8eea4908c 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -977,6 +977,8 @@ export class PlatformClient { branch?: string; buildExecutor?: 'container' | 'github_actions'; workflowPath?: string; + connectGrant?: string; + codeVerifier?: string; }, ): Promise { const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, { @@ -995,6 +997,39 @@ export class PlatformClient { return res.json(); } + /** + * Start the per-repo connect-authorization round-trip used by a non-owner org + * member (Task 7 platform contract). The callback redirects back to `redirect` + * with either `?connect_grant=` (proof of per-repo access) or + * `?error=repo_not_accessible`. Mirrors {@link getGithubLinkUrl} but carries + * the installation + repo the grant is scoped to. + */ + async getGithubConnectAuthorizeUrl( + redirect: string, + codeChallenge: string, + installationId: string, + repoFullName: string, + ): Promise<{ url: string }> { + const params = new URLSearchParams({ + redirect, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + installationId, + repoFullName, + }); + const res = await fetch(`${this.baseUrl}/github/connect-authorize-url?${params}`, { + headers: { + ...this.authHeaders, + }, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to start GitHub connect authorization', res); + } + + return res.json(); + } + async getGithubConnection(projectId: string): Promise { const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, { headers: { diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 97a0b561a..8f6df84ed 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -346,7 +346,65 @@ async function runInstallations(client: PlatformClient): Promise { // connect // --------------------------------------------------------------------------- -async function runConnect( +/** + * Per-repo connect authorization round-trip for a non-owner org member. + * + * When the connect route returns 403 `connect_authorization_required` (Task 8), + * the member has no proof they can access the specific repo under the shared + * installation. This drives the same localhost + PKCE handshake as + * {@link runGithubLink}/{@link runGithubJoin}, but against the platform's + * connect-authorize route (Task 7): the callback mints and returns the grant IN + * the redirect URL (`?connect_grant=…`), so there is no separate `/complete` + * POST — `finalize` just reads the grant out of the callback query. A + * `?error=repo_not_accessible` callback is mapped to a friendly per-repo message + * by `describeError`, which rejects out of the handshake (no grant, no retry). + * + * Returns the grant plus the PKCE verifier the caller must echo to the connect + * route so the platform can bind the grant to this exchange. + */ +async function runGithubConnectAuthorize( + client: PlatformClient, + installationId: string, + repoFullName: string, +): Promise<{ connectGrant: string; codeVerifier: string }> { + const verifier = createCodeVerifier(); + const challenge = createCodeChallenge(verifier); + const state = randomBytes(32).toString('hex'); + + const { connectGrant } = await awaitBrowserCallback<{ connectGrant: string }>({ + state, + buildAuthUrl: async (callback) => { + callback.searchParams.set('state', state); + const { url } = await client.getGithubConnectAuthorizeUrl( + callback.href, + challenge, + installationId, + repoFullName, + ); + return url; + }, + describeError: (p) => { + const e = p.get('error'); + if (e === 'repo_not_accessible') { + return `You don't have access to ${repoFullName} under this installation on GitHub.`; + } + return e ? 'GitHub authorization failed.' : null; + }, + onUrl: (url) => + log.message(`Open this URL to authorize access to ${repoFullName}:\n${pc.cyan(url)}`), + finalize: async (params) => { + const grant = params.get('connect_grant'); + if (!grant) { + throw new Error('github: connect authorization did not return a grant.'); + } + return { connectGrant: grant }; + }, + }); + + return { connectGrant, codeVerifier: verifier }; +} + +export async function runConnect( root: string, client: PlatformClient, args: Extract, @@ -526,17 +584,51 @@ async function runConnect( // ------------------------------------------------------------------------- // 6. Connect // ------------------------------------------------------------------------- - let connection: GithubConnection; - try { - connection = await client.connectGithub(projectId, { + const attemptConnect = (extra?: { connectGrant: string; codeVerifier: string }) => + client.connectGithub(projectId, { installationId: installation.installation_id, repoFullName, branch, buildExecutor: executor, workflowPath: args.workflowPath, + ...extra, }); + + let connection: GithubConnection; + try { + connection = await attemptConnect(); } catch (err) { - if (err instanceof PlatformApiError) { + if (!(err instanceof PlatformApiError)) { + throw err; + } + if (err.status === 403 && err.code === 'connect_authorization_required') { + // Non-owner org member: prove per-repo access via an authorize round-trip, + // then retry the connect EXACTLY ONCE with the resulting grant. This MUST + // come before the generic `describeConnectError` branch below, which maps + // ANY 403 to "no project access" and would otherwise swallow this signal. + const grant = await runGithubConnectAuthorize( + client, + installation.installation_id, + repoFullName, + ); + try { + connection = await attemptConnect({ + connectGrant: grant.connectGrant, + codeVerifier: grant.codeVerifier, + }); + } catch (retryErr) { + if (retryErr instanceof PlatformApiError && retryErr.status === 403) { + // A fresh grant should be accepted; a 403 here means it was rejected + // (expired, or the browser identity differs from the linked one). Do + // NOT fall through to the generic 403 "no project access" message. + log.error( + 'connect: GitHub authorization failed or expired. Run `void github connect` again.', + ); + process.exit(1); + } + throw retryErr; + } + } else { const body = parsePlatformErrorBody(err.body); const code = body?.error ?? body?.message ?? ''; const message = describeConnectError(err.status, code); @@ -546,7 +638,6 @@ async function runConnect( } throw err; } - throw err; } // Fail closed when an explicit --workflow was not applied. An older platform diff --git a/packages/void/test/unit/github-client.test.ts b/packages/void/test/unit/github-client.test.ts index cf2eed84d..0e6f1553f 100644 --- a/packages/void/test/unit/github-client.test.ts +++ b/packages/void/test/unit/github-client.test.ts @@ -165,6 +165,44 @@ describe('PlatformClient.connectGithub', () => { }); }); + it('includes connectGrant + codeVerifier in the JSON body when provided', async () => { + const connection: GithubConnection = { + id: 'conn_3', + project_id: projectId, + installation_id: '123456', + repo_full_name: 'octocat/hello-world', + repository_id: '9876', + branch: 'main', + build_executor: 'container', + workflow_path: '.github/workflows/void-deploy.yml', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + fetchMock.mockResolvedValueOnce(jsonResponse(connection)); + + const client = makeClient(); + const result = await client.connectGithub(projectId, { + installationId: '123456', + repoFullName: 'octocat/hello-world', + branch: 'main', + buildExecutor: 'container', + connectGrant: 'grant-abc', + codeVerifier: 'verifier-abc', + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + installationId: '123456', + repoFullName: 'octocat/hello-world', + branch: 'main', + buildExecutor: 'container', + connectGrant: 'grant-abc', + codeVerifier: 'verifier-abc', + }); + expect(result).toEqual(connection); + }); + it('throws PlatformApiError on non-2xx', async () => { fetchMock.mockResolvedValueOnce(new Response('not found', { status: 404 })); const client = makeClient(); @@ -180,6 +218,49 @@ describe('PlatformClient.connectGithub', () => { }); }); +describe('PlatformClient.getGithubConnectAuthorizeUrl', () => { + it('GETs connect-authorize-url with redirect + S256 challenge + installationId + repoFullName, auth headers, returns { url }', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ url: 'https://github.com/login/oauth/authorize?client_id=x' }), + ); + + const client = makeClient(); + const redirect = 'http://localhost:54321/callback?state=abc'; + const result = await client.getGithubConnectAuthorizeUrl( + redirect, + 'challenge-xyz', + '123456', + 'octocat/hello-world', + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe(`${apiUrl}/github/connect-authorize-url`); + expect(parsed.searchParams.get('redirect')).toBe(redirect); + expect(parsed.searchParams.get('code_challenge')).toBe('challenge-xyz'); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + expect(parsed.searchParams.get('installationId')).toBe('123456'); + expect(parsed.searchParams.get('repoFullName')).toBe('octocat/hello-world'); + expect(init.method).toBeUndefined(); + const headers = init.headers as Record; + expect(headers.Authorization).toBe(`Bearer ${token}`); + expect(result).toEqual({ url: 'https://github.com/login/oauth/authorize?client_id=x' }); + }); + + it('throws PlatformApiError on non-2xx (503 github_oauth_not_configured)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'github_oauth_not_configured' }, 503)); + const client = makeClient(); + await expect( + client.getGithubConnectAuthorizeUrl('http://localhost:1/callback', 'c', '1', 'o/r'), + ).rejects.toMatchObject({ + name: 'PlatformApiError', + status: 503, + code: 'github_oauth_not_configured', + }); + }); +}); + describe('PlatformClient.getGithubConnection', () => { it('GETs the right URL, sends auth headers, returns the parsed connection', async () => { const connection: GithubConnection = { diff --git a/packages/void/test/unit/github-connect-cmd.test.ts b/packages/void/test/unit/github-connect-cmd.test.ts new file mode 100644 index 000000000..1e1588f71 --- /dev/null +++ b/packages/void/test/unit/github-connect-cmd.test.ts @@ -0,0 +1,323 @@ +import { log } from '@clack/prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { createCodeChallenge } from '../../src/cli/auth.ts'; +import { + PlatformApiError, + type GithubConnection, + type GithubInstallation, + type PlatformClient, + type ProjectResponse, +} from '../../src/cli/client.ts'; +import type { GithubArgs } from '../../src/cli/github-args.ts'; +import { runConnect } from '../../src/cli/github-cmd.ts'; + +// Mock node:child_process so openBrowser (called by awaitBrowserCallback) never +// launches a real browser; the localhost callback is driven directly by fetch. +const mockExecFile = vi.fn(); +vi.mock('node:child_process', () => ({ + execFile: (...args: Array) => mockExecFile(...args), +})); + +const originalFetch = globalThis.fetch.bind(globalThis); + +const projectId = 'proj_test'; +const slug = 'my-app'; +const installationId = '123456'; +const repoFullName = 'octocat/hello-world'; + +function makeProject(): ProjectResponse { + return { + id: projectId, + user_id: 'user_1', + slug, + type: 'void', + created_at: '2024-01-01T00:00:00Z', + custom_domain: null, + }; +} + +function makeInstallation(): GithubInstallation { + return { + id: 'install_1', + installation_id: installationId, + account_login: 'octocat', + account_id: '1', + account_type: 'Organization', + repositories: [{ full_name: repoFullName, id: 1 }], + repository_selection: 'selected', + status: 'active', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; +} + +function makeConnection(overrides: Partial = {}): GithubConnection { + return { + id: 'conn_1', + project_id: projectId, + installation_id: installationId, + repo_full_name: repoFullName, + repository_id: '9876', + branch: 'main', + build_executor: 'container', + workflow_path: '.github/workflows/void-deploy.yml', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...overrides, + }; +} + +function authRequired(): PlatformApiError { + return new PlatformApiError( + 'Failed to connect GitHub repository', + 403, + JSON.stringify({ error: 'connect_authorization_required' }), + ); +} + +const baseArgs: Extract = { + subcommand: 'connect', + projectName: slug, + installationId, + repo: repoFullName, + branch: 'main', +}; + +/** + * Drive runConnect against a fake client. `getGithubConnectAuthorizeUrl` (the + * round-trip URL builder) resolves the localhost callback URL the moment the + * command requests it, so the test can POST back to the callback with whatever + * query it wants (mirroring the link/join command tests). When `callbackQuery` + * is omitted the round-trip is never expected to start, so no callback fires. + */ +function driveConnect(opts: { + connectGithub: ReturnType; + getGithubConnectAuthorizeUrl?: ReturnType; + callbackQuery?: (callbackUrl: URL) => URL; + args?: Partial>; +}): { + promise: Promise; + connectGithub: ReturnType; + getGithubConnectAuthorizeUrl: ReturnType; +} { + let resolveCallback: ((url: URL) => void) | undefined; + const callbackReady = new Promise((resolve) => { + resolveCallback = resolve; + }); + + const getGithubConnectAuthorizeUrl = + opts.getGithubConnectAuthorizeUrl ?? + vi.fn(async (redirect: string) => { + // `redirect` is the localhost callback URL (with the command's state). + resolveCallback?.(new URL(redirect)); + return { url: 'https://github.com/login/oauth/authorize?client_id=x' }; + }); + + const client = { + listProjects: vi.fn(async () => [makeProject()]), + listGithubInstallations: vi.fn(async () => [makeInstallation()]), + connectGithub: opts.connectGithub, + getGithubConnectAuthorizeUrl, + } as unknown as PlatformClient; + + const run = runConnect('/project', client, { ...baseArgs, ...opts.args }); + + // Fire the simulated browser redirect once the round-trip's callback URL is + // known. Swallow fetch errors: on the error paths the server is torn down + // mid-response (process.exit / throw), which can surface as ECONNRESET. + const fired = opts.callbackQuery + ? callbackReady.then(async (callbackUrl) => { + const hit = opts.callbackQuery!(callbackUrl); + await originalFetch(hit).catch(() => {}); + }) + : Promise.resolve(); + + return { + promise: run.finally(() => fired.catch(() => {})), + connectGithub: opts.connectGithub, + getGithubConnectAuthorizeUrl, + }; +} + +let exitSpy: ReturnType; +let successSpy: ReturnType; +let errorSpy: ReturnType; +let messageSpy: ReturnType; +const originalIsTTY = process.stdin.isTTY; + +beforeEach(() => { + mockExecFile.mockReset(); + // Force the non-interactive (non-TTY) path so tests never hit clack prompts. + (process.stdin as { isTTY?: boolean }).isTTY = false; + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + successSpy = vi.spyOn(log, 'success').mockImplementation(() => {}); + errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); + messageSpy = vi.spyOn(log, 'message').mockImplementation(() => {}); +}); + +afterEach(() => { + (process.stdin as { isTTY?: boolean }).isTTY = originalIsTTY; + exitSpy.mockRestore(); + successSpy.mockRestore(); + errorSpy.mockRestore(); + messageSpy.mockRestore(); + vi.unstubAllGlobals(); +}); + +describe('runConnect — non-owner member connect-authorization round-trip', () => { + it('runs the round-trip and retries connect with the grant + verifier on 403 connect_authorization_required', async () => { + const connectGithub = vi + .fn() + .mockRejectedValueOnce(authRequired()) + .mockResolvedValueOnce(makeConnection()); + + const { promise, getGithubConnectAuthorizeUrl } = driveConnect({ + connectGithub, + callbackQuery: (cb) => { + const hit = new URL(cb); // already carries the command's state + hit.searchParams.set('connect_grant', 'grant-xyz'); + return hit; + }, + }); + + await promise; + + // Round-trip was started for the specific installation + repo. + expect(getGithubConnectAuthorizeUrl).toHaveBeenCalledTimes(1); + const authorizeArgs = getGithubConnectAuthorizeUrl.mock.calls[0] as [ + string, + string, + string, + string, + ]; + expect(authorizeArgs[2]).toBe(installationId); + expect(authorizeArgs[3]).toBe(repoFullName); + + // Two connect attempts: bare first, grant-carrying retry. + expect(connectGithub).toHaveBeenCalledTimes(2); + + const firstBody = connectGithub.mock.calls[0]![1] as Record; + expect(firstBody.connectGrant).toBeUndefined(); + expect(firstBody.codeVerifier).toBeUndefined(); + + const retryBody = connectGithub.mock.calls[1]![1] as Record; + expect(retryBody).toMatchObject({ + installationId, + repoFullName, + branch: 'main', + buildExecutor: 'container', + connectGrant: 'grant-xyz', + }); + expect(typeof retryBody.codeVerifier).toBe('string'); + expect((retryBody.codeVerifier as string).length).toBeGreaterThan(0); + + // The verifier echoed to the retry is the one whose S256 challenge was sent + // to the authorize URL builder (PKCE binding is intact). + expect(authorizeArgs[1]).toBe(createCodeChallenge(retryBody.codeVerifier as string)); + + expect(successSpy).toHaveBeenCalledWith( + 'Connected octocat/hello-world (main) → project. Push to main to deploy.', + ); + expect(exitSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('does NOT run the round-trip for an owner whose first connect succeeds', async () => { + const connectGithub = vi.fn().mockResolvedValueOnce(makeConnection()); + + const { promise, getGithubConnectAuthorizeUrl } = driveConnect({ connectGithub }); + + await promise; + + expect(connectGithub).toHaveBeenCalledTimes(1); + const body = connectGithub.mock.calls[0]![1] as Record; + expect(body.connectGrant).toBeUndefined(); + expect(getGithubConnectAuthorizeUrl).not.toHaveBeenCalled(); + expect(successSpy).toHaveBeenCalledWith( + 'Connected octocat/hello-world (main) → project. Push to main to deploy.', + ); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('exits(1) with the auth-failed message (NOT "no project access") when the retry is rejected with 403', async () => { + const connectGithub = vi + .fn() + .mockRejectedValueOnce(authRequired()) + .mockRejectedValueOnce( + new PlatformApiError( + 'Failed to connect GitHub repository', + 403, + JSON.stringify({ error: 'invalid connect grant' }), + ), + ); + + const { promise, getGithubConnectAuthorizeUrl } = driveConnect({ + connectGithub, + callbackQuery: (cb) => { + const hit = new URL(cb); + hit.searchParams.set('connect_grant', 'grant-stale'); + return hit; + }, + }); + + await expect(promise).rejects.toThrow('process.exit(1)'); + + expect(getGithubConnectAuthorizeUrl).toHaveBeenCalledTimes(1); + expect(connectGithub).toHaveBeenCalledTimes(2); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + 'connect: GitHub authorization failed or expired. Run `void github connect` again.', + ); + // Must NOT surface the generic 403 mapping. + expect(errorSpy).not.toHaveBeenCalledWith("connect: You don't have access to this project."); + expect(successSpy).not.toHaveBeenCalled(); + }); + + it('surfaces the friendly per-repo message and attempts no retry when the callback is repo_not_accessible', async () => { + const connectGithub = vi.fn().mockRejectedValueOnce(authRequired()); + + const { promise, getGithubConnectAuthorizeUrl } = driveConnect({ + connectGithub, + callbackQuery: (cb) => { + const hit = new URL(cb); + hit.searchParams.set('error', 'repo_not_accessible'); + return hit; + }, + }); + + await expect(promise).rejects.toThrow( + "You don't have access to octocat/hello-world under this installation on GitHub.", + ); + + expect(getGithubConnectAuthorizeUrl).toHaveBeenCalledTimes(1); + // Only the initial attempt ran — no retry after the round-trip failed. + expect(connectGithub).toHaveBeenCalledTimes(1); + expect(successSpy).not.toHaveBeenCalled(); + }); + + it('maps a non-connect_authorization_required error via describeConnectError and exits (owner-path handling unchanged)', async () => { + const connectGithub = vi + .fn() + .mockRejectedValueOnce( + new PlatformApiError( + 'Failed to connect GitHub repository', + 409, + JSON.stringify({ error: 'project already connected' }), + ), + ); + + const { promise, getGithubConnectAuthorizeUrl } = driveConnect({ connectGithub }); + + await expect(promise).rejects.toThrow('process.exit(1)'); + + expect(getGithubConnectAuthorizeUrl).not.toHaveBeenCalled(); + expect(connectGithub).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + 'connect: This project is already connected to a GitHub repository.', + ); + expect(successSpy).not.toHaveBeenCalled(); + }); +}); From 4588c40802ead6462f02cf937cae07f4da1544be Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 4 Jul 2026 00:11:53 +0800 Subject: [PATCH 3/4] docs(cli): document `void github join` + org-member connect (Task 13/13) Add the `void github join` reference (discover + join a shared org's GitHub App installations) and a "Connecting as an organization member" note under `void github connect` describing the one-time per-repo browser authorization a non-owner member completes before connecting. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/cli.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9ecf2c69d..ef3ab5de8 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -597,6 +597,16 @@ void github installations List all GitHub App installations linked to your account. Each entry includes the `[id: ]` needed for `--installation` in non-interactive use. +### `void github join` + +``` +void github join +``` + +Join the GitHub App installations your organization already has. If a teammate installed the Void GitHub App on a shared GitHub organization, run `void github join` to gain access to those installations without re-installing. Void opens your browser to authorize (a localhost + PKCE handshake, the same mechanics as `void github link`), confirms with GitHub which installations you can manage, and records your membership. Afterwards `void github installations` lists them and `void github connect` can connect your own projects to their repositories. + +Requires an authenticated CLI (`void auth login` first) and organization-installation sharing enabled on your Void instance; when it is not enabled the command fails closed with a clear message. You can only join installations your GitHub authorization actually returns — you cannot name or join one you cannot access on GitHub. + ### `void github connect` ``` @@ -631,6 +641,10 @@ void github connect my-app \ **Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`). +**Connecting as an organization member** + +If you are a member of a GitHub organization but not the person who installed the App, `void github connect` first confirms that you personally have access to the specific repository. When it detects this, it opens your browser once to authorize access to that repo on GitHub (a localhost + PKCE handshake), then completes the connection automatically — no extra flags. Run `void github join` first so the installation appears in `void github installations`. You can only connect repositories you can access on GitHub; one you cannot see is refused with a clear message. + ### `void github update` ``` From be501617d77d651f6d6b354a970c76087c5c6444 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 4 Jul 2026 20:46:36 +0800 Subject: [PATCH 4/4] fix(cli): fail closed on non-TTY github connect per-repo authorization A non-owner org member connecting a repo under a shared org installation gets a 403 connect_authorization_required and runConnect starts an interactive authorize round-trip. That flow opens a browser and blocks on a localhost callback (auth.ts, ~5-min timeout) with no TTY guard, so a non-interactive `void github connect --repo o/r --branch main` in CI hung for 5 minutes then died instead of failing closed like the --branch / --repo non-TTY guards. Guard the round-trip on isTTY: in a non-interactive shell, fail closed immediately with an actionable "run locally to authorize" message. Migrate the three round-trip unit tests to the interactive (isTTY=true) path they actually exercise and add a regression test for the non-TTY fail-closed behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/github-cmd.ts | 10 +++++++++ .../void/test/unit/github-connect-cmd.test.ts | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 4178a6f2c..694cc0281 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -700,6 +700,16 @@ export async function runConnect( throw err; } if (err.status === 403 && err.code === 'connect_authorization_required') { + if (!isTTY) { + // The authorize round-trip opens a browser and blocks on a localhost callback + // (auth.ts, up to a 5-min timeout). A non-interactive shell (CI) has no browser, + // so fail closed immediately with an actionable message instead of hanging until + // the callback times out — same posture as the --branch / --repo guards above. + log.error( + 'connect: this installation requires per-repo authorization, which needs an interactive browser sign-in. Run `void github connect` locally to authorize, then retry.', + ); + process.exit(1); + } // Non-owner org member: prove per-repo access via an authorize round-trip, // then retry the connect EXACTLY ONCE with the resulting grant. This MUST // come before the generic `describeConnectError` branch below, which maps diff --git a/packages/void/test/unit/github-connect-cmd.test.ts b/packages/void/test/unit/github-connect-cmd.test.ts index 1e1588f71..1607680bf 100644 --- a/packages/void/test/unit/github-connect-cmd.test.ts +++ b/packages/void/test/unit/github-connect-cmd.test.ts @@ -168,6 +168,7 @@ afterEach(() => { describe('runConnect — non-owner member connect-authorization round-trip', () => { it('runs the round-trip and retries connect with the grant + verifier on 403 connect_authorization_required', async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; const connectGithub = vi .fn() .mockRejectedValueOnce(authRequired()) @@ -175,6 +176,7 @@ describe('runConnect — non-owner member connect-authorization round-trip', () const { promise, getGithubConnectAuthorizeUrl } = driveConnect({ connectGithub, + args: { executor: 'container' }, callbackQuery: (cb) => { const hit = new URL(cb); // already carries the command's state hit.searchParams.set('connect_grant', 'grant-xyz'); @@ -242,6 +244,7 @@ describe('runConnect — non-owner member connect-authorization round-trip', () }); it('exits(1) with the auth-failed message (NOT "no project access") when the retry is rejected with 403', async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; const connectGithub = vi .fn() .mockRejectedValueOnce(authRequired()) @@ -255,6 +258,7 @@ describe('runConnect — non-owner member connect-authorization round-trip', () const { promise, getGithubConnectAuthorizeUrl } = driveConnect({ connectGithub, + args: { executor: 'container' }, callbackQuery: (cb) => { const hit = new URL(cb); hit.searchParams.set('connect_grant', 'grant-stale'); @@ -276,10 +280,12 @@ describe('runConnect — non-owner member connect-authorization round-trip', () }); it('surfaces the friendly per-repo message and attempts no retry when the callback is repo_not_accessible', async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; const connectGithub = vi.fn().mockRejectedValueOnce(authRequired()); const { promise, getGithubConnectAuthorizeUrl } = driveConnect({ connectGithub, + args: { executor: 'container' }, callbackQuery: (cb) => { const hit = new URL(cb); hit.searchParams.set('error', 'repo_not_accessible'); @@ -320,4 +326,20 @@ describe('runConnect — non-owner member connect-authorization round-trip', () ); expect(successSpy).not.toHaveBeenCalled(); }); + + it('fails closed in non-interactive mode instead of starting the browser round-trip on 403 connect_authorization_required', async () => { + const connectGithub = vi.fn().mockRejectedValueOnce(authRequired()); + + const { promise, getGithubConnectAuthorizeUrl } = driveConnect({ connectGithub }); + + await expect(promise).rejects.toThrow('process.exit(1)'); + + expect(getGithubConnectAuthorizeUrl).not.toHaveBeenCalled(); + expect(connectGithub).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + 'connect: this installation requires per-repo authorization, which needs an interactive browser sign-in. Run `void github connect` locally to authorize, then retry.', + ); + expect(successSpy).not.toHaveBeenCalled(); + }); });