Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` | 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.
Expand Down
2 changes: 1 addition & 1 deletion packages/void/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
{
Expand Down
15 changes: 15 additions & 0 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 43 additions & 1 deletion packages/void/src/cli/github-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export const githubSubcommands: Array<SubcommandDef> = [
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" },
];
Expand Down Expand Up @@ -46,6 +51,11 @@ export type GithubArgs =
| {
subcommand: 'status';
projectName: string;
}
| {
subcommand: 'disconnect';
projectName: string;
yes?: boolean;
};

export function parseGithubArgs(args: Array<string>): GithubArgs {
Expand Down Expand Up @@ -256,7 +266,39 @@ export function parseGithubArgs(args: Array<string>): 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 <install|installations|connect|update|status|link|join>.',
'github: Usage: void github <install|installations|connect|update|status|disconnect|link|join>.',
);
}
138 changes: 137 additions & 1 deletion packages/void/src/cli/github-cmd.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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<GithubArgs, { subcommand: 'disconnect' }>,
): Promise<void> {
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 <name>, 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);
Comment thread
Brooooooklyn marked this conversation as resolved.
}
}
throw err;
}

log.success(`Disconnected ${connection.repo_full_name}.`);
}
25 changes: 25 additions & 0 deletions packages/void/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 <name>', 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: {
Expand Down
1 change: 1 addition & 0 deletions packages/void/test/unit/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading