Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,16 @@ void github installations

List all GitHub App installations linked to your account. Each entry includes the `[id: <installation_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`

```
Expand Down Expand Up @@ -633,6 +643,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`

```
Expand Down
2 changes: 1 addition & 1 deletion packages/void/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ if (helpPath) {
},
{
name: 'github',
hint: 'install | installations | connect | update | status | link',
hint: 'install | installations | connect | update | status | link | join',
description: 'Connect a GitHub repository for automatic deploys',
},
{
Expand Down
74 changes: 74 additions & 0 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,8 @@ export class PlatformClient {
branch?: string;
buildExecutor?: 'container' | 'github_actions';
workflowPath?: string;
connectGrant?: string;
codeVerifier?: string;
},
): Promise<GithubConnection> {
const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, {
Expand All @@ -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=<signed>` (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<GithubConnection> {
const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, {
headers: {
Expand Down Expand Up @@ -1071,4 +1106,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();
}
}
13 changes: 12 additions & 1 deletion packages/void/src/cli/github-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ export const githubSubcommands: Array<SubcommandDef> = [
description: "Show a project's GitHub connection (repo, branch, executor, 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;
Expand Down Expand Up @@ -70,6 +72,13 @@ export function parseGithubArgs(args: Array<string>): 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;
Expand Down Expand Up @@ -247,5 +256,7 @@ export function parseGithubArgs(args: Array<string>): GithubArgs {
return { subcommand, projectName };
}

throw new Error('github: Usage: void github <install|installations|connect|update|status|link>.');
throw new Error(
'github: Usage: void github <install|installations|connect|update|status|link|join>.',
);
}
181 changes: 175 additions & 6 deletions packages/void/src/cli/github-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ export async function runGithubCommand(root: string, args: GithubArgs): Promise<
return runStatus(root, client, args);
case 'link':
return runGithubLink(root, client);
case 'join':
return runGithubJoin(root, client);
}
}

Expand Down Expand Up @@ -304,6 +306,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> {
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
// ---------------------------------------------------------------------------
Expand All @@ -321,7 +389,65 @@ async function runInstallations(client: PlatformClient): Promise<void> {
// 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<GithubArgs, { subcommand: 'connect' }>,
Expand Down Expand Up @@ -556,17 +682,61 @@ async function runConnect(
// -------------------------------------------------------------------------
// 7. 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,
...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') {
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
// ANY 403 to "no project access" and would otherwise swallow this signal.
const grant = await runGithubConnectAuthorize(
client,
installation.installation_id,
repoFullName,
);
Comment thread
Brooooooklyn marked this conversation as resolved.
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);
Expand All @@ -576,7 +746,6 @@ async function runConnect(
}
throw err;
}
throw err;
}

// Fail closed when a custom workflow path (from --workflow or the prompt) was
Expand Down
Loading