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
24 changes: 24 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,30 @@ For Drizzle projects, deploy performs a read-only schema drift check. If a new m

Every deploy writes a structured JSONL trace to `~/.void/logs/deploy-<timestamp>.jsonl` regardless of `--debug`. On failure the path is printed at the end of the error message so you can attach it when reporting platform issues. `VOID_DEPLOY_DEBUG=1` is accepted as an alternate trigger for stderr mirroring.

When a deploy fails after it starts, the CLI also prints a summary of that trace under the error, so the cause is visible where the file is not — a CI runner, for example, is discarded with the job. The summary has two blocks: every `error` record with its flattened cause chain, then the last 20 records as a timeline.

Pre-flight failures print no summary. A missing project, a rejected flag combination, or an unsupported `--backend cloudflare` feature stops before any trace exists, and each of those prints its own message explaining what to change. A build failure prints no summary either — the build streams its own output straight to the terminal.

Void masks the credentials it emits itself: signed query parameters, bearer tokens, and any field whose key names a credential.

Masking your own values is left to your CI platform, which holds the secrets and masks them before the log is written. GitHub Actions does this for everything under `secrets.*`. Void does not guess at credential-shaped variable names, and it does not parse credentials out of values you supplied — a password inside a `DATABASE_URL` in your build command prints as written. Register such values as CI secrets, or keep them out of the build command.

```
■ deploy: Deploy failed: deploy in progress
│ Deployment: dpl_7zgitxdrxxz9
│ Detailed log: ~/.void/logs/deploy-2026-08-21T03-24-19-764Z.jsonl
│
│ Errors
│ 9.0s deploy_server_error
│ deploymentId=dpl_7zgitxdrxxz9
│ message=deploy in progress
│
│ Last 20 of 26 entries
│ 8.4s info finalize_start assets=98 workers=0
│ 8.6s info stream_deployment_id deploymentId=dpl_7zgitxdrxxz9
│ 9.0s error deploy_server_error message=deploy in progress
```

Project resolution precedence:

