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
63 changes: 61 additions & 2 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,44 @@
import { readFileSync } from 'node:fs';
import { join } from 'pathe';
import { cfAccessHeaders } from '../shared/cf-access.ts';
import { getPackageDir } from './agents.ts';
import { getApiUrl } from './auth.ts';
import { ensureCloudflaredToken } from './cf-access.ts';
import type { CliLogger } from './log-file.ts';
import type { AssetManifestEntry } from './package.ts';
import type { DeployEvent, DeployManifest } from './types.ts';

/**
* Current CLI version. Sent as `X-Void-CLI-Version` on every platform
* request so the platform's CLI-version gate can refuse stale CLIs
* cleanly (HTTP 426 + `cli_outdated`) instead of letting a wire
* mismatch surface as an opaque parse error.
*
* Resolved once per process. Returns `null` when package.json can't be
* read or has no `version` string — the ctor then OMITS the header
* entirely. We deliberately do NOT send a placeholder string like
* "unknown": the platform treats present-but-unparseable headers as a
* stale-CLI signal and would 426 the request, bricking the CLI on any
* packaging error. Missing-header is the documented pass-through path.
*/
export function readCliVersion(): string | null {
try {
const pkgPath = join(getPackageDir(), 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: unknown };
return typeof pkg.version === 'string' ? pkg.version : null;
} catch {
return null;
}
}

const VOID_CLI_VERSION = readCliVersion();

export type PlatformErrorPayload = {
error?: string;
message?: string;
deployment?: string;
minVersion?: string;
clientVersion?: string;
};

export function parsePlatformErrorBody(body: string): PlatformErrorPayload | null {
Expand All @@ -23,17 +53,41 @@ export function parsePlatformErrorBody(body: string): PlatformErrorPayload | nul
}
}

/**
* Detects the `cli_outdated` 426 response shape emitted by the platform's
* `cliVersionGate` middleware. When present, callers should surface the
* server's `message` field verbatim — it already includes upgrade
* instructions — rather than the default `<action>: <status> <body>`
* which would dump raw JSON at the user.
*/
export function isCliOutdatedError(err: unknown): err is PlatformApiError {
if (!(err instanceof PlatformApiError)) {
return false;
}
if (err.status !== 426) {
return false;
}
return parsePlatformErrorBody(err.body)?.error === 'cli_outdated';
}

export class PlatformApiError extends Error {
status: number;
body: string;
code?: string;

constructor(action: string, status: number, body: string) {
super(`${action}: ${status} ${body}`);
const parsed = parsePlatformErrorBody(body);
// cli_outdated: surface the server-supplied upgrade message verbatim
// instead of the raw `<action>: <status> <body>` default. The message
// already includes the `npm i -g void@latest` hint.
const message =
status === 426 && parsed?.error === 'cli_outdated' && parsed.message
? parsed.message
: `${action}: ${status} ${body}`;
super(message);
this.name = 'PlatformApiError';
this.status = status;
this.body = body;
const parsed = parsePlatformErrorBody(body);
this.code = parsed?.error ?? parsed?.message;
}
}
Expand Down Expand Up @@ -176,6 +230,11 @@ export class PlatformClient {
ensureCloudflaredToken(this.baseUrl);
this.authHeaders = {
Authorization: `Bearer ${token}`,
// Conditional spread: omit the header entirely if package.json was
// unreadable. The platform's gate treats missing as pass-through;
// sending a placeholder like "unknown" would trip the fail-closed
// 426 path and brick the CLI permanently.
...(VOID_CLI_VERSION !== null && { 'X-Void-CLI-Version': VOID_CLI_VERSION }),
...cfAccessHeaders(process.env),
};
}
Expand Down
10 changes: 9 additions & 1 deletion packages/void/src/cli/deploy-errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parsePlatformErrorBody, PlatformApiError } from './client.ts';
import { isCliOutdatedError, parsePlatformErrorBody, PlatformApiError } from './client.ts';

const BODY_SNIPPET_MAX = 200;

