Skip to content

Commit eb38a7b

Browse files
authored
fix(cli): send X-Void-CLI-Version header and surface 426 cleanly (#195)
The platform's deploy gate now rejects stale CLIs with HTTP 426 and a `cli_outdated` body. The CLI now: - sends `X-Void-CLI-Version` from package.json on every platform call, conditionally spread so a missing/unreadable package.json omits the header rather than emitting a literal "unknown" string (the gate fails closed on unparseable values, which would brick the CLI) - detects 426 + `cli_outdated` and surfaces the server's upgrade message verbatim instead of the default "<action>: <status> <body>" wrapper that dumps raw JSON at the user Tests cover the message swap, the type guard, the deploy-error formatter, and the package.json fallback path. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk: adds a new request header on platform API calls and improves messaging for a specific 426 error without changing deploy/business logic. Main risk is unexpected platform behavior if the version header is malformed, mitigated by omitting the header when unreadable. > > **Overview** > Adds CLI version reporting and friendlier handling of the platform’s stale-CLI gate. > > The `PlatformClient` now reads `package.json` once per process and conditionally sends `X-Void-CLI-Version` on all platform requests (omitting the header entirely if the version can’t be read). > > Introduces `cli_outdated` detection for HTTP 426 responses and surfaces the server-provided upgrade message verbatim (both in `PlatformApiError` and deploy stream error formatting), with updated/new tests asserting the header and message behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a70f18a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a408358 commit eb38a7b

6 files changed

Lines changed: 174 additions & 6 deletions

File tree

‎packages/void/src/cli/client.ts‎

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,44 @@
1+
import { readFileSync } from 'node:fs';
2+
import { join } from 'pathe';
13
import { cfAccessHeaders } from '../shared/cf-access.ts';
4+
import { getPackageDir } from './agents.ts';
25
import { getApiUrl } from './auth.ts';
36
import { ensureCloudflaredToken } from './cf-access.ts';
47
import type { CliLogger } from './log-file.ts';
58
import type { AssetManifestEntry } from './package.ts';
69
import type { DeployEvent, DeployManifest } from './types.ts';
710

11+
/**
12+
* Current CLI version. Sent as `X-Void-CLI-Version` on every platform
13+
* request so the platform's CLI-version gate can refuse stale CLIs
14+
* cleanly (HTTP 426 + `cli_outdated`) instead of letting a wire
15+
* mismatch surface as an opaque parse error.
16+
*
17+
* Resolved once per process. Returns `null` when package.json can't be
18+
* read or has no `version` string — the ctor then OMITS the header
19+
* entirely. We deliberately do NOT send a placeholder string like
20+
* "unknown": the platform treats present-but-unparseable headers as a
21+
* stale-CLI signal and would 426 the request, bricking the CLI on any
22+
* packaging error. Missing-header is the documented pass-through path.
23+
*/
24+
export function readCliVersion(): string | null {
25+
try {
26+
const pkgPath = join(getPackageDir(), 'package.json');
27+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: unknown };
28+
return typeof pkg.version === 'string' ? pkg.version : null;
29+
} catch {
30+
return null;
31+
}
32+
}
33+
34+
const VOID_CLI_VERSION = readCliVersion();
35+
836
export type PlatformErrorPayload = {
937
error?: string;
1038
message?: string;
1139
deployment?: string;
40+
minVersion?: string;
41+
clientVersion?: string;
1242
};
1343

1444
export function parsePlatformErrorBody(body: string): PlatformErrorPayload | null {
@@ -23,17 +53,41 @@ export function parsePlatformErrorBody(body: string): PlatformErrorPayload | nul
2353
}
2454
}
2555

