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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ playground/*/.node-version
playground-local
.worktrees
.svelte-kit/
docs/superpowers/
8 changes: 6 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,8 @@ 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.

In an interactive terminal you rarely need to run this yourself — `void github connect` runs the same join automatically when no active installations are linked to your account. Running `void github join` yourself matters mainly for non-interactive use (without a TTY, `void github connect` never opens a browser), or to link installations ahead of time.

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 All @@ -619,6 +621,8 @@ void github connect [project] [options]

Connect a GitHub repository to a Void project for automatic deploys. On every push to the configured branch, Void builds and deploys your project automatically.

Interactively (TTY), if your account has no active installations linked, `void github connect` first runs the same browser authorize as `void github join` automatically — if a teammate already installed the Void GitHub App on your organization, you join it on the spot and the connect continues. Only when no shared installation is found does it ask you to run `void github install`. The same recovery runs when `--installation <id>` names an installation your account is not linked to yet. Without a TTY, connect never opens a browser: it fails closed and tells you to run `void github install`, or `void github join` if your organization already installed the App.

**Options**

| Flag | Description |
Expand All @@ -636,7 +640,7 @@ Interactively (TTY), after resolving the repository and branch, `void github con

**Non-interactive use (CI)**

When stdin is not a TTY, `void github connect` never prompts — it fails closed and names any flag it needs. `--branch` is always required. `--project` must be resolvable (positional / `--project` / `VOID_PROJECT` / linked `.void/project.json`). `--installation` is required only when your account has more than one installation; otherwise the sole installation is used. `--repo` is required when the installation grants access to all repos or to more than one selected repo; when it grants exactly one repo that repo is used automatically. Use `void github installations` to discover the `installation_id`.
When stdin is not a TTY, `void github connect` never prompts — it fails closed and names any flag it needs. `--branch` is always required. `--project` must be resolvable (positional / `--project` / `VOID_PROJECT` / linked `.void/project.json`). `--installation` is required only when your account has more than one installation; otherwise the sole installation is used. `--repo` is required when the installation grants access to all repos or to more than one selected repo; when it grants exactly one repo that repo is used automatically. `--repo` is also required when the installation does not expose a repository list to your account (shared org installations hide it). Use `void github installations` to discover the `installation_id`.

```
void github connect my-app \
Expand All @@ -649,7 +653,7 @@ void github connect my-app \

**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.
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. Interactively (TTY), 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. Without a TTY, this per-repo authorization never opens a browser: connect fails closed with an error explaining that the installation requires per-repo authorization, which needs an interactive browser sign-in, and telling you to run `void github connect` locally to authorize, then retry. Interactively, connect joins the shared installation automatically when your account has no active installations linked, so running `void github join` first is optional — useful mainly to see the installation in `void github installations` beforehand. 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
175 changes: 121 additions & 54 deletions packages/void/src/cli/github-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,45 +311,49 @@ export async function runGithubLink(root: string, client: PlatformClient): Promi
// ---------------------------------------------------------------------------

/**
* 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.
* Browser round-trip (localhost + PKCE) that joins the caller to their
* organization's shared GitHub App installations. Returns the number of
* installations joined. Throws PlatformApiError — including the 503
* `github_oauth_not_configured` — so callers own the user-facing handling
* (`runGithubJoin` prints and exits; the connect auto-join fallback falls
* through to its install-first error).
*/
export async function runGithubJoin(root: string, client: PlatformClient): Promise<void> {
void root; // Signature parity with runGithubLink; routing uses `client`.

export async function performGithubJoin(client: PlatformClient): Promise<number> {
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.
const { 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);
},
});
return count;
}

