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
49 changes: 49 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <installation_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 <name>` | Project name (alias for the positional argument) |
| `--installation <id>` | GitHub App installation ID (required when you have multiple installations) |
| `--repo <owner/repo>` | Repository full name — required unless the installation grants exactly one repo |
| `--branch <name>` | Branch to deploy from — **required in non-interactive mode** |
| `--executor <type>` | 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`
Expand Down
28 changes: 19 additions & 9 deletions packages/void/src/cli/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,23 +233,33 @@ function page(title: string, message: string): string {
</head><body><div class="card"><h1>${title}</h1><p>${message}</p></div></body></html>`;
}

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);
}

/**
Expand All @@ -267,7 +277,7 @@ export async function runLogin(
onUrl?: (url: string) => void,
): Promise<string> {
const http = await import('node:http');
const { exec } = await import('node:child_process');
const { execFile } = await import('node:child_process');
Comment thread
Brooooooklyn marked this conversation as resolved.

const apiUrl = getApiUrl(root);
const state = randomBytes(32).toString('hex');
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/void/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)),
Expand Down
78 changes: 78 additions & 0 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -851,4 +876,57 @@ export class PlatformClient {
const json = (await res.json()) as { data: Array<LogEntry> };
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<Array<GithubInstallation>> {
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<GithubConnection> {
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();
}
}
115 changes: 115 additions & 0 deletions packages/void/src/cli/github-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { SubcommandDef } from './subcommand-prompt.ts';

export const githubSubcommands: Array<SubcommandDef> = [
{ 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<string>): 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 <install|installations|connect>.');
}
Loading