56+
/**
57+
* Detects the `cli_outdated` 426 response shape emitted by the platform's
58+
* `cliVersionGate` middleware. When present, callers should surface the
59+
* server's `message` field verbatim — it already includes upgrade
60+
* instructions — rather than the default `<action>: <status> <body>`
61+
* which would dump raw JSON at the user.
62+
*/
63+
export function isCliOutdatedError(err: unknown): err is PlatformApiError {
64+
if (!(err instanceof PlatformApiError)) {
65+
return false;
66+
}
67+
if (err.status !== 426) {
68+
return false;
69+
}
70+
return parsePlatformErrorBody(err.body)?.error === 'cli_outdated';
71+
}
72+
2673
export class PlatformApiError extends Error {
2774
status: number;
2875
body: string;
2976
code?: string;
3077

3178
constructor(action: string, status: number, body: string) {
32-
super(`${action}: ${status} ${body}`);
79+
const parsed = parsePlatformErrorBody(body);
80+
// cli_outdated: surface the server-supplied upgrade message verbatim
81+
// instead of the raw `<action>: <status> <body>` default. The message
82+
// already includes the `npm i -g void@latest` hint.
83+
const message =
84+
status === 426 && parsed?.error === 'cli_outdated' && parsed.message
85+
? parsed.message
86+
: `${action}: ${status} ${body}`;
87+
super(message);
3388
this.name = 'PlatformApiError';
3489
this.status = status;
3590
this.body = body;
36-
const parsed = parsePlatformErrorBody(body);
3791
this.code = parsed?.error ?? parsed?.message;
3892
}
3993
}
@@ -176,6 +230,11 @@ export class PlatformClient {
176230
ensureCloudflaredToken(this.baseUrl);
177231
this.authHeaders = {
178232
Authorization: `Bearer ${token}`,
233+
// Conditional spread: omit the header entirely if package.json was
234+
// unreadable. The platform's gate treats missing as pass-through;
235+
// sending a placeholder like "unknown" would trip the fail-closed
236+
// 426 path and brick the CLI permanently.
237+
...(VOID_CLI_VERSION !== null && { 'X-Void-CLI-Version': VOID_CLI_VERSION }),
179238
...cfAccessHeaders(process.env),
180239
};
181240
}

‎packages/void/src/cli/deploy-errors.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parsePlatformErrorBody, PlatformApiError } from './client.ts';
1+
import { isCliOutdatedError, parsePlatformErrorBody, PlatformApiError } from './client.ts';
22

33
const BODY_SNIPPET_MAX = 200;
44