Expand Down Expand Up @@ -75,6 +75,14 @@ export function formatStreamDeployError(
const parsedDeployment = parsed?.deployment ?? null;
const effectiveDeploymentId = parsedDeployment ?? deploymentId;

// cli_outdated: surface the platform's upgrade message verbatim with
// no "Deploy failed (HTTP 426)" wrapper. The message already includes
// `npm i -g void@latest`, so adding a status prefix would only obscure
// the actionable line.
if (isCliOutdatedError(err) && parsed?.message) {
return new Error(appendTrailingLines(parsed.message, effectiveDeploymentId, logPath));
}

if (err.status === 409 && parsedError) {
return new Error(
appendTrailingLines(
Expand Down
5 changes: 4 additions & 1 deletion packages/void/test/integration/projects-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ describe('runProjectsCommand', () => {
capture.restore();

expect(fetchMock).toHaveBeenCalledWith('https://api.example.test/projects', {
headers: { Authorization: 'Bearer test-token' },
headers: {
Authorization: 'Bearer test-token',
'X-Void-CLI-Version': expect.stringMatching(/^\d+\.\d+\.\d+/) as unknown as string,
},
});
expect(out).toContain('star-tracker');
expect(out).toContain('https://star-tracker.staging.void.app');
Expand Down
1 change: 1 addition & 0 deletions packages/void/test/integration/purge-cache-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ describe('runPurgeCacheCommand', () => {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
'X-Void-CLI-Version': expect.stringMatching(/^\d+\.\d+\.\d+/) as unknown as string,
},
body: JSON.stringify({ all: true }),
},
Expand Down
92 changes: 92 additions & 0 deletions packages/void/test/unit/cli-outdated.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { isCliOutdatedError, PlatformApiError, readCliVersion } from '../../src/cli/client.ts';
import { formatStreamDeployError } from '../../src/cli/deploy-errors.ts';

const OUTDATED_BODY = JSON.stringify({
error: 'cli_outdated',
message:
'Your void CLI is too old: it sent a multipart deploy body, which is no longer supported. Upgrade with: npm i -g void@latest (minimum version 0.7.0).',
minVersion: '0.7.0',
});

describe('PlatformApiError + cli_outdated', () => {
it('overrides the default message with the server-supplied upgrade text on 426', () => {
const err = new PlatformApiError('Deploy failed', 426, OUTDATED_BODY);
// Default would be `Deploy failed: 426 {...json...}` — readers shouldn't
// see raw JSON. Server message wins so the actionable upgrade line is
// up front.
expect(err.message).not.toContain('426');
expect(err.message).not.toContain('{');
expect(err.message).toContain('npm i -g void@latest');
expect(err.message).toContain('Upgrade with');
expect(err.code).toBe('cli_outdated');
expect(err.status).toBe(426);
});

it('keeps the default message when status is 426 but body is not cli_outdated', () => {
const err = new PlatformApiError('Deploy failed', 426, '{"error":"something_else"}');
expect(err.message).toContain('Deploy failed');
expect(err.message).toContain('426');
});

it('keeps the default message when body is cli_outdated but status is not 426', () => {
// Defensive: status is what the gate sets, body shape alone shouldn't trigger
// the override (avoids letting a misconfigured upstream forge upgrade prompts).
const err = new PlatformApiError('Deploy failed', 400, OUTDATED_BODY);
expect(err.message).toContain('400');
});
});

describe('isCliOutdatedError', () => {
it('detects 426 + cli_outdated', () => {
expect(isCliOutdatedError(new PlatformApiError('x', 426, OUTDATED_BODY))).toBe(true);
});

it('rejects unrelated PlatformApiErrors', () => {
expect(isCliOutdatedError(new PlatformApiError('x', 403, 'forbidden'))).toBe(false);
expect(isCliOutdatedError(new PlatformApiError('x', 426, '{"error":"other"}'))).toBe(false);
expect(isCliOutdatedError(new Error('plain'))).toBe(false);
expect(isCliOutdatedError(null)).toBe(false);
});
});

describe('formatStreamDeployError + cli_outdated', () => {
it('surfaces the server message verbatim with no HTTP wrapper', () => {
const err = new PlatformApiError('Deploy failed', 426, OUTDATED_BODY);
const formatted = formatStreamDeployError(err, null, null);
// Should NOT contain the standard "Deploy failed (HTTP 426)" wrapper
// because that buries the actionable line.
expect(formatted.message).not.toContain('Deploy failed (HTTP 426)');
expect(formatted.message).toContain('npm i -g void@latest');
});

it('appends deployment id and log path when supplied', () => {
const err = new PlatformApiError('Deploy failed', 426, OUTDATED_BODY);
const formatted = formatStreamDeployError(err, 'dep_abc', '/tmp/log.txt');
expect(formatted.message).toContain('npm i -g void@latest');
expect(formatted.message).toContain('Deployment: dep_abc');
expect(formatted.message).toContain('Detailed log: /tmp/log.txt');
});
});

describe('readCliVersion', () => {
// The CLI must NEVER emit the literal string "unknown" as its version
// header — the platform's gate rejects unparseable headers as
// cli_outdated, which would brick the CLI on any packaging error.
// Failure path returns null and the ctor omits the header entirely.
it('returns a parseable semver triplet in the test environment', () => {
const v = readCliVersion();
// Test runs from a real package root, so version must be readable.
expect(v).not.toBeNull();
expect(v).toMatch(/^\d+\.\d+\.\d+/);
// Critical regression guard: never the literal string "unknown".
expect(v).not.toBe('unknown');
});

it('return type allows null (caller must handle missing version)', () => {
// This is a type-level assertion — if the return type ever drifts
// back to `string`, this assignment will fail typecheck.
const v: string | null = readCliVersion();
expect(v === null || typeof v === 'string').toBe(true);
});
});
9 changes: 7 additions & 2 deletions packages/void/test/unit/direct-r2-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,13 @@ describe('PlatformClient.requestUploadUrls', () => {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${apiUrl}/projects/${projectId}/deploy/upload-urls`);
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
expect((init.headers as Record<string, string>).Authorization).toBe(`Bearer ${token}`);
const headers = init.headers as Record<string, string>;
expect(headers['Content-Type']).toBe('application/json');
expect(headers.Authorization).toBe(`Bearer ${token}`);
// Version header so the platform's cliVersionGate middleware can refuse
// stale CLIs cleanly (HTTP 426 + cli_outdated). Format must look like a
// semver triplet — exact value is package.json-driven.
expect(headers['X-Void-CLI-Version']).toMatch(/^\d+\.\d+\.\d+/);

const body = JSON.parse(init.body as string);
expect(body).toEqual({
Expand Down