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
193 changes: 97 additions & 96 deletions packages/void/src/cli/client.ts

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions packages/void/src/cli/create-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ export async function createProjectWithSlugRetry(
return project;
} catch (error) {
if (error instanceof PlatformApiError && isRetryableCreateError(error)) {
const reason = error.code || error.body || `HTTP ${error.status}`;
log.error(`project: Could not create project '${slug}': ${reason}.`);
log.error(`project: ${error.message}`);
defaultSlug = slug;
continue;
}
Expand Down
24 changes: 11 additions & 13 deletions packages/void/src/cli/deploy-errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isCliOutdatedError, parsePlatformErrorBody, PlatformApiError } from './client.ts';
import { platformErrorMessage } from './platform-errors.ts';

const BODY_SNIPPET_MAX = 200;

Expand Down Expand Up @@ -58,8 +59,8 @@ export function formatConnectionLostMessage(
* Three cases:
* 1. `PlatformApiError` with 409 + a JSON body carrying `{error, deployment}`
* — surface the server-supplied error and prefer the body's deployment id.
* 2. Any other `PlatformApiError` — surface as `Deploy failed (HTTP <status>): <detail>`
* where detail is the parsed `error`/`message` field or a trimmed body snippet.
* 2. Any other `PlatformApiError` — retain the client's actionable message and
* structured error fields; opaque provider failures use a trimmed body snippet.
* 3. Anything else — preserve the original message under a `Connection lost
* during deploy: <original>` prefix so the caller can still tell network,
* parse, and transport errors apart.
Expand All @@ -71,7 +72,7 @@ export function formatStreamDeployError(
): Error {
if (err instanceof PlatformApiError) {
const parsed = parsePlatformErrorBody(err.body);
const parsedError = parsed?.error ?? parsed?.message ?? null;
const parsedError = platformErrorMessage(parsed) ?? parsed?.error ?? null;
const parsedDeployment = parsed?.deployment ?? null;
const effectiveDeploymentId = parsedDeployment ?? deploymentId;

Expand All @@ -80,24 +81,21 @@ export function formatStreamDeployError(
// `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));
err.message = appendTrailingLines(err.message, effectiveDeploymentId, logPath);
return err;
}

if (err.status === 409 && parsedError) {
return new Error(
appendTrailingLines(
`deploy: Deploy failed: ${parsedError}`,
effectiveDeploymentId,
logPath,
),
);
if (parsedError || err.status === 401 || err.status === 403 || parsed !== null) {
err.message = appendTrailingLines(`deploy: ${err.message}`, effectiveDeploymentId, logPath);
return err;
}

const detail = parsedError ?? trimSnippet(err.body);
const base = detail
? `deploy: Deploy failed (HTTP ${err.status}): ${detail}`
: `deploy: Deploy failed (HTTP ${err.status}).`;
return new Error(appendTrailingLines(base, effectiveDeploymentId, logPath));
err.message = appendTrailingLines(base, effectiveDeploymentId, logPath);
return err;
}

const original = err instanceof Error ? err.message : String(err);
Expand Down
50 changes: 27 additions & 23 deletions packages/void/src/cli/github-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { collectGitDeployMetadata } from './git-metadata.ts';
import type { GithubArgs } from './github-args.ts';
import { pc } from './output.ts';
import { platformErrorMessage } from './platform-errors.ts';
import { readProjectConfig } from './project.ts';
import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts';

Expand Down Expand Up @@ -62,9 +63,6 @@ export function describeConnectError(status: number, errorCode: string | undefin
if (status === 400 && code.includes('not accessible')) {
return 'Repository not accessible by this installation. Check the App has access to it.';
}
if (status === 403) {
return "You don't have access to this project.";
}
if (status === 404) {
return 'Project or installation not found.';
}
Expand All @@ -86,6 +84,19 @@ export function describeUpdateError(status: number, errorCode: string | undefine
return describeConnectError(status, code);
}

function describeGithubClientError(
error: PlatformApiError,
describe: typeof describeConnectError,
): string | null {
const body = parsePlatformErrorBody(error.body);
// The client knows whether this was a project read, settings change, or an
// account-level GitHub operation. A 403 alone does not identify the cause.
if (platformErrorMessage(body) || error.status === 403) {
return error.message;
}
return describe(error.status, error.code);
}

/**
* Extract `owner/repo` from a `https://github.com/owner/repo(.git)?` URL.
* Returns null for non-GitHub URLs or null input.
Expand Down Expand Up @@ -787,8 +798,7 @@ export async function runConnect(
}
// Organization participant: prove per-repo access via an authorize round-trip,
// then retry the connect EXACTLY ONCE with the resulting grant. This MUST
// come before the generic `describeConnectError` branch below, which maps
// ANY 403 to "no project access" and would otherwise swallow this signal.
// come before the generic error display so users can authorize the repository.
const grant = await runGithubConnectAuthorize(
client,
installation.installation_id,
Expand All @@ -800,8 +810,12 @@ export async function runConnect(
codeVerifier: grant.codeVerifier,
});
} catch (retryErr) {
if (retryErr instanceof PlatformApiError && retryErr.status === 403) {
// A fresh grant should be accepted; a 403 here means it was rejected
if (
retryErr instanceof PlatformApiError &&
retryErr.status === 403 &&
retryErr.code === 'invalid connect grant'
) {
// A fresh grant should be accepted; this code means it was rejected
// (expired, or the browser identity differs from the linked one). Do
// NOT fall through to the generic 403 "no project access" message.
log.error(
Expand All @@ -812,9 +826,7 @@ export async function runConnect(
throw retryErr;
}
} else {
const body = parsePlatformErrorBody(err.body);
const code = body?.error ?? body?.message ?? '';
const message = describeConnectError(err.status, code);
const message = describeGithubClientError(err, describeConnectError);
if (message !== null) {
log.error(`connect: ${message}`);
process.exit(1);
Expand Down Expand Up @@ -918,9 +930,7 @@ export async function runGithubUpdate(
current = await client.getGithubConnection(projectId);
} catch (err) {
if (err instanceof PlatformApiError) {
const body = parsePlatformErrorBody(err.body);
const code = body?.error ?? body?.message ?? '';
const message = describeUpdateError(err.status, code);
const message = describeGithubClientError(err, describeUpdateError);
if (message !== null) {
log.error(`update: ${message}`);
process.exit(1);
Expand Down Expand Up @@ -1045,9 +1055,7 @@ export async function runGithubUpdate(
updated = await client.updateGithubConnection(projectId, patch);
} catch (err) {
if (err instanceof PlatformApiError) {
const body = parsePlatformErrorBody(err.body);
const code = body?.error ?? body?.message ?? '';
const message = describeUpdateError(err.status, code);
const message = describeGithubClientError(err, describeUpdateError);
if (message !== null) {
log.error(`update: ${message}`);
process.exit(1);
Expand Down Expand Up @@ -1142,9 +1150,7 @@ export async function runStatus(
connection = await client.getGithubConnection(projectId);
} catch (err) {
if (err instanceof PlatformApiError) {
const body = parsePlatformErrorBody(err.body);
const code = body?.error ?? body?.message ?? '';
const message = describeUpdateError(err.status, code);
const message = describeGithubClientError(err, describeUpdateError);
if (message !== null) {
log.error(`status: ${message}`);
process.exit(1);
Expand Down Expand Up @@ -1234,7 +1240,7 @@ export async function runGithubDisconnect(
log.info('This project is not connected to a GitHub repository. Nothing to disconnect.');
return;
}
const message = describeConnectError(err.status, code);
const message = describeGithubClientError(err, describeConnectError);
if (message !== null) {
log.error(`disconnect: ${message}`);
process.exit(1);
Expand Down Expand Up @@ -1276,9 +1282,7 @@ export async function runGithubDisconnect(
await client.deleteGithubConnection(projectId);
} catch (err) {
if (err instanceof PlatformApiError) {
const body = parsePlatformErrorBody(err.body);
const code = body?.error ?? body?.message ?? '';
const message = describeConnectError(err.status, code);
const message = describeGithubClientError(err, describeConnectError);
if (message !== null) {
log.error(`disconnect: ${message}`);
process.exit(1);
Expand Down
156 changes: 156 additions & 0 deletions packages/void/src/cli/platform-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { stripVTControlCharacters } from 'node:util';

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

export type ProjectPermission = 'read' | 'configure' | 'deploy' | 'settings' | 'members' | 'owner';

export function parsePlatformErrorBody(body: string): PlatformErrorPayload | null {
try {
const parsed: unknown = JSON.parse(body);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
const result: PlatformErrorPayload = {};
for (const key of ['error', 'message', 'deployment', 'minVersion', 'clientVersion'] as const) {
const value = (parsed as Record<string, unknown>)[key];
if (typeof value === 'string') {
result[key] = value;
}
}
return result;
} catch {
return null;
}
}

function plainExplanation(value: string | undefined): string | undefined {
const text = value
? stripVTControlCharacters(value)
// Keep line breaks and tabs, but not control characters that rewrite the terminal.
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, ' ')
.trim()
: undefined;
// Error fields should contain an explanation, not another serialized response
// or an upstream login/error page. Keep the original body on PlatformApiError.
return text && !text.startsWith('{') && !text.startsWith('[') && !/<[^>]+>/.test(text)
? text
: undefined;
}

export function platformErrorMessage(payload: PlatformErrorPayload | null): string | undefined {
const message = plainExplanation(payload?.message);
return message &&
!/^(forbidden|unauthorized|access denied|permission denied)[.!]?$/i.test(message)
? message
: undefined;
}

function unexplainedRejection(status: number): string {
if (status === 401) {
return 'Authentication was rejected. Check the credentials for the selected platform and any access gateway in front of it.';
}
if (status === 403) {
return 'Access was denied by the platform or its access gateway. Check the selected platform and ask its administrator to confirm your access.';
}
return 'The platform did not provide an explanation. Retry the operation; if it still fails, contact the platform administrator.';
}

function permissionMessage(permission?: ProjectPermission): string {
if (permission === 'owner') {
return 'Only the project owner can perform this action. Ask the owner to run it using their platform login.';
}
if (permission === 'read') {
return 'This action requires project membership (reader or higher). Ask a project admin or owner for access.';
}
if (permission === 'settings' || permission === 'members') {
return 'This action requires admin or owner access. Ask a project admin or owner to confirm your access.';
}
if (permission === 'configure' || permission === 'deploy') {
return 'This action requires collaborator, admin, or owner access. Ask a project admin or owner to confirm your access.';
}
return 'Ask the platform administrator to check your access to this action.';
}

const rejectionMessages: Record<string, string> = {
'invalid or expired token':
'The platform reported an invalid or expired token. Sign in again with `void connect <platform-url>`, or replace `VOID_TOKEN` if you supplied it.',
'missing authorization header':
'A platform login or token is required. Sign in with `void connect <platform-url>`, or supply a valid `VOID_TOKEN` for that platform.',
'session revoked; sign in again':
'Your platform session was revoked. Sign in again with `void connect <platform-url>`, or replace `VOID_TOKEN` if you supplied it.',
'user not found':
'This account is no longer available. Contact the platform administrator or sign in with an active account on this platform.',
'deploy credential revoked or expired':
'This project deploy credential was revoked or expired. Ask the project owner to issue a replacement and update the credential used by your automation.',
'deploy token build is no longer active':
'This build is no longer active. Start a new build to obtain a valid deployment credential.',
github_identity_required:
'Link your GitHub account with `void github link`, then retry this operation.',
connect_authorization_required:
'This GitHub repository requires authorization. Run `void github connect` in an interactive terminal to authorize access, then retry.',
'invalid connect grant':
'GitHub authorization failed or expired. Run `void github connect` again.',
'installation access revoked':
'Access to this GitHub installation was revoked. Ask its administrator to restore access, then run `void github join`.',
'account suspended':
'This account is suspended. Contact the platform administrator to restore access.',
'project owner suspended':
'The project owner is suspended. Ask the owner to contact the platform administrator.',
'project is being deleted':
'This project is being deleted. Wait for deletion to finish before creating a replacement.',
'registered user not found':
'No registered account matches that email address. Ask the recipient to sign in to this platform first, then invite their registered email.',
'the project owner cannot be invited or changed as a member':
'The project owner cannot be invited or changed through team membership commands. Choose another member.',
'the project owner cannot leave the project':
'The project owner cannot leave. Ask a platform administrator to transfer ownership first.',
'user is already a project member':
'This user is already a project member. Use `void project team list` to find their user ID, then `void project team role` to change their role.',
'project member not found':
'This user is no longer a project member. Run `void project team list` to check current members.',
'project has too many pending invitations':
'This project has reached its pending invitation limit. Run `void project team invitations` and revoke invitations that are no longer needed.',
};

export function formatPlatformApiError(
action: string,
status: number,
body: string,
permission?: ProjectPermission,
): string {
const parsed = parsePlatformErrorBody(body);
const message = platformErrorMessage(parsed);
if (status === 426 && parsed?.error === 'cli_outdated' && message) {
return message;
}
if (!parsed) {
if (status === 401 || status === 403) {
const plain = plainExplanation(body);
const explanation =
plain && !/^(forbidden|unauthorized|access denied|permission denied)[.!]?$/i.test(plain)
? plain.replace(/\s+/g, ' ').slice(0, 240)
: unexplainedRejection(status);
return `${action} (HTTP ${status}): ${explanation}`;
}
// Other opaque provider failures retain the existing diagnostics behavior.
return `${action}: ${status} ${body}`;
}
const detail =
message ??
(status === 403 && parsed.error === 'forbidden'
? `Access denied. ${permissionMessage(permission)}`
: ((parsed.error && Object.hasOwn(rejectionMessages, parsed.error)
? rejectionMessages[parsed.error]
: undefined) ??
(status === 401 && ['unauthorized', 'missing token'].includes(parsed.error ?? '')
? rejectionMessages['invalid or expired token']
: plainExplanation(parsed.error))));
return `${action} (HTTP ${status}): ${detail || unexplainedRejection(status)}`;
}
9 changes: 8 additions & 1 deletion packages/void/src/cli/project-team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type ProjectMemberResponse,
} from './client.ts';
import { cliTitle } from './output.ts';
import { parsePlatformErrorBody, platformErrorMessage } from './platform-errors.ts';
import type { ProjectArgs } from './project-args.ts';
import { readProjectConfig } from './project.ts';
import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts';
Expand Down Expand Up @@ -77,6 +78,11 @@ function invitationResponseError(error: unknown, apiUrl: string): unknown {
if (!(error instanceof PlatformApiError)) {
return error;
}
// New APIs explain the rejection, including scoped-token and suspension cases.
// Do not mistake every 403 for an invitation addressed to another account.
if (platformErrorMessage(parsePlatformErrorBody(error.body))) {
return error;
}
let message: string;
if (error.code === 'project invitation not found') {
message = `Invitation not found on ${apiUrl}. Check VOID_API_URL if set, connect to the platform in your invitation with \`void connect <url>\`, then run \`void project team pending\` to check the invitation ID. If it is missing, ask a project administrator for a new invitation.`;
Expand All @@ -91,7 +97,8 @@ function invitationResponseError(error: unknown, apiUrl: string): unknown {
} else {
return error;
}
return new Error(message, { cause: error });
error.message = message;
return error;
}

export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs): Promise<void> {
Expand Down
Loading
Loading