1. `--project <name>`
Expand Down
18 changes: 18 additions & 0 deletions packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import {
} from './github-oidc.ts';
import { lintDestinationSplats, lintDuplicateSources, mergeRoutingRules } from './headers.ts';
import { openDeployLog, type CliLogger } from './log-file.ts';
import { buildDeployLogSummary } from './log-summary.ts';
import { promptForLoginToken } from './login.ts';
import { cliTitle, createSpinner, pc } from './output.ts';
import {
Expand Down Expand Up @@ -423,6 +424,23 @@ export async function runDeploy(root: string, options?: RunDeployOptions): Promi
cliLog.info('deploy_end', { ok: true });
} catch (err) {
cliLog.error('deploy_end', err, { ok: false });
// Flush before reading the file back. The `Detailed log:` path is useless
// on a CI runner that is destroyed with the job, so inline the errors and
// the trailing timeline into the message the caller prints.
await cliLog.close().catch(() => {});
Comment thread
Brooooooklyn marked this conversation as resolved.
try {
if (err instanceof Error) {
const summary = buildDeployLogSummary(cliLog.path);
if (summary) {
err.message = `${err.message}\n${summary}`;
}
}
} catch {
// A postmortem aid must never replace the failure it describes. The
// assignment alone can throw: `message` is read-only on a frozen Error
// and getter-only on a DOMException, which an aborted fetch produces.
// Losing the summary is fine; losing the deploy error is not.
}
throw err;
} finally {
await cliLog.close().catch(() => {});
Expand Down
39 changes: 38 additions & 1 deletion packages/void/src/cli/log-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,51 @@ export type CliLogger = {
close(): Promise<void>;
};

type ErrChainNode = {
export type ErrChainNode = {
name?: string;
message?: string;
code?: string | number;
errno?: string | number;
cause?: ErrChainNode;
};

/**
* Flatten an already-serialized `ErrChainNode` (as read back out of a JSONL
* record) to a single inline string. Mirrors `formatErrorChainInline`, which
* does the same job for a live `Error` before it is written.
*/
export function formatErrorNodeInline(
node: ErrChainNode | string | null | undefined,
depth = 0,
): string {
// `typeof null` is `'object'`, so the null check has to come first.
if (node == null || typeof node !== 'object') {
return String(node);
}
// A JSONL record is data, not a typed value. Coerce every part instead of
// trusting the declared shape: a hand-edited log, or an `Error` subclass that
// assigned a non-string `message`, must not make a postmortem aid throw.
const message = node.message == null ? '' : String(node.message);
if (depth > 5) {
return message;
}
let head = node.name ? `${String(node.name)}: ${message}` : message;
const tags: Array<string> = [];
if (node.code !== undefined) {
tags.push(`code=${node.code}`);
}
if (node.errno !== undefined) {
tags.push(`errno=${node.errno}`);
}
if (tags.length > 0) {
head = `${head} (${tags.join(' ')})`;
}
if (node.cause != null) {
return `${head} -> ${formatErrorNodeInline(node.cause, depth + 1)}`;
}
return head;
}

/**
* Walk `err.cause` up to 6 levels and return a plain JSON-friendly chain.
* Skips stack traces deliberately — keeps output line-oriented and readable.
Expand Down
227 changes: 227 additions & 0 deletions packages/void/src/cli/log-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { readFileSync } from 'node:fs';
import pc from 'picocolors';
import { type ErrChainNode, formatErrorNodeInline } from './log-file.ts';

/** How many trailing records the tail block shows. */
export const LOG_TAIL_LIMIT = 20;
/** How many error records the error block shows. */
export const LOG_ERROR_LIMIT = 5;

const FIELD_VALUE_MAX = 80;
const LINE_MAX = 160;
const MSG_COLUMN_MAX = 28;
const ELAPSED_WIDTH = 7;
const LEVEL_WIDTH = 5;
/** Width of `' ' + elapsed + ' '`, used to indent the error sub-lines. */
const DETAIL_INDENT = ' '.repeat(2 + ELAPSED_WIDTH + 2);

/**
* `runDeploy` writes this record last, and its `err` field is the very message
* printed above the summary. Keeping it out of the error block avoids echoing
* the same text twice; it still shows up as one line in the tail.
*/
const TERMINAL_ERROR_MSG = 'deploy_end';

const RESERVED_KEYS = new Set(['ts', 'elapsedMs', 'lvl', 'msg', 'err']);

/**
* The trace was a file the user opened by hand; the summary publishes it to
* whatever reads stderr, which in CI is a stored, often public, log.
*
* Scrubbing here covers only credentials this code emits itself — a presigned
* URL it built, an auth header it set, a field it named. Scrubbing user data is
* left to the CI platform, which owns the secrets and masks them before the log
* is written. GitHub Actions does this for everything under `secrets.*`. Void
* does not guess at credential-shaped names or at credentials embedded in
* values the user supplied.
*/
/** Query parameters whose value must never reach a CI log. */
const SIGNED_QUERY_RE =
/([?&](?:X-Amz-[A-Za-z0-9-]+|[Ss]ignature|sig|token|access_token|key)=)[^&\s]+/g;
const BEARER_RE = /\b(Bearer\s+)\S+/g;
/**
* Field keys whose value is masked whole, whatever it looks like. `auth` is
* deliberately absent — it matches metadata keys such as `authMethod` far more
* often than credentials.
*/
const SECRET_KEY_RE =
/token|secret|password|passwd|credential|cookie|signature|api[-_]?key|private[-_]?key/i;

type LogRecord = {
elapsedMs?: number;
lvl?: string;
msg?: string;
err?: ErrChainNode | string;
[key: string]: unknown;
};

function redact(text: string): string {
return text.replace(SIGNED_QUERY_RE, '$1[redacted]').replace(BEARER_RE, '$1[redacted]');
}

function truncate(text: string, max: number): string {
return text.length <= max ? text : `${text.slice(0, max)}...`;
}

/** Collapse every run of whitespace so one record always renders as one line. */
function oneLine(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}

function stringifyValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (value === null || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}

/** Render one `key=value` pair, collapsed, redacted, and length-capped. */
function formatField(key: string, value: unknown): string {
if (SECRET_KEY_RE.test(key)) {
return `${key}=[redacted]`;
}
return `${key}=${truncate(redact(oneLine(stringifyValue(value))), FIELD_VALUE_MAX)}`;
}

function fieldPairs(record: LogRecord): Array<string> {
const pairs: Array<string> = [];
for (const [key, value] of Object.entries(record)) {
if (RESERVED_KEYS.has(key) || value === undefined) {
continue;
}
pairs.push(formatField(key, value));
}
if (record.err !== undefined) {
pairs.push(
`err=${truncate(redact(oneLine(formatErrorNodeInline(record.err))), FIELD_VALUE_MAX)}`,
);
}
return pairs;
}

function formatElapsed(elapsedMs: unknown): string {
const ms = typeof elapsedMs === 'number' && Number.isFinite(elapsedMs) ? elapsedMs : 0;
return `${(ms / 1000).toFixed(1)}s`.padStart(ELAPSED_WIDTH);
}

function colorLevel(level: string, padded: string): string {
if (level === 'error') {
return pc.red(padded);
}
if (level === 'warn') {
return pc.yellow(padded);
}
// `info` and `debug` are context, not signal — dim them so the red and
// yellow rows stand out in a wall of CI output.
return pc.dim(padded);
}

function parseRecords(lines: Array<string>): Array<LogRecord> {
const records: Array<LogRecord> = [];
for (const raw of lines) {
const trimmed = raw.trim();
if (!trimmed) {
continue;
}
try {
const parsed: unknown = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
records.push(parsed as LogRecord);
}
} catch {
// A partially flushed or corrupt line must not hide the rest of the log.
}
}
return records;
}

/** One aligned line: ` 8.9s error deploy_server_error message=...`. */
function formatTailLine(record: LogRecord, msgWidth: number): string {
const level = typeof record.lvl === 'string' ? record.lvl : 'info';
const msg = typeof record.msg === 'string' ? record.msg : '';
const elapsed = formatElapsed(record.elapsedMs);
const paddedLevel = level.padEnd(LEVEL_WIDTH);
const paddedMsg = msg.padEnd(msgWidth);
const head = ` ${elapsed} ${paddedLevel} ${paddedMsg}`;
const fields = fieldPairs(record).join(' ');
if (!fields) {
return ` ${elapsed} ${colorLevel(level, paddedLevel)} ${msg}`;
}
const budget = Math.max(20, LINE_MAX - head.length);
return ` ${elapsed} ${colorLevel(level, paddedLevel)} ${paddedMsg} ${truncate(fields, budget)}`;
}

/** An error entry: a headline, then one indented line per field. */
function formatErrorEntry(record: LogRecord): Array<string> {
const msg = typeof record.msg === 'string' ? record.msg : 'error';
const lines = [` ${formatElapsed(record.elapsedMs)} ${pc.red(msg)}`];
for (const pair of fieldPairs(record)) {
lines.push(`${DETAIL_INDENT}${pair}`);
}
return lines;
}

/**
* Turn raw JSONL lines into the block appended to a failed deploy's error
* message. Returns `null` when there is nothing worth printing.
*
* The block has two parts: every `error` record with its flattened cause
* chain, then the last `LOG_TAIL_LIMIT` records for timeline context.
*/
export function formatDeployLogSummary(lines: Array<string>): string | null {
const records = parseRecords(lines);
if (records.length === 0) {
return null;
}

const blocks: Array<string> = [];

const errors = records.filter((r) => r.lvl === 'error' && r.msg !== TERMINAL_ERROR_MSG);
if (errors.length > 0) {
const shown = errors.slice(0, LOG_ERROR_LIMIT);
const errorLines = shown.flatMap((record) => formatErrorEntry(record));
if (errors.length > shown.length) {
errorLines.push(`${DETAIL_INDENT}(+${errors.length - shown.length} more)`);
}
blocks.push([pc.bold('Errors'), ...errorLines].join('\n'));
}

const tail = records.slice(-LOG_TAIL_LIMIT);
const msgWidth = Math.min(
MSG_COLUMN_MAX,
tail.reduce((max, r) => Math.max(max, typeof r.msg === 'string' ? r.msg.length : 0), 0),
);
const heading =
records.length > tail.length
? `Last ${LOG_TAIL_LIMIT} of ${records.length} entries`
: `Log (${records.length} ${records.length === 1 ? 'entry' : 'entries'})`;
blocks.push(
[pc.bold(heading), ...tail.map((record) => formatTailLine(record, msgWidth))].join('\n'),
);

return `\n${blocks.join('\n\n')}`;
}

/**
* Read a deploy JSONL log and format its summary. Returns `null` for a missing
* path or any read failure — a postmortem aid must never mask the real error.
*/
export function buildDeployLogSummary(path: string | null | undefined): string | null {
if (!path) {
return null;
}
let text: string;
try {
text = readFileSync(path, 'utf-8');
} catch {
return null;
}
return formatDeployLogSummary(text.split('\n'));
}
Loading