diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 18a869684..4e1444947 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -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 { @@ -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 `: ` + * 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 `: ` 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; } } @@ -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), }; } diff --git a/packages/void/src/cli/deploy-errors.ts b/packages/void/src/cli/deploy-errors.ts index bee736a43..1e69c8dd2 100644 --- a/packages/void/src/cli/deploy-errors.ts +++ b/packages/void/src/cli/deploy-errors.ts @@ -1,4 +1,4 @@ -import { parsePlatformErrorBody, PlatformApiError } from './client.ts'; +import { isCliOutdatedError, parsePlatformErrorBody, PlatformApiError } from './client.ts'; const BODY_SNIPPET_MAX = 200; @@ -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( diff --git a/packages/void/test/integration/projects-command.test.ts b/packages/void/test/integration/projects-command.test.ts index 69fb08d14..3aecb446c 100644 --- a/packages/void/test/integration/projects-command.test.ts +++ b/packages/void/test/integration/projects-command.test.ts @@ -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'); diff --git a/packages/void/test/integration/purge-cache-command.test.ts b/packages/void/test/integration/purge-cache-command.test.ts index 26598ed5b..c98034134 100644 --- a/packages/void/test/integration/purge-cache-command.test.ts +++ b/packages/void/test/integration/purge-cache-command.test.ts @@ -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 }), }, diff --git a/packages/void/test/unit/cli-outdated.test.ts b/packages/void/test/unit/cli-outdated.test.ts new file mode 100644 index 000000000..eb58e257b --- /dev/null +++ b/packages/void/test/unit/cli-outdated.test.ts @@ -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); + }); +}); diff --git a/packages/void/test/unit/direct-r2-client.test.ts b/packages/void/test/unit/direct-r2-client.test.ts index 9611ccd82..7d297801d 100644 --- a/packages/void/test/unit/direct-r2-client.test.ts +++ b/packages/void/test/unit/direct-r2-client.test.ts @@ -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)['Content-Type']).toBe('application/json'); - expect((init.headers as Record).Authorization).toBe(`Bearer ${token}`); + const headers = init.headers as Record; + 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({