diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 99426185fb..4322da3c07 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -161,6 +161,10 @@ text layer. Therefore stdout is payload-only for either machine flag. Do not cha layer to move spinners; that would change text output globally. Handlers check `--output` first and use established encoders before the `output.format` branch. +Plan-gated denials carry an optional `entitlement: { feature, upgrade_url }` on the json and +stream-json error object. `mapHttpError` attaches it from the Management API's +`entitlement_required` body, so a handler that rebuilds a mapped status error must forward it. + ## Tests Follow the root testing philosophy and use the package scripts in `package.json`. Cover meaningful diff --git a/apps/cli/src/command-internal/http-errors.ts b/apps/cli/src/command-internal/http-errors.ts index eb3f93a9c5..168deb48d0 100644 --- a/apps/cli/src/command-internal/http-errors.ts +++ b/apps/cli/src/command-internal/http-errors.ts @@ -3,6 +3,8 @@ import { Effect } from "effect"; import * as HttpBody from "effect/http/HttpBody"; import * as HttpClientError from "effect/http/HttpClientError"; +import type { PlanGateEntitlement } from "../shared/output/normalize-error.ts"; + // HttpClientError reasons that indicate the server returned an actual response (vs a transport // failure). Anything in this set surfaces as an `UnexpectedStatusError`; everything else maps // to a `NetworkError`. @@ -95,8 +97,44 @@ export type StatusErrorFactory = new (args: { readonly status: number; readonly body: string; readonly message: string; + readonly entitlement?: PlanGateEntitlement; }) => E; +const FEATURE_KEY_PATTERN = /^[\w.-]{1,128}$/; +const MAX_UPGRADE_URL_LEN = 2048; + +function readString(obj: unknown, key: string): string { + if (typeof obj === "object" && obj !== null && key in obj) { + const value = (obj as Record)[key]; + return typeof value === "string" ? value : ""; + } + return ""; +} + +// The parsed `href` percent-encodes control and bidi characters, so the URL is safe to print. +function normalizeUpgradeUrl(raw: string): string | undefined { + if (raw.length > MAX_UPGRADE_URL_LEN || !URL.canParse(raw)) return undefined; + const url = new URL(raw); + const isWebUrl = url.protocol === "https:" || url.protocol === "http:"; + const hasCredentials = url.username !== "" || url.password !== ""; + return isWebUrl && !hasCredentials ? url.href : undefined; +} + +/** + * Reads the Management API's plan-gate error body, + * `{ error: { code: "entitlement_required", feature, upgrade_url } }`. + * Hand-validated: the packages/api codegen emits no non-2xx schemas. + */ +export function parsePlanGateEnvelope(body: unknown): PlanGateEntitlement | undefined { + if (typeof body !== "object" || body === null) return undefined; + const error = (body as { readonly error?: unknown }).error; + if (readString(error, "code") !== "entitlement_required") return undefined; + const feature = readString(error, "feature"); + const upgradeUrl = normalizeUpgradeUrl(readString(error, "upgrade_url")); + if (!FEATURE_KEY_PATTERN.test(feature) || upgradeUrl === undefined) return undefined; + return { feature, upgrade_url: upgradeUrl }; +} + /** * Builds an error mapper that classifies a `SupabaseApiError` into either a * typed network error or a typed unexpected-status error. Shared by command @@ -128,11 +166,18 @@ export function mapHttpError(opts: { Effect.orElseSucceed(() => cause.reason.description ?? ""), ); const body = sanitizeErrorBody(rawBody); + const entitlement = + status >= 400 && status < 500 + ? parsePlanGateEnvelope( + yield* cause.response.json.pipe(Effect.orElseSucceed(() => undefined)), + ) + : undefined; return yield* Effect.fail( new opts.statusError({ status, body, message: opts.statusMessage(status, body), + ...(entitlement === undefined ? {} : { entitlement }), }), ); } diff --git a/apps/cli/src/command-internal/http-errors.unit.test.ts b/apps/cli/src/command-internal/http-errors.unit.test.ts index aa430d1532..85b8058133 100644 --- a/apps/cli/src/command-internal/http-errors.unit.test.ts +++ b/apps/cli/src/command-internal/http-errors.unit.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; import { SupabaseApiInputError } from "@supabase/api/effect"; import { Data, Effect } from "effect"; import * as HttpBody from "effect/http/HttpBody"; +import * as HttpClientError from "effect/http/HttpClientError"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { classifyCliErrorActionability } from "../shared/telemetry/error-actionability.ts"; import { sanitizeInlineName, mapHttpError, unexpectedStatusMessage } from "./http-errors.ts"; @@ -60,6 +63,97 @@ describe("mapHttpError", () => { ); }); +const UPGRADE_URL = "https://supabase.com/dashboard/org/acme/billing"; + +function statusFailure(status: number, body: string): HttpClientError.HttpClientError { + const request = HttpClientRequest.get("https://api.supabase.com/v1/projects/test/backups"); + const response = HttpClientResponse.fromWeb(request, new Response(body, { status })); + return new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); +} + +function planGateBody(error: Record, message = "Upgrade required"): string { + return JSON.stringify({ message, error: { code: "entitlement_required", ...error } }); +} + +describe("mapHttpError plan-gate entitlement", () => { + it.effect("attaches the entitlement from a gated body longer than the embedded body cap", () => + Effect.gen(function* () { + const body = planGateBody({ feature: "backups", upgrade_url: UPGRADE_URL }, "x".repeat(2000)); + + const error = yield* mapError(statusFailure(402, body)).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TestStatusError", + status: 402, + entitlement: { feature: "backups", upgrade_url: UPGRADE_URL }, + }); + }), + ); + + it.effect.each(["", "Bad Gateway", '{"message":"Not Found"}'])( + "keeps the plain status error for a non-envelope 4xx body %j", + (body) => + Effect.gen(function* () { + const error = yield* mapError(statusFailure(403, body)).pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "TestStatusError", status: 403, body }); + expect(error).not.toHaveProperty("entitlement"); + }), + ); + + it.effect("ignores a plan-gate body on a 5xx", () => + Effect.gen(function* () { + const body = planGateBody({ feature: "backups", upgrade_url: UPGRADE_URL }); + + const error = yield* mapError(statusFailure(503, body)).pipe(Effect.flip); + + expect(error).not.toHaveProperty("entitlement"); + }), + ); + + it.effect.each([ + { case: "a non-web upgrade_url", feature: "backups", upgradeUrl: "javascript:alert(1)" }, + { + case: "an upgrade_url carrying credentials", + feature: "backups", + upgradeUrl: "https://supabase.com@evil.example/billing", + }, + { + case: "a feature carrying a bidi override", + feature: `backups${String.fromCodePoint(0x202e)}`, + upgradeUrl: UPGRADE_URL, + }, + ])("drops the entitlement for $case", ({ feature, upgradeUrl }) => + Effect.gen(function* () { + const body = planGateBody({ feature, upgrade_url: upgradeUrl }); + + const error = yield* mapError(statusFailure(402, body)).pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "TestStatusError", status: 402 }); + expect(error).not.toHaveProperty("entitlement"); + }), + ); + + it.effect("percent-encodes control and bidi characters in the upgrade_url", () => + Effect.gen(function* () { + const rlo = String.fromCodePoint(0x202e); + const csi = String.fromCodePoint(0x9b); + const body = planGateBody({ + feature: "backups", + upgrade_url: `${UPGRADE_URL}?ref=${rlo}${csi}`, + }); + + const error = yield* mapError(statusFailure(402, body)).pipe(Effect.flip); + + expect(error).toMatchObject({ + entitlement: { feature: "backups", upgrade_url: `${UPGRADE_URL}?ref=%E2%80%AE%C2%9B` }, + }); + }), + ); +}); + describe("unexpectedStatusMessage", () => { it("shapes the generic unexpected-status message", () => { expect(unexpectedStatusMessage(500, '{"message":"boom"}')).toBe( diff --git a/apps/cli/src/command-internal/upgrade-suggest.ts b/apps/cli/src/command-internal/upgrade-suggest.ts index e45dfec831..23202f3593 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.ts @@ -14,6 +14,7 @@ import { PropOrgSlug, } from "../shared/telemetry/event-catalog.ts"; import { Output } from "../shared/output/output.service.ts"; +import { parsePlanGateEnvelope } from "./http-errors.ts"; import { billingUrl } from "./profile.ts"; import { block } from "./command-permissions/model.ts"; @@ -33,33 +34,6 @@ export const upgradeSuggestion = block("upgrade-suggest", [ }, ]); -function readString(obj: unknown, key: string): string { - if (typeof obj === "object" && obj !== null && key in obj) { - const value = (obj as Record)[key]; - return typeof value === "string" ? value : ""; - } - return ""; -} - -interface PlanGateEnvelope { - readonly feature: string; - readonly upgradeUrl: string; -} - -// Hand-validated: the packages/api codegen emits no non-2xx schemas. -function parsePlanGateEnvelope(body: unknown): PlanGateEnvelope | undefined { - if (typeof body !== "object" || body === null) return undefined; - const error = (body as { readonly error?: unknown }).error; - if (typeof error !== "object" || error === null) return undefined; - const code = readString(error, "code"); - const feature = readString(error, "feature"); - const upgradeUrl = readString(error, "upgrade_url"); - if (code !== "entitlement_required" || feature === "" || upgradeUrl === "") { - return undefined; - } - return { feature, upgradeUrl }; -} - function orgSlugFromUpgradeUrl(upgradeUrl: string): string { const match = /\/org\/([^/]+)/.exec(upgradeUrl); return match?.[1] ?? ""; @@ -138,9 +112,9 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: const envelope = Option.isSome(body) ? parsePlanGateEnvelope(body.value) : undefined; if (envelope !== undefined) { gate = { - billingUrl: envelope.upgradeUrl, + billingUrl: envelope.upgrade_url, feature: envelope.feature, - orgSlug: orgSlugFromUpgradeUrl(envelope.upgradeUrl), + orgSlug: orgSlugFromUpgradeUrl(envelope.upgrade_url), }; } } diff --git a/apps/cli/src/commands/branches/branches.errors.ts b/apps/cli/src/commands/branches/branches.errors.ts index 293e7722c5..49d3dba3c4 100644 --- a/apps/cli/src/commands/branches/branches.errors.ts +++ b/apps/cli/src/commands/branches/branches.errors.ts @@ -6,6 +6,7 @@ import { ErrorActionabilityId, statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; +import type { PlanGateEntitlement } from "../../shared/output/normalize-error.ts"; export class BranchesListNetworkError extends Data.TaggedError("BranchesListNetworkError")<{ readonly message: string; @@ -48,6 +49,7 @@ export class BranchesCreateUnexpectedStatusError extends Data.TaggedError( readonly body: string; readonly message: string; readonly upgradeSuggested?: boolean; + readonly entitlement?: PlanGateEntitlement; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { // A non-gated 409 means the branch name already exists (user input, not a raw API status); @@ -184,6 +186,7 @@ export class BranchesUpdateUnexpectedStatusError extends Data.TaggedError( readonly body: string; readonly message: string; readonly upgradeSuggested?: boolean; + readonly entitlement?: PlanGateEntitlement; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return statusCodeActionability(this.status, { diff --git a/apps/cli/src/commands/branches/create/create.handler.ts b/apps/cli/src/commands/branches/create/create.handler.ts index d268599387..7d6d70b973 100644 --- a/apps/cli/src/commands/branches/create/create.handler.ts +++ b/apps/cli/src/commands/branches/create/create.handler.ts @@ -115,6 +115,7 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra body: mapped.body, message: mapped.message, upgradeSuggested, + entitlement: mapped.entitlement, }), ), ), diff --git a/apps/cli/src/commands/branches/create/create.integration.test.ts b/apps/cli/src/commands/branches/create/create.integration.test.ts index 3b7a4643d7..e592609af6 100644 --- a/apps/cli/src/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/commands/branches/create/create.integration.test.ts @@ -28,6 +28,7 @@ import { branchesCreateCommand, type BranchesCreateFlags } from "./create.comman import { branchesCreate } from "./create.handler.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; type CreatedBranch = typeof V1CreateABranchOutput.Type; @@ -487,6 +488,32 @@ describe("branches create integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("carries the envelope's entitlement on the JSON-mode failure", () => { + const upgradeUrl = "https://supabase.com/dashboard/org/env-org/billing"; + const { layer, out } = setup({ + format: "json", + status: 402, + response: { + message: "Branching is supported only on the Pro plan or above", + error: { + code: "entitlement_required", + feature: "branching_limit", + upgrade_url: upgradeUrl, + }, + }, + }); + return Effect.gen(function* () { + yield* branchesCreate({ ...baseFlags, name: Option.some("feat-x") }).pipe( + withJsonErrorHandling, + ); + expect(out.failures).toHaveLength(1); + expect(out.failures[0]).toMatchObject({ + code: "BranchesCreateUnexpectedStatusError", + entitlement: { feature: "branching_limit", upgrade_url: upgradeUrl }, + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("does NOT fire upgrade suggested on 500 (Go skips 5xx)", () => { const { layer, analytics } = setup({ status: 500 }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts index 46b20bace2..edc60a59c7 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts @@ -2,6 +2,7 @@ import { type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -156,6 +157,36 @@ describe("branches unpause integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("carries the plan-gate entitlement on a JSON-mode gated restore", () => { + const upgradeUrl = "https://supabase.com/dashboard/org/acme/billing"; + const out = mockOutput({ format: "json" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => + jsonResponse(request, 402, { + message: "Restoring this project requires a paid plan", + error: { + code: "entitlement_required", + feature: "project_restore_size", + upgrade_url: upgradeUrl, + }, + }), + ), + }); + const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); + const layer = buildTestRuntime({ out, api, cliSettings }); + return Effect.gen(function* () { + yield* branchesUnpause({ ...baseFlags, name: Option.some(BRANCH_REF) }).pipe( + withJsonErrorHandling, + ); + expect(out.failures).toHaveLength(1); + expect(out.failures[0]).toMatchObject({ + code: "BranchesUnpauseUnexpectedStatusError", + entitlement: { feature: "project_restore_size", upgrade_url: upgradeUrl }, + }); + }).pipe(Effect.provide(layer)); + }); + it.effect( "unpauses the branch chosen from the pickers and fills the real linked-project cache", () => { diff --git a/apps/cli/src/commands/branches/update/update.handler.ts b/apps/cli/src/commands/branches/update/update.handler.ts index b07c87874b..735b1cbf3b 100644 --- a/apps/cli/src/commands/branches/update/update.handler.ts +++ b/apps/cli/src/commands/branches/update/update.handler.ts @@ -74,6 +74,7 @@ export const branchesUpdate = Effect.fn("branches.update")(function* (flags: Bra body: mapped.body, message: mapped.message, upgradeSuggested, + entitlement: mapped.entitlement, }), ), ), diff --git a/apps/cli/src/commands/sso/add/add.handler.ts b/apps/cli/src/commands/sso/add/add.handler.ts index 7ae676619d..8356a045fc 100644 --- a/apps/cli/src/commands/sso/add/add.handler.ts +++ b/apps/cli/src/commands/sso/add/add.handler.ts @@ -113,6 +113,7 @@ const handleAddError = ( body: mapped.body, message: mapped.message, upgradeSuggested, + entitlement: mapped.entitlement, }); }), ), diff --git a/apps/cli/src/commands/sso/list/list.handler.ts b/apps/cli/src/commands/sso/list/list.handler.ts index 53a38f4f3c..d3f8f7ada7 100644 --- a/apps/cli/src/commands/sso/list/list.handler.ts +++ b/apps/cli/src/commands/sso/list/list.handler.ts @@ -52,6 +52,7 @@ const handleListError = (ref: string, cause: SupabaseApiError) => body: mapped.body, message: mapped.message, upgradeSuggested, + entitlement: mapped.entitlement, }); }), ), diff --git a/apps/cli/src/commands/sso/remove/remove.handler.ts b/apps/cli/src/commands/sso/remove/remove.handler.ts index a14198767d..455129c8e6 100644 --- a/apps/cli/src/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/commands/sso/remove/remove.handler.ts @@ -49,6 +49,7 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr body: mapped.body, message: mapped.message, upgradeSuggested, + entitlement: mapped.entitlement, }); }), ), diff --git a/apps/cli/src/commands/sso/sso.errors.ts b/apps/cli/src/commands/sso/sso.errors.ts index a826f877bb..9b09dc43de 100644 --- a/apps/cli/src/commands/sso/sso.errors.ts +++ b/apps/cli/src/commands/sso/sso.errors.ts @@ -6,6 +6,7 @@ import { planLimitGatedActionability, statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; +import type { PlanGateEntitlement } from "../../shared/output/normalize-error.ts"; import type { SsoFileErrorReason } from "./sso.saml.ts"; function ssoFileActionability(reason: SsoFileErrorReason): CliErrorActionabilityDeclaration { @@ -85,6 +86,7 @@ export class SsoListUnexpectedStatusError extends Data.TaggedError("SsoListUnexp readonly body: string; readonly message: string; readonly upgradeSuggested?: boolean; + readonly entitlement?: PlanGateEntitlement; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); @@ -116,6 +118,7 @@ export class SsoAddUnexpectedStatusError extends Data.TaggedError("SsoAddUnexpec readonly body: string; readonly message: string; readonly upgradeSuggested?: boolean; + readonly entitlement?: PlanGateEntitlement; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); @@ -287,6 +290,7 @@ export class SsoUpdateUnexpectedStatusError extends Data.TaggedError( readonly body: string; readonly message: string; readonly upgradeSuggested?: boolean; + readonly entitlement?: PlanGateEntitlement; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return statusCodeActionability(this.status, { @@ -343,6 +347,7 @@ export class SsoRemoveUnexpectedStatusError extends Data.TaggedError( readonly body: string; readonly message: string; readonly upgradeSuggested?: boolean; + readonly entitlement?: PlanGateEntitlement; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); diff --git a/apps/cli/src/commands/sso/update/update.handler.ts b/apps/cli/src/commands/sso/update/update.handler.ts index 7927a86314..623fb606d9 100644 --- a/apps/cli/src/commands/sso/update/update.handler.ts +++ b/apps/cli/src/commands/sso/update/update.handler.ts @@ -160,6 +160,7 @@ const handleGetError = (gate: GateContext, providerId: string, cause: Supa body: mapped.body, message: mapped.message, upgradeSuggested, + entitlement: mapped.entitlement, }); }), ), @@ -175,6 +176,7 @@ const handlePutError = (gate: GateContext, cause: SupabaseApiError) => body: mapped.body, message: mapped.message, upgradeSuggested, + entitlement: mapped.entitlement, }); }), ), diff --git a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts index c92ee4dd27..b7e11002b3 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts @@ -79,6 +79,7 @@ export const vanitySubdomainsActivate = Effect.fn("vanity-subdomains.activate")( body: mapped.body, message: mapped.message, upgradeSuggested, + entitlement: mapped.entitlement, }); }), ), diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts index 6b1309ff1f..37c9e18d98 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -83,6 +83,7 @@ export const vanitySubdomainsCheckAvailability = Effect.fn("vanity-subdomains.ch body: mapped.body, message: mapped.message, upgradeSuggested, + entitlement: mapped.entitlement, }); }), ), diff --git a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.errors.ts b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.errors.ts index 5133a95f6a..9f42ef2276 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.errors.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.errors.ts @@ -5,6 +5,7 @@ import { ErrorActionabilityId, statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; +import type { PlanGateEntitlement } from "../../shared/output/normalize-error.ts"; /** * Raised by the `activate` and `check-availability` handlers when @@ -69,6 +70,7 @@ export class VanitySubdomainsCheckUnexpectedStatusError extends Data.TaggedError readonly body: string; readonly message: string; readonly upgradeSuggested?: boolean; + readonly entitlement?: PlanGateEntitlement; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return statusCodeActionability(this.status, { @@ -98,6 +100,7 @@ export class VanitySubdomainsActivateUnexpectedStatusError extends Data.TaggedEr readonly body: string; readonly message: string; readonly upgradeSuggested?: boolean; + readonly entitlement?: PlanGateEntitlement; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return statusCodeActionability(this.status, { diff --git a/apps/cli/src/shared/output/normalize-error.ts b/apps/cli/src/shared/output/normalize-error.ts index 3ed617daac..439a61461e 100644 --- a/apps/cli/src/shared/output/normalize-error.ts +++ b/apps/cli/src/shared/output/normalize-error.ts @@ -6,11 +6,18 @@ import { stripControlSequences } from "./strip-control-sequences.ts"; import type { CliErrorSuggestionContext } from "../cli/subcommand-flag-suggestions.ts"; import { formatCliErrorsForDisplay } from "../cli/subcommand-flag-suggestions.ts"; +/** The plan-gate signal machine-mode errors carry, mirroring the Management API's `entitlement_required` body. */ +export type PlanGateEntitlement = { + readonly feature: string; + readonly upgrade_url: string; +}; + type NormalizedCliError = { readonly code: string; readonly message: string; readonly detail?: string; readonly suggestion?: string; + readonly entitlement?: PlanGateEntitlement; }; type ErrorRecord = Record; @@ -32,6 +39,14 @@ const readRawString = (value: ErrorRecord, key: string): string | undefined => { return typeof field === "string" ? field : undefined; }; +const readEntitlement = (error: ErrorRecord): PlanGateEntitlement | undefined => { + const entitlement = error["entitlement"]; + if (!isErrorRecord(entitlement)) return undefined; + const feature = readString(entitlement, "feature"); + const upgradeUrl = readString(entitlement, "upgrade_url"); + return feature && upgradeUrl ? { feature, upgrade_url: upgradeUrl } : undefined; +}; + /** A config parse failure's reason and position, without the source text it quotes, which can hold secrets. */ export function configParseCauseMessage(cause: unknown): string { const text = cause instanceof Error ? cause.message : String(cause); @@ -161,11 +176,13 @@ export function normalizeCliError( // whitespace (e.g. `suggestLegacyBundle`'s leading `\n` for a blank // separator line); `readString` would trim exactly that away. const suggestion = readRawString(error, "suggestion"); + const entitlement = readEntitlement(error); return { code, message, ...(detail && detail !== message ? { detail } : {}), ...(suggestion !== undefined && suggestion.length > 0 ? { suggestion } : {}), + ...(entitlement === undefined ? {} : { entitlement }), }; } diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 67f28e2fff..bab2a0de97 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -30,6 +30,7 @@ import { import { Tty } from "../runtime/tty.service.ts"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError, StdoutClosedError } from "./errors.ts"; import { MachineErrorContext } from "./machine-error-context.service.ts"; +import type { PlanGateEntitlement } from "./normalize-error.ts"; import { Output } from "./output.service.ts"; import type { OutputFormat, StreamEvent } from "./types.ts"; @@ -498,7 +499,13 @@ export const textOutputLayer = Layer.effect( }), result: () => Effect.void, success: (message: string) => logAround(log.success, message), - fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => + fail: (err: { + code: string; + message: string; + detail?: string; + suggestion?: string; + entitlement?: PlanGateEntitlement; + }) => Effect.sync(() => { // A command failure is terminal, so a still-shown task spinner is dropped. if (activeSpinner !== undefined) { @@ -593,7 +600,13 @@ export const jsonOutputLayer = Layer.effect( }), result, success: (message: string, data?: Record) => result({ ...data, message }), - fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => + fail: (err: { + code: string; + message: string; + detail?: string; + suggestion?: string; + entitlement?: PlanGateEntitlement; + }) => Effect.gen(function* () { const extra = yield* readMachineErrorContext(); // `extra` spreads first so the envelope's own `_tag`/`error` can't @@ -682,7 +695,13 @@ export const streamJsonOutputLayer = Layer.effect( }), result, success: (message: string, data?: Record) => result({ ...data, message }), - fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => + fail: (err: { + code: string; + message: string; + detail?: string; + suggestion?: string; + entitlement?: PlanGateEntitlement; + }) => Effect.gen(function* () { const extra = yield* readMachineErrorContext(); // `extra` spreads first so the event's own `type`/`error`/`timestamp` diff --git a/apps/cli/src/shared/output/output.service.ts b/apps/cli/src/shared/output/output.service.ts index eb0668a11d..716ffecd6b 100644 --- a/apps/cli/src/shared/output/output.service.ts +++ b/apps/cli/src/shared/output/output.service.ts @@ -2,6 +2,7 @@ import type { Effect } from "effect"; import { Context } from "effect"; import type { NonInteractiveError } from "./errors.ts"; +import type { PlanGateEntitlement } from "./normalize-error.ts"; import type { OutputFormat, StreamEvent } from "./types.ts"; export interface OutputTask { @@ -90,6 +91,7 @@ interface OutputShape { readonly message: string; readonly detail?: string; readonly suggestion?: string; + readonly entitlement?: PlanGateEntitlement; }) => Effect.Effect; /** * Writes a raw chunk to stdout or stderr without framing.