diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1c66f641e..4ab8a5c0c 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 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)** + +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 \ + --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/auth.ts b/packages/void/src/cli/auth.ts index af325fcfa..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}

`; } -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/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/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/src/cli/github-args.ts b/packages/void/src/cli/github-args.ts new file mode 100644 index 000000000..1b1e988c9 --- /dev/null +++ b/packages/void/src/cli/github-args.ts @@ -0,0 +1,115 @@ +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') { + if (i + 1 >= rest.length) { + throw new Error('github: --installation requires a value.'); + } + 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.'); + } + 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.'); + } + 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]!; + 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).'); + } + 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/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts new file mode 100644 index 000000000..76136c756 --- /dev/null +++ b/packages/void/src/cli/github-cmd.ts @@ -0,0 +1,356 @@ +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) +// --------------------------------------------------------------------------- + +/** + * 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. + */ +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}) [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}`; + } + 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 { execFile } = await import('node:child_process'); + openBrowser(url, execFile); + 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 , set VOID_PROJECT, or link one with `void project link`.', + ); + 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}) [id: ${i.installation_id}]`, + })), + }); + 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); + } 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); + } + 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); + } + } + + // ------------------------------------------------------------------------- + // 4. Resolve branch + // ------------------------------------------------------------------------- + const branchDefault = args.branch ?? collectGitDeployMetadata(root, null).gitRef ?? 'main'; + + let branch: string; + if (args.branch) { + branch = args.branch; + } else if (!isTTY) { + 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:', + placeholder: branchDefault, + initialValue: branchDefault, + }); + if (isCancel(input)) { + cancel('Cancelled.'); + process.exit(0); + } + branch = (input as string).trim() || branchDefault; + } + + // ------------------------------------------------------------------------- + // 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 ?? ''; + const message = describeConnectError(err.status, code); + if (message !== null) { + log.error(`connect: ${message}`); + process.exit(1); + } + throw err; + } + throw err; + } + + log.success(`Connected ${repoFullName} (${branch}) → project. Push to ${branch} to deploy.`); +} diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index fb6a7bfc3..e2bce200a 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -190,6 +190,55 @@ 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: '--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 (required in non-interactive mode)', + }, + { + command: '--executor ', + description: 'Build executor: container (default) | github_actions', + }, + ], + examples: [ + { + 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)', + }, + ], + }, + }, + }, db: { summary: 'Manage local and remote database state.', usage: ['void db '], @@ -407,6 +456,8 @@ export function printGlobalHelp(): void { '', ...globalCommandGroup('domain'), '', + ...globalCommandGroup('github'), + '', categoryTitle('database'), ...globalCommandLines('db', { execute: 'execute ', 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'; 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); + }); }); 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..9d410595a --- /dev/null +++ b/packages/void/test/unit/github-args.test.ts @@ -0,0 +1,301 @@ +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 --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([ + '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('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).', + ); + }); + + 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 .', + ); + }); + }); +}); 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, + }); + }); +}); 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..126283181 --- /dev/null +++ b/packages/void/test/unit/github-cmd.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vite-plus/test'; +import type { GithubInstallation } from '../../src/cli/client.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', () => { + 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, 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'); + }); + + 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('•'); + }); +});