diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 255aa73a61..248aae28d3 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -229,9 +229,12 @@ 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. +Plan-gated denials carry an optional `entitlement: { feature, upgrade_url }` on the error. +`mapHttpError` attaches it from the Management API's `entitlement_required` body. The json and +stream-json error object carries it, the text layer's `fail` prints the upgrade hint from it, and +`withCommandTelemetry` fires `cli_upgrade_suggested` from it, so a handler that rebuilds a mapped +status error must forward it. `suggestUpgrade` prints and fires only on its entitlements-lookup +fallback. ## Targets and hosted consent diff --git a/apps/cli/src/command-internal/upgrade-suggest.ts b/apps/cli/src/command-internal/upgrade-suggest.ts index 23202f3593..7322fe59a5 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.ts @@ -1,6 +1,3 @@ -import { styleText } from "node:util"; - -import type { SupabaseApiError } from "@supabase/api/effect"; import { Effect, Option, type Redacted } from "effect"; import * as HttpClientError from "effect/http/HttpClientError"; import type * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -13,6 +10,7 @@ import { PropFeatureKey, PropOrgSlug, } from "../shared/telemetry/event-catalog.ts"; +import { upgradeHint } from "../shared/output/normalize-error.ts"; import { Output } from "../shared/output/output.service.ts"; import { parsePlanGateEnvelope } from "./http-errors.ts"; import { billingUrl } from "./profile.ts"; @@ -34,32 +32,10 @@ export const upgradeSuggestion = block("upgrade-suggest", [ }, ]); -function orgSlugFromUpgradeUrl(upgradeUrl: string): string { - const match = /\/org\/([^/]+)/.exec(upgradeUrl); - return match?.[1] ?? ""; -} - export function gateResponse(cause: unknown): HttpClientResponse.HttpClientResponse | undefined { return HttpClientError.isHttpClientError(cause) ? cause.response : undefined; } -export const gateMapError = - ( - opts: { readonly projectRef: string; readonly featureKey?: string }, - mapError: (cause: SupabaseApiError, upgradeSuggested: boolean) => Effect.Effect, - ) => - (cause: SupabaseApiError) => - Effect.gen(function* () { - const response = gateResponse(cause); - const upgradeSuggested = yield* suggestUpgrade({ - projectRef: opts.projectRef, - featureKey: opts.featureKey, - statusCode: response?.status ?? 0, - response, - }); - return yield* mapError(cause, upgradeSuggested); - }); - /** * Suggests an upgrade when a request fails with a plan-gate error. Never * fails the caller. @@ -91,8 +67,6 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: * an unauthenticated request), `undefined` uses the client's own token. */ readonly accessToken?: Option.Option>; - /** Set false for call sites that must not fire `EventUpgradeSuggested` (vanity check-availability). */ - readonly trackAnalytics?: boolean; }) { if (opts.statusCode < 400 || opts.statusCode >= 500) { return false; @@ -103,81 +77,64 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: const cliSettings = yield* CommandSettings; yield* Effect.annotateCurrentSpan("upgrade.gated", false); - let gate: - | { readonly billingUrl: string; readonly feature: string; readonly orgSlug: string } - | undefined; - if (opts.response !== undefined) { const body = yield* opts.response.json.pipe(Effect.option); const envelope = Option.isSome(body) ? parsePlanGateEnvelope(body.value) : undefined; if (envelope !== undefined) { - gate = { - billingUrl: envelope.upgrade_url, - feature: envelope.feature, - orgSlug: orgSlugFromUpgradeUrl(envelope.upgrade_url), - }; + // The error carries `entitlement`, so the command wrapper prints the hint and fires the event. + yield* Effect.annotateCurrentSpan("upgrade.gated", true); + return true; } } - if (gate === undefined) { - if (opts.featureKey === undefined || opts.featureKey === "") { - return false; - } - - if (opts.accessToken !== undefined && Option.isNone(opts.accessToken)) { - return false; - } + if (opts.featureKey === undefined || opts.featureKey === "") { + return false; + } - const api = yield* CommandPlatformApi; - const requestOptions = { - baseUrl: opts.apiUrl, - accessToken: opts.accessToken === undefined ? undefined : opts.accessToken.value, - }; - const project = yield* api.v1 - .getProject({ ref: opts.projectRef }, requestOptions) - .pipe(Effect.option); - if (Option.isNone(project)) { - return false; - } - const orgSlug = project.value.organization_slug; - if (orgSlug.length === 0) { - return false; - } + if (opts.accessToken !== undefined && Option.isNone(opts.accessToken)) { + return false; + } - const entitlements = yield* api.v1 - .getOrganizationEntitlements({ slug: orgSlug }, requestOptions) - .pipe(Effect.option); - if (Option.isNone(entitlements)) { - return false; - } + const api = yield* CommandPlatformApi; + const requestOptions = { + baseUrl: opts.apiUrl, + accessToken: opts.accessToken === undefined ? undefined : opts.accessToken.value, + }; + const project = yield* api.v1 + .getProject({ ref: opts.projectRef }, requestOptions) + .pipe(Effect.option); + if (Option.isNone(project)) { + return false; + } + const orgSlug = project.value.organization_slug; + if (orgSlug.length === 0) { + return false; + } - const gated = entitlements.value.entitlements.some( - (entry) => entry.feature.key === opts.featureKey && entry.hasAccess === false, - ); - if (!gated) { - return false; - } + const entitlements = yield* api.v1 + .getOrganizationEntitlements({ slug: orgSlug }, requestOptions) + .pipe(Effect.option); + if (Option.isNone(entitlements)) { + return false; + } - gate = { - billingUrl: billingUrl(cliSettings.profile, orgSlug), - feature: opts.featureKey, - orgSlug, - }; + const gated = entitlements.value.entitlements.some( + (entry) => entry.feature.key === opts.featureKey && entry.hasAccess === false, + ); + if (!gated) { + return false; } yield* Effect.annotateCurrentSpan("upgrade.gated", true); - const suggestion = `Your organization does not have access to this feature. Upgrade your plan: ${styleText("bold", gate.billingUrl)}`; if (output.format === "text") { - yield* output.raw(suggestion + "\n", "stderr"); + yield* output.raw(upgradeHint(billingUrl(cliSettings.profile, orgSlug)) + "\n", "stderr"); } - if (opts.trackAnalytics !== false) { - yield* analytics.capture(EventUpgradeSuggested, { - [PropFeatureKey]: gate.feature, - [PropOrgSlug]: gate.orgSlug, - }); - } + yield* analytics.capture(EventUpgradeSuggested, { + [PropFeatureKey]: opts.featureKey, + [PropOrgSlug]: orgSlug, + }); return true; }); diff --git a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts index 4da09f494e..186c27c06b 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts @@ -258,20 +258,6 @@ describe("suggestUpgrade", () => { }).pipe(Effect.provide(layer)); }); - it.live("skips cli_upgrade_suggested analytics when trackAnalytics=false", () => { - const { layer, analytics, out } = setup(); - return Effect.gen(function* () { - yield* suggestUpgrade({ - projectRef: VALID_REF, - featureKey: "branching_limit", - statusCode: 402, - trackAnalytics: false, - }); - expect(analytics.captured).toHaveLength(0); - expect(out.stderrText).toContain("Upgrade your plan:"); - }).pipe(Effect.provide(layer)); - }); - const ENVELOPE_BODY = { message: "Branching is supported only on the Pro plan or above", error: { @@ -286,31 +272,29 @@ describe("suggestUpgrade", () => { return jsonResponse(request, status, body); } - it.live("envelope path suggests with zero API calls and envelope-derived telemetry", () => { - const { layer, out, analytics, api } = setup(); - return Effect.gen(function* () { - yield* suggestUpgrade({ - projectRef: VALID_REF, - featureKey: "branching_limit", - statusCode: 402, - response: gateResponse(402, ENVELOPE_BODY), - }); - expect(out.stderrText).toContain("Upgrade your plan:"); - expect(out.stderrText).toContain("/org/env-org/billing"); - expect(analytics.captured).toEqual([ - { - event: "cli_upgrade_suggested", - properties: { feature_key: "branching_persistent", org_slug: "env-org" }, - }, - ]); - expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); + it.live( + "envelope path confirms the gate with zero API calls and leaves the hint and event to the command wrapper", + () => { + const { layer, out, analytics, api } = setup(); + return Effect.gen(function* () { + const suggested = yield* suggestUpgrade({ + projectRef: VALID_REF, + featureKey: "branching_limit", + statusCode: 402, + response: gateResponse(402, ENVELOPE_BODY), + }); + expect(suggested).toBe(true); + expect(out.stderrText).toBe(""); + expect(analytics.captured).toHaveLength(0); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); it.live("envelope path works without a featureKey (envelope-only sites)", () => { const { layer, out, analytics, api } = setup(); return Effect.gen(function* () { - yield* suggestUpgrade({ + const suggested = yield* suggestUpgrade({ projectRef: VALID_REF, statusCode: 400, response: gateResponse(400, { @@ -322,14 +306,10 @@ describe("suggestUpgrade", () => { }, }), }); + expect(suggested).toBe(true); expect(api.requests).toHaveLength(0); - expect(out.stderrText).toContain("Upgrade your plan:"); - expect(analytics.captured).toEqual([ - { - event: "cli_upgrade_suggested", - properties: { feature_key: "custom_domain", org_slug: "env-org" }, - }, - ]); + expect(out.stderrText).toBe(""); + expect(analytics.captured).toHaveLength(0); }).pipe(Effect.provide(layer)); }); @@ -436,31 +416,4 @@ describe("suggestUpgrade", () => { expect(out.stderrText).toContain("/org/test-org/billing"); }).pipe(Effect.provide(layer)); }); - - it.live("envelope path respects json output mode (no stderr, telemetry fires)", () => { - const { layer, out, analytics } = setup({ format: "json" }); - return Effect.gen(function* () { - yield* suggestUpgrade({ - projectRef: VALID_REF, - statusCode: 402, - response: gateResponse(402, ENVELOPE_BODY), - }); - expect(out.stderrText).toBe(""); - expect(analytics.captured).toHaveLength(1); - }).pipe(Effect.provide(layer)); - }); - - it.live("envelope path respects trackAnalytics=false", () => { - const { layer, out, analytics } = setup(); - return Effect.gen(function* () { - yield* suggestUpgrade({ - projectRef: VALID_REF, - statusCode: 402, - response: gateResponse(402, ENVELOPE_BODY), - trackAnalytics: false, - }); - expect(analytics.captured).toHaveLength(0); - expect(out.stderrText).toContain("Upgrade your plan:"); - }).pipe(Effect.provide(layer)); - }); }); diff --git a/apps/cli/src/commands/branches/branches.errors.ts b/apps/cli/src/commands/branches/branches.errors.ts index 3b50c50be4..be68eb94e7 100644 --- a/apps/cli/src/commands/branches/branches.errors.ts +++ b/apps/cli/src/commands/branches/branches.errors.ts @@ -48,19 +48,14 @@ export class BranchesCreateUnexpectedStatusError extends Data.TaggedError( readonly status: number; 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); - // the gate check stays ahead so a plan-limited 409 still classifies as plan_limit. - if (this.upgradeSuggested !== true && this.status === 409) { + // A 409 means the branch name already exists (user input, not a raw API status). + if (this.status === 409) { return { ...actionability.invalidInput, fingerprint_suffix: "conflict" }; } - return statusCodeActionability(this.status, { - upgradeSuggested: this.upgradeSuggested, - notFoundIsInvalidInput: true, - }); + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); } } @@ -185,12 +180,10 @@ export class BranchesUpdateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; - readonly upgradeSuggested?: boolean; readonly entitlement?: PlanGateEntitlement; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return statusCodeActionability(this.status, { - upgradeSuggested: this.upgradeSuggested, notFoundIsInvalidInput: true, }); } diff --git a/apps/cli/src/commands/branches/branches.errors.unit.test.ts b/apps/cli/src/commands/branches/branches.errors.unit.test.ts index 999ae05c5a..93de74ad4c 100644 --- a/apps/cli/src/commands/branches/branches.errors.unit.test.ts +++ b/apps/cli/src/commands/branches/branches.errors.unit.test.ts @@ -10,6 +10,10 @@ import { } from "./branches.errors.ts"; const body = { body: "not found", message: "boom" }; +const gatedEntitlement = { + feature: "branching_limit", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", +}; describe("branch operation 404s classify as invalid input", () => { it("pause 404 → invalid input", () => { @@ -67,7 +71,7 @@ describe("gated branch operation 404s", () => { new BranchesUpdateUnexpectedStatusError({ status: 404, ...body, - upgradeSuggested: true, + entitlement: gatedEntitlement, }), ); expect(result.error_category).toBe("plan_limit"); @@ -79,7 +83,7 @@ describe("gated branch operation 404s", () => { new BranchesCreateUnexpectedStatusError({ status: 404, ...body, - upgradeSuggested: true, + entitlement: gatedEntitlement, }), ); expect(result.error_category).toBe("plan_limit"); @@ -102,7 +106,7 @@ describe("branches create 409 = duplicate branch name", () => { new BranchesCreateUnexpectedStatusError({ status: 409, ...body, - upgradeSuggested: true, + entitlement: gatedEntitlement, }), ); expect(result.error_category).toBe("plan_limit"); diff --git a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md index a4b49f9ea3..08ed73bf36 100644 --- a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md @@ -52,10 +52,10 @@ Target: hosted, consent default Yes — see [Command target](../../../../docs/co ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ----------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=` → the ref, a bare `--remote` → `true`; `--project-ref` whitelisted when ref-shaped, otherwise redacted) | -| `cli_upgrade_suggested` | on a 4xx whose body is the `entitlement_required` envelope | `{feature_key: "branching_limit", org_slug}` | +| Event | When | Notable properties / groups | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=` → the ref, a bare `--remote` → `true`; `--project-ref` whitelisted when ref-shaped, otherwise redacted) | +| `cli_upgrade_suggested` | post-run (via wrapper), before `cli_command_executed`, on a 4xx whose body is the `entitlement_required` envelope | `{feature_key: "branching_limit", org_slug}` | ## Output diff --git a/apps/cli/src/commands/branches/create/create.handler.ts b/apps/cli/src/commands/branches/create/create.handler.ts index 3294498b62..1c3e939a34 100644 --- a/apps/cli/src/commands/branches/create/create.handler.ts +++ b/apps/cli/src/commands/branches/create/create.handler.ts @@ -15,7 +15,6 @@ import { } from "../../../command-internal/struct-output.encoders.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; -import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import { BRANCH_RESPONSE_SHAPE } from "../branches.response-shape.ts"; import { BranchesBranchNameEmptyError, @@ -27,7 +26,7 @@ import type { BranchesCreateFlags } from "./create.command.ts"; type CreatedBranch = typeof V1CreateABranchOutput.Type; -const mapCreateErrorRaw = mapHttpError({ +const mapCreateError = mapHttpError({ networkError: BranchesCreateNetworkError, statusError: BranchesCreateUnexpectedStatusError, networkMessage: (cause) => `failed to create preview branch: ${cause}`, @@ -102,24 +101,7 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra }) .pipe( Effect.tapError(() => creating?.fail() ?? Effect.void), - // Runs the plan-gate check before mapping the error, even for a gated 4xx. - Effect.catch( - gateMapError({ projectRef: ref }, (cause, upgradeSuggested) => - mapCreateErrorRaw(cause).pipe( - Effect.catchTag("BranchesCreateUnexpectedStatusError", (mapped) => - Effect.fail( - new BranchesCreateUnexpectedStatusError({ - status: mapped.status, - body: mapped.body, - message: mapped.message, - upgradeSuggested, - entitlement: mapped.entitlement, - }), - ), - ), - ), - ), - ), + Effect.catch(mapCreateError), ); yield* creating?.clear ?? Effect.void; 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 21bca71f14..cf66f0fd53 100644 --- a/apps/cli/src/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/commands/branches/create/create.integration.test.ts @@ -409,7 +409,7 @@ describe("branches create integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("envelope on 402 suggests upgrade with no extra API calls", () => { + it.effect("fails a 402 envelope with its entitlement and no extra API calls", () => { const { layer, out, analytics, api } = setup({ status: 402, response: { @@ -422,20 +422,20 @@ describe("branches create integration", () => { }, }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( branchesCreate({ ...baseFlags, name: Option.some("feat-x") }), ); - expect(Exit.isFailure(exit)).toBe(true); + expect(error).toMatchObject({ + entitlement: { + feature: "branching_limit", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain("/branches"); assertPermissionDrift({ command: "branches create", requests: api.requests, exact: true }); - expect(out.stderrText).toContain("/org/env-org/billing"); - expect(analytics.captured).toEqual([ - { - event: "cli_upgrade_suggested", - properties: { feature_key: "branching_limit", org_slug: "env-org" }, - }, - ]); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + expect(analytics.captured).toHaveLength(0); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md index 2398ff13d3..20cfe44ac9 100644 --- a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md @@ -43,14 +43,17 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | ----------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| Event | When | Notable properties | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| `cli_upgrade_suggested` | post-run (via wrapper), before `cli_command_executed`, on a restore 4xx whose body is the `entitlement_required` envelope | `feature_key` (from the envelope), `org_slug` (parsed from `upgrade_url`) | ## Output Silent on success in every mode (the handler writes nothing itself; the target banner and any `Confirmed by --yes` line go to stderr). +A restore denied with the `entitlement_required` envelope prints the upgrade hint on stderr before the error in text mode. + ## Notes - `--remote=` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches unpause` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=. `` The branch itself stays a positional argument. diff --git a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md index 3cff71b826..0e649d9dfb 100644 --- a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md @@ -15,16 +15,14 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## API Routes -| Method | Path | Auth | When | Request body | Response | -| ------- | ----------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | none | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | none | `{ref}` | -| `PATCH` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always | `{branch_name?, git_branch?, persistent?, status?, notify_url?}` (only set flags emitted) | full `BranchResponse` | -| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | upgrade-suggest path | none | `{organization_slug}` | -| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | upgrade-suggest path | none | `[{feature: {key}, hasAccess, ...}]` | -| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | none | `[{name, project_ref, …}]` | -| `GET` | `/v1/projects` | Bearer token | no project ref from `--remote=`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | none | `[{id, name, organization_slug, region, …}]` | -| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | none | `{ref, name, organization_id, organization_slug}` | +| Method | Path | Auth | When | Request body | Response | +| ------- | ------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | none | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | none | `{ref}` | +| `PATCH` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always | `{branch_name?, git_branch?, persistent?, status?, notify_url?}` (only set flags emitted) | full `BranchResponse` | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | none | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--remote=`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | none | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | none | `{ref, name, organization_id, organization_slug}` | ## Environment Variables @@ -45,10 +43,10 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Telemetry Events Fired -| Event | When | Notable properties | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | -| `cli_upgrade_suggested` | on 4xx with `branching_persistent` entitlement gated | `{feature_key: "branching_persistent", org_slug}` | +| Event | When | Notable properties | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| `cli_upgrade_suggested` | post-run (via wrapper), before `cli_command_executed`, on a 4xx whose body is the `entitlement_required` envelope | `feature_key` (from the envelope), `org_slug` (parsed from `upgrade_url`) | ## Output @@ -61,5 +59,4 @@ For `--output {json,yaml,toml,env}`, the header goes to stderr followed by the e ## Notes - `--remote=` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches update` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=. `` The branch itself stays a positional argument. -- The upgrade-suggest call uses the branch's own resolved project ref (`resolveBranchProjectRef`) — not the parent `--remote=` value — so the entitlements check is scoped to the branch's org. - **Consent.** Asks once, after the branch name or ID is resolved to its ref and before the PATCH, with default No: `Update preview branch `. The question also shows the branch project ref and each change that was requested (`--name`, `--git-branch`, `--persistent`, `--status`, `--notify-url`). diff --git a/apps/cli/src/commands/branches/update/update.command.ts b/apps/cli/src/commands/branches/update/update.command.ts index e7dae7809a..c6a1f6dba3 100644 --- a/apps/cli/src/commands/branches/update/update.command.ts +++ b/apps/cli/src/commands/branches/update/update.command.ts @@ -6,7 +6,6 @@ import { compose } from "../../../command-internal/command-permissions/model.ts" import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { branchPrompt } from "../branches.prompt.ts"; import { branchRef } from "../../../command-internal/branch-ref.resolver.ts"; -import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -66,7 +65,6 @@ export const branchesUpdateCommand = Command.make("update", config).pipe( projectRefPicker, branchPrompt, branchRef, - upgradeSuggestion, linkedProjectCache, ), ), diff --git a/apps/cli/src/commands/branches/update/update.handler.ts b/apps/cli/src/commands/branches/update/update.handler.ts index 947600a118..b403645fea 100644 --- a/apps/cli/src/commands/branches/update/update.handler.ts +++ b/apps/cli/src/commands/branches/update/update.handler.ts @@ -14,7 +14,6 @@ import { import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; -import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import { BRANCH_RESPONSE_SHAPE } from "../branches.response-shape.ts"; import { BranchesUpdateNetworkError, @@ -79,26 +78,7 @@ export const branchesUpdate = Effect.fn("branches.update")(function* (flags: Bra }) .pipe( Effect.tapError(() => patching?.fail() ?? Effect.void), - // Scopes the entitlements check to the branch's own org via its resolved project ref. - Effect.catch( - gateMapError( - { projectRef: branchRef, featureKey: "branching_persistent" }, - (cause, upgradeSuggested) => - mapUpdateError(cause).pipe( - Effect.catchTag("BranchesUpdateUnexpectedStatusError", (mapped) => - Effect.fail( - new BranchesUpdateUnexpectedStatusError({ - status: mapped.status, - body: mapped.body, - message: mapped.message, - upgradeSuggested, - entitlement: mapped.entitlement, - }), - ), - ), - ), - ), - ), + Effect.catch(mapUpdateError), ); yield* patching?.clear ?? Effect.void; diff --git a/apps/cli/src/commands/branches/update/update.integration.test.ts b/apps/cli/src/commands/branches/update/update.integration.test.ts index 3dabc2fecc..08afde478d 100644 --- a/apps/cli/src/commands/branches/update/update.integration.test.ts +++ b/apps/cli/src/commands/branches/update/update.integration.test.ts @@ -24,6 +24,7 @@ import type { BranchesUpdateFlags } from "./update.command.ts"; import { branchesUpdate } from "./update.handler.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; type UpdatedBranch = typeof V1UpdateABranchConfigOutput.Type; @@ -45,35 +46,6 @@ const UPDATED: UpdatedBranch = { with_data: false, }; -const ORG_SLUG = "test-org"; - -function projectResponse(ref: string = BRANCH_REF) { - return { - id: ref, - ref, - organization_id: "org", - organization_slug: ORG_SLUG, - name: "Test", - region: "us-east-1", - created_at: "2026-01-01T00:00:00Z", - status: "ACTIVE_HEALTHY" as const, - database: { host: "h", version: "15", postgres_engine: "15", release_channel: "ga" }, - }; -} - -function entitlementResponse(opts: { readonly featureKey: string; readonly hasAccess: boolean }) { - return { - entitlements: [ - { - feature: { key: opts.featureKey, type: "boolean" as const }, - hasAccess: opts.hasAccess, - type: "boolean" as const, - config: { enabled: !opts.hasAccess }, - }, - ], - }; -} - // Resolver's UUID-path response; its `ref` feeds the PATCH's branch_id_or_ref, so it must // stay UUID-shaped to satisfy the oneOf union. const BRANCH_CONFIG = { @@ -316,87 +288,88 @@ describe("branches update integration", () => { }).pipe(Effect.provide(layer)); }); - // Exercises the production-shape branchRef end-to-end; the upgrade-suggest helper must - // receive the resolved branch's project ref, not the parent ref. it.effect( - "fires cli_upgrade_suggested with the branch ref + branching_persistent on 4xx gated", + "fails a persistent-branch 402 envelope with its entitlement and no extra API calls", () => { const out = mockOutput({ format: "text" }); const analytics = mockAnalytics(); - const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - - // The gate's follow-up project and entitlements GETs share the PATCH's - // mockCommandPlatformApi handler. - const apiMock = mockCommandPlatformApi({ + const upgradeUrl = "https://supabase.com/dashboard/org/env-org/billing"; + const api = mockCommandPlatformApi({ handler: (request) => - Effect.sync(() => { - if (request.method === "PATCH" && request.url.includes("/v1/branches/")) { - return jsonResponse(request, 402, { message: "upgrade required" }); - } - if (request.method === "GET" && request.url.endsWith(`/v1/projects/${BRANCH_REF}`)) { - return jsonResponse(request, 200, projectResponse(BRANCH_REF)); - } - if ( - request.method === "GET" && - request.url.endsWith(`/v1/organizations/${ORG_SLUG}/entitlements`) - ) { - return jsonResponse( - request, - 200, - entitlementResponse({ - featureKey: "branching_persistent", - hasAccess: false, - }), - ); - } - return jsonResponse(request, 200, null); - }), + Effect.sync(() => + jsonResponse(request, 402, { + message: "Persistent branches are not available on your plan.", + error: { + code: "entitlement_required", + feature: "branching_persistent", + upgrade_url: upgradeUrl, + }, + }), + ), }); - const layer = buildRuntime({ out, - api: apiMock, - cliSettings, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), analytics, }); return Effect.gen(function* () { - yield* Effect.exit( + const error = yield* Effect.flip( branchesUpdate({ ...baseFlags, branchId: Option.some(BRANCH_REF), persistent: Option.some(true), }), ); - const projectCall = apiMock.requests.find( - (r) => r.method === "GET" && r.url.endsWith(`/v1/projects/${BRANCH_REF}`), - ); - expect(projectCall).toBeDefined(); - const entitlementsCall = apiMock.requests.find((r) => - r.url.endsWith(`/v1/organizations/${ORG_SLUG}/entitlements`), - ); - expect(entitlementsCall).toBeDefined(); - expect(sequence(apiMock.requests)).toEqual([ - `PATCH ${API}/v1/branches/${BRANCH_REF}`, - `GET ${API}/v1/projects/${BRANCH_REF}`, - `GET ${API}/v1/organizations/${ORG_SLUG}/entitlements`, - ]); + expect(error).toMatchObject({ + entitlement: { feature: "branching_persistent", upgrade_url: upgradeUrl }, + }); + expect(sequence(api.requests)).toEqual([`PATCH ${API}/v1/branches/${BRANCH_REF}`]); assertPermissionDrift({ command: "branches update", activeFlags: { persistent: true }, - requests: apiMock.requests, + requests: api.requests, + exact: true, }); - expect(analytics.captured).toEqual([ - { - event: "cli_upgrade_suggested", - properties: { feature_key: "branching_persistent", org_slug: ORG_SLUG }, - }, - ]); - expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + expect(analytics.captured).toHaveLength(0); }).pipe(Effect.provide(layer)); }, ); + it.effect("makes one request and suggests no upgrade on a 4xx without the envelope", () => { + const out = mockOutput({ format: "text" }); + const analytics = mockAnalytics(); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => jsonResponse(request, 400, { message: "Invalid branch name" })), + }); + const layer = buildRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + analytics, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + branchesUpdate({ + ...baseFlags, + branchId: Option.some(BRANCH_REF), + persistent: Option.some(true), + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(classifyCliCauseActionability(exit.cause).error_category).not.toBe("plan_limit"); + } + expect(sequence(api.requests)).toEqual([`PATCH ${API}/v1/branches/${BRANCH_REF}`]); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + expect(analytics.captured).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + it.effect( "updates the branch chosen from the pickers and fills the real linked-project cache", () => { diff --git a/apps/cli/src/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index 78de585c3c..d750175a79 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -60,10 +60,10 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ----------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (all redacted — no `domains` flag is telemetry-safe); a rejected `--include-raw-output` fails with `error_fingerprint` ending in `:removed_flag` (`RemovedSurfaceError`) | -| `cli_upgrade_suggested` | `create`/`get`/`activate`/`reverify` 4xx carrying the `entitlement_required` envelope | `feature_key` (from the envelope), `org_slug` (parsed from `upgrade_url`); envelope-only, no entitlements fallback | +| Event | When | Notable properties / groups | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (all redacted — no `domains` flag is telemetry-safe); a rejected `--include-raw-output` fails with `error_fingerprint` ending in `:removed_flag` (`RemovedSurfaceError`) | +| `cli_upgrade_suggested` | post-run (via wrapper), before `cli_command_executed`, on a `create`/`get`/`activate`/`reverify` 4xx carrying the `entitlement_required` envelope | `feature_key` (from the envelope), `org_slug` (parsed from `upgrade_url`); envelope-only, no entitlements fallback | ## Output diff --git a/apps/cli/src/commands/domains/activate/activate.handler.ts b/apps/cli/src/commands/domains/activate/activate.handler.ts index 3ecc38ee71..167db3d162 100644 --- a/apps/cli/src/commands/domains/activate/activate.handler.ts +++ b/apps/cli/src/commands/domains/activate/activate.handler.ts @@ -8,7 +8,6 @@ import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { emitHostnameResult } from "../domains.emit.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; -import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import type { DomainsActivateFlags } from "./activate.command.ts"; const mapActivateError = mapDomainsHttpError("activate"); @@ -33,7 +32,7 @@ export const domainsActivate = Effect.fn("domains.activate")(function* ( output.format === "text" ? yield* output.task("Activating custom hostname...") : undefined; const response = yield* api.v1.activateCustomHostname({ ref }).pipe( Effect.tapError(() => activating?.fail() ?? Effect.void), - Effect.catch(gateMapError({ projectRef: ref }, mapActivateError)), + Effect.catch(mapActivateError), ); yield* activating?.clear ?? Effect.void; diff --git a/apps/cli/src/commands/domains/activate/activate.integration.test.ts b/apps/cli/src/commands/domains/activate/activate.integration.test.ts index 9f91574859..efb0ad9151 100644 --- a/apps/cli/src/commands/domains/activate/activate.integration.test.ts +++ b/apps/cli/src/commands/domains/activate/activate.integration.test.ts @@ -91,7 +91,7 @@ const baseFlags = { }; describe("domains activate integration", () => { - it.effect("suggests upgrade from entitlement_required envelope on 400", () => { + it.effect("fails with the entitlement_required envelope's entitlement on 400", () => { const { layer, out, analytics, api } = setup({ status: 400, response: { @@ -105,18 +105,17 @@ describe("domains activate integration", () => { }, }); return Effect.gen(function* () { - const exit = yield* Effect.exit(domainsActivate(baseFlags)); - expect(Exit.isFailure(exit)).toBe(true); + const error = yield* Effect.flip(domainsActivate(baseFlags)); + expect(error).toMatchObject({ + entitlement: { + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }); expect(api.requests).toHaveLength(1); assertPermissionDrift({ command: "domains activate", requests: api.requests }); - expect(out.stderrText).toContain("Upgrade your plan:"); - expect(out.stderrText).toContain("/org/env-org/billing"); - expect(analytics.captured).toEqual([ - { - event: "cli_upgrade_suggested", - properties: { feature_key: "custom_domain", org_slug: "env-org" }, - }, - ]); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + expect(analytics.captured).toHaveLength(0); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/domains/create/create.handler.ts b/apps/cli/src/commands/domains/create/create.handler.ts index 8eaf665f15..de861add98 100644 --- a/apps/cli/src/commands/domains/create/create.handler.ts +++ b/apps/cli/src/commands/domains/create/create.handler.ts @@ -11,7 +11,6 @@ import { verifyCname } from "../domains.cname.ts"; import { emitHostnameResult } from "../domains.emit.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; -import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import type { DomainsCreateFlags } from "./create.command.ts"; const mapCreateError = mapDomainsHttpError("create"); @@ -48,7 +47,7 @@ export const domainsCreate = Effect.fn("domains.create")(function* (flags: Domai .updateHostnameConfig({ ref, custom_hostname: flags.customHostname }) .pipe( Effect.tapError(() => creating?.fail() ?? Effect.void), - Effect.catch(gateMapError({ projectRef: ref }, mapCreateError)), + Effect.catch(mapCreateError), ); yield* creating?.clear ?? Effect.void; diff --git a/apps/cli/src/commands/domains/create/create.integration.test.ts b/apps/cli/src/commands/domains/create/create.integration.test.ts index a73af421a2..f47bc3216a 100644 --- a/apps/cli/src/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/commands/domains/create/create.integration.test.ts @@ -151,7 +151,7 @@ describe("domains create integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("suggests upgrade from entitlement_required envelope on 400", () => { + it.effect("fails with the entitlement_required envelope's entitlement on 400", () => { const { layer, out, analytics, api } = setup({ apiStatus: 400, apiResponse: { @@ -165,19 +165,18 @@ describe("domains create integration", () => { }, }); return Effect.gen(function* () { - const exit = yield* Effect.exit(domainsCreate(flags())); - expect(Exit.isFailure(exit)).toBe(true); + const error = yield* Effect.flip(domainsCreate(flags())); + expect(error).toMatchObject({ + entitlement: { + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }); expect(api.requests).toHaveLength(2); expect(postedToInitialize(api)).toBe(true); assertPermissionDrift({ command: "domains create", requests: api.requests }); - expect(out.stderrText).toContain("Upgrade your plan:"); - expect(out.stderrText).toContain("/org/env-org/billing"); - expect(analytics.captured).toEqual([ - { - event: "cli_upgrade_suggested", - properties: { feature_key: "custom_domain", org_slug: "env-org" }, - }, - ]); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + expect(analytics.captured).toHaveLength(0); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/domains/get/get.handler.ts b/apps/cli/src/commands/domains/get/get.handler.ts index fb14b2e700..8dfacc7905 100644 --- a/apps/cli/src/commands/domains/get/get.handler.ts +++ b/apps/cli/src/commands/domains/get/get.handler.ts @@ -7,7 +7,6 @@ import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.serv import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { emitHostnameResult } from "../domains.emit.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; -import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import type { DomainsGetFlags } from "./get.command.ts"; const mapGetError = mapDomainsHttpError("get"); @@ -29,7 +28,7 @@ export const domainsGet = Effect.fn("domains.get")(function* (flags: DomainsGetF : undefined; const response = yield* api.v1.getHostnameConfig({ ref }).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.catch(gateMapError({ projectRef: ref }, mapGetError)), + Effect.catch(mapGetError), ); yield* fetching?.clear ?? Effect.void; diff --git a/apps/cli/src/commands/domains/get/get.integration.test.ts b/apps/cli/src/commands/domains/get/get.integration.test.ts index ab61e36eba..e74e099b47 100644 --- a/apps/cli/src/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/commands/domains/get/get.integration.test.ts @@ -373,7 +373,7 @@ describe("domains get integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("suggests upgrade from entitlement_required envelope on 400", () => { + it.effect("fails with the entitlement_required envelope's entitlement on 400", () => { const { layer, out, analytics, api } = setup({ status: 400, response: { @@ -387,18 +387,17 @@ describe("domains get integration", () => { }, }); return Effect.gen(function* () { - const exit = yield* Effect.exit(domainsGet(baseFlags)); - expect(Exit.isFailure(exit)).toBe(true); + const error = yield* Effect.flip(domainsGet(baseFlags)); + expect(error).toMatchObject({ + entitlement: { + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }); expect(api.requests).toHaveLength(1); assertPermissionDrift({ command: "domains get", requests: api.requests }); - expect(out.stderrText).toContain("Upgrade your plan:"); - expect(out.stderrText).toContain("/org/env-org/billing"); - expect(analytics.captured).toEqual([ - { - event: "cli_upgrade_suggested", - properties: { feature_key: "custom_domain", org_slug: "env-org" }, - }, - ]); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + expect(analytics.captured).toHaveLength(0); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/domains/reverify/reverify.handler.ts b/apps/cli/src/commands/domains/reverify/reverify.handler.ts index 6683caea67..bb77ea5590 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.handler.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.handler.ts @@ -7,7 +7,6 @@ import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.serv import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { emitHostnameResult } from "../domains.emit.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; -import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import type { DomainsReverifyFlags } from "./reverify.command.ts"; const mapReverifyError = mapDomainsHttpError("re-verify"); @@ -27,7 +26,7 @@ export const domainsReverify = Effect.fn("domains.reverify")(function* ( output.format === "text" ? yield* output.task("Re-verifying custom hostname...") : undefined; const response = yield* api.v1.verifyDnsConfig({ ref }).pipe( Effect.tapError(() => reverifying?.fail() ?? Effect.void), - Effect.catch(gateMapError({ projectRef: ref }, mapReverifyError)), + Effect.catch(mapReverifyError), ); yield* reverifying?.clear ?? Effect.void; diff --git a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts index 5eb4006728..8ebd89e7fa 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts @@ -83,7 +83,7 @@ const baseFlags = { }; describe("domains reverify integration", () => { - it.effect("suggests upgrade from entitlement_required envelope on 400", () => { + it.effect("fails with the entitlement_required envelope's entitlement on 400", () => { const { layer, out, analytics, api } = setup({ status: 400, response: { @@ -97,17 +97,16 @@ describe("domains reverify integration", () => { }, }); return Effect.gen(function* () { - const exit = yield* Effect.exit(domainsReverify(baseFlags)); - expect(Exit.isFailure(exit)).toBe(true); - expect(api.requests).toHaveLength(1); - expect(out.stderrText).toContain("Upgrade your plan:"); - expect(out.stderrText).toContain("/org/env-org/billing"); - expect(analytics.captured).toEqual([ - { - event: "cli_upgrade_suggested", - properties: { feature_key: "custom_domain", org_slug: "env-org" }, + const error = yield* Effect.flip(domainsReverify(baseFlags)); + expect(error).toMatchObject({ + entitlement: { + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", }, - ]); + }); + expect(api.requests).toHaveLength(1); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + expect(analytics.captured).toHaveLength(0); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md index 7666b32bfc..7ca7cfdd1e 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -52,10 +52,10 @@ Target: hosted, consent default No — see [Command target](../../../../docs/com ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ----------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------ | -| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | -| `cli_upgrade_suggested` | 4xx whose body is the `entitlement_required` envelope | `feature_key=vanity_subdomain`, `org_slug` | +| Event | When | Notable properties / groups | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | +| `cli_upgrade_suggested` | post-run (via wrapper), before `cli_command_executed`, on a 4xx whose body is the `entitlement_required` envelope | `feature_key=vanity_subdomain`, `org_slug` | ## Output 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 fc3e6dd81a..752a1b41f3 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts @@ -2,7 +2,6 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; -import { gateResponse, suggestUpgrade } from "../../../command-internal/upgrade-suggest.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; @@ -68,26 +67,7 @@ export const vanitySubdomainsActivate = Effect.fn("vanity-subdomains.activate")( }) .pipe( Effect.tapError(() => activating?.fail() ?? Effect.void), - Effect.catch((cause) => - mapActivateError(cause).pipe( - Effect.catchTag("VanitySubdomainsActivateUnexpectedStatusError", (mapped) => - Effect.gen(function* () { - const upgradeSuggested = yield* suggestUpgrade({ - projectRef: ref, - statusCode: mapped.status, - response: gateResponse(cause), - }); - return yield* new VanitySubdomainsActivateUnexpectedStatusError({ - status: mapped.status, - body: mapped.body, - message: mapped.message, - upgradeSuggested, - entitlement: mapped.entitlement, - }); - }), - ), - ), - ), + Effect.catch(mapActivateError), ); yield* activating?.clear ?? Effect.void; diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md index 06ced207b0..07267fddaa 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md @@ -49,12 +49,10 @@ Target: hosted, no consent — see [Command target](../../../../docs/command-tar ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | - -This command may print an upgrade suggestion for a 4xx whose body is the `entitlement_required` envelope, but it does not fire -`cli_upgrade_suggested`. +| Event | When | Notable properties / groups | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | +| `cli_upgrade_suggested` | post-run (via wrapper), before `cli_command_executed`, on a 4xx whose body is the `entitlement_required` envelope | `feature_key=vanity_subdomain`, `org_slug` | ## Output 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 d1a937f196..bf1b76a774 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 @@ -2,7 +2,6 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; -import { gateResponse, suggestUpgrade } from "../../../command-internal/upgrade-suggest.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; @@ -64,29 +63,7 @@ export const vanitySubdomainsCheckAvailability = Effect.fn("vanity-subdomains.ch }) .pipe( Effect.tapError(() => checking?.fail() ?? Effect.void), - Effect.catch((cause) => - mapCheckError(cause).pipe( - Effect.catchTag("VanitySubdomainsCheckUnexpectedStatusError", (mapped) => - Effect.gen(function* () { - // Unlike `activate`, this command suppresses the upgrade-suggestion - // analytics event. - const upgradeSuggested = yield* suggestUpgrade({ - projectRef: ref, - statusCode: mapped.status, - response: gateResponse(cause), - trackAnalytics: false, - }); - return yield* new VanitySubdomainsCheckUnexpectedStatusError({ - status: mapped.status, - body: mapped.body, - message: mapped.message, - upgradeSuggested, - entitlement: mapped.entitlement, - }); - }), - ), - ), - ), + Effect.catch(mapCheckError), ); yield* checking?.clear ?? Effect.void; diff --git a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md index 2d1c761edc..24a1398a2c 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -48,10 +48,10 @@ Target: hosted, no consent — see [Command target](../../../../docs/command-tar ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ----------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | -| `cli_upgrade_suggested` | 4xx carrying the `entitlement_required` envelope (gate open, so the request was made) | `feature_key` (from the envelope), `org_slug` (parsed from `upgrade_url`); envelope-only, no entitlements fallback | +| Event | When | Notable properties / groups | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | +| `cli_upgrade_suggested` | post-run (via wrapper), before `cli_command_executed`, on a 4xx carrying the `entitlement_required` envelope (gate open, so the request was made) | `feature_key` (from the envelope), `org_slug` (parsed from `upgrade_url`); envelope-only, no entitlements fallback | ## Output diff --git a/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts b/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts index f6fd34b390..ca6ff999aa 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts @@ -15,7 +15,6 @@ import { shapeStruct, } from "../../../command-internal/struct-output.encoders.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; -import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import { VanitySubdomainsGetNetworkError, VanitySubdomainsGetUnexpectedStatusError, @@ -52,7 +51,7 @@ export const vanitySubdomainsGet = Effect.fn("vanity-subdomains.get")(function* output.format === "text" ? yield* output.task("Getting vanity subdomain...") : undefined; const response = yield* api.v1.getVanitySubdomainConfig({ ref }).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.catch(gateMapError({ projectRef: ref }, mapGetError)), + Effect.catch(mapGetError), ); yield* fetching?.clear ?? Effect.void; 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 9f42ef2276..1b5bbe2858 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.errors.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.errors.ts @@ -69,14 +69,10 @@ export class VanitySubdomainsCheckUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; - readonly upgradeSuggested?: boolean; readonly entitlement?: PlanGateEntitlement; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return statusCodeActionability(this.status, { - upgradeSuggested: this.upgradeSuggested, - notFoundIsInvalidInput: true, - }); + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); } } @@ -99,14 +95,10 @@ export class VanitySubdomainsActivateUnexpectedStatusError extends Data.TaggedEr readonly status: number; readonly body: string; readonly message: string; - readonly upgradeSuggested?: boolean; readonly entitlement?: PlanGateEntitlement; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return statusCodeActionability(this.status, { - upgradeSuggested: this.upgradeSuggested, - notFoundIsInvalidInput: true, - }); + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); } } diff --git a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index 1c5717df04..a43f1619d1 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -173,7 +173,7 @@ describe("vanity-subdomains get", () => { }).pipe(Effect.provide(layer)); }); - it.effect("suggests upgrade from entitlement_required envelope on 400", () => { + it.effect("fails with the entitlement_required envelope's entitlement on 400", () => { const out = mockOutput({ format: "text" }); const analytics = mockAnalytics(); const api = mockCommandPlatformApi({ @@ -192,20 +192,19 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api, analytics }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }), ); - expect(Exit.isFailure(exit)).toBe(true); + expect(error).toMatchObject({ + entitlement: { + feature: "vanity_subdomain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }); expect(api.requests).toHaveLength(1); assertPermissionDrift({ command: "vanity-subdomains get", requests: api.requests }); - expect(out.stderrText).toContain("Upgrade your plan:"); - expect(out.stderrText).toContain("/org/env-org/billing"); - expect(analytics.captured).toEqual([ - { - event: "cli_upgrade_suggested", - properties: { feature_key: "vanity_subdomain", org_slug: "env-org" }, - }, - ]); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + expect(analytics.captured).toHaveLength(0); }).pipe(Effect.provide(layer)); }); @@ -493,23 +492,27 @@ describe("vanity-subdomains check-availability", () => { }).pipe(Effect.provide(layer)); }); - it.effect("suggests upgrade for gated checks without firing analytics", () => { + it.effect("fails gated checks with the envelope's entitlement", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 400, body: VANITY_GATE_ENVELOPE } }); const analytics = mockAnalytics(); const layer = runtimeWith({ out, api, analytics }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( vanitySubdomainsCheckAvailability({ remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(out.stderrText).toContain("Upgrade your plan:"); - expect(out.stderrText).toContain("/org/env-org/billing"); + expect(error).toMatchObject({ + entitlement: { + feature: "vanity_subdomain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }); + expect(out.stderrText).not.toContain("Upgrade your plan:"); expect(analytics.captured).toHaveLength(0); expect(requestLines(api)).toEqual([ `POST ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/check-availability`, @@ -758,29 +761,28 @@ describe("vanity-subdomains activate", () => { }).pipe(Effect.provide(layer)); }); - it.effect("suggests upgrade and fires analytics for gated activation", () => { + it.effect("fails gated activation with the envelope's entitlement", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 400, body: VANITY_GATE_ENVELOPE } }); const analytics = mockAnalytics(); const layer = runtimeWith({ out, api, analytics }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( vanitySubdomainsActivate({ remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(out.stderrText).toContain("Upgrade your plan:"); - expect(out.stderrText).toContain("/org/env-org/billing"); - expect(analytics.captured).toEqual([ - { - event: "cli_upgrade_suggested", - properties: { feature_key: "vanity_subdomain", org_slug: "env-org" }, + expect(error).toMatchObject({ + entitlement: { + feature: "vanity_subdomain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", }, - ]); + }); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + expect(analytics.captured).toHaveLength(0); expect(requestLines(api)).toEqual([ `POST ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/activate`, ]); diff --git a/apps/cli/src/shared/output/normalize-error.ts b/apps/cli/src/shared/output/normalize-error.ts index 0de304ffd2..9348edaa78 100644 --- a/apps/cli/src/shared/output/normalize-error.ts +++ b/apps/cli/src/shared/output/normalize-error.ts @@ -1,3 +1,5 @@ +import { styleText } from "node:util"; + import { Cause, Option } from "effect"; import { CliError } from "effect/cli"; import { unwrapNativeFailure } from "../telemetry/error-actionability.ts"; @@ -12,6 +14,10 @@ export type PlanGateEntitlement = { readonly upgrade_url: string; }; +/** The text-mode stderr line that tells the user a plan upgrade unlocks the denied feature. */ +export const upgradeHint = (upgradeUrl: string): string => + `Your organization does not have access to this feature. Upgrade your plan: ${styleText("bold", upgradeUrl)}`; + type NormalizedCliError = { readonly code: string; readonly message: string; diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 6703f93178..0932006209 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -32,7 +32,7 @@ import { Tty } from "../runtime/tty.service.ts"; import { CommandTarget } from "./command-target.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 { type PlanGateEntitlement, upgradeHint } from "./normalize-error.ts"; import { Output } from "./output.service.ts"; import type { OutputFormat, StreamEvent } from "./types.ts"; @@ -554,6 +554,9 @@ export const textOutputLayer = Layer.effect( activeSpinner.pauses = 0; activeSpinner = undefined; } + if (err.entitlement !== undefined) { + process.stderr.write(upgradeHint(err.entitlement.upgrade_url) + "\n"); + } // Bypasses clack's `log.error` framing (`│` guide + `■` icon): a // red-styled message on stderr, optionally followed by a suggestion. process.stderr.write(styleText("red", err.message) + "\n"); diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index d648802385..799263af05 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -28,6 +28,7 @@ import { import { commandTargetLayer } from "./command-target.layer.ts"; import { machineErrorContextLayer } from "./machine-error-context.layer.ts"; import { MachineErrorContext } from "./machine-error-context.service.ts"; +import { upgradeHint } from "./normalize-error.ts"; import { Output } from "./output.service.ts"; import { jsonOutputLayer, @@ -289,6 +290,42 @@ describe("Output", () => { ); }); + it.effect( + "fail prints the upgrade hint once, before the message, for a plan-gated denial", + () => { + const upgradeUrl = "https://supabase.com/dashboard/org/acme/billing"; + const writes: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + const originalArgv = process.argv; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stderr.write; + process.argv = originalArgv.filter((arg) => arg !== "--debug"); + return Effect.gen(function* () { + const out = yield* Output; + yield* out.fail({ + code: "E_TEST", + message: "boom", + entitlement: { feature: "project_restore_size", upgrade_url: upgradeUrl }, + }); + expect(writes).toEqual([ + upgradeHint(upgradeUrl) + "\n", + "\x1B[31mboom\x1B[39m\n", + "Try rerunning the command with --debug to troubleshoot the error.\n", + ]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = originalWrite; + process.argv = originalArgv; + }), + ), + ); + }, + ); + it.effect("fail omits the --debug suggestion when --debug is set", () => { const writes: string[] = []; const originalWrite = process.stderr.write.bind(process.stderr); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 95fc766de4..f2994bd543 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -640,9 +640,9 @@ function stackKindDeclaration(value: unknown): CliErrorActionabilityDeclaration } /** - * The declaration for a failure confirmed plan-gated by the entitlement - * check (`suggestUpgrade`). Shared so every gated surface groups under - * the same fingerprint family. + * The declaration for a failure confirmed plan-gated, by its `entitlement` + * or by the entitlement check (`suggestUpgrade`). Shared so every gated + * surface groups under the same fingerprint family. */ export const planLimitGatedActionability: CliErrorActionabilityDeclaration = { ...actionability.planLimit, @@ -1145,7 +1145,12 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { if (depth >= MAX_CAUSE_DEPTH) { return toActionability(actionability.unknown, "error", "CauseChainLimit"); } - const declared = readDeclaration(error); + // `mapHttpError` attaches `entitlement` only from a 4xx `entitlement_required` body, so it + // marks a plan-gated denial on any status error class, declared field or not. + const declared = + isErrorRecord(error) && isErrorRecord(error["entitlement"]) + ? planLimitGatedActionability + : readDeclaration(error); if (declared !== undefined) { const stableTaggedName = readStableTaggedPrototypeName(error); const tag = readErrorTag(error); diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index f9fb5a2b38..714544354d 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -147,6 +147,20 @@ describe("classifyCliErrorActionability", () => { expect(statusCodeActionability(undefined).error_category).toBe("network"); }); + it("classifies a status error carrying a plan-gate entitlement as plan_limit", () => { + const gated = Object.assign(new DeclaredStatusError({ status: 402 }), { + entitlement: { + feature: "project_restore_size", + upgrade_url: "https://supabase.com/dashboard/org/acme/billing", + }, + }); + expect(classifyCliErrorActionability(gated)).toMatchObject({ + error_category: "plan_limit", + error_fingerprint: "tag:DeclaredStatusError:plan_limit", + suggestion_type: "upgrade_plan", + }); + }); + it("rejects malformed declarations and arbitrary remediation text", () => { class InvalidDeclaration extends Data.Error { get [ErrorActionabilityId]() { diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index dd4c16b7c7..7f31681a60 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -6,6 +6,7 @@ import { getCommandRuntimeSpanName, } from "../shared/runtime/command-runtime.service.ts"; import { failureBesideClosedStdout } from "../shared/output/errors.ts"; +import { normalizeCause } from "../shared/output/normalize-error.ts"; import { Output } from "../shared/output/output.service.ts"; import { CreateTicketFlag, @@ -31,10 +32,13 @@ import { DbAdvisorsFailOnError } from "../commands/db/advisors/advisors.errors.t import { DbLintFailOnError } from "../commands/db/lint/lint.errors.ts"; import { EventCommandExecuted, + EventUpgradeSuggested, PropConfigKey, PropDurationMs, PropErrorCategory, PropExitCode, + PropFeatureKey, + PropOrgSlug, PropOutputFormat, PropStackBackend, } from "../shared/telemetry/event-catalog.ts"; @@ -103,6 +107,11 @@ interface CommandTelemetryOptions = never> readonly aliases?: Readonly>; } +function orgSlugFromUpgradeUrl(upgradeUrl: string): string { + const match = /\/org\/([^/]+)/.exec(upgradeUrl); + return match?.[1] ?? ""; +} + /** * Rejects an out-of-enum `-o`/`--output` value before the command runs, so no * `cli_command_executed` event fires for a rejected flag. `OutputFlag` is read optionally, since @@ -573,6 +582,22 @@ function withCommandAnalyticsImplementation + Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.void, + ), + ); + } yield* analytics .capture(EventCommandExecuted, { diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index 86388f7e77..06eee8918b 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -25,6 +25,7 @@ import { PropWorkflow, } from "../shared/telemetry/event-catalog.ts"; import { ProcessControl } from "../shared/runtime/process-control.service.ts"; +import { BranchesCreateUnexpectedStatusError } from "../commands/branches/branches.errors.ts"; import { ConfigDiffLoadConfigError } from "../commands/config/diff/diff.errors.ts"; import { CliConfigValueError } from "../config/cli-config.errors.ts"; import { DbDumpRunError } from "../commands/db/dump/dump.errors.ts"; @@ -1418,6 +1419,56 @@ describe("withCommandTelemetry", () => { ); }); + describe("plan-gated denials", () => { + const gatedFailure = new BranchesCreateUnexpectedStatusError({ + status: 402, + body: "{}", + message: "unexpected create branch status 402", + entitlement: { + feature: "branching_limit", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }); + const runGated = () => { + const analytics = mockContextualAnalytics(); + return Effect.fail(gatedFailure).pipe( + withCommandTelemetry(), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "json" }).layer, + Stdio.layerTest({ args: Effect.succeed(["branches", "create"]) }), + commandRuntimeLayer(["branches", "create"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), + Effect.exit, + Effect.map(() => analytics.captured), + ); + }; + + it.live("fires cli_upgrade_suggested once, before cli_command_executed", () => + runGated().pipe( + Effect.tap((captured) => + Effect.sync(() => { + expect(captured.map((event) => event.event)).toEqual([ + "cli_upgrade_suggested", + "cli_command_executed", + ]); + expect(captured[0]?.properties).toMatchObject({ + feature_key: "branching_limit", + org_slug: "env-org", + }); + expect(captured[1]?.properties).toMatchObject({ + exit_code: 1, + error_category: "plan_limit", + }); + }), + ), + ), + ); + }); + it.live("skips analytics capture when analytics are disabled", () => { const analytics = mockContextualAnalytics();