diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e3f7600d8..ba36ffe34 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -692,6 +692,29 @@ void github status my-app **Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`). +### `void github disconnect` + +``` +void github disconnect [project] +``` + +Disconnect a project from its GitHub repository, stopping automatic deploys. Any in-flight builds for the project are cancelled (their deploy tokens are revoked) before the connection is removed. If the project has no connection, it reports that and exits successfully. To point a project at a different repository, disconnect first, then run `void github connect`. + +You are asked to confirm before anything is removed. Pass `--yes` to skip the prompt; `--yes` is **required** in a non-interactive shell (CI), where there is no prompt to answer. + +**Options** + +| Flag | Description | +| ------------------ | ----------------------------------------------------------------- | +| `--project ` | Project name (alias for the positional argument) | +| `--yes` | Skip the confirmation prompt (required in non-interactive shells) | + +``` +void github disconnect my-app --yes +``` + +**Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`). + ## Build Inspect Deploy-on-GitHub builds. diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 3356fe56a..5583b43a3 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -206,7 +206,7 @@ if (helpPath) { }, { name: 'github', - hint: 'install | installations | connect | update | status | link | join', + hint: 'install | installations | connect | update | status | disconnect | link | join', description: 'Connect a GitHub repository for automatic deploys', }, { diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 8eea4908c..70fa1c3a9 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -1068,6 +1068,21 @@ export class PlatformClient { return res.json(); } + async deleteGithubConnection(projectId: string): Promise<{ ok: boolean }> { + const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, { + method: 'DELETE', + headers: { + ...this.authHeaders, + }, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to disconnect GitHub repository', res); + } + + return res.json(); + } + async getGithubLinkUrl(redirect: string, codeChallenge: string): Promise<{ url: string }> { const params = new URLSearchParams({ redirect, diff --git a/packages/void/src/cli/github-args.ts b/packages/void/src/cli/github-args.ts index 94f477fb9..8fa2ca6fe 100644 --- a/packages/void/src/cli/github-args.ts +++ b/packages/void/src/cli/github-args.ts @@ -18,6 +18,11 @@ export const githubSubcommands: Array = [ arg: '[project]', description: "Show a project's GitHub connection (repo, branch, executor, workflow)", }, + { + name: 'disconnect', + arg: '[project]', + description: 'Disconnect a project from its GitHub repository', + }, { name: 'link', description: 'Link your GitHub account to Void' }, { name: 'join', description: "Join your organization's shared GitHub App installations" }, ]; @@ -46,6 +51,11 @@ export type GithubArgs = | { subcommand: 'status'; projectName: string; + } + | { + subcommand: 'disconnect'; + projectName: string; + yes?: boolean; }; export function parseGithubArgs(args: Array): GithubArgs { @@ -256,7 +266,39 @@ export function parseGithubArgs(args: Array): GithubArgs { return { subcommand, projectName }; } + if (subcommand === 'disconnect') { + let projectName = ''; + let yes = false; + + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + if (arg === '--yes' || arg === '-y') { + yes = true; + } 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.startsWith('-')) { + throw new Error(`github: Unexpected argument '${arg}'.`); + } else if (projectName) { + throw new Error(`github: Unexpected argument '${arg}'.`); + } else { + projectName = arg; + } + } + + return { subcommand, projectName, yes }; + } + throw new Error( - 'github: Usage: void github .', + 'github: Usage: void github .', ); } diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 694cc0281..7e3d5aa5f 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto'; -import { cancel, isCancel, log, select, text } from '@clack/prompts'; +import { cancel, confirm, isCancel, log, select, text } from '@clack/prompts'; import { awaitBrowserCallback, createCodeChallenge, @@ -175,6 +175,8 @@ export async function runGithubCommand(root: string, args: GithubArgs): Promise< return runGithubUpdate(root, client, args); case 'status': return runStatus(root, client, args); + case 'disconnect': + return runGithubDisconnect(root, client, args); case 'link': return runGithubLink(root, client); case 'join': @@ -1080,3 +1082,137 @@ export async function runStatus( log.message(formatConnection(connection)); } + +// --------------------------------------------------------------------------- +// disconnect +// --------------------------------------------------------------------------- + +/** + * `void github disconnect [project]` — remove a project's GitHub connection so + * pushes no longer auto-deploy. The server (DELETE /projects/:id/github) + * terminalizes any in-flight builds (revoking their deploy tokens) before + * dropping the connection row. Destructive, so a TTY run confirms first; --yes + * skips the prompt and is REQUIRED in a non-interactive shell. A project with no + * connection is already in the goal state — report it and exit 0. + */ +export async function runGithubDisconnect( + root: string, + client: PlatformClient, + args: Extract, +): Promise { + const isTTY = process.stdin.isTTY === true; + + // ------------------------------------------------------------------------- + // 1. Resolve project (identical resolution order to connect/update/status). + // ------------------------------------------------------------------------- + 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(`disconnect: 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( + 'disconnect: No project specified. Pass a project name or --project , set VOID_PROJECT, or link one with `void project link`.', + ); + process.exit(1); + } else { + 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. Fetch the current connection. A 404 "connection not found" means the + // project already has no connection — disconnect's goal state — so report + // success and stop. Other errors (403 forbidden, 404 project not found) + // map through the shared connect error strings and exit 1. + // ------------------------------------------------------------------------- + let connection: GithubConnection; + try { + connection = await client.getGithubConnection(projectId); + } catch (err) { + if (err instanceof PlatformApiError) { + const body = parsePlatformErrorBody(err.body); + const code = body?.error ?? body?.message ?? ''; + if (err.status === 404 && code.includes('connection not found')) { + log.info('This project is not connected to a GitHub repository. Nothing to disconnect.'); + return; + } + const message = describeConnectError(err.status, code); + if (message !== null) { + log.error(`disconnect: ${message}`); + process.exit(1); + } + } + throw err; + } + + // ------------------------------------------------------------------------- + // 3. Confirm — this cancels in-flight builds and drops the connection. --yes + // skips the prompt; a non-interactive shell has nothing to prompt, so it + // must pass --yes (fail closed, same posture as connect/update). + // ------------------------------------------------------------------------- + if (!args.yes) { + if (!isTTY) { + log.error( + 'disconnect: Refusing to disconnect without confirmation in a non-interactive shell. Re-run with --yes.', + ); + process.exit(1); + } + const ok = await confirm({ + message: `Disconnect ${connection.repo_full_name} from this project? This cancels any in-flight builds.`, + initialValue: false, + }); + if (isCancel(ok)) { + cancel('Cancelled.'); + process.exit(0); + } + if (!ok) { + log.info('Disconnect cancelled.'); + return; + } + } + + // ------------------------------------------------------------------------- + // 4. Disconnect. + // ------------------------------------------------------------------------- + try { + await client.deleteGithubConnection(projectId); + } 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(`disconnect: ${message}`); + process.exit(1); + } + } + throw err; + } + + log.success(`Disconnected ${connection.repo_full_name}.`); +} diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 53a4218d1..233806a72 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -208,6 +208,10 @@ const HELP_TREE: HelpTree = { command: 'status [project]', description: "Show a project's GitHub connection (repo, branch, executor, workflow)", }, + { + command: 'disconnect [project]', + description: 'Disconnect a project from its GitHub repository', + }, { command: 'link', description: 'Link your GitHub account to Void' }, { command: 'join', @@ -311,6 +315,27 @@ const HELP_TREE: HelpTree = { }, ], }, + disconnect: { + summary: 'Disconnect a project from its GitHub repository.', + usage: ['void github disconnect [project] [options]'], + options: [ + { command: '--project ', description: 'Project name (alias for positional)' }, + { + command: '--yes', + description: 'Skip the confirmation prompt (required in non-interactive shells)', + }, + ], + examples: [ + { + command: 'void github disconnect', + description: "Disconnect the linked project's repository", + }, + { + command: 'void github disconnect my-app --yes', + description: 'Disconnect a specific project without confirmation', + }, + ], + }, }, }, build: { diff --git a/packages/void/test/unit/cli-help.test.ts b/packages/void/test/unit/cli-help.test.ts index f9fba8d32..f67d68906 100644 --- a/packages/void/test/unit/cli-help.test.ts +++ b/packages/void/test/unit/cli-help.test.ts @@ -66,6 +66,7 @@ describe('CLI help resolver', () => { expect(hasHelpPage(['github', 'installations'])).toBe(true); expect(hasHelpPage(['github', 'connect'])).toBe(true); expect(hasHelpPage(['github', 'status'])).toBe(true); + expect(hasHelpPage(['github', 'disconnect'])).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 index c24da1f44..621a4bc2c 100644 --- a/packages/void/test/unit/github-args.test.ts +++ b/packages/void/test/unit/github-args.test.ts @@ -516,6 +516,63 @@ describe('parseGithubArgs', () => { }); }); + describe('disconnect', () => { + it('parses with no args (defaults)', () => { + expect(parseGithubArgs(['disconnect'])).toEqual({ + subcommand: 'disconnect', + projectName: '', + yes: false, + }); + }); + + it('parses a positional project', () => { + expect(parseGithubArgs(['disconnect', 'my-app'])).toEqual({ + subcommand: 'disconnect', + projectName: 'my-app', + yes: false, + }); + }); + + it('parses --project', () => { + expect(parseGithubArgs(['disconnect', '--project', 'my-app'])).toEqual({ + subcommand: 'disconnect', + projectName: 'my-app', + yes: false, + }); + }); + + it('parses --yes and -y', () => { + expect(parseGithubArgs(['disconnect', 'my-app', '--yes'])).toEqual({ + subcommand: 'disconnect', + projectName: 'my-app', + yes: true, + }); + expect(parseGithubArgs(['disconnect', '-y', 'my-app'])).toEqual({ + subcommand: 'disconnect', + projectName: 'my-app', + yes: true, + }); + }); + + it('throws on a second positional', () => { + expect(() => parseGithubArgs(['disconnect', 'a', 'b'])).toThrow( + "github: Unexpected argument 'b'.", + ); + }); + + it('throws on an unknown flag', () => { + expect(() => parseGithubArgs(['disconnect', '--nope'])).toThrow( + "github: Unexpected argument '--nope'.", + ); + }); + + it('throws when --project has no value', () => { + expect(() => parseGithubArgs(['disconnect', '--project'])).toThrow( + 'github: --project requires a value.', + ); + }); + }); + describe('link', () => { it('parses link with no args', () => { expect(parseGithubArgs(['link'])).toEqual({ subcommand: 'link' }); @@ -553,13 +610,13 @@ describe('parseGithubArgs', () => { describe('unknown subcommand', () => { it('throws on empty args', () => { expect(() => parseGithubArgs([])).toThrow( - 'github: Usage: void github .', + 'github: Usage: void github .', ); }); it('throws on unknown subcommand', () => { expect(() => parseGithubArgs(['unknown'])).toThrow( - 'github: Usage: void github .', + 'github: Usage: void github .', ); }); }); diff --git a/packages/void/test/unit/github-client.test.ts b/packages/void/test/unit/github-client.test.ts index 0e6f1553f..44cdbbf09 100644 --- a/packages/void/test/unit/github-client.test.ts +++ b/packages/void/test/unit/github-client.test.ts @@ -373,6 +373,33 @@ describe('PlatformClient.updateGithubConnection', () => { }); }); +describe('PlatformClient.deleteGithubConnection', () => { + it('DELETEs the right URL with auth headers and no body, returns parsed { ok }', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + + const client = makeClient(); + const result = await client.deleteGithubConnection(projectId); + + 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('DELETE'); + expect(init.body).toBeUndefined(); + const headers = init.headers as Record; + expect(headers.Authorization).toBe(`Bearer ${token}`); + expect(result).toEqual({ ok: true }); + }); + + it('throws PlatformApiError on non-2xx', async () => { + fetchMock.mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + const client = makeClient(); + await expect(client.deleteGithubConnection(projectId)).rejects.toMatchObject({ + name: 'PlatformApiError', + status: 403, + }); + }); +}); + describe('PlatformClient.getGithubLinkUrl', () => { it('GETs link-url with redirect + S256 challenge query, auth headers, returns { url }', async () => { fetchMock.mockResolvedValueOnce( diff --git a/packages/void/test/unit/github-disconnect-cmd.test.ts b/packages/void/test/unit/github-disconnect-cmd.test.ts new file mode 100644 index 000000000..a252f9574 --- /dev/null +++ b/packages/void/test/unit/github-disconnect-cmd.test.ts @@ -0,0 +1,206 @@ +import { confirm, log } from '@clack/prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + PlatformApiError, + type GithubConnection, + type PlatformClient, +} from '../../src/cli/client.ts'; +import { runGithubDisconnect } from '../../src/cli/github-cmd.ts'; + +// `vi.spyOn(clack, 'confirm')` cannot intercept the handler's named `confirm` +// import under this project's ESM Vitest config ("Module namespace is not +// configurable in ESM"). Mock the module instead, keeping every other export +// real (log/cancel/isCancel/select/text) so only `confirm` is replaced with a +// drivable spy. `vi.mock` is hoisted above the imports above. +vi.mock('@clack/prompts', async (imp) => ({ + ...(await imp()), + confirm: vi.fn(), +})); + +const projectId = 'proj_1'; +const slug = 'my-app'; + +function makeProject() { + return { id: projectId, slug } as unknown as Awaited< + ReturnType + >[number]; +} + +function makeConnection(overrides: Partial = {}): GithubConnection { + return { + id: 'conn_1', + project_id: projectId, + installation_id: '123456', + repo_full_name: 'octocat/hello-world', + repository_id: '9876', + branch: 'main', + build_executor: 'container', + workflow_path: '.github/workflows/void-deploy.yml', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + ...overrides, + }; +} + +type FakeClient = Pick< + PlatformClient, + 'listProjects' | 'getGithubConnection' | 'deleteGithubConnection' +>; + +function makeClient(over: Partial = {}): { + client: PlatformClient; + getGithubConnection: ReturnType; + deleteGithubConnection: ReturnType; +} { + const listProjects = vi.fn(async () => [makeProject()]); + const getGithubConnection = vi.fn(async () => makeConnection()); + const deleteGithubConnection = vi.fn(async () => ({ ok: true })); + const client = { + listProjects, + getGithubConnection, + deleteGithubConnection, + ...over, + } as unknown as PlatformClient; + return { + client, + getGithubConnection: + (over.getGithubConnection as ReturnType) ?? getGithubConnection, + deleteGithubConnection: + (over.deleteGithubConnection as ReturnType) ?? deleteGithubConnection, + }; +} + +let exitSpy: ReturnType; +let successSpy: ReturnType; +let infoSpy: ReturnType; +let errorSpy: ReturnType; +let originalIsTTY: boolean | undefined; + +beforeEach(() => { + // The mocked `confirm` is a persistent vi.fn() from the module factory; + // vi.restoreAllMocks() does not reset its call history, so clear it per-test + // to keep the `toHaveBeenCalledTimes(1)` assertion honest. + vi.mocked(confirm).mockReset(); + originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as never); + successSpy = vi.spyOn(log, 'success').mockImplementation(() => {}); + infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {}); + errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + vi.restoreAllMocks(); +}); + +describe('runGithubDisconnect', () => { + it('disconnects with --yes in a non-interactive shell', async () => { + const { client, deleteGithubConnection } = makeClient(); + + await runGithubDisconnect('/project', client, { + subcommand: 'disconnect', + projectName: slug, + yes: true, + }); + + expect(deleteGithubConnection).toHaveBeenCalledWith(projectId); + expect(successSpy).toHaveBeenCalledWith('Disconnected octocat/hello-world.'); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('refuses without --yes in a non-interactive shell (exit 1, no delete)', async () => { + const { client, deleteGithubConnection } = makeClient(); + + await expect( + runGithubDisconnect('/project', client, { subcommand: 'disconnect', projectName: slug }), + ).rejects.toThrow('process.exit(1)'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(deleteGithubConnection).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + 'disconnect: Refusing to disconnect without confirmation in a non-interactive shell. Re-run with --yes.', + ); + }); + + it('reports success and does not delete when the project is not connected', async () => { + const notConnected = new PlatformApiError( + 'Failed to get GitHub connection', + 404, + JSON.stringify({ error: 'connection not found' }), + ); + const { client, deleteGithubConnection } = makeClient({ + getGithubConnection: vi.fn(async () => { + throw notConnected; + }), + }); + + await runGithubDisconnect('/project', client, { + subcommand: 'disconnect', + projectName: slug, + yes: true, + }); + + expect(deleteGithubConnection).not.toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledWith( + 'This project is not connected to a GitHub repository. Nothing to disconnect.', + ); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('maps a 403 on read to a no-access message (exit 1)', async () => { + const forbidden = new PlatformApiError( + 'Failed to get GitHub connection', + 403, + JSON.stringify({ error: 'forbidden' }), + ); + const { client, deleteGithubConnection } = makeClient({ + getGithubConnection: vi.fn(async () => { + throw forbidden; + }), + }); + + await expect( + runGithubDisconnect('/project', client, { + subcommand: 'disconnect', + projectName: slug, + yes: true, + }), + ).rejects.toThrow('process.exit(1)'); + + expect(errorSpy).toHaveBeenCalledWith("disconnect: You don't have access to this project."); + expect(deleteGithubConnection).not.toHaveBeenCalled(); + }); + + it('prompts to confirm in a TTY and aborts when declined', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + vi.mocked(confirm).mockResolvedValue(false as never); + const { client, deleteGithubConnection } = makeClient(); + + await runGithubDisconnect('/project', client, { + subcommand: 'disconnect', + projectName: slug, + }); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(deleteGithubConnection).not.toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledWith('Disconnect cancelled.'); + }); + + it('prompts to confirm in a TTY and disconnects when accepted', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + vi.mocked(confirm).mockResolvedValue(true as never); + const { client, deleteGithubConnection } = makeClient(); + + await runGithubDisconnect('/project', client, { + subcommand: 'disconnect', + projectName: slug, + }); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(deleteGithubConnection).toHaveBeenCalledWith(projectId); + expect(successSpy).toHaveBeenCalledWith('Disconnected octocat/hello-world.'); + }); +});