From 8e40694f1b07d2d7eed200042e1245519fc6dec6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 7 Jul 2026 22:26:55 +0800 Subject: [PATCH 01/10] docs: spec and plan for github connect auto-join Co-Authored-By: Claude Fable 5 --- .../2026-07-07-github-connect-auto-join.md | 649 ++++++++++++++++++ ...6-07-07-github-connect-auto-join-design.md | 108 +++ 2 files changed, 757 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-github-connect-auto-join.md create mode 100644 docs/superpowers/specs/2026-07-07-github-connect-auto-join-design.md diff --git a/docs/superpowers/plans/2026-07-07-github-connect-auto-join.md b/docs/superpowers/plans/2026-07-07-github-connect-auto-join.md new file mode 100644 index 000000000..c6092b5ca --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-github-connect-auto-join.md @@ -0,0 +1,649 @@ +# `void github connect` auto-join Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When `void github connect` finds zero active installations (or a missing `--installation` id) on a TTY, auto-run the existing join browser OAuth flow instead of telling the user to install an app their org already installed. + +**Architecture:** CLI-only change in `packages/void/src/cli/github-cmd.ts`. Extract the join browser round-trip from `runGithubJoin` into a reusable `performGithubJoin(client)` helper; `runConnect` calls it as a fallback, then re-lists installations. Also fix the member repo dead-end: the server redacts `repositories` to `[]` for non-owner rows, so `selected` + empty list must prompt for `owner/repo` on a TTY instead of erroring. No server changes. + +**Tech Stack:** TypeScript (Node 24 native type stripping), `@clack/prompts`, vitest via `vite-plus/test`, `vp` (Vite+) task runner. + +**Spec:** `docs/superpowers/specs/2026-07-07-github-connect-auto-join-design.md` + +## Global Constraints + +- Repo: `/Users/brooklyn/workspace/voidzero/void`. Do NOT touch `void-platform` or any server code. All production changes live in `packages/void/src/cli/github-cmd.ts`; tests in `packages/void/test/unit/`. +- `runGithubJoin`'s user-facing behavior (messages, exit codes) must remain byte-identical. +- Exact error copy (verbatim, including backticks): + - TTY, after auto-join found nothing: `No GitHub App installation found for your account or your organizations. Run \`void github install\` first.` + - non-TTY, empty list: `No active GitHub App installation. Run \`void github install\` first, or \`void github join\` if your organization already installed the Void GitHub App.` + - non-TTY, `selected` + empty repositories: `connect: --repo is required — this installation does not expose a repository list to your account.` +- The auto-join browser flow must never start when `process.stdin.isTTY !== true`, and must run at most once per `runConnect` invocation. +- A 503 `github_oauth_not_configured` from the join flow inside `runConnect` falls through to the install-first error (exit 1); it must NOT print `Organization installation sharing is not enabled on this Void instance.` (that copy stays exclusive to `void github join`). +- Test commands: `vp test run packages/void/test/unit/github-connect-cmd.test.ts` and `vp test run packages/void/test/unit/github-join-cmd.test.ts`. Typecheck: `vp run --filter ./packages/void typecheck`. Run from repo root. Do not run the full suite until final verification. +- Commit messages follow the repo's conventional style, e.g. `feat: auto-join org installations during \`void github connect\` (…)`. + +--- + +### Task 1: Extract `performGithubJoin` from `runGithubJoin` + +Pure refactor — existing join tests are the safety net. + +**Files:** + +- Modify: `packages/void/src/cli/github-cmd.ts:322-373` (`runGithubJoin`) +- Test (existing, must stay green): `packages/void/test/unit/github-join-cmd.test.ts` + +**Interfaces:** + +- Consumes: `awaitBrowserCallback`, `createCodeVerifier`, `createCodeChallenge` from `./auth.ts`; `PlatformClient`, `PlatformApiError` from `./client.ts` (all already imported). +- Produces: `export async function performGithubJoin(client: PlatformClient): Promise` — returns joined count, throws `PlatformApiError` (including 503 `github_oauth_not_configured`). Task 2 depends on this exact name/signature. + +- [ ] **Step 1: Replace `runGithubJoin` with helper + wrapper** + +Replace the whole function body at `github-cmd.ts:322-373` with: + +```ts +/** + * 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 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 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. + 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`.'); + } +} +``` + +- [ ] **Step 2: Verify existing join tests still pass** + +Run: `vp test run packages/void/test/unit/github-join-cmd.test.ts` +Expected: PASS (all existing tests, unchanged). + +- [ ] **Step 3: Typecheck** + +Run: `vp run --filter ./packages/void typecheck` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add packages/void/src/cli/github-cmd.ts +git commit -m "refactor: extract performGithubJoin from runGithubJoin" +``` + +--- + +### Task 2: Auto-join fallback in `runConnect` + +**Files:** + +- Modify: `packages/void/src/cli/github-cmd.ts:500-539` (`runConnect` step 2) +- Test: `packages/void/test/unit/github-connect-cmd.test.ts` + +**Interfaces:** + +- Consumes: `performGithubJoin(client: PlatformClient): Promise` from Task 1 (same file). +- Produces: nothing new for later tasks. + +- [ ] **Step 1: Extend the test harness for the join round-trip** + +In `packages/void/test/unit/github-connect-cmd.test.ts`: + +1. Add an `info` spy next to the existing log spies (both `beforeEach` and `afterEach`): + +```ts +let infoSpy: ReturnType; +// beforeEach: +infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {}); +// afterEach: +infoSpy.mockRestore(); +``` + +2. Extend `driveConnect` so a test can drive the join round-trip. Add to its `opts` type: + +```ts +listGithubInstallations?: ReturnType; +getGithubJoinUrl?: ReturnType; +completeGithubJoin?: ReturnType; +joinCallbackQuery?: (callbackUrl: URL) => URL; +``` + +Inside `driveConnect`, mirror the existing connect-authorize wiring for join (the join callback URL resolves the moment the command requests the join URL): + +```ts +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 })); +``` + +Add `listGithubInstallations: opts.listGithubInstallations ?? vi.fn(async () => [makeInstallation()])`, `getGithubJoinUrl`, and `completeGithubJoin` to the fake `client` object, and fire the join callback the same way the connect-authorize callback is fired: + +```ts +const joinFired = opts.joinCallbackQuery + ? joinCallbackReady.then(async (callbackUrl) => { + const hit = opts.joinCallbackQuery!(callbackUrl); + await originalFetch(hit).catch(() => {}); + }) + : Promise.resolve(); +``` + +Chain it into the returned promise (`.finally(() => joinFired.catch(() => {}))`, alongside the existing `fired`) and return `getGithubJoinUrl`, `completeGithubJoin`, and `listGithubInstallations` from `driveConnect` so tests can assert on them. + +- [ ] **Step 2: Write the failing tests** + +Append a new `describe` block. `makeInstallation()` and `makeConnection()` already exist; `joined()` below is the standard join callback driver: + +```ts +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); + }); +}); +``` + +Also check this file for an existing test asserting the old copy `No active GitHub App installation. Run \`void github install\` first.` — if present, update it to the new non-TTY copy (the behavior it covers is now the non-TTY branch). + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `vp test run packages/void/test/unit/github-connect-cmd.test.ts` +Expected: the new `auto-join fallback` tests FAIL (old error copy printed; `getGithubJoinUrl` never called). + +- [ ] **Step 4: Implement the fallback** + +In `runConnect`, replace step 2 (`github-cmd.ts:500-539`) with: + +```ts + // ------------------------------------------------------------------------- + // 2. Resolve installation + // ------------------------------------------------------------------------- + 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( + 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) { + 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); + } + installation = found; + } else if (installations.length === 1) { +``` + +(The rest of step 2 — single-installation shortcut, non-TTY multi error, picker — is unchanged.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `vp test run packages/void/test/unit/github-connect-cmd.test.ts` +Expected: PASS (new and pre-existing tests). + +- [ ] **Step 6: Typecheck and commit** + +Run: `vp run --filter ./packages/void typecheck` — expected clean. + +```bash +git add packages/void/src/cli/github-cmd.ts packages/void/test/unit/github-connect-cmd.test.ts +git commit -m "feat: auto-join org installations during \`void github connect\`" +``` + +--- + +### Task 3: Member repo prompt — fix the redacted-repository dead-end + +**Files:** + +- Modify: `packages/void/src/cli/github-cmd.ts:544-596` (`runConnect` step 3) +- Test: `packages/void/test/unit/github-connect-cmd.test.ts` + +**Interfaces:** + +- Consumes: `parseGithubRepoFullName`, `collectGitDeployMetadata`, `text`, `isCancel`, `cancel` (all already imported in github-cmd.ts). +- Produces: module-private `promptRepoFullName(root: string): Promise` used by both the `'all'` branch and the new `selected`+empty branch. + +- [ ] **Step 1: Write the failing tests** + +The repo prompt uses `@clack/prompts`' `text`, so the test file needs a partial module mock. At the top of `github-connect-cmd.test.ts` (next to the existing `node:child_process` mock), replace the plain clack import with: + +```ts +const mockText = vi.fn(); +vi.mock('@clack/prompts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, text: (...args: Array) => mockText(...args) }; +}); +``` + +(The existing `import { log } from '@clack/prompts'` and the `log` spies keep working — `log` passes through from the actual module.) Reset it in `beforeEach` with `mockText.mockReset()`. Existing tests never reach a `text` prompt (they pass `repo` and `branch` in args), so they are unaffected. + +Append tests: + +```ts +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):' }), + ); + 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(); + }); +}); +``` + +(`connectGithub(projectId, body)` is the real signature — `packages/void/src/cli/client.ts:972-983`; `body.repoFullName` is where the typed repo lands.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `vp test run packages/void/test/unit/github-connect-cmd.test.ts` +Expected: new tests FAIL — TTY case exits with `The installation has no accessible repositories…`, non-TTY case prints that same old copy instead of the new `--repo` copy. + +- [ ] **Step 3: Implement** + +Add the helper above `runConnect` (module-private): + +```ts +/** + * 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(); +} +``` + +Rewrite step 3's `selected` branch (`:548-569`) so the empty-list case prompts on a TTY, and the `'all'` branch (`:570-596`) uses the same helper: + +```ts + } else if (installation.repository_selection === 'selected') { + if (installation.repositories.length === 0) { + // 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) { + log.error('connect: Multiple repositories available. Pass --repo .'); + process.exit(1); + } else { + const picked = await select({ + message: 'Select a repository:', + options: installation.repositories.map((r) => ({ value: r.full_name, label: r.full_name })), + }); + if (isCancel(picked)) { + cancel('Cancelled.'); + process.exit(0); + } + repoFullName = picked as string; + } + } else { + // 'all' — repos aren't enumerable. Do NOT infer the repo from git in CI: + // GITHUB_REPOSITORY / origin can point at a fork or the wrong checkout and + // would silently bind the project to the wrong repo. Require --repo, the + // same way --branch is required in non-interactive mode. + if (!isTTY) { + log.error( + 'connect: --repo is required in non-interactive mode for an all-repositories installation.', + ); + process.exit(1); + } + repoFullName = await promptRepoFullName(root); + } +``` + +The `'all'` branch's old inline prompt (and its `connect: Repository is required.` empty-check, now covered by `validate`) is deleted. If any existing test asserts `connect: Repository is required.`, update it to expect the `validate` rejection instead (check with a grep before assuming). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `vp test run packages/void/test/unit/github-connect-cmd.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck and commit** + +Run: `vp run --filter ./packages/void typecheck` — expected clean. + +```bash +git add packages/void/src/cli/github-cmd.ts packages/void/test/unit/github-connect-cmd.test.ts +git commit -m "fix: prompt for owner/repo when a shared installation hides its repository list" +``` + +--- + +### Task 4: Docs + +**Files:** + +- Modify: `docs/reference/cli.md` (sections `### void github join` ~line 604 and `### void github connect` ~lines 614-639) + +**Interfaces:** none. + +- [ ] **Step 1: Update `docs/reference/cli.md`** + +1. In the `### void github connect` section, after the paragraph describing what connect does, add: + +> If your account has no installations yet, `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. + +2. In the non-TTY paragraph (~line 639, "When stdin is not a TTY, `void github connect` never prompts…"), append to the `--repo` sentence: + +> `--repo` is also required when the installation does not expose a repository list to your account (shared org installations hide it). + +3. In the `### void github join` section (~line 610), append: + +> You rarely need to run this yourself: `void github connect` runs the same join automatically when your account has no installations yet. + +4. Grep `packages/void/AGENT_PROMPT.md` and `packages/void/getting-started-prompt.txt` for `github install` / `github join` / `github connect` — as of planning they have no such references, so expect no changes; update only if a stale instruction turns up. Do not touch `packages/void/skills/void/docs` (gitignored, auto-copied at build). + +- [ ] **Step 2: Commit** + +```bash +git add docs/reference/cli.md +git commit -m "docs: describe github connect auto-join fallback" +``` + +--- + +### Final verification (after all tasks) + +- [ ] Run: `vp test run packages/void` — expected: full package suite PASS. +- [ ] Run: `vp run check` — expected: clean typecheck across packages. diff --git a/docs/superpowers/specs/2026-07-07-github-connect-auto-join-design.md b/docs/superpowers/specs/2026-07-07-github-connect-auto-join-design.md new file mode 100644 index 000000000..ec6a67de3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-github-connect-auto-join-design.md @@ -0,0 +1,108 @@ +# `void github connect` auto-join — Design + +Date: 2026-07-07 +Status: Approved + +## Problem + +An org teammate runs `void github connect` after someone else in their org already +installed the Void GitHub App. `GET /github/installations` only returns +installations where the caller is the owner or already has an active membership +row, so the teammate sees zero installations and the CLI exits with +`No active GitHub App installation. Run `void github install` first.` — telling +them to install an app that is already installed. + +## Decision + +CLI-only change (`packages/void/src/cli/github-cmd.ts`). When `connect` finds +zero active installations on an interactive terminal, auto-run the **existing** +join browser OAuth flow instead of erroring, then re-list and continue. + +Rejected alternative: a silent server-side org-membership probe via +`GET /orgs/:org/memberships/:username` with the installation token. Rejected +because the GitHub App lacks the org `Members: Read` permission (adding it +forces a re-consent wave across every installed org, see +void-platform `meta/operations.md`), the platform stores only numeric GitHub +account ids (no username), and void-platform design doc 0074 deliberately +requires access to be proven by the user's own GitHub authorization, never a +Void-side assertion. + +Rejected alternative: repo-first precheck endpoint ("does an active org install +cover repo X?") before the popup. Rejected because it leaks private-repo +existence and org Void usage to any authenticated user, weakening the +opaque-404 stance of design doc 0074. + +## Behavior + +``` +void github connect + │ + ▼ +list installations (active) + │ + ├─ non-empty ────────────────────────────► continue as today + │ + └─ EMPTY + ├─ not a TTY (CI) ──► error (no popup); message also mentions + │ `void github join` + └─ TTY: + log: no installation linked yet — checking whether your + organization already installed the app… + ▼ + existing join OAuth popup (same endpoints; no server change) + ├─ joined ≥ 1 ──► re-list installations ──► continue connect + ├─ joined 0 ────► error: "No GitHub App installation found for + │ your account or your organizations. Run + │ `void github install` first." + └─ 503 github_oauth_not_configured ──► same error as joined-0 + (fall through, no crash) +``` + +The "someone else already installed AND the installation covers the repo" +condition is enforced by existing machinery: join only binds installations +GitHub confirms the user can access; repo coverage is enforced at connect +(owner: cached coverage check; member: per-repo connect-authorize popup). + +## Changes + +1. **Extract `performGithubJoin(client): Promise`** from + `runGithubJoin` (`github-cmd.ts:322-373`): the PKCE + browser round-trip + + `completeGithubJoin`, returning the joined count and throwing + `PlatformApiError` (including the 503) instead of calling `process.exit`. + `runGithubJoin` keeps its exact current user-facing behavior on top of it. +2. **Empty-list fallback in `runConnect` step 2** (`:503-510`): on empty list + + TTY, run `performGithubJoin`; on count > 0 re-list; if the list is still + empty (or join threw the 503), print the improved error and exit 1. +3. **`--installation ` recovery** (`:514-519`): when the id is not in the + list (TTY only), attempt the join once, re-list, re-find; if still missing, + keep the current error. +4. **Member repo prompt fix in step 3** (`:548-553`): the server redacts + `repositories` to `[]` for non-owner (member) rows, so + `repository_selection === 'selected'` with an empty list currently + dead-ends with "The installation has no accessible repositories". On a TTY, + prompt for `owner/repo` as free-text input instead (validated as + `owner/name` shape); the server still verifies real access afterward. + Non-TTY: keep an error, reworded to suggest `--repo `. +5. **Docs**: update `docs/reference/cli.md` for `github connect`; grep + `packages/void/skills/void`, `packages/void/AGENT_PROMPT.md`, + `packages/void/getting-started-prompt.txt` for stale "run install/join + first" guidance. + +## Not changing + +No server/API changes (void-platform untouched). No GitHub App permission +changes. No change to the per-repo connect-authorize popup for members. Trust +model of design doc 0074 intact. + +## Testing + +Unit tests beside existing `github-cmd` tests with a mocked `PlatformClient`: + +- empty list → join returns 1 → re-list returns install → connect proceeds +- empty list → join returns 0 → improved error, exit 1 +- empty list → join throws 503 `github_oauth_not_configured` → improved error +- non-TTY + empty list → no join attempt, error mentions `void github join` +- `--installation ` not in list → join recovers it → connect proceeds +- member repo prompt: `selected` + empty repositories + TTY → free-text prompt + used; invalid `owner/repo` input re-prompted/rejected; non-TTY → error + suggesting `--repo` From 598becf27d8c7295a4234096743cbebbb3ec62b4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 7 Jul 2026 22:29:25 +0800 Subject: [PATCH 02/10] refactor: extract performGithubJoin from runGithubJoin Co-Authored-By: Claude Fable 5 --- packages/void/src/cli/github-cmd.ts | 64 +++++++++++++++-------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 694cc0281..01369527b 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. From 3184d3b0a5f5d57aad54c7be53a463eaa1b92c0c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 7 Jul 2026 22:36:55 +0800 Subject: [PATCH 03/10] feat: auto-join org installations during `void github connect` Co-Authored-By: Claude Fable 5 --- packages/void/src/cli/github-cmd.ts | 56 +++++- .../void/test/unit/github-connect-cmd.test.ts | 176 +++++++++++++++++- 2 files changed, 225 insertions(+), 7 deletions(-) diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 01369527b..357b8aa03 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -504,19 +504,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); diff --git a/packages/void/test/unit/github-connect-cmd.test.ts b/packages/void/test/unit/github-connect-cmd.test.ts index 1607680bf..be46c8800 100644 --- a/packages/void/test/unit/github-connect-cmd.test.ts +++ b/packages/void/test/unit/github-connect-cmd.test.ts @@ -94,11 +94,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 +120,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 +158,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,6 +179,7 @@ let exitSpy: ReturnType; let successSpy: ReturnType; let errorSpy: ReturnType; let messageSpy: ReturnType; +let infoSpy: ReturnType; const originalIsTTY = process.stdin.isTTY; beforeEach(() => { @@ -155,6 +192,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 +201,7 @@ afterEach(() => { successSpy.mockRestore(); errorSpy.mockRestore(); messageSpy.mockRestore(); + infoSpy.mockRestore(); vi.unstubAllGlobals(); }); @@ -343,3 +382,136 @@ 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); + }); +}); From 40d0c2f0e862d9598fe013ef01baa91cab932cad Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 7 Jul 2026 22:44:12 +0800 Subject: [PATCH 04/10] fix: prompt for owner/repo when a shared installation hides its repository list Co-Authored-By: Claude Fable 5 --- packages/void/src/cli/github-cmd.ts | 55 ++++++++++++------- .../void/test/unit/github-connect-cmd.test.ts | 53 ++++++++++++++++++ 2 files changed, 89 insertions(+), 19 deletions(-) diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 357b8aa03..96254c5b9 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -451,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, @@ -597,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) { @@ -628,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 be46c8800..502626542 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'; @@ -184,6 +193,7 @@ 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) => { @@ -515,3 +525,46 @@ describe('runConnect — auto-join fallback', () => { 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):' }), + ); + 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(); + }); +}); From 51b1e0cf8bad73357f27e5119af490d9fd6c2284 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 7 Jul 2026 22:49:13 +0800 Subject: [PATCH 05/10] docs: describe github connect auto-join fallback Co-Authored-By: Claude Fable 5 --- docs/reference/cli.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b9bd9e3f4..5d7c2b61d 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. +You rarely need to run this yourself — `void github connect` runs the same join automatically when your account has no installations yet. + 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. +If your account has no installations yet, `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. + **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 \ From 93fe2dc0777594f06377ca802b3734c7a821c61e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 7 Jul 2026 22:54:27 +0800 Subject: [PATCH 06/10] docs: scope github connect auto-join docs to interactive sessions Co-Authored-By: Claude Fable 5 --- docs/reference/cli.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5d7c2b61d..a05be6725 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -609,7 +609,7 @@ 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. -You rarely need to run this yourself — `void github connect` runs the same join automatically when your account has no installations yet. +In an interactive terminal you rarely need to run this yourself — `void github connect` runs the same join automatically when your account has no installations yet. 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. @@ -621,7 +621,7 @@ 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. -If your account has no installations yet, `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. +Interactively (TTY), if your account has no installations yet, `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** @@ -653,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. 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. Interactively, connect joins the shared installation automatically when your account has none yet, 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` From 2c2a1437bc45c2ff546487026d9c2e708fc73864 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 7 Jul 2026 22:59:11 +0800 Subject: [PATCH 07/10] docs: auto-join triggers on no active installations Co-Authored-By: Claude Fable 5 --- docs/reference/cli.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a05be6725..a5283a166 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -609,7 +609,7 @@ 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 your account has no installations yet. 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. +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. @@ -621,7 +621,7 @@ 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 installations yet, `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. +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** @@ -653,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. Interactively, connect joins the shared installation automatically when your account has none yet, 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. +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. 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` From b7b20414f75d5c94347a6e93653581ae903a8de9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 7 Jul 2026 23:03:56 +0800 Subject: [PATCH 08/10] docs: scope org-member connect authorization to interactive sessions Co-Authored-By: Claude Fable 5 --- docs/reference/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a5283a166..aed55fe8a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -653,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. 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. +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` From f7fafdb4d34020c59434ab0b92bf9bf5faa1c89d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 7 Jul 2026 23:22:10 +0800 Subject: [PATCH 09/10] test: cover repo prompt owner/repo validation Assert directly on the validate callback passed to @clack/prompts' text() in promptRepoFullName: rejects malformed/empty input with 'Enter the repository as owner/repo.' and accepts owner/repo (including padded input). Co-Authored-By: Claude Fable 5 --- packages/void/test/unit/github-connect-cmd.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/void/test/unit/github-connect-cmd.test.ts b/packages/void/test/unit/github-connect-cmd.test.ts index 502626542..b4e1030c8 100644 --- a/packages/void/test/unit/github-connect-cmd.test.ts +++ b/packages/void/test/unit/github-connect-cmd.test.ts @@ -548,6 +548,14 @@ describe('runConnect — member repo prompt (redacted repository list)', () => { 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 }; From eada3205ec66707d9dbf38873ea4c287730d450a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 7 Jul 2026 23:30:48 +0800 Subject: [PATCH 10/10] chore: ignore docs/superpowers Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + .../2026-07-07-github-connect-auto-join.md | 649 ------------------ ...6-07-07-github-connect-auto-join-design.md | 108 --- 3 files changed, 1 insertion(+), 757 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-07-github-connect-auto-join.md delete mode 100644 docs/superpowers/specs/2026-07-07-github-connect-auto-join-design.md 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/superpowers/plans/2026-07-07-github-connect-auto-join.md b/docs/superpowers/plans/2026-07-07-github-connect-auto-join.md deleted file mode 100644 index c6092b5ca..000000000 --- a/docs/superpowers/plans/2026-07-07-github-connect-auto-join.md +++ /dev/null @@ -1,649 +0,0 @@ -# `void github connect` auto-join Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** When `void github connect` finds zero active installations (or a missing `--installation` id) on a TTY, auto-run the existing join browser OAuth flow instead of telling the user to install an app their org already installed. - -**Architecture:** CLI-only change in `packages/void/src/cli/github-cmd.ts`. Extract the join browser round-trip from `runGithubJoin` into a reusable `performGithubJoin(client)` helper; `runConnect` calls it as a fallback, then re-lists installations. Also fix the member repo dead-end: the server redacts `repositories` to `[]` for non-owner rows, so `selected` + empty list must prompt for `owner/repo` on a TTY instead of erroring. No server changes. - -**Tech Stack:** TypeScript (Node 24 native type stripping), `@clack/prompts`, vitest via `vite-plus/test`, `vp` (Vite+) task runner. - -**Spec:** `docs/superpowers/specs/2026-07-07-github-connect-auto-join-design.md` - -## Global Constraints - -- Repo: `/Users/brooklyn/workspace/voidzero/void`. Do NOT touch `void-platform` or any server code. All production changes live in `packages/void/src/cli/github-cmd.ts`; tests in `packages/void/test/unit/`. -- `runGithubJoin`'s user-facing behavior (messages, exit codes) must remain byte-identical. -- Exact error copy (verbatim, including backticks): - - TTY, after auto-join found nothing: `No GitHub App installation found for your account or your organizations. Run \`void github install\` first.` - - non-TTY, empty list: `No active GitHub App installation. Run \`void github install\` first, or \`void github join\` if your organization already installed the Void GitHub App.` - - non-TTY, `selected` + empty repositories: `connect: --repo is required — this installation does not expose a repository list to your account.` -- The auto-join browser flow must never start when `process.stdin.isTTY !== true`, and must run at most once per `runConnect` invocation. -- A 503 `github_oauth_not_configured` from the join flow inside `runConnect` falls through to the install-first error (exit 1); it must NOT print `Organization installation sharing is not enabled on this Void instance.` (that copy stays exclusive to `void github join`). -- Test commands: `vp test run packages/void/test/unit/github-connect-cmd.test.ts` and `vp test run packages/void/test/unit/github-join-cmd.test.ts`. Typecheck: `vp run --filter ./packages/void typecheck`. Run from repo root. Do not run the full suite until final verification. -- Commit messages follow the repo's conventional style, e.g. `feat: auto-join org installations during \`void github connect\` (…)`. - ---- - -### Task 1: Extract `performGithubJoin` from `runGithubJoin` - -Pure refactor — existing join tests are the safety net. - -**Files:** - -- Modify: `packages/void/src/cli/github-cmd.ts:322-373` (`runGithubJoin`) -- Test (existing, must stay green): `packages/void/test/unit/github-join-cmd.test.ts` - -**Interfaces:** - -- Consumes: `awaitBrowserCallback`, `createCodeVerifier`, `createCodeChallenge` from `./auth.ts`; `PlatformClient`, `PlatformApiError` from `./client.ts` (all already imported). -- Produces: `export async function performGithubJoin(client: PlatformClient): Promise` — returns joined count, throws `PlatformApiError` (including 503 `github_oauth_not_configured`). Task 2 depends on this exact name/signature. - -- [ ] **Step 1: Replace `runGithubJoin` with helper + wrapper** - -Replace the whole function body at `github-cmd.ts:322-373` with: - -```ts -/** - * 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 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 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. - 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`.'); - } -} -``` - -- [ ] **Step 2: Verify existing join tests still pass** - -Run: `vp test run packages/void/test/unit/github-join-cmd.test.ts` -Expected: PASS (all existing tests, unchanged). - -- [ ] **Step 3: Typecheck** - -Run: `vp run --filter ./packages/void typecheck` -Expected: clean. - -- [ ] **Step 4: Commit** - -```bash -git add packages/void/src/cli/github-cmd.ts -git commit -m "refactor: extract performGithubJoin from runGithubJoin" -``` - ---- - -### Task 2: Auto-join fallback in `runConnect` - -**Files:** - -- Modify: `packages/void/src/cli/github-cmd.ts:500-539` (`runConnect` step 2) -- Test: `packages/void/test/unit/github-connect-cmd.test.ts` - -**Interfaces:** - -- Consumes: `performGithubJoin(client: PlatformClient): Promise` from Task 1 (same file). -- Produces: nothing new for later tasks. - -- [ ] **Step 1: Extend the test harness for the join round-trip** - -In `packages/void/test/unit/github-connect-cmd.test.ts`: - -1. Add an `info` spy next to the existing log spies (both `beforeEach` and `afterEach`): - -```ts -let infoSpy: ReturnType; -// beforeEach: -infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {}); -// afterEach: -infoSpy.mockRestore(); -``` - -2. Extend `driveConnect` so a test can drive the join round-trip. Add to its `opts` type: - -```ts -listGithubInstallations?: ReturnType; -getGithubJoinUrl?: ReturnType; -completeGithubJoin?: ReturnType; -joinCallbackQuery?: (callbackUrl: URL) => URL; -``` - -Inside `driveConnect`, mirror the existing connect-authorize wiring for join (the join callback URL resolves the moment the command requests the join URL): - -```ts -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 })); -``` - -Add `listGithubInstallations: opts.listGithubInstallations ?? vi.fn(async () => [makeInstallation()])`, `getGithubJoinUrl`, and `completeGithubJoin` to the fake `client` object, and fire the join callback the same way the connect-authorize callback is fired: - -```ts -const joinFired = opts.joinCallbackQuery - ? joinCallbackReady.then(async (callbackUrl) => { - const hit = opts.joinCallbackQuery!(callbackUrl); - await originalFetch(hit).catch(() => {}); - }) - : Promise.resolve(); -``` - -Chain it into the returned promise (`.finally(() => joinFired.catch(() => {}))`, alongside the existing `fired`) and return `getGithubJoinUrl`, `completeGithubJoin`, and `listGithubInstallations` from `driveConnect` so tests can assert on them. - -- [ ] **Step 2: Write the failing tests** - -Append a new `describe` block. `makeInstallation()` and `makeConnection()` already exist; `joined()` below is the standard join callback driver: - -```ts -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); - }); -}); -``` - -Also check this file for an existing test asserting the old copy `No active GitHub App installation. Run \`void github install\` first.` — if present, update it to the new non-TTY copy (the behavior it covers is now the non-TTY branch). - -- [ ] **Step 3: Run tests to verify they fail** - -Run: `vp test run packages/void/test/unit/github-connect-cmd.test.ts` -Expected: the new `auto-join fallback` tests FAIL (old error copy printed; `getGithubJoinUrl` never called). - -- [ ] **Step 4: Implement the fallback** - -In `runConnect`, replace step 2 (`github-cmd.ts:500-539`) with: - -```ts - // ------------------------------------------------------------------------- - // 2. Resolve installation - // ------------------------------------------------------------------------- - 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( - 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) { - 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); - } - installation = found; - } else if (installations.length === 1) { -``` - -(The rest of step 2 — single-installation shortcut, non-TTY multi error, picker — is unchanged.) - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `vp test run packages/void/test/unit/github-connect-cmd.test.ts` -Expected: PASS (new and pre-existing tests). - -- [ ] **Step 6: Typecheck and commit** - -Run: `vp run --filter ./packages/void typecheck` — expected clean. - -```bash -git add packages/void/src/cli/github-cmd.ts packages/void/test/unit/github-connect-cmd.test.ts -git commit -m "feat: auto-join org installations during \`void github connect\`" -``` - ---- - -### Task 3: Member repo prompt — fix the redacted-repository dead-end - -**Files:** - -- Modify: `packages/void/src/cli/github-cmd.ts:544-596` (`runConnect` step 3) -- Test: `packages/void/test/unit/github-connect-cmd.test.ts` - -**Interfaces:** - -- Consumes: `parseGithubRepoFullName`, `collectGitDeployMetadata`, `text`, `isCancel`, `cancel` (all already imported in github-cmd.ts). -- Produces: module-private `promptRepoFullName(root: string): Promise` used by both the `'all'` branch and the new `selected`+empty branch. - -- [ ] **Step 1: Write the failing tests** - -The repo prompt uses `@clack/prompts`' `text`, so the test file needs a partial module mock. At the top of `github-connect-cmd.test.ts` (next to the existing `node:child_process` mock), replace the plain clack import with: - -```ts -const mockText = vi.fn(); -vi.mock('@clack/prompts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, text: (...args: Array) => mockText(...args) }; -}); -``` - -(The existing `import { log } from '@clack/prompts'` and the `log` spies keep working — `log` passes through from the actual module.) Reset it in `beforeEach` with `mockText.mockReset()`. Existing tests never reach a `text` prompt (they pass `repo` and `branch` in args), so they are unaffected. - -Append tests: - -```ts -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):' }), - ); - 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(); - }); -}); -``` - -(`connectGithub(projectId, body)` is the real signature — `packages/void/src/cli/client.ts:972-983`; `body.repoFullName` is where the typed repo lands.) - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `vp test run packages/void/test/unit/github-connect-cmd.test.ts` -Expected: new tests FAIL — TTY case exits with `The installation has no accessible repositories…`, non-TTY case prints that same old copy instead of the new `--repo` copy. - -- [ ] **Step 3: Implement** - -Add the helper above `runConnect` (module-private): - -```ts -/** - * 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(); -} -``` - -Rewrite step 3's `selected` branch (`:548-569`) so the empty-list case prompts on a TTY, and the `'all'` branch (`:570-596`) uses the same helper: - -```ts - } else if (installation.repository_selection === 'selected') { - if (installation.repositories.length === 0) { - // 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) { - log.error('connect: Multiple repositories available. Pass --repo .'); - process.exit(1); - } else { - const picked = await select({ - message: 'Select a repository:', - options: installation.repositories.map((r) => ({ value: r.full_name, label: r.full_name })), - }); - if (isCancel(picked)) { - cancel('Cancelled.'); - process.exit(0); - } - repoFullName = picked as string; - } - } else { - // 'all' — repos aren't enumerable. Do NOT infer the repo from git in CI: - // GITHUB_REPOSITORY / origin can point at a fork or the wrong checkout and - // would silently bind the project to the wrong repo. Require --repo, the - // same way --branch is required in non-interactive mode. - if (!isTTY) { - log.error( - 'connect: --repo is required in non-interactive mode for an all-repositories installation.', - ); - process.exit(1); - } - repoFullName = await promptRepoFullName(root); - } -``` - -The `'all'` branch's old inline prompt (and its `connect: Repository is required.` empty-check, now covered by `validate`) is deleted. If any existing test asserts `connect: Repository is required.`, update it to expect the `validate` rejection instead (check with a grep before assuming). - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `vp test run packages/void/test/unit/github-connect-cmd.test.ts` -Expected: PASS. - -- [ ] **Step 5: Typecheck and commit** - -Run: `vp run --filter ./packages/void typecheck` — expected clean. - -```bash -git add packages/void/src/cli/github-cmd.ts packages/void/test/unit/github-connect-cmd.test.ts -git commit -m "fix: prompt for owner/repo when a shared installation hides its repository list" -``` - ---- - -### Task 4: Docs - -**Files:** - -- Modify: `docs/reference/cli.md` (sections `### void github join` ~line 604 and `### void github connect` ~lines 614-639) - -**Interfaces:** none. - -- [ ] **Step 1: Update `docs/reference/cli.md`** - -1. In the `### void github connect` section, after the paragraph describing what connect does, add: - -> If your account has no installations yet, `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. - -2. In the non-TTY paragraph (~line 639, "When stdin is not a TTY, `void github connect` never prompts…"), append to the `--repo` sentence: - -> `--repo` is also required when the installation does not expose a repository list to your account (shared org installations hide it). - -3. In the `### void github join` section (~line 610), append: - -> You rarely need to run this yourself: `void github connect` runs the same join automatically when your account has no installations yet. - -4. Grep `packages/void/AGENT_PROMPT.md` and `packages/void/getting-started-prompt.txt` for `github install` / `github join` / `github connect` — as of planning they have no such references, so expect no changes; update only if a stale instruction turns up. Do not touch `packages/void/skills/void/docs` (gitignored, auto-copied at build). - -- [ ] **Step 2: Commit** - -```bash -git add docs/reference/cli.md -git commit -m "docs: describe github connect auto-join fallback" -``` - ---- - -### Final verification (after all tasks) - -- [ ] Run: `vp test run packages/void` — expected: full package suite PASS. -- [ ] Run: `vp run check` — expected: clean typecheck across packages. diff --git a/docs/superpowers/specs/2026-07-07-github-connect-auto-join-design.md b/docs/superpowers/specs/2026-07-07-github-connect-auto-join-design.md deleted file mode 100644 index ec6a67de3..000000000 --- a/docs/superpowers/specs/2026-07-07-github-connect-auto-join-design.md +++ /dev/null @@ -1,108 +0,0 @@ -# `void github connect` auto-join — Design - -Date: 2026-07-07 -Status: Approved - -## Problem - -An org teammate runs `void github connect` after someone else in their org already -installed the Void GitHub App. `GET /github/installations` only returns -installations where the caller is the owner or already has an active membership -row, so the teammate sees zero installations and the CLI exits with -`No active GitHub App installation. Run `void github install` first.` — telling -them to install an app that is already installed. - -## Decision - -CLI-only change (`packages/void/src/cli/github-cmd.ts`). When `connect` finds -zero active installations on an interactive terminal, auto-run the **existing** -join browser OAuth flow instead of erroring, then re-list and continue. - -Rejected alternative: a silent server-side org-membership probe via -`GET /orgs/:org/memberships/:username` with the installation token. Rejected -because the GitHub App lacks the org `Members: Read` permission (adding it -forces a re-consent wave across every installed org, see -void-platform `meta/operations.md`), the platform stores only numeric GitHub -account ids (no username), and void-platform design doc 0074 deliberately -requires access to be proven by the user's own GitHub authorization, never a -Void-side assertion. - -Rejected alternative: repo-first precheck endpoint ("does an active org install -cover repo X?") before the popup. Rejected because it leaks private-repo -existence and org Void usage to any authenticated user, weakening the -opaque-404 stance of design doc 0074. - -## Behavior - -``` -void github connect - │ - ▼ -list installations (active) - │ - ├─ non-empty ────────────────────────────► continue as today - │ - └─ EMPTY - ├─ not a TTY (CI) ──► error (no popup); message also mentions - │ `void github join` - └─ TTY: - log: no installation linked yet — checking whether your - organization already installed the app… - ▼ - existing join OAuth popup (same endpoints; no server change) - ├─ joined ≥ 1 ──► re-list installations ──► continue connect - ├─ joined 0 ────► error: "No GitHub App installation found for - │ your account or your organizations. Run - │ `void github install` first." - └─ 503 github_oauth_not_configured ──► same error as joined-0 - (fall through, no crash) -``` - -The "someone else already installed AND the installation covers the repo" -condition is enforced by existing machinery: join only binds installations -GitHub confirms the user can access; repo coverage is enforced at connect -(owner: cached coverage check; member: per-repo connect-authorize popup). - -## Changes - -1. **Extract `performGithubJoin(client): Promise`** from - `runGithubJoin` (`github-cmd.ts:322-373`): the PKCE + browser round-trip + - `completeGithubJoin`, returning the joined count and throwing - `PlatformApiError` (including the 503) instead of calling `process.exit`. - `runGithubJoin` keeps its exact current user-facing behavior on top of it. -2. **Empty-list fallback in `runConnect` step 2** (`:503-510`): on empty list + - TTY, run `performGithubJoin`; on count > 0 re-list; if the list is still - empty (or join threw the 503), print the improved error and exit 1. -3. **`--installation ` recovery** (`:514-519`): when the id is not in the - list (TTY only), attempt the join once, re-list, re-find; if still missing, - keep the current error. -4. **Member repo prompt fix in step 3** (`:548-553`): the server redacts - `repositories` to `[]` for non-owner (member) rows, so - `repository_selection === 'selected'` with an empty list currently - dead-ends with "The installation has no accessible repositories". On a TTY, - prompt for `owner/repo` as free-text input instead (validated as - `owner/name` shape); the server still verifies real access afterward. - Non-TTY: keep an error, reworded to suggest `--repo `. -5. **Docs**: update `docs/reference/cli.md` for `github connect`; grep - `packages/void/skills/void`, `packages/void/AGENT_PROMPT.md`, - `packages/void/getting-started-prompt.txt` for stale "run install/join - first" guidance. - -## Not changing - -No server/API changes (void-platform untouched). No GitHub App permission -changes. No change to the per-repo connect-authorize popup for members. Trust -model of design doc 0074 intact. - -## Testing - -Unit tests beside existing `github-cmd` tests with a mocked `PlatformClient`: - -- empty list → join returns 1 → re-list returns install → connect proceeds -- empty list → join returns 0 → improved error, exit 1 -- empty list → join throws 503 `github_oauth_not_configured` → improved error -- non-TTY + empty list → no join attempt, error mentions `void github join` -- `--installation ` not in list → join recovers it → connect proceeds -- member repo prompt: `selected` + empty repositories + TTY → free-text prompt - used; invalid `owner/repo` input re-prompted/rejected; non-TTY → error - suggesting `--repo`