@@ -75,6 +75,14 @@ export function formatStreamDeployError(
7575
const parsedDeployment = parsed?.deployment ?? null;
7676
const effectiveDeploymentId = parsedDeployment ?? deploymentId;
7777

78+
// cli_outdated: surface the platform's upgrade message verbatim with
79+
// no "Deploy failed (HTTP 426)" wrapper. The message already includes
80+
// `npm i -g void@latest`, so adding a status prefix would only obscure
81+
// the actionable line.
82+
if (isCliOutdatedError(err) && parsed?.message) {
83+
return new Error(appendTrailingLines(parsed.message, effectiveDeploymentId, logPath));
84+
}
85+
7886
if (err.status === 409 && parsedError) {
7987
return new Error(
8088
appendTrailingLines(

‎packages/void/test/integration/projects-command.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@ describe('runProjectsCommand', () => {
9999
capture.restore();
100100

101101
expect(fetchMock).toHaveBeenCalledWith('https://api.example.test/projects', {
102-
headers: { Authorization: 'Bearer test-token' },
102+
headers: {
103+
Authorization: 'Bearer test-token',
104+
'X-Void-CLI-Version': expect.stringMatching(/^\d+\.\d+\.\d+/) as unknown as string,
105+
},
103106
});
104107
expect(out).toContain('star-tracker');
105108
expect(out).toContain('https://star-tracker.staging.void.app');

‎packages/void/test/integration/purge-cache-command.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ describe('runPurgeCacheCommand', () => {
9696
headers: {
9797
'Content-Type': 'application/json',
9898
Authorization: 'Bearer test-token',
99+
'X-Void-CLI-Version': expect.stringMatching(/^\d+\.\d+\.\d+/) as unknown as string,
99100
},
100101
body: JSON.stringify({ all: true }),
101102
},
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { isCliOutdatedError, PlatformApiError, readCliVersion } from '../../src/cli/client.ts';
3+
import { formatStreamDeployError } from '../../src/cli/deploy-errors.ts';
4+
5+
const OUTDATED_BODY = JSON.stringify({
6+
error: 'cli_outdated',
7+
message:
8+
'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).',
9+
minVersion: '0.7.0',
10+
});
11+
12+
describe('PlatformApiError + cli_outdated', () => {
13+
it('overrides the default message with the server-supplied upgrade text on 426', () => {
14+
const err = new PlatformApiError('Deploy failed', 426, OUTDATED_BODY);
15+
// Default would be `Deploy failed: 426 {...json...}` — readers shouldn't
16+
// see raw JSON. Server message wins so the actionable upgrade line is
17+
// up front.
18+
expect(err.message).not.toContain('426');
19+
expect(err.message).not.toContain('{');
20+
expect(err.message).toContain('npm i -g void@latest');
21+
expect(err.message).toContain('Upgrade with');
22+
expect(err.code).toBe('cli_outdated');
23+
expect(err.status).toBe(426);
24+
});
25+
26+
it('keeps the default message when status is 426 but body is not cli_outdated', () => {
27+
const err = new PlatformApiError('Deploy failed', 426, '{"error":"something_else"}');
28+
expect(err.message).toContain('Deploy failed');
29+
expect(err.message).toContain('426');
30+
});
31+
32+
it('keeps the default message when body is cli_outdated but status is not 426', () => {
33+
// Defensive: status is what the gate sets, body shape alone shouldn't trigger
34+
// the override (avoids letting a misconfigured upstream forge upgrade prompts).
35+
const err = new PlatformApiError('Deploy failed', 400, OUTDATED_BODY);
36+
expect(err.message).toContain('400');
37+
});
38+
});
39+
40+
describe('isCliOutdatedError', () => {
41+
it('detects 426 + cli_outdated', () => {
42+
expect(isCliOutdatedError(new PlatformApiError('x', 426, OUTDATED_BODY))).toBe(true);
43+
});
44+
45+
it('rejects unrelated PlatformApiErrors', () => {
46+
expect(isCliOutdatedError(new PlatformApiError('x', 403, 'forbidden'))).toBe(false);
47+
expect(isCliOutdatedError(new PlatformApiError('x', 426, '{"error":"other"}'))).toBe(false);
48+
expect(isCliOutdatedError(new Error('plain'))).toBe(false);
49+
expect(isCliOutdatedError(null)).toBe(false);
50+
});
51+
});
52+
53+
describe('formatStreamDeployError + cli_outdated', () => {
54+
it('surfaces the server message verbatim with no HTTP wrapper', () => {
55+
const err = new PlatformApiError('Deploy failed', 426, OUTDATED_BODY);
56+
const formatted = formatStreamDeployError(err, null, null);
57+
// Should NOT contain the standard "Deploy failed (HTTP 426)" wrapper
58+
// because that buries the actionable line.
59+
expect(formatted.message).not.toContain('Deploy failed (HTTP 426)');
60+
expect(formatted.message).toContain('npm i -g void@latest');
61+
});
62+
63+
it('appends deployment id and log path when supplied', () => {
64+
const err = new PlatformApiError('Deploy failed', 426, OUTDATED_BODY);
65+
const formatted = formatStreamDeployError(err, 'dep_abc', '/tmp/log.txt');
66+
expect(formatted.message).toContain('npm i -g void@latest');
67+
expect(formatted.message).toContain('Deployment: dep_abc');
68+
expect(formatted.message).toContain('Detailed log: /tmp/log.txt');
69+
});
70+
});
71+
72+
describe('readCliVersion', () => {
73+
// The CLI must NEVER emit the literal string "unknown" as its version
74+
// header — the platform's gate rejects unparseable headers as
75+
// cli_outdated, which would brick the CLI on any packaging error.
76+
// Failure path returns null and the ctor omits the header entirely.
77+
it('returns a parseable semver triplet in the test environment', () => {
78+
const v = readCliVersion();
79+
// Test runs from a real package root, so version must be readable.
80+
expect(v).not.toBeNull();
81+
expect(v).toMatch(/^\d+\.\d+\.\d+/);
82+
// Critical regression guard: never the literal string "unknown".
83+
expect(v).not.toBe('unknown');
84+
});
85+
86+
it('return type allows null (caller must handle missing version)', () => {
87+
// This is a type-level assertion — if the return type ever drifts
88+
// back to `string`, this assignment will fail typecheck.
89+
const v: string | null = readCliVersion();
90+
expect(v === null || typeof v === 'string').toBe(true);
91+
});
92+
});

‎packages/void/test/unit/direct-r2-client.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,13 @@ describe('PlatformClient.requestUploadUrls', () => {
6464
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
6565
expect(url).toBe(`${apiUrl}/projects/${projectId}/deploy/upload-urls`);
6666
expect(init.method).toBe('POST');
67-
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
68-
expect((init.headers as Record<string, string>).Authorization).toBe(`Bearer ${token}`);
67+
const headers = init.headers as Record<string, string>;
68+
expect(headers['Content-Type']).toBe('application/json');
69+
expect(headers.Authorization).toBe(`Bearer ${token}`);
70+
// Version header so the platform's cliVersionGate middleware can refuse
71+
// stale CLIs cleanly (HTTP 426 + cli_outdated). Format must look like a
72+
// semver triplet — exact value is package.json-driven.
73+
expect(headers['X-Void-CLI-Version']).toMatch(/^\d+\.\d+\.\d+/);
6974

7075
const body = JSON.parse(init.body as string);
7176
expect(body).toEqual({

0 commit comments

Comments
 (0)