import { resolvePortalBase } from './facade.js'; import { redactDeep } from './redact.js'; import { classifyBillingRefusal, gateRefusalOf } from './billing-refusal.js'; /** * Error codes shared with the backend facade and the MCP plugin. * Must stay in sync across all three. */ export const ERROR_CODES = [ 'AUTH_REQUIRED', 'AUTH_INVALID', 'AUTH_FORBIDDEN', 'NOT_FOUND', 'VALIDATION_ERROR', // M3.2 piece-2 — inline `code` body cap (350 KB) exceeded. Exit 5 // (validation family) — the request is well-formed but oversized; // `nextAction` points the operator at the Portal upload flow. 'PAYLOAD_TOO_LARGE', 'CONFLICT', // M3.2 piece-1 / piece-4 — `If-Match: ` etag mismatch on // `test code put`. Exit 6, same family as CONFLICT (the caller should // re-fetch and retry); kept distinct so machine consumers can route on // it without parsing message strings. 'PRECONDITION_FAILED', // M3.2 piece-1 — `Idempotency-Key` reused with a different request // body. Exit 6; distinct from CONFLICT (snapshot-in-flight) so // automation can tell "you mutated the body between retries" from // "a Portal write is in flight." 'IDEMPOTENCY_BODY_MISMATCH', 'RATE_LIMITED', // Emitted natively by the backend as HTTP 402 with `details.required` (the // shortfall) — non-retriable, exit 12: out-of-credits cannot self-heal with // retries. The CLI additionally re-maps the legacy credits sub-case of a // RATE_LIMITED envelope (message matching /insufficient credits/i or // details.required present) for older backends. The genuine per-minute // throttle, 60/min/key ("Run trigger rate limit exceeded") retains code // RATE_LIMITED / exit 11 / retriable. 'INSUFFICIENT_CREDITS', // Emitted natively by the backend as a fatal HTTP 403 when a plan-gated // feature is requested by a tier that doesn't include it (e.g. auto-auth on // a Free-plan org). `details` carries `{feature, plan, reason: 'plan'}`; // exit code 13 (non-retriable) — the fix is an upgrade, not a retry. // Distinct from silent tier DOWNGRADES (a request that still succeeds with // the feature dropped): those are surfaced via the response's advisory // fields, not this error. 'FEATURE_GATED', // Client-side re-map of the org-ambiguity sub-case of a CONFLICT (409) // envelope. Raised when a membership-key caller's testId resolves to // projects in more than one of their organizations (uuid-derived ids // should not normally collide, so this is pathological — a shared-id // union-resolution edge case, not a transient snapshot race). Detected // when the backend returns `code: "CONFLICT"` with // `details.reason === "ambiguous_org"`. Same exit-code family as CONFLICT // (exit 6) but kept as a distinct code — unlike a generic CONFLICT this is // never retried (retrying resolves the same ambiguity again) and // `details.candidates[]` needs its own rendering. 'AMBIGUOUS_ORG', 'UNSUPPORTED', // The running CLI is older than the backend's minimum supported version. // Backend emits this as HTTP 426 when version enforcement is enabled. Exit // 14, non-retriable — a too-old client cannot self-heal by retrying; the // user must upgrade. `nextAction` carries the upgrade instruction and // `details` echoes { minVersion, latestVersion, yourVersion }. 'CLIENT_TOO_OLD', 'INTERNAL', 'UNAVAILABLE', ] as const; export type ErrorCode = (typeof ERROR_CODES)[number]; export function isErrorCode(value: unknown): value is ErrorCode { return typeof value === 'string' && (ERROR_CODES as readonly string[]).includes(value); } /** * Exit-code mapping. INTERNAL maps to 1 * (generic CLI failure) intentionally — operators triage by `requestId`, * not by exit code. */ export function exitCodeFor(code: ErrorCode): number { switch (code) { case 'AUTH_REQUIRED': case 'AUTH_INVALID': case 'AUTH_FORBIDDEN': return 3; case 'NOT_FOUND': return 4; case 'VALIDATION_ERROR': case 'PAYLOAD_TOO_LARGE': return 5; case 'CONFLICT': case 'PRECONDITION_FAILED': case 'IDEMPOTENCY_BODY_MISMATCH': case 'AMBIGUOUS_ORG': return 6; case 'UNSUPPORTED': return 7; case 'CLIENT_TOO_OLD': return 14; case 'UNAVAILABLE': return 10; case 'RATE_LIMITED': return 11; case 'INSUFFICIENT_CREDITS': return 12; case 'FEATURE_GATED': return 13; case 'INTERNAL': return 1; } } /** * Whether an error code is an authentication failure. DERIVED from the single * `exitCodeFor` mapping (auth === exit 3) rather than a second hand-maintained * list of code strings: a future `AUTH_*` code gets classified as auth the * moment it is given an exit-3 row, so it can never be present in one place and * missing in the other. The `ERROR_CODES` membership check keeps `exitCodeFor` * off unknown/out-of-contract strings (its switch has no default). */ export function isAuthCode(code: string): boolean { return (ERROR_CODES as readonly string[]).includes(code) && exitCodeFor(code as ErrorCode) === 3; } export class CLIError extends Error { readonly exitCode: number; /** * Stable machine error code — mirrored into telemetry's `errorCode` field * and into the `--output json` error envelope (`{error:{code,...}}`). * Defaults to the generic, deliberately out-of-catalog `'CLI_ERROR'` * bucket for a plain `CLIError` (same convention as `InterruptError`'s * `'INTERRUPTED'` and `RequestTimeoutError`'s `'REQUEST_TIMEOUT'` below — * none of these are backend-issued codes, so they don't belong in * `ERROR_CODES`). `ApiError` overrides this with the real catalog * `ErrorCode` parsed from the server envelope. * * Before this field existed, `classifyCliError()`'s plain-`CLIError` * fallback branch had nothing to report and telemetry events landed with * `errorCode=None` — the same silent gap the final uncaught-exception * branch had. */ readonly code: string; constructor(message: string, exitCode = 1, code = 'CLI_ERROR') { super(message); this.name = 'CLIError'; this.exitCode = exitCode; this.code = code; } } /** * Best-effort extraction of a `.code` string off an arbitrary thrown value — * covers Node built-in errors (`ENOENT`, `ECONNREFUSED`, …) that reach the * top-level catch without ever being wrapped in a `CLIError`. Used by the * genuinely-uncaught-exception fallback in both `classifyCliError()` and * `index.ts`'s final catch branch, so a filesystem/network error reports its * real code instead of falling into the generic `'UNCAUGHT_EXCEPTION'` * bucket. */ export function extractNodeErrorCode(err: unknown): string | undefined { if (typeof err !== 'object' || err === null || !('code' in err)) return undefined; const code = (err as { code?: unknown }).code; return typeof code === 'string' && code.length > 0 ? code : undefined; } /** * Termination signals with graceful handling, mapped to their conventional * `128 + signum` exit code. sourceRef: POSIX signal numbers (SIGHUP=1, * SIGINT=2, SIGTERM=15). Lives here (not interrupt.ts) so `InterruptError` * needs no import cycle; `interrupt.ts` re-exports for back-compat. */ export const TERMINATION_EXIT_CODES = { SIGINT: 130, // 128 + 2 SIGTERM: 143, // 128 + 15 SIGHUP: 129, // 128 + 1 } as const; export type TerminationSignal = keyof typeof TERMINATION_EXIT_CODES; /** * User-initiated interrupt (SIGINT/SIGTERM/SIGHUP) observed during an armed * wait or a disarmed request. Run/wait paths mark the error so `index.ts` can give * recovery guidance only when there is a run to re-attach to. * * Deliberately NOT in `ERROR_CODES` — on a signal the CLI * exits 130/143 without consulting the error catalog. The JSON-mode stderr * envelope uses the out-of-catalog code `"INTERRUPTED"`. Same client-local * synthetic category as `RequestTimeoutError`. */ export class InterruptError extends CLIError { readonly signal: TerminationSignal; runWaitContext = false; constructor(signal: TerminationSignal) { super(`Interrupted by ${signal}.`, TERMINATION_EXIT_CODES[signal], 'INTERRUPTED'); this.name = 'InterruptError'; this.signal = signal; } } export class NotImplementedError extends CLIError { constructor(commandPath: string) { super(`Command not yet implemented: ${commandPath}`, 2, 'NOT_IMPLEMENTED'); this.name = 'NotImplementedError'; } } export interface ErrorEnvelopeBody { code: ErrorCode; message: string; nextAction: string; requestId: string; details: Record; } export interface ErrorEnvelope { error: ErrorEnvelopeBody; } /** * Server-returned API error. The CLI preserves the facade's nonempty * `nextAction` and synthesizes one for billing refusals that omit it. * Locally-detected pre-flight conditions use the same envelope shape. */ export class ApiError extends CLIError { // `override`: CLIError now declares its own `code: string` (defaulting // 'CLI_ERROR'), so `noImplicitOverride` requires this modifier on the // narrowed redeclaration. Under useDefineForClassFields (the default at // this tsconfig's ES2022 target) this field IS re-initialized to // `undefined` right after `super(...)` returns — but the constructor body // below unconditionally reassigns `this.code = envelope.code` afterward, // so the final value is always the envelope's code, never the CLIError // base default. See the pinned regression test in errors.test.ts // ("ApiError.code overrides the CLIError base default"). override readonly code: ErrorCode; readonly requestId: string; readonly nextAction: string; readonly serverNextAction: string; readonly details: Record; readonly httpStatus: number | undefined; readonly apiUrl: string | undefined; /** * Parsed `Retry-After` header value in milliseconds, when present on the * HTTP response. Only set for `RATE_LIMITED` (429) responses where the * server is telling the caller how long to wait. Clamped to `[1s, 300s]` * per the outer retry loop contract. `undefined` when the header was * absent or unparseable. */ readonly retryAfterMs: number | undefined; constructor( envelope: ErrorEnvelopeBody, httpStatus?: number, retryAfterMs?: number, apiUrl?: string, serverNextAction?: string, ) { super(envelope.message, exitCodeFor(envelope.code)); this.name = 'ApiError'; this.code = envelope.code; this.requestId = envelope.requestId; this.nextAction = envelope.nextAction; this.serverNextAction = serverNextAction ?? envelope.nextAction; // Redact at the single point every ApiError is constructed — server // envelopes (fromEnvelope), locally-fabricated ones (authRequired, // localValidationError), and any future direct `new ApiError(...)` — so // `.details` and `.getDetail(...)` are already clean everywhere they are // read: `Output.error`'s JSON envelope, `index.ts`'s hand-built ApiError // envelope, and the AUTH_FORBIDDEN/CLIENT_TOO_OLD/AMBIGUOUS_ORG text-mode // blocks in `index.ts` that print specific detail keys directly (those // never go through `Output.error` at all, so redacting only at the // render call sites would miss them). Server envelopes are already // redacted server-side; this is defence in depth, not the only layer. this.details = redactDeep(envelope.details); this.httpStatus = httpStatus; this.apiUrl = apiUrl; this.retryAfterMs = retryAfterMs; } /** * @param apiUrl Optional API endpoint the failing request targeted. Used to * resolve environment-correct portal links in synthesized `nextAction` * text (dev and prod portals live on different domains — never hardcode). */ static fromEnvelope( raw: unknown, httpStatus?: number, retryAfterMs?: number, apiUrl?: string, ): ApiError { const envelope = parseEnvelopeBody(raw, httpStatus, apiUrl); const serverNextAction = envelope.nextAction; if (!envelope.nextAction) { const refusal = classifyBillingRefusal({ ...envelope, apiUrl }); if (refusal) { envelope.nextAction = envelope.code === 'INSUFFICIENT_CREDITS' ? insufficientCreditsNextAction(apiUrl) : refusal.nextAction; } } return new ApiError(envelope, httpStatus, retryAfterMs, apiUrl, serverNextAction); } /** * Type-safe accessor for a single key inside `details`. Avoids the * `(err.details as { foo?: unknown })?.foo` cast pattern that every * 412 / CONFLICT handler had to repeat. * * @param key The key to read from `details`. * @param validator Optional type-guard. When provided, returns * `undefined` if the guard rejects the value. * * @example * const ver = err.getDetail( * 'currentCodeVersion', * (v): v is string => typeof v === 'string', * ); */ getDetail(key: string, validator?: (v: unknown) => v is T): T | undefined { const details = this.details; if (details === undefined || details === null || typeof details !== 'object') { return undefined; } const value = (details as Record)[key]; if (value === undefined) return undefined; if (validator && !validator(value)) return undefined; return value as T; } /** * Local fabrication of a missing-key error. Wording matches the server * AUTH_REQUIRED template so the user sees the same thing whether the * miss was detected before or after sending the request. */ /** * `profile` is supplied only when a NON-default profile was selected — by * `--profile` or by `TESTSPRITE_PROFILE`. Naming it matters because the * selection is invisible otherwise: a typo in either one picks a profile * that has no credentials, and the generic message sends the reader off to * configure the default profile they already have. */ static authRequired(requestId = 'local', profile?: string): ApiError { if (profile !== undefined) { return new ApiError({ code: 'AUTH_REQUIRED', // `message` stays byte-identical to the default-profile case: the // wording is stable per error code, so callers can pattern-match on it. // The profile belongs in the remediation and in `details`, which are // the per-error context. message: 'Authentication is required.', nextAction: `No credentials are stored for profile "${profile}". Run \`testsprite setup --profile ${profile}\`` + ' to configure it, or drop the profile selection if you did not mean to use one' + ' (`--profile` flag, TESTSPRITE_PROFILE env var). `testsprite doctor` prints the profile in use.', requestId, // Safe to echo: every credential read passes the profile name through // `assertValidProfileName` first, so it matches /^[A-Za-z0-9._-]+$/. details: { profile }, }); } return new ApiError({ code: 'AUTH_REQUIRED', message: 'Authentication is required.', nextAction: 'Run `testsprite setup` (interactive — prompts for your API key and installs the verification skill),' + ' or set TESTSPRITE_API_KEY and run `testsprite setup --from-env` for non-interactive flows.', requestId, details: {}, }); } } /** * Fabricate a locally-detected VALIDATION_ERROR envelope using the same * shape the backend facade emits, so callers see identical wording whether * the error is caught before or after a network round-trip. * * `kind` controls how `nextAction` is phrased: * * - `'flag'` (default) — wraps the field name as a CLI flag: * `Flag \`--page-size\` is invalid: must be a positive integer.` * - `'field'` — uses the bare field path: * `Field \`planSteps[0].description\` is invalid: must be a string.` * * Use `'field'` for JSON-body paths to avoid fabricating a `--fieldName` * flag that doesn't exist in the CLI surface. * * `reason` is trailed with a period UNLESS it already ends with one — some * call sites compose `reason` from multiple already-punctuated sentences * (e.g. `'... Remove --target-url.'`), and unconditionally appending a * second period produced a doubled `..` at the end of the rendered message. */ export function localValidationError( field: string, reason: string, accepted?: unknown, kind: 'flag' | 'field' = 'flag', ): ApiError { const subject = kind === 'flag' ? `Flag \`--${field.replace(/[A-Z]/g, ch => `-${ch.toLowerCase()}`)}\`` : `Field \`${field}\``; const punctuatedReason = reason.endsWith('.') ? reason : `${reason}.`; return ApiError.fromEnvelope({ error: { code: 'VALIDATION_ERROR', message: 'Invalid request.', nextAction: `${subject} is invalid: ${punctuatedReason}`, requestId: 'local', details: accepted === undefined ? { field, reason } : { field, reason, accepted }, }, }); } /** * Network/transport failure not associated with a server envelope. Treated * as `UNAVAILABLE` for exit-code purposes per design §8.2. */ export class TransportError extends ApiError { constructor(message: string, requestId = 'local') { super({ code: 'UNAVAILABLE', message, nextAction: 'Service is temporarily unavailable. The CLI retries with exponential backoff; if this persists, report it.', requestId, details: {}, }); this.name = 'TransportError'; } } /** * Client-side per-request timeout. The fetch was aborted because the * configurable per-request deadline elapsed before the server responded. * * Maps to `UNSUPPORTED` for exit-code purposes (exit 7) — the same bucket * as `TimeoutError` from the polling path. Use `--request-timeout ` or * `TESTSPRITE_REQUEST_TIMEOUT_MS` to extend the deadline when targeting a * slow backend. */ export class RequestTimeoutError extends CLIError { readonly requestId: string; readonly timeoutMs: number; constructor(timeoutMs: number, requestId = 'local') { const timeoutSec = Math.round(timeoutMs / 1000); super( `Request timed out after ${timeoutSec}s (client-side). ` + `Use --request-timeout or TESTSPRITE_REQUEST_TIMEOUT_MS to extend the deadline.`, 7, 'REQUEST_TIMEOUT', ); this.name = 'RequestTimeoutError'; this.requestId = requestId; this.timeoutMs = timeoutMs; } } /** * Every facade-emitted HTTP status maps to a * known error code. When the envelope is missing or its `code` field is * unrecognized (proxy returned an empty body, content was mangled), we fall * back to the status-derived code so the retry budget and exit code match * what the contract promises. */ function codeFromHttpStatus(status: number | undefined): ErrorCode { switch (status) { case 400: return 'VALIDATION_ERROR'; case 401: return 'AUTH_INVALID'; case 402: return 'INSUFFICIENT_CREDITS'; case 403: return 'AUTH_FORBIDDEN'; case 404: return 'NOT_FOUND'; case 409: return 'CONFLICT'; case 412: return 'PRECONDITION_FAILED'; case 413: return 'PAYLOAD_TOO_LARGE'; case 426: return 'CLIENT_TOO_OLD'; case 429: return 'RATE_LIMITED'; case 501: return 'UNSUPPORTED'; case 503: return 'UNAVAILABLE'; default: return 'INTERNAL'; } } /** * Detect the "insufficient credits" sub-case of a RATE_LIMITED (429) envelope. * * Detection signals (require at least one strong signal): * 1. `details.required` is present (a number > 0) — the credit cost field * that the backend attaches only to the credits error, not to throttle. * 2. `message` matches /insufficient credits/i — the backend's stable * template for this error. * * The genuine per-minute throttle has message "Run trigger rate limit exceeded: …" * and no `details.required`, so it cannot match either signal. * * This is a CLIENT-SIDE detection bridge. The backend will emit a distinct * `INSUFFICIENT_CREDITS` code in a future release at which point this * function becomes unreachable for new backends. */ function isInsufficientCredits( rawCode: string, message: string, details: Record, ): boolean { if (rawCode !== 'RATE_LIMITED') return false; const hasRequiredField = typeof details.required === 'number' && details.required > 0; const hasCreditsMessage = /insufficient credits/i.test(message); return hasRequiredField || hasCreditsMessage; } /** * The synthesized `nextAction` for an INSUFFICIENT_CREDITS refusal when the * backend supplied none (older backends; the CLI's own all-credits batch * refusal). Portal links resolve per environment from the API endpoint (dev and * prod portals live on different domains); an unknown host gets the route only — * a hardcoded domain would point at the wrong environment. * * V3 API keys are membership-bound: a key minted for a team org spends THAT * org's wallet, not the holder's personal one, and there is no request-scoped * signal at this layer to tell which kind of key just failed. So the hint * deliberately does NOT assert "top up your (personal) credits" as the only * path — it offers the personal-key link (still correct for the common case) * alongside an honest org-bound alternative, rather than guessing. The * `testsprite usage` pointer is the CLI-native way to check the balance before * a large run fan-out. */ export function insufficientCreditsNextAction(apiUrl?: string): string { const portalBase = apiUrl === undefined ? undefined : resolvePortalBase(apiUrl); return ( (portalBase !== undefined ? `Top up credits at ${portalBase}/dashboard/settings/billing (personal keys) — ask an org admin if this key is organization-bound — or upgrade your plan at ${portalBase}/pricing.` : 'Top up credits on the portal Billing page (/dashboard/settings/billing) if this is a personal key — ask an org admin if it is organization-bound — or upgrade your plan (/pricing).') + ' Run `testsprite usage` to check your current balance before the next run.' ); } /** * Detect the "ambiguous org" sub-case of a CONFLICT (409) envelope: a * membership-key caller's testId resolved to projects in more than one of * their organizations. `details.reason === "ambiguous_org"` is the backend's * stable discriminator (see `CliAmbiguousTestError` server-side) — distinct * from the default CONFLICT meaning (snapshot in flight). */ function isAmbiguousOrgConflict(rawCode: string, details: Record): boolean { return rawCode === 'CONFLICT' && details.reason === 'ambiguous_org'; } function parseEnvelopeBody(raw: unknown, httpStatus?: number, apiUrl?: string): ErrorEnvelopeBody { if (typeof raw !== 'object' || raw === null) { const fallbackCode = codeFromHttpStatus(httpStatus); return { code: fallbackCode, message: fallbackCode === 'INTERNAL' ? 'Server returned a malformed error response.' : `Server returned ${httpStatus} without an error envelope.`, nextAction: fallbackCode === 'INTERNAL' ? 'Server error. Retry once; if it persists, report the `requestId` to support@testsprite.com.' : '', requestId: 'unknown', details: {}, }; } const obj = raw as Record; const wireCode = typeof obj.code === 'string' ? obj.code : ''; // The exact wire codes the workspace-gate body can send — checked on their // own (not just the open-ended `_limit_exceeded` suffix) so a body with no // HTTP status attached still has something narrow to match against. const isKnownGateWireCode = wireCode === 'feature_not_entitled' || wireCode === 'workspace_paused' || wireCode === 'environment_limit_exceeded' || wireCode === 'testlist_limit_exceeded' || wireCode === 'schedule_limit_exceeded' || wireCode === 'github_repo_limit_exceeded' || wireCode === 'fixture_limit_exceeded' || wireCode === 'project_limit_exceeded'; // Only take this raw shape for the HTTP statuses the gate actually answers // with (402/403) — matching purely on `code` let an unrelated 429 body that // happens to end in `_limit_exceeded` (e.g. a future `rate_limit_exceeded`) // get reclassified as a non-retriable FEATURE_GATED and stop being retried. // With no status at all, only the exact known codes above qualify: the // open-ended suffix match is too permissive without a status to corroborate it. const takesRawGateShape = httpStatus === 402 || httpStatus === 403 ? isKnownGateWireCode || /_limit_exceeded$/.test(wireCode) : httpStatus === undefined && isKnownGateWireCode; if (takesRawGateShape) { // A `reason` on the body is the server's and passes through as-is. // Otherwise the wire code says which gate refusal it is, named as the // gate names it. const reason = typeof obj.reason === 'string' && obj.reason !== '' ? obj.reason : wireCode.endsWith('_limit_exceeded') ? 'limit_exceeded' : wireCode === 'workspace_paused' ? 'paused' : 'not_entitled'; const details: Record = { reason }; for (const key of [ 'feature', 'plan', 'limit', 'current', 'requiredPlan', 'orgId', 'billingUrl', 'action', 'pauseKind', 'pauseTarget', ]) { if (obj[key] !== undefined && obj[key] !== null) details[key] = obj[key]; } if (gateRefusalOf(reason) === 'limit_exceeded' && details.feature === undefined) { details.feature = wireCode.slice(0, -'_limit_exceeded'.length); } return { code: 'FEATURE_GATED', message: typeof obj.message === 'string' ? obj.message : 'This action is unavailable on the current plan.', nextAction: typeof obj.nextAction === 'string' ? obj.nextAction : '', requestId: typeof obj.requestId === 'string' ? obj.requestId : 'unknown', details, }; } // Raw Nest/Express 404 shape: `{ message, error: "Not Found", statusCode }`. // The current parser was matching `obj.error` (a string) and falling to // `Server error.` while losing the actual `message` from the body // (e.g. `Cannot POST /api/cli/v1/tests/{id}/runs`). Detect this shape // explicitly so the user sees something actionable like // "endpoint not deployed" instead of the generic CLI failure text. if (typeof obj.error === 'string' && typeof obj.message === 'string') { const message = obj.message; const cannotMethod = /^Cannot (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) /.exec(message); const isRouteMissing404 = (httpStatus === 404 || obj.statusCode === 404) && !!cannotMethod; return { code: codeFromHttpStatus(httpStatus), message: isRouteMissing404 ? `${message} — endpoint not available on the current backend deployment.` : message, nextAction: isRouteMissing404 ? 'Verify the CLI is targeting the right environment (check `testsprite auth status` for `env`), and confirm that backend deployment includes this endpoint — it may need a newer backend release.' : '', requestId: 'unknown', details: { statusCode: obj.statusCode }, }; } const errorObj = (obj.error ?? obj) as Record; const rawCode = isErrorCode(errorObj.code) ? errorObj.code : codeFromHttpStatus(httpStatus); const message = typeof errorObj.message === 'string' ? errorObj.message : 'Server error.'; const details = typeof errorObj.details === 'object' && errorObj.details !== null ? (errorObj.details as Record) : {}; const nextAction = typeof errorObj.nextAction === 'string' ? errorObj.nextAction : ''; const requestId = typeof errorObj.requestId === 'string' ? errorObj.requestId : 'unknown'; // Client-side re-map: RATE_LIMITED with credits signal -> INSUFFICIENT_CREDITS. // This makes the code non-retriable and sets exit 12 before the retry // decision in http.ts sees it, so BOTH retry and exit-code paths are correct. // A nextAction pointing to the billing page is synthesized when the backend // didn't supply one (older backend versions). if (isInsufficientCredits(rawCode, message, details)) { // Synthesize a nextAction when the backend did not supply one, or when // the backend-supplied nextAction lacks the CLI pre-flight hint. // The `testsprite usage` command is the CLI-native way to check balance // before a large run fan-out (dogfood L1868 + L1890). // Portal links resolve per environment from the API endpoint (dev and // prod portals live on different domains); unknown hosts get the route // only — a hardcoded domain would point at the wrong environment. // // V3 API keys are membership-bound: a key minted for a team org spends // THAT org's wallet, not the holder's personal one, and there is no // request-scoped signal here (this branch fires client-side, purely // from the HTTP status/body) to tell which kind of key just failed. So // this synthesized hint deliberately does NOT assert "top up your // (personal) credits" as the only path — it offers the personal-key // link (still correct for the common case) alongside an honest // org-bound alternative, rather than guessing. return { code: 'INSUFFICIENT_CREDITS', message, nextAction: nextAction !== '' ? nextAction : insufficientCreditsNextAction(apiUrl), requestId, details, }; } // Client-side re-map: CONFLICT with the ambiguous-org discriminator -> // AMBIGUOUS_ORG. Same message/nextAction as the backend supplied — the // server already writes an actionable template — but a distinct `code` // so the retry decision (http.ts) and the candidates rendering (index.ts) // can key off it directly instead of re-parsing `details.reason`. if (isAmbiguousOrgConflict(rawCode, details)) { return { code: 'AMBIGUOUS_ORG', message, nextAction, requestId, details, }; } return { code: rawCode, message, nextAction, requestId, details, }; }