From feb1efabc07cda908f25c82d38d001ae7f1804b3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 21:57:53 +0800 Subject: [PATCH 01/13] feat(cli): add PlatformClient GitHub methods + types Add GithubInstallation and GithubConnection exported types plus getGithubInstallUrl, listGithubInstallations, and connectGithub methods to PlatformClient. Tests mirror direct-r2-client.test.ts fetch-stub style and cover GETs, POST body/headers, and error path. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/client.ts | 78 ++++++++ packages/void/test/unit/github-client.test.ts | 179 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 packages/void/test/unit/github-client.test.ts diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index a71dbabc3..c37aaeaa6 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -184,6 +184,31 @@ export type DomainStatusResponse = DomainResponse & { awaiting_deployment?: boolean; }; +export type GithubInstallation = { + id: string; + installation_id: string; + account_login: string; + account_id: string; + account_type: 'User' | 'Organization'; + repositories: Array<{ full_name: string; id: number }>; + repository_selection: 'all' | 'selected'; + status: 'active' | 'suspended' | 'deleted'; + created_at: string; + updated_at: string; +}; + +export type GithubConnection = { + id: string; + project_id: string; + installation_id: string; + repo_full_name: string; + repository_id: string | null; + branch: string; + build_executor: 'container' | 'github_actions'; + created_at: string; + updated_at: string; +}; + /** * Asset declaration shape for `POST /:id/deploy/upload-urls`. Each entry * carries both hashes the platform needs: @@ -851,4 +876,57 @@ export class PlatformClient { const json = (await res.json()) as { data: Array }; return json.data; } + + async getGithubInstallUrl(): Promise<{ url: string }> { + const res = await fetch(`${this.baseUrl}/github/install-url`, { + headers: { + ...this.authHeaders, + }, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to get GitHub install URL', res); + } + + return res.json(); + } + + async listGithubInstallations(): Promise> { + const res = await fetch(`${this.baseUrl}/github/installations`, { + headers: { + ...this.authHeaders, + }, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to list GitHub installations', res); + } + + return res.json(); + } + + async connectGithub( + projectId: string, + body: { + installationId: string; + repoFullName: string; + branch?: string; + buildExecutor?: 'container' | 'github_actions'; + }, + ): Promise { + const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.authHeaders, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to connect GitHub repository', res); + } + + return res.json(); + } } diff --git a/packages/void/test/unit/github-client.test.ts b/packages/void/test/unit/github-client.test.ts new file mode 100644 index 000000000..c61284cd1 --- /dev/null +++ b/packages/void/test/unit/github-client.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + PlatformClient, + type GithubConnection, + type GithubInstallation, +} from '../../src/cli/client.ts'; + +const fetchMock = vi.fn(); +let originalFetch: typeof globalThis.fetch | undefined; + +beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + fetchMock.mockReset(); +}); + +afterEach(() => { + if (originalFetch) { + globalThis.fetch = originalFetch; + } +}); + +const apiUrl = 'https://api.test'; +const token = 'auth-token-x'; +const projectId = 'proj_test'; + +function makeClient(): PlatformClient { + return new PlatformClient(token, { apiUrl }); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('PlatformClient.getGithubInstallUrl', () => { + it('GETs the right URL, sends auth headers, returns parsed { url }', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ url: 'https://github.com/apps/void-test/installations/new' }), + ); + + const client = makeClient(); + const result = await client.getGithubInstallUrl(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${apiUrl}/github/install-url`); + expect(init.method).toBeUndefined(); + const headers = init.headers as Record; + expect(headers.Authorization).toBe(`Bearer ${token}`); + expect(result).toEqual({ url: 'https://github.com/apps/void-test/installations/new' }); + }); + + it('throws PlatformApiError on non-2xx', async () => { + fetchMock.mockResolvedValueOnce(new Response('unauthorized', { status: 401 })); + const client = makeClient(); + await expect(client.getGithubInstallUrl()).rejects.toMatchObject({ + name: 'PlatformApiError', + status: 401, + }); + }); +}); + +describe('PlatformClient.listGithubInstallations', () => { + it('GETs the right URL and returns the parsed array', async () => { + const installations: Array = [ + { + id: 'install_1', + installation_id: '123456', + account_login: 'octocat', + account_id: '1', + account_type: 'User', + repositories: [{ full_name: 'octocat/hello-world', id: 1 }], + repository_selection: 'selected', + status: 'active', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + ]; + + fetchMock.mockResolvedValueOnce(jsonResponse(installations)); + + const client = makeClient(); + const result = await client.listGithubInstallations(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${apiUrl}/github/installations`); + expect(init.method).toBeUndefined(); + const headers = init.headers as Record; + expect(headers.Authorization).toBe(`Bearer ${token}`); + expect(result).toEqual(installations); + }); +}); + +describe('PlatformClient.connectGithub', () => { + it('POSTs the right URL with Content-Type and exact JSON body, returns parsed connection', async () => { + const connection: GithubConnection = { + id: 'conn_1', + project_id: projectId, + installation_id: '123456', + repo_full_name: 'octocat/hello-world', + repository_id: '9876', + branch: 'main', + build_executor: 'container', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + fetchMock.mockResolvedValueOnce(jsonResponse(connection)); + + const client = makeClient(); + const result = await client.connectGithub(projectId, { + installationId: '123456', + repoFullName: 'octocat/hello-world', + branch: 'main', + buildExecutor: 'container', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${apiUrl}/projects/${projectId}/github`); + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + expect(headers.Authorization).toBe(`Bearer ${token}`); + expect(JSON.parse(init.body as string)).toEqual({ + installationId: '123456', + repoFullName: 'octocat/hello-world', + branch: 'main', + buildExecutor: 'container', + }); + expect(result).toEqual(connection); + }); + + it('omits optional fields from JSON body when not provided', async () => { + const connection: GithubConnection = { + id: 'conn_2', + project_id: projectId, + installation_id: '123456', + repo_full_name: 'octocat/hello-world', + repository_id: null, + branch: 'main', + build_executor: 'container', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + fetchMock.mockResolvedValueOnce(jsonResponse(connection)); + + const client = makeClient(); + await client.connectGithub(projectId, { + installationId: '123456', + repoFullName: 'octocat/hello-world', + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + installationId: '123456', + repoFullName: 'octocat/hello-world', + }); + }); + + it('throws PlatformApiError on non-2xx', async () => { + fetchMock.mockResolvedValueOnce(new Response('not found', { status: 404 })); + const client = makeClient(); + await expect( + client.connectGithub(projectId, { + installationId: '123456', + repoFullName: 'octocat/hello-world', + }), + ).rejects.toMatchObject({ + name: 'PlatformApiError', + status: 404, + }); + }); +}); From b6da13d5bd130f9e5aa03481b0d828ce61781d64 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 22:01:08 +0800 Subject: [PATCH 02/13] feat(cli): add github subcommand arg parser Adds github-args.ts with githubSubcommands defs, GithubArgs discriminated union, and parseGithubArgs; covers install, installations, connect (+flags). Unit tests for all paths including error cases. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/github-args.ts | 76 +++++++++ packages/void/test/unit/github-args.test.ts | 180 ++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 packages/void/src/cli/github-args.ts create mode 100644 packages/void/test/unit/github-args.test.ts diff --git a/packages/void/src/cli/github-args.ts b/packages/void/src/cli/github-args.ts new file mode 100644 index 000000000..c255d9ac4 --- /dev/null +++ b/packages/void/src/cli/github-args.ts @@ -0,0 +1,76 @@ +import type { SubcommandDef } from './subcommand-prompt.ts'; + +export const githubSubcommands: Array = [ + { name: 'install', description: 'Open the GitHub App install page in your browser' }, + { name: 'installations', description: 'List your GitHub App installations' }, + { + name: 'connect', + arg: '[project]', + description: 'Connect a repository to a project for auto-deploy', + }, +]; + +export type GithubArgs = + | { subcommand: 'install' } + | { subcommand: 'installations' } + | { + subcommand: 'connect'; + projectName: string; + installationId?: string; + repo?: string; + branch?: string; + executor?: 'container' | 'github_actions'; + }; + +export function parseGithubArgs(args: Array): GithubArgs { + const [subcommand, ...rest] = args; + + if (subcommand === 'install') { + if (rest.length > 0) { + throw new Error(`github: Unexpected argument '${rest[0]}'.`); + } + return { subcommand }; + } + + if (subcommand === 'installations') { + if (rest.length > 0) { + throw new Error(`github: Unexpected argument '${rest[0]}'.`); + } + return { subcommand }; + } + + if (subcommand === 'connect') { + let projectName = ''; + let installationId: string | undefined; + let repo: string | undefined; + let branch: string | undefined; + let executor: 'container' | 'github_actions' | undefined; + + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + if (arg === '--installation') { + installationId = rest[++i]; + } else if (arg === '--repo') { + repo = rest[++i]; + } else if (arg === '--branch') { + branch = rest[++i]; + } else if (arg === '--executor') { + const value = rest[++i]; + if (value !== 'container' && value !== 'github_actions') { + throw new Error('github: invalid --executor (expected container|github_actions).'); + } + executor = value; + } else if (arg.startsWith('-')) { + throw new Error(`github: Unexpected argument '${arg}'.`); + } else if (projectName) { + throw new Error(`github: Unexpected argument '${arg}'.`); + } else { + projectName = arg; + } + } + + return { subcommand, projectName, installationId, repo, branch, executor }; + } + + throw new Error('github: Usage: void github .'); +} diff --git a/packages/void/test/unit/github-args.test.ts b/packages/void/test/unit/github-args.test.ts new file mode 100644 index 000000000..b35481016 --- /dev/null +++ b/packages/void/test/unit/github-args.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { parseGithubArgs } from '../../src/cli/github-args.ts'; + +describe('parseGithubArgs', () => { + describe('install', () => { + it('parses install with no args', () => { + expect(parseGithubArgs(['install'])).toEqual({ subcommand: 'install' }); + }); + + it('rejects extra positional', () => { + expect(() => parseGithubArgs(['install', 'extra'])).toThrow( + "github: Unexpected argument 'extra'.", + ); + }); + + it('rejects a flag', () => { + expect(() => parseGithubArgs(['install', '--flag'])).toThrow( + "github: Unexpected argument '--flag'.", + ); + }); + }); + + describe('installations', () => { + it('parses installations with no args', () => { + expect(parseGithubArgs(['installations'])).toEqual({ subcommand: 'installations' }); + }); + + it('rejects extra positional', () => { + expect(() => parseGithubArgs(['installations', 'extra'])).toThrow( + "github: Unexpected argument 'extra'.", + ); + }); + + it('rejects a flag', () => { + expect(() => parseGithubArgs(['installations', '--flag'])).toThrow( + "github: Unexpected argument '--flag'.", + ); + }); + }); + + describe('connect', () => { + it('parses connect with no args', () => { + expect(parseGithubArgs(['connect'])).toEqual({ + subcommand: 'connect', + projectName: '', + installationId: undefined, + repo: undefined, + branch: undefined, + executor: undefined, + }); + }); + + it('parses connect with a project name', () => { + expect(parseGithubArgs(['connect', 'my-project'])).toEqual({ + subcommand: 'connect', + projectName: 'my-project', + installationId: undefined, + repo: undefined, + branch: undefined, + executor: undefined, + }); + }); + + it('parses --installation flag', () => { + const result = parseGithubArgs(['connect', '--installation', '42']); + expect(result).toEqual({ + subcommand: 'connect', + projectName: '', + installationId: '42', + repo: undefined, + branch: undefined, + executor: undefined, + }); + }); + + it('parses --repo flag', () => { + const result = parseGithubArgs(['connect', '--repo', 'owner/name']); + expect(result).toEqual({ + subcommand: 'connect', + projectName: '', + installationId: undefined, + repo: 'owner/name', + branch: undefined, + executor: undefined, + }); + }); + + it('parses --branch flag', () => { + const result = parseGithubArgs(['connect', '--branch', 'main']); + expect(result).toEqual({ + subcommand: 'connect', + projectName: '', + installationId: undefined, + repo: undefined, + branch: 'main', + executor: undefined, + }); + }); + + it('parses --executor container', () => { + const result = parseGithubArgs(['connect', '--executor', 'container']); + expect(result).toEqual({ + subcommand: 'connect', + projectName: '', + installationId: undefined, + repo: undefined, + branch: undefined, + executor: 'container', + }); + }); + + it('parses --executor github_actions', () => { + const result = parseGithubArgs(['connect', '--executor', 'github_actions']); + expect(result).toEqual({ + subcommand: 'connect', + projectName: '', + installationId: undefined, + repo: undefined, + branch: undefined, + executor: 'github_actions', + }); + }); + + it('parses all flags together with project name', () => { + expect( + parseGithubArgs([ + 'connect', + 'my-project', + '--installation', + '42', + '--repo', + 'owner/repo', + '--branch', + 'main', + '--executor', + 'container', + ]), + ).toEqual({ + subcommand: 'connect', + projectName: 'my-project', + installationId: '42', + repo: 'owner/repo', + branch: 'main', + executor: 'container', + }); + }); + + it('rejects invalid --executor value', () => { + expect(() => parseGithubArgs(['connect', '--executor', 'docker'])).toThrow( + 'github: invalid --executor (expected container|github_actions).', + ); + }); + + it('rejects unknown flag', () => { + expect(() => parseGithubArgs(['connect', '--unknown'])).toThrow( + "github: Unexpected argument '--unknown'.", + ); + }); + + it('rejects extra positional after project name', () => { + expect(() => parseGithubArgs(['connect', 'my-project', 'extra'])).toThrow( + "github: Unexpected argument 'extra'.", + ); + }); + }); + + describe('unknown subcommand', () => { + it('throws on empty args', () => { + expect(() => parseGithubArgs([])).toThrow( + 'github: Usage: void github .', + ); + }); + + it('throws on unknown subcommand', () => { + expect(() => parseGithubArgs(['unknown'])).toThrow( + 'github: Usage: void github .', + ); + }); + }); +}); From 9273c57232b0dca4ffef0d961d2cc2bdb0fa8791 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 22:03:40 +0800 Subject: [PATCH 03/13] fix(cli): error on github connect value-flag with no value Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/github-args.ts | 12 +++++++++++ packages/void/test/unit/github-args.test.ts | 24 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/void/src/cli/github-args.ts b/packages/void/src/cli/github-args.ts index c255d9ac4..0d969af32 100644 --- a/packages/void/src/cli/github-args.ts +++ b/packages/void/src/cli/github-args.ts @@ -49,12 +49,24 @@ export function parseGithubArgs(args: Array): GithubArgs { for (let i = 0; i < rest.length; i++) { const arg = rest[i]; if (arg === '--installation') { + if (i + 1 >= rest.length) { + throw new Error('github: --installation requires a value.'); + } installationId = rest[++i]; } else if (arg === '--repo') { + if (i + 1 >= rest.length) { + throw new Error('github: --repo requires a value.'); + } repo = rest[++i]; } else if (arg === '--branch') { + if (i + 1 >= rest.length) { + throw new Error('github: --branch requires a value.'); + } branch = rest[++i]; } else if (arg === '--executor') { + if (i + 1 >= rest.length) { + throw new Error('github: --executor requires a value.'); + } const value = rest[++i]; if (value !== 'container' && value !== 'github_actions') { throw new Error('github: invalid --executor (expected container|github_actions).'); diff --git a/packages/void/test/unit/github-args.test.ts b/packages/void/test/unit/github-args.test.ts index b35481016..f56854ada 100644 --- a/packages/void/test/unit/github-args.test.ts +++ b/packages/void/test/unit/github-args.test.ts @@ -145,6 +145,30 @@ describe('parseGithubArgs', () => { }); }); + it('rejects --installation with no value', () => { + expect(() => parseGithubArgs(['connect', '--installation'])).toThrow( + 'github: --installation requires a value.', + ); + }); + + it('rejects --repo with no value', () => { + expect(() => parseGithubArgs(['connect', '--repo'])).toThrow( + 'github: --repo requires a value.', + ); + }); + + it('rejects --branch with no value', () => { + expect(() => parseGithubArgs(['connect', '--branch'])).toThrow( + 'github: --branch requires a value.', + ); + }); + + it('rejects --executor with no value', () => { + expect(() => parseGithubArgs(['connect', '--executor'])).toThrow( + 'github: --executor requires a value.', + ); + }); + it('rejects invalid --executor value', () => { expect(() => parseGithubArgs(['connect', '--executor', 'docker'])).toThrow( 'github: invalid --executor (expected container|github_actions).', From a01f8586f6775b78b43ca351c3e4081793135a23 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 22:07:33 +0800 Subject: [PATCH 04/13] feat(cli): implement void github command (install/installations/connect) - Export openBrowser from auth.ts - Add github-cmd.ts: runGithubCommand dispatching install/installations/connect - Pure helpers: parseGithubRepoFullName, formatInstallations - Unit tests for both pure helpers (13 tests) Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/auth.ts | 2 +- packages/void/src/cli/github-cmd.ts | 342 +++++++++++++++++++++ packages/void/test/unit/github-cmd.test.ts | 117 +++++++ 3 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 packages/void/src/cli/github-cmd.ts create mode 100644 packages/void/test/unit/github-cmd.test.ts diff --git a/packages/void/src/cli/auth.ts b/packages/void/src/cli/auth.ts index af325fcfa..4bac8a1ad 100644 --- a/packages/void/src/cli/auth.ts +++ b/packages/void/src/cli/auth.ts @@ -233,7 +233,7 @@ function page(title: string, message: string): string {

${title}

${message}

`; } -function openBrowser(url: string, exec: typeof import('node:child_process').exec): void { +export function openBrowser(url: string, exec: typeof import('node:child_process').exec): void { if (process.platform === 'win32') { // `start` treats the first quoted arg as window title, pass empty title exec(`start "" "${url}"`); diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts new file mode 100644 index 000000000..a026b02ad --- /dev/null +++ b/packages/void/src/cli/github-cmd.ts @@ -0,0 +1,342 @@ +import { cancel, isCancel, log, select, text } from '@clack/prompts'; +import { openBrowser, getToken } from './auth.ts'; +import { + PlatformApiError, + PlatformClient, + parsePlatformErrorBody, + type GithubInstallation, +} from './client.ts'; +import { collectGitDeployMetadata } from './git-metadata.ts'; +import type { GithubArgs } from './github-args.ts'; +import { pc } from './output.ts'; +import { readProjectConfig } from './project.ts'; +import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; + +// --------------------------------------------------------------------------- +// Pure helpers (exported for unit tests) +// --------------------------------------------------------------------------- + +/** + * Extract `owner/repo` from a `https://github.com/owner/repo(.git)?` URL. + * Returns null for non-GitHub URLs or null input. + */ +export function parseGithubRepoFullName(repositoryUrl: string | null): string | null { + if (!repositoryUrl) { + return null; + } + try { + const url = new URL(repositoryUrl); + if (url.hostname !== 'github.com') { + return null; + } + // pathname is like /owner/repo or /owner/repo.git + const parts = url.pathname + .replace(/^\//, '') + .replace(/\.git$/, '') + .split('/'); + if (parts.length < 2 || !parts[0] || !parts[1]) { + return null; + } + return `${parts[0]}/${parts[1]}`; + } catch { + return null; + } +} + +/** + * Format a list of GitHub App installations into a human-readable string. + * One block per installation: + * account_login (account_type) — selection, N repos, status + * [repo list for 'selected' installs] + */ +export function formatInstallations(installations: Array): string { + return installations + .map((inst) => { + const header = `${inst.account_login} (${inst.account_type}) — ${inst.repository_selection}, ${inst.repositories.length} repos, ${inst.status}`; + if (inst.repository_selection === 'selected' && inst.repositories.length > 0) { + const repos = inst.repositories.map((r) => ` • ${r.full_name}`).join('\n'); + return `${header}\n${repos}`; + } + return header; + }) + .join('\n\n'); +} + +// --------------------------------------------------------------------------- +// Main command +// --------------------------------------------------------------------------- + +export async function runGithubCommand(root: string, args: GithubArgs): Promise { + const token = getToken(root); + if (!token) { + log.error('Not logged in. Run `void auth login` first.'); + process.exit(1); + } + const client = new PlatformClient(token, { root }); + + switch (args.subcommand) { + case 'install': + return runInstall(root, client); + case 'installations': + return runInstallations(client); + case 'connect': + return runConnect(root, client, args); + } +} + +// --------------------------------------------------------------------------- +// install +// --------------------------------------------------------------------------- + +async function runInstall(root: string, client: PlatformClient): Promise { + let url: string; + try { + ({ url } = await client.getGithubInstallUrl()); + } catch (err) { + if (err instanceof PlatformApiError && err.status === 403) { + log.error( + 'Your account has no linked GitHub identity. Run `void auth login` and choose GitHub.', + ); + process.exit(1); + } + throw err; + } + + log.message(`Open this URL to install the Void GitHub App:\n${pc.cyan(url)}`); + const { exec } = await import('node:child_process'); + openBrowser(url, exec); + log.message('After installing, run `void github connect` to link a repository to your project.'); +} + +// --------------------------------------------------------------------------- +// installations +// --------------------------------------------------------------------------- + +async function runInstallations(client: PlatformClient): Promise { + const installations = await client.listGithubInstallations(); + if (installations.length === 0) { + log.info('No GitHub App installations. Run `void github install` first.'); + return; + } + log.message(formatInstallations(installations)); +} + +// --------------------------------------------------------------------------- +// connect +// --------------------------------------------------------------------------- + +async function runConnect( + root: string, + client: PlatformClient, + args: Extract, +): Promise { + const isTTY = process.stdin.isTTY === true; + + // ------------------------------------------------------------------------- + // 1. Resolve project + // ------------------------------------------------------------------------- + let projectId: string; + + const requestedSlug = getRequestedProjectSlug({ projectSlug: args.projectName || undefined }); + + if (requestedSlug) { + const projects = await client.listProjects(); + const resolved = resolveProjectBySlug(projects, requestedSlug); + if (!resolved) { + log.error(`connect: No project found with slug '${requestedSlug}'.`); + process.exit(1); + } + projectId = resolved.projectId; + } else { + const linked = readProjectConfig(root); + if (linked) { + projectId = linked.projectId; + } else if (!isTTY) { + log.error('connect: No project specified. Pass a project name or --project .'); + process.exit(1); + } else { + // Prompt to pick from list + const projects = await client.listProjects(); + if (projects.length === 0) { + log.error('No projects found. Create one first with `void deploy`.'); + process.exit(1); + } + const picked = await select({ + message: 'Select a project:', + options: projects.map((p) => ({ value: p.id, label: p.slug })), + }); + if (isCancel(picked)) { + cancel('Cancelled.'); + process.exit(0); + } + projectId = picked as string; + } + } + + // ------------------------------------------------------------------------- + // 2. Resolve installation + // ------------------------------------------------------------------------- + const installations = (await client.listGithubInstallations()).filter( + (i) => i.status === 'active', + ); + + if (installations.length === 0) { + log.error('No active GitHub App installation. Run `void github install` first.'); + process.exit(1); + } + + let installation: GithubInstallation; + + if (args.installationId) { + const 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) { + installation = installations[0]!; + } else if (!isTTY) { + log.error('connect: Multiple installations found. Pass --installation .'); + process.exit(1); + } else { + const picked = await select({ + message: 'Select a GitHub App installation:', + options: installations.map((i) => ({ + value: i.installation_id, + label: `${i.account_login} (${i.account_type})`, + })), + }); + if (isCancel(picked)) { + cancel('Cancelled.'); + process.exit(0); + } + installation = installations.find((i) => i.installation_id === picked)!; + } + + // ------------------------------------------------------------------------- + // 3. Resolve repo full name + // ------------------------------------------------------------------------- + let repoFullName: string; + + if (args.repo) { + 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); + } + if (!isTTY) { + log.error('connect: Multiple repositories available. Pass --repo .'); + process.exit(1); + } + 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 not enumerable; fall back to git + const gitDefault = parseGithubRepoFullName(collectGitDeployMetadata(root, null).repositoryUrl); + if (!isTTY) { + if (!gitDefault) { + log.error('connect: Cannot detect repository from git. Pass --repo .'); + process.exit(1); + } + repoFullName = gitDefault; + } else { + 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); + } + } + } + + // ------------------------------------------------------------------------- + // 4. Resolve branch + // ------------------------------------------------------------------------- + const branchDefault = args.branch ?? collectGitDeployMetadata(root, null).gitRef ?? 'main'; + + let branch: string; + if (args.branch) { + branch = args.branch; + } else if (!isTTY) { + branch = branchDefault; + } else { + const input = await text({ + message: 'Branch to deploy from:', + placeholder: branchDefault, + initialValue: branchDefault, + }); + if (isCancel(input)) { + cancel('Cancelled.'); + process.exit(0); + } + branch = ((input as string) || branchDefault).trim(); + } + + // ------------------------------------------------------------------------- + // 5. Executor + // ------------------------------------------------------------------------- + const executor = args.executor ?? 'container'; + + // ------------------------------------------------------------------------- + // 6. Connect + // ------------------------------------------------------------------------- + try { + await client.connectGithub(projectId, { + installationId: installation.installation_id, + repoFullName, + branch, + buildExecutor: executor, + }); + } catch (err) { + if (err instanceof PlatformApiError) { + const body = parsePlatformErrorBody(err.body); + const code = body?.error ?? body?.message ?? ''; + + if (err.status === 409) { + if (code === 'already_connected') { + log.error('connect: This project is already connected to a repository.'); + } else { + log.error( + 'connect: Installation is not active. Re-install the GitHub App and try again.', + ); + } + process.exit(1); + } + if (err.status === 400) { + log.error(`connect: Repository '${repoFullName}' is not accessible via this installation.`); + process.exit(1); + } + if (err.status === 403) { + log.error('connect: Forbidden. Check that you own the project and the installation.'); + process.exit(1); + } + if (err.status === 404) { + log.error('connect: Project or installation not found.'); + process.exit(1); + } + throw err; + } + throw err; + } + + log.success(`Connected ${repoFullName} (${branch}) → project. Push to ${branch} to deploy.`); +} diff --git a/packages/void/test/unit/github-cmd.test.ts b/packages/void/test/unit/github-cmd.test.ts new file mode 100644 index 000000000..dc8589b34 --- /dev/null +++ b/packages/void/test/unit/github-cmd.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vite-plus/test'; +import type { GithubInstallation } from '../../src/cli/client.ts'; +import { formatInstallations, parseGithubRepoFullName } from '../../src/cli/github-cmd.ts'; + +describe('parseGithubRepoFullName', () => { + it('extracts owner/repo from a plain https GitHub URL', () => { + expect(parseGithubRepoFullName('https://github.com/acme/my-app')).toBe('acme/my-app'); + }); + + it('strips the .git suffix', () => { + expect(parseGithubRepoFullName('https://github.com/acme/my-app.git')).toBe('acme/my-app'); + }); + + it('returns null for a non-GitHub URL', () => { + expect(parseGithubRepoFullName('https://gitlab.com/acme/my-app')).toBe(null); + }); + + it('returns null for a non-GitHub URL with .git suffix', () => { + expect(parseGithubRepoFullName('https://bitbucket.org/acme/my-app.git')).toBe(null); + }); + + it('returns null for null input', () => { + expect(parseGithubRepoFullName(null)).toBe(null); + }); + + it('returns null for an unparseable string', () => { + expect(parseGithubRepoFullName('not-a-url')).toBe(null); + }); + + it('returns null for a GitHub URL with only one path segment', () => { + expect(parseGithubRepoFullName('https://github.com/acme')).toBe(null); + }); +}); + +function makeInstall( + overrides: Partial & Pick, +): GithubInstallation { + return { + id: 'inst_1', + installation_id: '42', + account_id: '1', + account_type: 'User', + repositories: [], + repository_selection: 'all', + status: 'active', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...overrides, + }; +} + +describe('formatInstallations', () => { + it('includes account_login, account_type, selection, repo count, and status', () => { + const result = formatInstallations([makeInstall({ account_login: 'acme' })]); + expect(result).toContain('acme'); + expect(result).toContain('User'); + expect(result).toContain('all'); + expect(result).toContain('0 repos'); + expect(result).toContain('active'); + }); + + it('lists repo full names for a selected installation', () => { + const inst = makeInstall({ + account_login: 'org', + account_type: 'Organization', + repository_selection: 'selected', + repositories: [ + { full_name: 'org/alpha', id: 1 }, + { full_name: 'org/beta', id: 2 }, + ], + }); + const result = formatInstallations([inst]); + expect(result).toContain('org/alpha'); + expect(result).toContain('org/beta'); + expect(result).toContain('selected'); + expect(result).toContain('2 repos'); + }); + + it('does not list repos for an all-selection installation', () => { + const inst = makeInstall({ + account_login: 'user', + repository_selection: 'all', + repositories: [{ full_name: 'user/private', id: 99 }], + }); + const result = formatInstallations([inst]); + // The repo list block should not appear (no bullet points) + expect(result).not.toContain('user/private'); + expect(result).toContain('all'); + }); + + it('returns an empty string for an empty list', () => { + expect(formatInstallations([])).toBe(''); + }); + + it('separates multiple installations with a blank line', () => { + const a = makeInstall({ account_login: 'alice' }); + const b = makeInstall({ account_login: 'bob' }); + const result = formatInstallations([a, b]); + expect(result).toContain('alice'); + expect(result).toContain('bob'); + // Two blocks separated by double newline + expect(result).toContain('\n\n'); + }); + + it('does not list repos for a selected installation with zero repos', () => { + const inst = makeInstall({ + account_login: 'empty-org', + repository_selection: 'selected', + repositories: [], + }); + const result = formatInstallations([inst]); + expect(result).toContain('selected'); + expect(result).toContain('0 repos'); + // No bullet character since repo list is empty + expect(result).not.toContain('•'); + }); +}); From 31f557d13d776937ddc692dcd4464a8ef5f6ba5d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 22:12:51 +0800 Subject: [PATCH 05/13] fix(cli): correct github connect error mapping to real API strings Extract `describeConnectError` pure helper with substring matching against the actual API error strings ('project already connected', 'installation is not active', 'repository not accessible by installation'). Replaces the broken `code === 'already_connected'` exact-match that never fired, which caused already-connected repos to show the wrong "not active" message. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/github-cmd.ts | 50 +++++++++++++--------- packages/void/test/unit/github-cmd.test.ts | 48 ++++++++++++++++++++- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index a026b02ad..eb88f7bc3 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -16,6 +16,32 @@ import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project // Pure helpers (exported for unit tests) // --------------------------------------------------------------------------- +/** + * Map a PlatformApiError status + error code string to a friendly user message. + * Returns null for unrecognised cases (caller should re-throw). + */ +export function describeConnectError(status: number, errorCode: string | undefined): string | null { + const code = errorCode ?? ''; + if (status === 409) { + if (code.includes('already connected')) { + return 'This project is already connected to a GitHub repository.'; + } + if (code.includes('not active')) { + return 'Installation is not active. Re-install the GitHub App and try again.'; + } + } + if (status === 400 && code.includes('not accessible')) { + return 'Repository not accessible by this installation. Check the App has access to it.'; + } + if (status === 403) { + return "You don't have access to this project."; + } + if (status === 404) { + return 'Project or installation not found.'; + } + return null; +} + /** * Extract `owner/repo` from a `https://github.com/owner/repo(.git)?` URL. * Returns null for non-GitHub URLs or null input. @@ -310,27 +336,9 @@ async function runConnect( if (err instanceof PlatformApiError) { const body = parsePlatformErrorBody(err.body); const code = body?.error ?? body?.message ?? ''; - - if (err.status === 409) { - if (code === 'already_connected') { - log.error('connect: This project is already connected to a repository.'); - } else { - log.error( - 'connect: Installation is not active. Re-install the GitHub App and try again.', - ); - } - process.exit(1); - } - if (err.status === 400) { - log.error(`connect: Repository '${repoFullName}' is not accessible via this installation.`); - process.exit(1); - } - if (err.status === 403) { - log.error('connect: Forbidden. Check that you own the project and the installation.'); - process.exit(1); - } - if (err.status === 404) { - log.error('connect: Project or installation not found.'); + const message = describeConnectError(err.status, code); + if (message !== null) { + log.error(`connect: ${message}`); process.exit(1); } throw err; diff --git a/packages/void/test/unit/github-cmd.test.ts b/packages/void/test/unit/github-cmd.test.ts index dc8589b34..c3c494d67 100644 --- a/packages/void/test/unit/github-cmd.test.ts +++ b/packages/void/test/unit/github-cmd.test.ts @@ -1,6 +1,52 @@ import { describe, expect, it } from 'vite-plus/test'; import type { GithubInstallation } from '../../src/cli/client.ts'; -import { formatInstallations, parseGithubRepoFullName } from '../../src/cli/github-cmd.ts'; +import { + describeConnectError, + formatInstallations, + parseGithubRepoFullName, +} from '../../src/cli/github-cmd.ts'; + +describe('describeConnectError', () => { + it('returns already-connected message for 409 / "project already connected"', () => { + const msg = describeConnectError(409, 'project already connected'); + expect(msg).toBe('This project is already connected to a GitHub repository.'); + }); + + it('returns not-active message for 409 / "installation is not active"', () => { + const msg = describeConnectError(409, 'installation is not active'); + expect(msg).toBe('Installation is not active. Re-install the GitHub App and try again.'); + }); + + it('returns not-accessible message for 400 / "repository not accessible by installation"', () => { + const msg = describeConnectError(400, 'repository not accessible by installation'); + expect(msg).toBe( + 'Repository not accessible by this installation. Check the App has access to it.', + ); + }); + + it('returns forbidden message for 403', () => { + const msg = describeConnectError(403, 'forbidden'); + expect(msg).toBe("You don't have access to this project."); + }); + + it('returns not-found message for 404 / "project not found"', () => { + const msg = describeConnectError(404, 'project not found'); + expect(msg).toBe('Project or installation not found.'); + }); + + it('returns not-found message for 404 / "installation not found"', () => { + const msg = describeConnectError(404, 'installation not found'); + expect(msg).toBe('Project or installation not found.'); + }); + + it('returns null for an unrecognised status / code', () => { + expect(describeConnectError(500, undefined)).toBeNull(); + }); + + it('returns null for an unrecognised 409 code', () => { + expect(describeConnectError(409, 'some other conflict')).toBeNull(); + }); +}); describe('parseGithubRepoFullName', () => { it('extracts owner/repo from a plain https GitHub URL', () => { From 7633a0aacaeffc44324e233f5219da952f76cb1c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 22:17:20 +0800 Subject: [PATCH 06/13] feat(cli): wire void github command into dispatcher + help Register `github` in the CLI dispatcher (non-interactive dispatch + interactive top-level list/switch) and add the HELP_TREE entry with install/installations/connect subcommands. Extend cli-help.test.ts to assert all three subcommands appear in help. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/cli.ts | 14 ++++++++ packages/void/src/cli/help.ts | 43 ++++++++++++++++++++++++ packages/void/test/unit/cli-help.test.ts | 21 ++++++++++++ 3 files changed, 78 insertions(+) diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index b95fa3b58..486e285c3 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -10,6 +10,7 @@ import { dbSubcommands, parseDbArgs } from './db-args.ts'; import { domainSubcommands, parseDomainArgs } from './domain-args.ts'; import { envSubcommands, parseEnvArgs } from './env-args.ts'; import { genSubcommands, parseGenArgs } from './gen-args.ts'; +import { githubSubcommands, parseGithubArgs } from './github-args.ts'; import { getHelpPath, printGlobalHelp, printHelpPage, printVoidHeader } from './help.ts'; import { parsePrepareArgs } from './prepare-args.ts'; import { projectSubcommands, parseProjectArgs } from './project-args.ts'; @@ -132,6 +133,10 @@ if (helpPath) { runSubcommandGroup('gen', genSubcommands, parseGenArgs, (root, parsed) => import('./gen.ts').then(({ runGenCommand }) => runGenCommand(root, parsed)), ); +} else if (command === 'github') { + runSubcommandGroup('github', githubSubcommands, parseGithubArgs, (root, parsed) => + import('./github-cmd.ts').then(({ runGithubCommand }) => runGithubCommand(root, parsed)), + ); } else if (command === 'prepare') { const root = process.cwd(); let prepareArgs; @@ -194,6 +199,11 @@ if (helpPath) { hint: 'add | delete | list | status', description: 'Configure custom domains', }, + { + name: 'github', + hint: 'install | installations | connect', + description: 'Connect a GitHub repository for automatic deploys', + }, { name: 'db', hint: 'status | reset | seed | execute | migrate | studio | export | rename-migrations', @@ -261,6 +271,10 @@ if (helpPath) { return runSubcommandGroup('domain', domainSubcommands, parseDomainArgs, (r, p) => import('./domain.ts').then(({ runDomainCommand }) => runDomainCommand(r, p)), ); + case 'github': + return runSubcommandGroup('github', githubSubcommands, parseGithubArgs, (r, p) => + import('./github-cmd.ts').then(({ runGithubCommand }) => runGithubCommand(r, p)), + ); case 'db': return runSubcommandGroup('db', dbSubcommands, parseDbArgs, (r, p) => import('./db.ts').then(({ runDbCommand }) => runDbCommand(r, p)), diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index fb6a7bfc3..260671180 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -190,6 +190,47 @@ const HELP_TREE: HelpTree = { }, }, }, + github: { + summary: 'Connect a GitHub repository for automatic deploys.', + usage: ['void github '], + commands: [ + { command: 'install', description: 'Open the GitHub App install page in your browser' }, + { command: 'installations', description: 'List your GitHub App installations' }, + { + command: 'connect [project]', + description: 'Connect a repository to a project for auto-deploy', + }, + ], + children: { + install: basicSubcommand( + 'Open the GitHub App install page in your browser.', + 'void github install', + ), + installations: basicSubcommand( + 'List your GitHub App installations.', + 'void github installations', + ), + connect: { + summary: 'Connect a repository to a project for automatic deploys.', + usage: ['void github connect [project] [options]'], + options: [ + { command: '--installation ', description: 'GitHub App installation ID' }, + { command: '--repo ', description: 'Repository full name (owner/repo)' }, + { command: '--branch ', description: 'Branch to deploy from (default: main)' }, + { + command: '--executor ', + description: 'Build executor: container (default) | github_actions', + }, + ], + examples: [ + { + command: 'void github connect [project]', + description: 'Connect a repository to a project', + }, + ], + }, + }, + }, db: { summary: 'Manage local and remote database state.', usage: ['void db '], @@ -407,6 +448,8 @@ export function printGlobalHelp(): void { '', ...globalCommandGroup('domain'), '', + ...globalCommandGroup('github'), + '', categoryTitle('database'), ...globalCommandLines('db', { execute: 'execute ', diff --git a/packages/void/test/unit/cli-help.test.ts b/packages/void/test/unit/cli-help.test.ts index d0598c0e1..263913363 100644 --- a/packages/void/test/unit/cli-help.test.ts +++ b/packages/void/test/unit/cli-help.test.ts @@ -45,4 +45,25 @@ describe('CLI help resolver', () => { expect(hasHelpPage(['missing'])).toBe(false); expect(hasHelpPage(['db', 'missing'])).toBe(false); }); + + it('resolves github command help requests', () => { + expect(getHelpPath(['help', 'github'])).toEqual(['github']); + expect(getHelpPath(['github', '--help'])).toEqual(['github']); + expect(getHelpPath(['github', '-h'])).toEqual(['github']); + }); + + it('resolves github subcommand help requests', () => { + expect(getHelpPath(['help', 'github', 'install'])).toEqual(['github', 'install']); + expect(getHelpPath(['help', 'github', 'installations'])).toEqual(['github', 'installations']); + expect(getHelpPath(['help', 'github', 'connect'])).toEqual(['github', 'connect']); + expect(getHelpPath(['github', 'connect', '--help'])).toEqual(['github', 'connect']); + }); + + it('tracks known github help pages', () => { + expect(hasHelpPage(['github'])).toBe(true); + expect(hasHelpPage(['github', 'install'])).toBe(true); + expect(hasHelpPage(['github', 'installations'])).toBe(true); + expect(hasHelpPage(['github', 'connect'])).toBe(true); + expect(hasHelpPage(['github', 'missing'])).toBe(false); + }); }); From 44b43b467a8ee7a7c01a22443350ed0515803ebe Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 22:40:08 +0800 Subject: [PATCH 07/13] fix(cli): harden void github connect (empty-flag guard, --project, installation id, non-TTY --branch) Co-Authored-By: Claude Opus 4.8 --- docs/reference/cli.md | 49 +++++++++++ packages/void/src/cli/github-args.ts | 35 +++++++- packages/void/src/cli/github-cmd.ts | 7 +- packages/void/src/cli/help.ts | 10 ++- packages/void/test/unit/github-args.test.ts | 97 +++++++++++++++++++++ packages/void/test/unit/github-cmd.test.ts | 3 +- 6 files changed, 192 insertions(+), 9 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1c66f641e..6512a396d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -566,6 +566,55 @@ PORT=3000 See [Environment Variables](../guide/env-vars.md) for the full guide. +## GitHub + +### `void github install` + +``` +void github install +``` + +Open the GitHub App install page in your browser. After installing, run `void github connect` to link a repository to your project. + +### `void github installations` + +``` +void github installations +``` + +List all GitHub App installations linked to your account. Each entry includes the `[id: ]` needed for `--installation` in non-interactive use. + +### `void github connect` + +``` +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. + +**Options** + +| Flag | Description | +| --------------------- | ----------------------------------------------------------------------------- | +| `--project ` | Project name (alias for the positional argument) | +| `--installation ` | GitHub App installation ID (required when you have multiple installations) | +| `--repo ` | Repository full name — required when the installation has access to all repos | +| `--branch ` | Branch to deploy from — **required in non-interactive mode** | +| `--executor ` | Build executor: `container` (default) or `github_actions` | + +**Non-interactive use (CI)** + +All of `--project` (or positional), `--installation`, `--repo`, and `--branch` must be supplied explicitly when stdin is not a TTY. Use `void github installations` to discover the `installation_id`. + +``` +void github connect my-app \ + --installation 42 \ + --repo owner/my-app \ + --branch main +``` + +**Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`). + ## Custom Domains ### `void domain add` diff --git a/packages/void/src/cli/github-args.ts b/packages/void/src/cli/github-args.ts index 0d969af32..1b1e988c9 100644 --- a/packages/void/src/cli/github-args.ts +++ b/packages/void/src/cli/github-args.ts @@ -52,22 +52,49 @@ export function parseGithubArgs(args: Array): GithubArgs { if (i + 1 >= rest.length) { throw new Error('github: --installation requires a value.'); } - installationId = rest[++i]; + const value = rest[++i]!; + if (value.trim() === '') { + throw new Error('github: --installation requires a value.'); + } + installationId = value; } else if (arg === '--repo') { if (i + 1 >= rest.length) { throw new Error('github: --repo requires a value.'); } - repo = rest[++i]; + const value = rest[++i]!; + if (value.trim() === '') { + throw new Error('github: --repo requires a value.'); + } + repo = value; } else if (arg === '--branch') { if (i + 1 >= rest.length) { throw new Error('github: --branch requires a value.'); } - branch = rest[++i]; + const value = rest[++i]!; + if (value.trim() === '') { + throw new Error('github: --branch requires a value.'); + } + branch = value; + } else if (arg === '--project') { + if (i + 1 >= rest.length) { + throw new Error('github: --project requires a value.'); + } + const value = rest[++i]!; + if (value.trim() === '') { + throw new Error('github: --project requires a value.'); + } + if (projectName) { + throw new Error(`github: Unexpected argument '${value}'.`); + } + projectName = value; } else if (arg === '--executor') { if (i + 1 >= rest.length) { throw new Error('github: --executor requires a value.'); } - const value = rest[++i]; + const value = rest[++i]!; + if (value.trim() === '') { + throw new Error('github: --executor requires a value.'); + } if (value !== 'container' && value !== 'github_actions') { throw new Error('github: invalid --executor (expected container|github_actions).'); } diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index eb88f7bc3..ed4baae5e 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -78,7 +78,7 @@ export function parseGithubRepoFullName(repositoryUrl: string | null): string | export function formatInstallations(installations: Array): string { return installations .map((inst) => { - const header = `${inst.account_login} (${inst.account_type}) — ${inst.repository_selection}, ${inst.repositories.length} repos, ${inst.status}`; + const header = `${inst.account_login} (${inst.account_type}) [id: ${inst.installation_id}] — ${inst.repository_selection}, ${inst.repositories.length} repos, ${inst.status}`; if (inst.repository_selection === 'selected' && inst.repositories.length > 0) { const repos = inst.repositories.map((r) => ` • ${r.full_name}`).join('\n'); return `${header}\n${repos}`; @@ -230,7 +230,7 @@ async function runConnect( message: 'Select a GitHub App installation:', options: installations.map((i) => ({ value: i.installation_id, - label: `${i.account_login} (${i.account_type})`, + label: `${i.account_login} (${i.account_type}) [id: ${i.installation_id}]`, })), }); if (isCancel(picked)) { @@ -303,7 +303,8 @@ async function runConnect( if (args.branch) { branch = args.branch; } else if (!isTTY) { - branch = branchDefault; + log.error('connect: --branch is required in non-interactive mode. Pass --branch .'); + process.exit(1); } else { const input = await text({ message: 'Branch to deploy from:', diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 260671180..e2bce200a 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -214,9 +214,13 @@ const HELP_TREE: HelpTree = { summary: 'Connect a repository to a project for automatic deploys.', usage: ['void github connect [project] [options]'], options: [ + { command: '--project ', description: 'Project name (alias for positional)' }, { command: '--installation ', description: 'GitHub App installation ID' }, { command: '--repo ', description: 'Repository full name (owner/repo)' }, - { command: '--branch ', description: 'Branch to deploy from (default: main)' }, + { + command: '--branch ', + description: 'Branch to deploy from (required in non-interactive mode)', + }, { command: '--executor ', description: 'Build executor: container (default) | github_actions', @@ -227,6 +231,10 @@ const HELP_TREE: HelpTree = { command: 'void github connect [project]', description: 'Connect a repository to a project', }, + { + command: 'void github connect my-app --installation 42 --repo owner/repo --branch main', + description: 'Non-interactive connect (all flags explicit)', + }, ], }, }, diff --git a/packages/void/test/unit/github-args.test.ts b/packages/void/test/unit/github-args.test.ts index f56854ada..9d410595a 100644 --- a/packages/void/test/unit/github-args.test.ts +++ b/packages/void/test/unit/github-args.test.ts @@ -121,6 +121,36 @@ describe('parseGithubArgs', () => { }); }); + it('parses --project flag', () => { + const result = parseGithubArgs(['connect', '--project', 'my-project']); + expect(result).toEqual({ + subcommand: 'connect', + projectName: 'my-project', + installationId: undefined, + repo: undefined, + branch: undefined, + executor: undefined, + }); + }); + + it('--project flag is an alias for the positional', () => { + const positional = parseGithubArgs(['connect', 'my-project']); + const flag = parseGithubArgs(['connect', '--project', 'my-project']); + expect(flag).toEqual(positional); + }); + + it('rejects --project when positional already set', () => { + expect(() => parseGithubArgs(['connect', 'my-project', '--project', 'other'])).toThrow( + "github: Unexpected argument 'other'.", + ); + }); + + it('rejects --project when --project already set', () => { + expect(() => + parseGithubArgs(['connect', '--project', 'first', '--project', 'second']), + ).toThrow("github: Unexpected argument 'second'."); + }); + it('parses all flags together with project name', () => { expect( parseGithubArgs([ @@ -145,30 +175,97 @@ describe('parseGithubArgs', () => { }); }); + it('parses all flags together with --project flag', () => { + expect( + parseGithubArgs([ + 'connect', + '--project', + 'my-project', + '--installation', + '42', + '--repo', + 'owner/repo', + '--branch', + 'main', + '--executor', + 'container', + ]), + ).toEqual({ + subcommand: 'connect', + projectName: 'my-project', + installationId: '42', + repo: 'owner/repo', + branch: 'main', + executor: 'container', + }); + }); + it('rejects --installation with no value', () => { expect(() => parseGithubArgs(['connect', '--installation'])).toThrow( 'github: --installation requires a value.', ); }); + it('rejects --installation with empty value', () => { + expect(() => parseGithubArgs(['connect', '--installation', ''])).toThrow( + 'github: --installation requires a value.', + ); + }); + + it('rejects --installation with whitespace-only value', () => { + expect(() => parseGithubArgs(['connect', '--installation', ' '])).toThrow( + 'github: --installation requires a value.', + ); + }); + it('rejects --repo with no value', () => { expect(() => parseGithubArgs(['connect', '--repo'])).toThrow( 'github: --repo requires a value.', ); }); + it('rejects --repo with empty value', () => { + expect(() => parseGithubArgs(['connect', '--repo', ''])).toThrow( + 'github: --repo requires a value.', + ); + }); + it('rejects --branch with no value', () => { expect(() => parseGithubArgs(['connect', '--branch'])).toThrow( 'github: --branch requires a value.', ); }); + it('rejects --branch with empty value', () => { + expect(() => parseGithubArgs(['connect', '--branch', ''])).toThrow( + 'github: --branch requires a value.', + ); + }); + + it('rejects --project with no value', () => { + expect(() => parseGithubArgs(['connect', '--project'])).toThrow( + 'github: --project requires a value.', + ); + }); + + it('rejects --project with empty value', () => { + expect(() => parseGithubArgs(['connect', '--project', ''])).toThrow( + 'github: --project requires a value.', + ); + }); + it('rejects --executor with no value', () => { expect(() => parseGithubArgs(['connect', '--executor'])).toThrow( 'github: --executor requires a value.', ); }); + it('rejects --executor with empty value', () => { + expect(() => parseGithubArgs(['connect', '--executor', ''])).toThrow( + 'github: --executor requires a value.', + ); + }); + it('rejects invalid --executor value', () => { expect(() => parseGithubArgs(['connect', '--executor', 'docker'])).toThrow( 'github: invalid --executor (expected container|github_actions).', diff --git a/packages/void/test/unit/github-cmd.test.ts b/packages/void/test/unit/github-cmd.test.ts index c3c494d67..126283181 100644 --- a/packages/void/test/unit/github-cmd.test.ts +++ b/packages/void/test/unit/github-cmd.test.ts @@ -96,10 +96,11 @@ function makeInstall( } describe('formatInstallations', () => { - it('includes account_login, account_type, selection, repo count, and status', () => { + it('includes account_login, account_type, installation_id, selection, repo count, and status', () => { const result = formatInstallations([makeInstall({ account_login: 'acme' })]); expect(result).toContain('acme'); expect(result).toContain('User'); + expect(result).toContain('[id: 42]'); expect(result).toContain('all'); expect(result).toContain('0 repos'); expect(result).toContain('active'); From d7f0a7b8a361ef53bfdc3d30821bf556df9ebf0f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 22:54:05 +0800 Subject: [PATCH 08/13] fix(cli): auto-pick single selected repo + accurate no-project message - In the `selected` repo-resolution branch, auto-pick when exactly one repo is accessible (mirrors the installation-resolution single-item pattern); only error with "Multiple repositories" when count > 1 and non-TTY. - Expand the non-TTY no-project error to mention VOID_PROJECT and `void project link` as additional resolution sources. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/github-cmd.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index ed4baae5e..34cb84c24 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -178,7 +178,9 @@ async function runConnect( if (linked) { projectId = linked.projectId; } else if (!isTTY) { - log.error('connect: No project specified. Pass a project name or --project .'); + log.error( + 'connect: No project specified. Pass a project name or --project , set VOID_PROJECT, or link one with `void project link`.', + ); process.exit(1); } else { // Prompt to pick from list @@ -253,20 +255,22 @@ async function runConnect( 'connect: The installation has no accessible repositories. Update the GitHub App install to grant access.', ); process.exit(1); - } - if (!isTTY) { + } 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; } - 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 not enumerable; fall back to git const gitDefault = parseGithubRepoFullName(collectGitDeployMetadata(root, null).repositoryUrl); From 2eaba5f37415f3fd2082732044a9c19339c4e1b0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 23:00:08 +0800 Subject: [PATCH 09/13] fix(cli): github connect rejects whitespace-only branch (trim before fallback) Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/github-cmd.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 34cb84c24..2e24ee40e 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -319,7 +319,7 @@ async function runConnect( cancel('Cancelled.'); process.exit(0); } - branch = ((input as string) || branchDefault).trim(); + branch = (input as string).trim() || branchDefault; } // ------------------------------------------------------------------------- From 7d8fc595df51958c0f257e0f291c23caf4c96a1a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 23:24:17 +0800 Subject: [PATCH 10/13] fix(cli): launch browser via execFile/argv instead of a shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openBrowser built shell command strings with the URL interpolated (start ""/open/xdg-open/wslview). A login or install URL is server- or git-derived, so a crafted URL could inject shell commands. Pass the URL as a single argv element via execFile — no shell, no parsing. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/auth.ts | 28 +++++++++++++++++++--------- packages/void/src/cli/github-cmd.ts | 4 ++-- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/void/src/cli/auth.ts b/packages/void/src/cli/auth.ts index 4bac8a1ad..59e819c57 100644 --- a/packages/void/src/cli/auth.ts +++ b/packages/void/src/cli/auth.ts @@ -233,23 +233,33 @@ function page(title: string, message: string): string {

${title}

${message}

`; } -export function openBrowser(url: string, exec: typeof import('node:child_process').exec): void { +export function openBrowser( + url: string, + execFile: typeof import('node:child_process').execFile, +): void { + // Launch via argv (execFile), never a shell: the URL is passed as a single + // argument so shell metacharacters in it can't be interpreted as commands. + const ignore = (): void => {}; if (process.platform === 'win32') { - // `start` treats the first quoted arg as window title, pass empty title - exec(`start "" "${url}"`); + // `start` is a cmd builtin; the empty "" is the (ignored) window title. + execFile('cmd.exe', ['/c', 'start', '', url], ignore); return; } if (process.platform === 'darwin') { - exec(`open "${url}"`); + execFile('open', [url], ignore); return; } // Linux — including WSL2 where `xdg-open` typically has no usable handler. - // Try `wslview` (wslu), then `cmd.exe /c start`, then fall back to `xdg-open`. + // Try `wslview` (wslu), then fall back to Windows `start` via cmd.exe. if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) { - exec(`wslview "${url}" 2>/dev/null || cmd.exe /c start "" "${url}" 2>/dev/null`); + execFile('wslview', [url], (error) => { + if (error) { + execFile('cmd.exe', ['/c', 'start', '', url], ignore); + } + }); return; } - exec(`xdg-open "${url}"`); + execFile('xdg-open', [url], ignore); } /** @@ -267,7 +277,7 @@ export async function runLogin( onUrl?: (url: string) => void, ): Promise { const http = await import('node:http'); - const { exec } = await import('node:child_process'); + const { execFile } = await import('node:child_process'); const apiUrl = getApiUrl(root); const state = randomBytes(32).toString('hex'); @@ -372,7 +382,7 @@ export async function runLogin( loginUrl.searchParams.set('code_challenge_method', 'S256'); onUrl?.(loginUrl.href); - openBrowser(loginUrl.href, exec); + openBrowser(loginUrl.href, execFile); }); // 5-minute timeout diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 2e24ee40e..70a86ce43 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -129,8 +129,8 @@ async function runInstall(root: string, client: PlatformClient): Promise { } log.message(`Open this URL to install the Void GitHub App:\n${pc.cyan(url)}`); - const { exec } = await import('node:child_process'); - openBrowser(url, exec); + const { execFile } = await import('node:child_process'); + openBrowser(url, execFile); log.message('After installing, run `void github connect` to link a repository to your project.'); } From 89365bb380303dedf4597b4524170fb1649b04fa Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 23:27:54 +0800 Subject: [PATCH 11/13] fix(cli): require --repo for all-repo installs in non-interactive connect The 'all' repository_selection path inferred the repo from git (GITHUB_REPOSITORY / origin) when --repo was omitted. In CI, a fork, or a wrong-cwd checkout that silently binds the project to the wrong repo. Fail closed and require --repo, matching the --branch rule, the 'selected' multi-repo path, and the documented contract. The git default is now only the interactive prompt placeholder. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/github-cmd.ts | 45 +++++++++++++++-------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 70a86ce43..76136c756 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -272,29 +272,30 @@ async function runConnect( repoFullName = picked as string; } } else { - // 'all' — repos not enumerable; fall back to git - const gitDefault = parseGithubRepoFullName(collectGitDeployMetadata(root, null).repositoryUrl); + // '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) { - if (!gitDefault) { - log.error('connect: Cannot detect repository from git. Pass --repo .'); - process.exit(1); - } - repoFullName = gitDefault; - } else { - 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); - } + log.error( + 'connect: --repo is required in non-interactive mode for an all-repositories installation.', + ); + 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); } } From 3fd474c41aec532bb3968da7bc66b45f5ae9e473 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 23:49:27 +0800 Subject: [PATCH 12/13] test(cli): mock execFile in auth-login (openBrowser no longer uses exec) openBrowser switched from exec to execFile in 7d8fc595, but the node:child_process mock still only exported `exec`, so runLogin's import of execFile failed under vitest. Export execFile from the mock. Co-Authored-By: Claude Opus 4.8 --- packages/void/test/unit/auth-login.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/void/test/unit/auth-login.test.ts b/packages/void/test/unit/auth-login.test.ts index 678441a2a..ab94b8099 100644 --- a/packages/void/test/unit/auth-login.test.ts +++ b/packages/void/test/unit/auth-login.test.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { runLogin } from '../../src/cli/auth.ts'; -const mockExec = vi.fn(); +const mockExecFile = vi.fn(); const exchangeRequests: Array<{ url: string; headers: Record; @@ -20,11 +20,11 @@ function codeChallengeFor(verifier: string): string { } vi.mock('node:child_process', () => ({ - exec: (...args: Array) => mockExec(...args), + execFile: (...args: Array) => mockExecFile(...args), })); beforeEach(() => { - mockExec.mockReset(); + mockExecFile.mockReset(); exchangeRequests.length = 0; process.env.VOID_API_URL = 'https://api.test'; From 880d06f0b2929e6ca2cd42d74cbe9975f162ef8a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 22 Jun 2026 23:59:32 +0800 Subject: [PATCH 13/13] docs(cli): correct non-interactive github connect flag requirements The connect docs contradicted themselves: the --repo row said it was required only for all-repo installs, while the CI paragraph said all flags were always required in non-TTY. Neither matched the code, which auto-picks a sole installation / sole selected repo (server-authoritative and server-validated) and requires a flag only when ambiguous, for all-repo, or for --branch. Document the actual fail-closed-on-ambiguity behavior. Co-Authored-By: Claude Opus 4.8 --- docs/reference/cli.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6512a396d..4ab8a5c0c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -594,17 +594,17 @@ Connect a GitHub repository to a Void project for automatic deploys. On every pu **Options** -| Flag | Description | -| --------------------- | ----------------------------------------------------------------------------- | -| `--project ` | Project name (alias for the positional argument) | -| `--installation ` | GitHub App installation ID (required when you have multiple installations) | -| `--repo ` | Repository full name — required when the installation has access to all repos | -| `--branch ` | Branch to deploy from — **required in non-interactive mode** | -| `--executor ` | Build executor: `container` (default) or `github_actions` | +| Flag | Description | +| --------------------- | ------------------------------------------------------------------------------- | +| `--project ` | Project name (alias for the positional argument) | +| `--installation ` | GitHub App installation ID (required when you have multiple installations) | +| `--repo ` | Repository full name — required unless the installation grants exactly one repo | +| `--branch ` | Branch to deploy from — **required in non-interactive mode** | +| `--executor ` | Build executor: `container` (default) or `github_actions` | **Non-interactive use (CI)** -All of `--project` (or positional), `--installation`, `--repo`, and `--branch` must be supplied explicitly when stdin is not a TTY. 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. Use `void github installations` to discover the `installation_id`. ``` void github connect my-app \