Skip to content
35 changes: 35 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Use this page as a command reference. If you are setting up a project for the fi
| `void project rollback` | Roll back to a previous deployment |
| `void project cancel` | Cancel an active deployment |
| `void project purge-cache` | Purge all cached pages |
| `void build logs` | Stream, tail, or download build logs |
| `void mcp` | Start the Void MCP server |
| `void init` | Setup wizard for new or existing projects |

Expand Down Expand Up @@ -651,6 +652,40 @@ void github update my-app --executor github_actions

**Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`).

## Build

Inspect Deploy-on-GitHub builds.

### `void build logs`

```
void build logs [build] [--follow] [--output <file>] [--project <slug>]
```

Stream, tail, or download the build logs for a **container** build. With no
`[build]` argument, targets the project's most recent build.

| Flag | Purpose | Default |
| ------------------------ | ----------------------------------------------------------------- | ------- |
| `--follow`, `-f` | Live-tail: poll until the build reaches a terminal status. | off |
| `--output <file>`, `-o` | Write logs to a file instead of stdout (appends while following). | stdout |
| `--project <slug>`, `-p` | Target project. | linked |

**Project resolution** follows the same order as deploy: positional / `--project`,
`VOID_PROJECT`, linked project (`.void/project.json`).

Builds run on **GitHub Actions** keep their logs on GitHub — the command prints
the Actions run URL instead of streaming. Only the last 10,000 log lines of a
container build are retained.

Examples:

```
void build logs # print the latest build's logs
void build logs -f # follow the latest build until it finishes
void build logs bld_123 -o build.log # download a specific build's logs
```

## Custom Domains

### `void domain add`
Expand Down
66 changes: 66 additions & 0 deletions packages/void/src/cli/build-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { SubcommandDef } from './subcommand-prompt.ts';

export const buildSubcommands: Array<SubcommandDef> = [
{
name: 'logs',
arg: '[build]',
description: 'Stream, tail, or download logs for a build',
},
];

export type BuildArgs = {
subcommand: 'logs';
buildId?: string;
follow: boolean;
output?: string;
projectName?: string;
};

export function parseBuildArgs(args: Array<string>): BuildArgs {
const subcommand = args[0];
const rest = args.slice(1);

if (subcommand !== 'logs') {
throw new Error(
'build: Usage: void build logs [build] [--follow] [--output <file>] [--project <slug>].',
);
}

let buildId: string | undefined;
let follow = false;
let output: string | undefined;
let projectName: string | undefined;

for (let i = 0; i < rest.length; i++) {
const arg = rest[i]!;
if (arg === '-f' || arg === '--follow') {
follow = true;
} else if (arg === '-o' || arg === '--output') {
if (i + 1 >= rest.length) {
throw new Error('build: --output requires a value.');
}
const value = rest[++i]!;
if (value.trim() === '') {
throw new Error('build: --output requires a value.');
}
output = value;
} else if (arg === '-p' || arg === '--project') {
if (i + 1 >= rest.length) {
throw new Error('build: --project requires a value.');
}
const value = rest[++i]!;
if (value.trim() === '') {
throw new Error('build: --project requires a value.');
}
projectName = value;
} else if (arg.startsWith('-')) {
throw new Error(`build: Unexpected argument '${arg}'.`);
} else if (buildId) {
throw new Error(`build: Unexpected argument '${arg}'.`);
} else {
buildId = arg;
}
}

return { subcommand, buildId, follow, output, projectName };
}
212 changes: 212 additions & 0 deletions packages/void/src/cli/build-cmd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { appendFileSync, writeFileSync } from 'node:fs';
import { intro, log, outro } from '@clack/prompts';
import { getToken } from './auth.ts';
import type { BuildArgs } from './build-args.ts';
import { PlatformApiError, PlatformClient, type BuildLogsResponse } from './client.ts';
import { cliTitle, dim, pc } from './output.ts';
import { readProjectConfig } from './project.ts';
import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts';

const POLL_MS = 1000;

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Classifies a `getBuildLogs` failure during a follow/first-page fetch as fatal
* (surface + exit) vs transient (retry). A client error (4xx) is permanent —
* the build is gone, access was revoked, or the CLI is too old (426
* `cli_outdated`) — so retrying can't fix it; it is fatal, matching how the rest
* of the CLI surfaces 426 (see `deploy-errors.ts`, `github-cmd.ts`). The sole
* exception is 429 (rate limit), which IS transient. Everything else
* (5xx/timeout/DO-read blip) is transient and retried through.
*/
export function isFatalFollowError(err: unknown): boolean {
return (
err instanceof PlatformApiError && err.status >= 400 && err.status < 500 && err.status !== 429
);
}

/**
* Pure follow-loop: repeatedly fetch pages since a cursor, write each new
* line, and stop when the build reaches a terminal status. `fetchPage`,
* `write`, and `sleep` are injected so this is unit-testable. Returns the
* final status + terminal flag.
*
* A transient `fetchPage` failure (502/timeout/DO-read blip) must NOT kill the
* live tail: on catch we consult the injected `isFatal` — a fatal error
* (e.g. 401/403/404) rethrows, otherwise we call `onRetry` and retry from the
* SAME cursor on the next tick (no cursor advance, no write), so the tail
* survives blips instead of exiting the CLI.
*/
export async function followBuildLogs(opts: {
fetchPage: (since: number) => Promise<BuildLogsResponse>;
write: (line: string) => void;
sleep: (ms: number) => Promise<void>;
since: number;
status: string;
pollMs?: number;
isFatal?: (err: unknown) => boolean;
onRetry?: (err: unknown) => void;
}): Promise<{ status: string; terminal: boolean }> {
let cursor = opts.since;
let status = opts.status;
const pollMs = opts.pollMs ?? POLL_MS;
for (;;) {
await opts.sleep(pollMs);
let pageResult: BuildLogsResponse;
try {
pageResult = await opts.fetchPage(cursor);
} catch (err) {
if (opts.isFatal?.(err)) {
throw err;
}
opts.onRetry?.(err);
// Retry from the same cursor after the next sleep; do not advance or write.
continue;
}
for (const l of pageResult.lines) {
opts.write(l.line);
}
cursor = pageResult.lastSeq;
status = pageResult.status;
if (pageResult.terminal) {
return { status, terminal: true };
}
}
}

export async function runBuildCommand(root: string, args: BuildArgs): Promise<void> {
if (args.subcommand === 'logs') {
return runBuildLogsCommand(root, args);
}
}

async function runBuildLogsCommand(
root: string,
args: Extract<BuildArgs, { subcommand: 'logs' }>,
): Promise<void> {
console.log();
intro(cliTitle('build logs'));

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

// Resolve project: --project/positional slug -> VOID_PROJECT -> linked config.
let projectId: string;
const requestedSlug = getRequestedProjectSlug({ projectSlug: args.projectName });
if (requestedSlug) {
const projects = await client.listProjects();
const resolved = resolveProjectBySlug(projects, requestedSlug);
if (!resolved) {
log.error(`build: No project found with slug '${requestedSlug}'.`);
process.exit(1);
}
projectId = resolved.projectId;
} else {
const linked = readProjectConfig(root);
if (!linked) {
log.error(
'build: No project specified. Pass a project with --project <slug>, set VOID_PROJECT, or link one with `void project link`.',
);
process.exit(1);
}
projectId = linked.projectId;
}

// Resolve build: explicit id or the newest build.
let buildId = args.buildId;
if (!buildId) {
const builds = await client.listBuilds(projectId, 1);
if (builds.length === 0) {
outro('No builds found. Push to your connected branch to trigger one.');
return;
}
buildId = builds[0]!.id;
}

// Writer: append to a file, or print to stdout.
const write = args.output
? (line: string): void => appendFileSync(args.output!, `${line}\n`)
: (line: string): void => console.log(line);

// `isFatalFollowError` decides fatal vs transient (see its doc). The SAME
// policy governs both the first-page fetch below and the follow loop.
const onRetry = (err: unknown): void =>
log.warn(
`build: transient error while following (${err instanceof Error ? err.message : String(err)}); retrying…`,
);

// First page (since=0). A transient failure on the FIRST poll must not kill
// `--follow` either: on a fatal error we exit with a friendly message, on a
// non-fatal error we exit immediately ONLY for the one-shot (non-follow)
// command; under --follow we onRetry + sleep and re-request since=0.
let first: BuildLogsResponse;
for (;;) {
try {
first = await client.getBuildLogs(projectId, buildId, 0);
break;
} catch (err) {
if (isFatalFollowError(err)) {
if (err instanceof PlatformApiError && err.status === 404) {
log.error(`build: Build '${buildId}' not found for this project.`);
} else {
log.error(err instanceof Error ? err.message : String(err));
}
process.exit(1);
}
if (!args.follow) {
log.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
onRetry(err);
await sleep(POLL_MS);
}
}

// github_actions builds keep their logs on GitHub.
if (first.external) {
log.message(`This build ran on GitHub Actions. View logs:\n${pc.cyan(first.external)}`);
return;
}

// Truncate/create the output file ONCE before the first line. The `write`
// writer appends (so follow-mode appends work), but the initial dump must not
// concatenate onto a stale file from a previous run. Placed after the external
// early-return so an external build never creates an empty file.
if (args.output) {
writeFileSync(args.output, '');
}

for (const l of first.lines) {
write(l.line);
}

let status = first.status;
let terminal = first.terminal;

if (args.follow && !terminal) {
({ status, terminal } = await followBuildLogs({
fetchPage: (since) => client.getBuildLogs(projectId, buildId!, since),
write,
sleep,
since: first.lastSeq,
status,
isFatal: isFatalFollowError,
onRetry,
}));
}

if (!terminal) {
outro(dim('Build still running — pass -f to follow.'));
return;
}

outro(dim(`Build ${status}.`));
}
14 changes: 14 additions & 0 deletions packages/void/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join } from 'pathe';
import { getPackageDir } from './agents.ts';
import { parseDeployArgs } from './args.ts';
import { authSubcommands, parseAuthArgs } from './auth-args.ts';
import { buildSubcommands, parseBuildArgs } from './build-args.ts';
import { dbSubcommands, parseDbArgs } from './db-args.ts';
import { domainSubcommands, parseDomainArgs } from './domain-args.ts';
import { envSubcommands, parseEnvArgs } from './env-args.ts';
Expand Down Expand Up @@ -137,6 +138,10 @@ if (helpPath) {
runSubcommandGroup('github', githubSubcommands, parseGithubArgs, (root, parsed) =>
import('./github-cmd.ts').then(({ runGithubCommand }) => runGithubCommand(root, parsed)),
);
} else if (command === 'build') {
runSubcommandGroup('build', buildSubcommands, parseBuildArgs, (root, parsed) =>
import('./build-cmd.ts').then(({ runBuildCommand }) => runBuildCommand(root, parsed)),
);
} else if (command === 'prepare') {
const root = process.cwd();
let prepareArgs;
Expand Down Expand Up @@ -204,6 +209,11 @@ if (helpPath) {
hint: 'install | installations | connect | update | link',
description: 'Connect a GitHub repository for automatic deploys',
},
{
name: 'build',
hint: 'logs',
description: 'Inspect remote builds',
},
{
name: 'db',
hint: 'status | reset | seed | execute | migrate | studio | export | rename-migrations',
Expand Down Expand Up @@ -275,6 +285,10 @@ if (helpPath) {
return runSubcommandGroup('github', githubSubcommands, parseGithubArgs, (r, p) =>
import('./github-cmd.ts').then(({ runGithubCommand }) => runGithubCommand(r, p)),
);
case 'build':
return runSubcommandGroup('build', buildSubcommands, parseBuildArgs, (r, p) =>
import('./build-cmd.ts').then(({ runBuildCommand }) => runBuildCommand(r, p)),
);
case 'db':
return runSubcommandGroup('db', dbSubcommands, parseDbArgs, (r, p) =>
import('./db.ts').then(({ runDbCommand }) => runDbCommand(r, p)),
Expand Down
Loading