diff --git a/.gitignore b/.gitignore index 877ad8d70..a90ec7a4c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ playground/*/.node-version playground-local .worktrees .svelte-kit/ +docs/superpowers/ diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b9bd9e3f4..aed55fe8a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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` @@ -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 ` 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 | @@ -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 \ @@ -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` diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 694cc0281..96254c5b9 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -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 root; // Signature parity with runGithubLink; routing uses `client`. - +export async function performGithubJoin(client: PlatformClient): Promise { 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 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. @@ -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 { + 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, @@ -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 => { + 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); @@ -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 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) { @@ -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); } // ------------------------------------------------------------------------- diff --git a/packages/void/test/unit/github-connect-cmd.test.ts b/packages/void/test/unit/github-connect-cmd.test.ts index 1607680bf..b4e1030c8 100644 --- a/packages/void/test/unit/github-connect-cmd.test.ts +++ b/packages/void/test/unit/github-connect-cmd.test.ts @@ -18,6 +18,15 @@ vi.mock('node:child_process', () => ({ execFile: (...args: Array) => mockExecFile(...args), })); +// Partial mock of @clack/prompts: only `text` is replaced (for the repo +// prompt); everything else — including `log`, which the spies below target — +// passes through from the actual module. +const mockText = vi.fn(); +vi.mock('@clack/prompts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, text: (...args: Array) => mockText(...args) }; +}); + const originalFetch = globalThis.fetch.bind(globalThis); const projectId = 'proj_test'; @@ -94,11 +103,18 @@ function driveConnect(opts: { connectGithub: ReturnType; getGithubConnectAuthorizeUrl?: ReturnType; callbackQuery?: (callbackUrl: URL) => URL; + listGithubInstallations?: ReturnType; + getGithubJoinUrl?: ReturnType; + completeGithubJoin?: ReturnType; + joinCallbackQuery?: (callbackUrl: URL) => URL; args?: Partial>; }): { promise: Promise; connectGithub: ReturnType; getGithubConnectAuthorizeUrl: ReturnType; + listGithubInstallations: ReturnType; + getGithubJoinUrl: ReturnType; + completeGithubJoin: ReturnType; } { let resolveCallback: ((url: URL) => void) | undefined; const callbackReady = new Promise((resolve) => { @@ -113,11 +129,30 @@ function driveConnect(opts: { return { url: 'https://github.com/login/oauth/authorize?client_id=x' }; }); + let resolveJoinCallback: ((url: URL) => void) | undefined; + const joinCallbackReady = new Promise((resolve) => { + resolveJoinCallback = resolve; + }); + + const getGithubJoinUrl = + opts.getGithubJoinUrl ?? + vi.fn(async (redirect: string) => { + resolveJoinCallback?.(new URL(redirect)); + return { url: 'https://github.com/login/oauth/authorize?client_id=x' }; + }); + const completeGithubJoin = + opts.completeGithubJoin ?? vi.fn(async () => ({ status: 'joined', count: 0 })); + + const listGithubInstallations = + opts.listGithubInstallations ?? vi.fn(async () => [makeInstallation()]); + const client = { listProjects: vi.fn(async () => [makeProject()]), - listGithubInstallations: vi.fn(async () => [makeInstallation()]), + listGithubInstallations, connectGithub: opts.connectGithub, getGithubConnectAuthorizeUrl, + getGithubJoinUrl, + completeGithubJoin, } as unknown as PlatformClient; const run = runConnect('/project', client, { ...baseArgs, ...opts.args }); @@ -132,10 +167,20 @@ function driveConnect(opts: { }) : Promise.resolve(); + const joinFired = opts.joinCallbackQuery + ? joinCallbackReady.then(async (callbackUrl) => { + const hit = opts.joinCallbackQuery!(callbackUrl); + await originalFetch(hit).catch(() => {}); + }) + : Promise.resolve(); + return { - promise: run.finally(() => fired.catch(() => {})), + promise: run.finally(() => fired.catch(() => {})).finally(() => joinFired.catch(() => {})), connectGithub: opts.connectGithub, getGithubConnectAuthorizeUrl, + listGithubInstallations, + getGithubJoinUrl, + completeGithubJoin, }; } @@ -143,10 +188,12 @@ let exitSpy: ReturnType; let successSpy: ReturnType; let errorSpy: ReturnType; let messageSpy: ReturnType; +let infoSpy: ReturnType; const originalIsTTY = process.stdin.isTTY; beforeEach(() => { mockExecFile.mockReset(); + mockText.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) => { @@ -155,6 +202,7 @@ beforeEach(() => { successSpy = vi.spyOn(log, 'success').mockImplementation(() => {}); errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); messageSpy = vi.spyOn(log, 'message').mockImplementation(() => {}); + infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {}); }); afterEach(() => { @@ -163,6 +211,7 @@ afterEach(() => { successSpy.mockRestore(); errorSpy.mockRestore(); messageSpy.mockRestore(); + infoSpy.mockRestore(); vi.unstubAllGlobals(); }); @@ -343,3 +392,187 @@ describe('runConnect — non-owner member connect-authorization round-trip', () expect(successSpy).not.toHaveBeenCalled(); }); }); + +const joinCallback = (cb: URL) => { + const hit = new URL(cb); // already carries the command's state + hit.searchParams.set('join_code', 'jc-1'); + return hit; +}; + +describe('runConnect — auto-join fallback', () => { + it('auto-joins and continues when the caller has no installations yet', async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + const listGithubInstallations = vi + .fn() + .mockResolvedValueOnce([]) // before join + .mockResolvedValueOnce([makeInstallation()]); // after join + const completeGithubJoin = vi.fn(async () => ({ status: 'joined', count: 1 })); + const connectGithub = vi.fn().mockResolvedValueOnce(makeConnection()); + + const { promise, getGithubJoinUrl } = driveConnect({ + connectGithub, + listGithubInstallations, + completeGithubJoin, + joinCallbackQuery: joinCallback, + args: { executor: 'container' }, + }); + await promise; + + expect(getGithubJoinUrl).toHaveBeenCalledTimes(1); + expect(completeGithubJoin).toHaveBeenCalledWith('jc-1', expect.any(String)); + expect(listGithubInstallations).toHaveBeenCalledTimes(2); + expect(connectGithub).toHaveBeenCalledTimes(1); + }); + + it('errors with install-first guidance when auto-join finds nothing', async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + const listGithubInstallations = vi.fn(async () => []); + const completeGithubJoin = vi.fn(async () => ({ status: 'joined', count: 0 })); + const connectGithub = vi.fn(); + + const { promise } = driveConnect({ + connectGithub, + listGithubInstallations, + completeGithubJoin, + joinCallbackQuery: joinCallback, + args: { executor: 'container' }, + }); + await expect(promise).rejects.toThrow('process.exit(1)'); + + expect(errorSpy).toHaveBeenCalledWith( + 'No GitHub App installation found for your account or your organizations. Run `void github install` first.', + ); + expect(listGithubInstallations).toHaveBeenCalledTimes(1); // zero joined → no re-list + expect(connectGithub).not.toHaveBeenCalled(); + }); + + it('falls through to the install-first error when join OAuth is not configured (503)', async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + const listGithubInstallations = vi.fn(async () => []); + const getGithubJoinUrl = vi + .fn() + .mockRejectedValueOnce( + new PlatformApiError( + 'Failed to get GitHub join URL', + 503, + JSON.stringify({ error: 'github_oauth_not_configured' }), + ), + ); + const connectGithub = vi.fn(); + + const { promise } = driveConnect({ + connectGithub, + listGithubInstallations, + getGithubJoinUrl, + args: { executor: 'container' }, + }); + await expect(promise).rejects.toThrow('process.exit(1)'); + + expect(errorSpy).toHaveBeenCalledWith( + 'No GitHub App installation found for your account or your organizations. Run `void github install` first.', + ); + // The join-command-only copy must not leak into connect: + expect(errorSpy).not.toHaveBeenCalledWith( + 'Organization installation sharing is not enabled on this Void instance.', + ); + }); + + it('never starts the join round-trip without a TTY', async () => { + // beforeEach already forces isTTY = false + const listGithubInstallations = vi.fn(async () => []); + const getGithubJoinUrl = vi.fn(); + const connectGithub = vi.fn(); + + const { promise } = driveConnect({ + connectGithub, + listGithubInstallations, + getGithubJoinUrl, + }); + await expect(promise).rejects.toThrow('process.exit(1)'); + + expect(getGithubJoinUrl).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + 'No active GitHub App installation. Run `void github install` first, or `void github join` if your organization already installed the Void GitHub App.', + ); + }); + + it('recovers a missing --installation id via auto-join', async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + const other: GithubInstallation = { + ...makeInstallation(), + id: 'install_2', + installation_id: '999999', + account_login: 'someone-else', + }; + const listGithubInstallations = vi + .fn() + .mockResolvedValueOnce([other]) // target missing + .mockResolvedValueOnce([other, makeInstallation()]); // joined + const completeGithubJoin = vi.fn(async () => ({ status: 'joined', count: 1 })); + const connectGithub = vi.fn().mockResolvedValueOnce(makeConnection()); + + const { promise, getGithubJoinUrl } = driveConnect({ + connectGithub, + listGithubInstallations, + completeGithubJoin, + joinCallbackQuery: joinCallback, + args: { executor: 'container' }, // baseArgs pins installationId = '123456' + }); + await promise; + + expect(getGithubJoinUrl).toHaveBeenCalledTimes(1); + expect(listGithubInstallations).toHaveBeenCalledTimes(2); + expect(connectGithub).toHaveBeenCalledTimes(1); + }); +}); + +describe('runConnect — member repo prompt (redacted repository list)', () => { + const memberInstallation = (): GithubInstallation => ({ + ...makeInstallation(), + repositories: [], // server redacts the list for non-owner (member) rows + repository_selection: 'selected', + }); + + it('prompts for owner/repo on a TTY instead of dead-ending', async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + mockText.mockResolvedValueOnce(repoFullName); // user types octocat/hello-world + const connectGithub = vi.fn().mockResolvedValueOnce(makeConnection()); + + const { promise } = driveConnect({ + connectGithub, + listGithubInstallations: vi.fn(async () => [memberInstallation()]), + args: { executor: 'container', repo: undefined }, // no --repo + }); + await promise; + + expect(mockText).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Repository (owner/repo):' }), + ); + // The prompt's validate callback rejects anything that isn't owner/repo. + const textOptions = mockText.mock.calls[0]![0] as { + validate: (value?: string) => string | undefined; + }; + expect(textOptions.validate('not-a-repo')).toBe('Enter the repository as owner/repo.'); + expect(textOptions.validate('')).toBe('Enter the repository as owner/repo.'); + expect(textOptions.validate('octocat/hello-world')).toBeUndefined(); + expect(textOptions.validate(' octocat/hello-world ')).toBeUndefined(); + expect(connectGithub).toHaveBeenCalledTimes(1); + // client.connectGithub(projectId, body) — body.repoFullName carries the typed repo + const body = connectGithub.mock.calls[0]![1] as { repoFullName: string }; + expect(body.repoFullName).toBe(repoFullName); + }); + + it('fails closed without a TTY, naming --repo', async () => { + const connectGithub = vi.fn(); + const { promise } = driveConnect({ + connectGithub, + listGithubInstallations: vi.fn(async () => [memberInstallation()]), + args: { repo: undefined }, + }); + await expect(promise).rejects.toThrow('process.exit(1)'); + expect(errorSpy).toHaveBeenCalledWith( + 'connect: --repo is required — this installation does not expose a repository list to your account.', + ); + expect(mockText).not.toHaveBeenCalled(); + }); +});