export async function runGithubJoin(root: string, client: PlatformClient): Promise<void> {
void root; // Signature parity with runGithubLink; routing uses `client`.

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);
},
}));
count = await performGithubJoin(client);
} catch (err) {
// Fail closed: org-installation sharing OAuth is not configured on this
// instance. The membership flow has no already-joined conflict to map.
Expand Down Expand Up @@ -447,6 +451,30 @@ async function runGithubConnectAuthorize(
return { connectGrant, codeVerifier: verifier };
}

/**
* Free-text repository prompt used when the installation's repository list is
* not enumerable client-side: `repository_selection === 'all'`, or a shared
* org installation whose list the server redacts for non-owner members. The
* server re-verifies actual access on connect, so this is DX, not security.
*/
async function promptRepoFullName(root: string): Promise<string> {
const gitDefault = parseGithubRepoFullName(collectGitDeployMetadata(root, null).repositoryUrl);
const input = await text({
message: 'Repository (owner/repo):',
placeholder: gitDefault ?? 'owner/repo',
initialValue: gitDefault ?? '',
validate: (value) =>
/^[^/\s]+\/[^/\s]+$/.test((value ?? '').trim())
? undefined
: 'Enter the repository as owner/repo.',
});
if (isCancel(input)) {
cancel('Cancelled.');
process.exit(0);
}
return (input as string).trim();
}

export async function runConnect(
root: string,
client: PlatformClient,
Expand Down Expand Up @@ -500,19 +528,65 @@ export async function runConnect(
// -------------------------------------------------------------------------
// 2. Resolve installation
// -------------------------------------------------------------------------
const installations = (await client.listGithubInstallations()).filter(
(i) => i.status === 'active',
);
const listActiveInstallations = async () =>
(await client.listGithubInstallations()).filter((i) => i.status === 'active');

// Auto-join fallback: an org member whose org already installed the App has
// no membership row until a join round-trip records one, so their list is
// empty (or missing the shared installation) even though the App is
// installed. Run the join flow once instead of telling them to re-install.
// Browser round-trips are TTY-only, mirroring every other prompt here. A 503
// (sharing not configured) falls through to the install-first error.
let autoJoinAttempted = false;
const attemptAutoJoin = async (): Promise<number> => {
autoJoinAttempted = true;
try {
return await performGithubJoin(client);
} catch (err) {
if (
err instanceof PlatformApiError &&
err.status === 503 &&
err.code === 'github_oauth_not_configured'
) {
return 0;
}
throw err;
}
};

let installations = await listActiveInstallations();

if (installations.length === 0 && isTTY) {
log.info(
'No GitHub App installation is linked to your account yet — checking whether your organization already installed the Void GitHub App…',
);
if ((await attemptAutoJoin()) > 0) {
installations = await listActiveInstallations();
}
}

if (installations.length === 0) {
log.error('No active GitHub App installation. Run `void github install` first.');
log.error(
isTTY
? 'No GitHub App installation found for your account or your organizations. Run `void github install` first.'
: 'No active GitHub App installation. Run `void github install` first, or `void github join` if your organization already installed the Void GitHub App.',
);
process.exit(1);
}

let installation: GithubInstallation;

if (args.installationId) {
const found = installations.find((i) => i.installation_id === args.installationId);
let found = installations.find((i) => i.installation_id === args.installationId);
if (!found && isTTY && !autoJoinAttempted) {
log.info(
`Installation '${args.installationId}' is not linked to your account yet — checking whether your organization shares it…`,
);
if ((await attemptAutoJoin()) > 0) {
installations = await listActiveInstallations();
found = installations.find((i) => i.installation_id === args.installationId);
}
}
if (!found) {
log.error(`connect: No active installation found with id '${args.installationId}'.`);
process.exit(1);
Expand Down Expand Up @@ -547,10 +621,17 @@ export async function runConnect(
repoFullName = args.repo;
} else if (installation.repository_selection === 'selected') {
if (installation.repositories.length === 0) {
log.error(
'connect: The installation has no accessible repositories. Update the GitHub App install to grant access.',
);
process.exit(1);
// The server redacts `repositories` to [] for non-owner (member) rows of
// shared org installations, and an owner install can genuinely have zero
// grants. Either way the list is not enumerable here — take free text
// and let the server's access check decide.
if (!isTTY) {
log.error(
'connect: --repo <owner/repo> is required — this installation does not expose a repository list to your account.',
);
process.exit(1);
}
repoFullName = await promptRepoFullName(root);
} else if (installation.repositories.length === 1) {
repoFullName = installation.repositories[0]!.full_name;
} else if (!isTTY) {
Expand Down Expand Up @@ -578,21 +659,7 @@ export async function runConnect(
);
process.exit(1);
}
const gitDefault = parseGithubRepoFullName(collectGitDeployMetadata(root, null).repositoryUrl);
const input = await text({
message: 'Repository (owner/repo):',
placeholder: gitDefault ?? 'owner/repo',
initialValue: gitDefault ?? '',
});
if (isCancel(input)) {
cancel('Cancelled.');
process.exit(0);
}
repoFullName = (input as string).trim();
if (!repoFullName) {
log.error('connect: Repository is required.');
process.exit(1);
}
repoFullName = await promptRepoFullName(root);
}

// -------------------------------------------------------------------------
Expand Down
Loading