From 9b1ca992975d220b4188662c9f873493b4b23d19 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Fri, 29 May 2026 19:46:03 +0530 Subject: [PATCH 1/3] fix --- apps/cli/docs/go-cli-porting-status.md | 8 +- .../activate/SIDE_EFFECTS.md | 74 +++++++++------ .../activate/activate.command.ts | 12 ++- .../activate/activate.handler.ts | 90 ++++++++++++++++-- .../check-availability/SIDE_EFFECTS.md | 74 +++++++++------ .../check-availability.command.ts | 12 ++- .../check-availability.handler.ts | 91 +++++++++++++++++-- .../vanity-subdomains/delete/SIDE_EFFECTS.md | 65 +++++++------ .../delete/delete.command.ts | 12 ++- .../delete/delete.handler.ts | 49 +++++++++- .../vanity-subdomains/get/SIDE_EFFECTS.md | 73 +++++++++------ .../vanity-subdomains/get/get.command.ts | 12 ++- .../vanity-subdomains/get/get.handler.ts | 81 ++++++++++++++++- .../vanity-subdomains.errors.ts | 57 ++++++++++++ .../legacy/shared/legacy-upgrade-suggest.ts | 11 ++- 15 files changed, 574 insertions(+), 147 deletions(-) create mode 100644 apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 7055959346..7e8592cb43 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -246,10 +246,10 @@ Legend: | `domains reverify` | `wrapped` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | | `domains activate` | `wrapped` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | | `domains delete` | `wrapped` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | -| `vanity-subdomains activate` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | +| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | | `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | | `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | | `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md index 1b6ce9c4ee..e71c0666b2 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -2,58 +2,72 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | after ref resolution, on success and failure | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ---------------------------- | ---------------------- | -| `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{vanity_subdomain: string}` | `{subdomain, status}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------------------------ | --------------------------- | +| `POST` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------- | -| `0` | success — vanity subdomain activated | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from activate endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyVanitySubdomainsActivateUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsActivateNetworkError`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ----------------------- | ------------------------------------------ | ------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| `cli_upgrade_suggested` | gated 4xx responses only | `feature_key=vanity_subdomain`, `org_slug` | ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` / legacy `--output pretty` + +Prints: + +```text +Activated vanity subdomain at +``` + +### Legacy `--output {json,yaml,toml,env}` -Prints activation result to stdout. +Encodes the response object directly. ### `--output-format json` -Single JSON object emitted to stdout on success. +Single structured success event with the full response object. ### `--output-format stream-json` -One `result` event on success. - -```ndjson -{"type":"result","data":{...}} -``` +One `result` event with the full response object. ## Notes -- Requires `--desired-subdomain` flag (mandatory). -- Requires `--project-ref` or a linked project (`.supabase/config.json`). -- After activation, the project's auth services will no longer function on the `{project-ref}.{supabase-domain}` hostname. +- The legacy `--output` flag wins over TS `--output-format` when both are provided. +- `linked-project.json` is written after ref resolution, even when the API call fails. +- On gated 4xx responses this command prints an upgrade suggestion and fires `cli_upgrade_suggested`. diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts index 57ba773e97..2683f2b820 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsActivate } from "./activate.handler.ts"; const config = { @@ -19,5 +23,11 @@ export const legacyVanitySubdomainsActivateCommand = Command.make("activate", co "Activate a vanity subdomain for your Supabase project. This reconfigures your Supabase project to respond to requests on your vanity subdomain. After the vanity subdomain is activated, your project's auth services will no longer function on the {project-ref}.{supabase-domain} hostname.", ), Command.withShortDescription("Activate a vanity subdomain"), - Command.withHandler((flags) => legacyVanitySubdomainsActivate(flags)), + Command.withHandler((flags) => + legacyVanitySubdomainsActivate(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "activate"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts index f4f4d3690f..d3248f6dfb 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts @@ -1,13 +1,91 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyVanitySubdomainsActivateNetworkError, + LegacyVanitySubdomainsActivateUnexpectedStatusError, +} from "../vanity-subdomains.errors.ts"; import type { LegacyVanitySubdomainsActivateFlags } from "./activate.command.ts"; +const mapActivateError = mapLegacyHttpError({ + networkError: LegacyVanitySubdomainsActivateNetworkError, + statusError: LegacyVanitySubdomainsActivateUnexpectedStatusError, + networkMessage: (cause) => `failed activate vanity subdomain: ${cause}`, + statusMessage: (status, body) => `unexpected activate vanity subdomain status ${status}: ${body}`, +}); + export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomains.activate")( function* (flags: LegacyVanitySubdomainsActivateFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["vanity-subdomains", "activate"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - args.push("--desired-subdomain", flags.desiredSubdomain); - yield* proxy.exec(args); + const output = yield* Output; + const legacyOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const response = yield* api.v1 + .activateVanitySubdomainConfig({ + ref, + vanity_subdomain: flags.desiredSubdomain, + }) + .pipe( + Effect.catch((cause) => + Effect.gen(function* () { + const mapped = yield* Effect.flip(mapActivateError(cause)); + if (mapped._tag === "LegacyVanitySubdomainsActivateUnexpectedStatusError") { + yield* legacySuggestUpgrade({ + projectRef: ref, + featureKey: "vanity_subdomain", + statusCode: mapped.status, + }); + } + return yield* Effect.fail(mapped); + }), + ), + ); + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); + + if (legacyOutput === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (legacyOutput === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (legacyOutput === "toml") { + yield* output.raw(encodeToml({ CustomDomain: response.custom_domain }) + "\n"); + return; + } + if (legacyOutput === "env") { + yield* output.raw(encodeEnv(response) + "\n"); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + yield* output.raw(`Activated vanity subdomain at ${response.custom_domain}\n`); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }, ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md index bd3ed8d04f..dc361d9fbc 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md @@ -2,57 +2,73 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | after ref resolution, on success and failure | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------------------- | ------------ | ------------ | -------------------------------- | -| `GET` | `/v1/projects/{ref}/vanity-subdomain/check-availability` | Bearer token | none | `{available, available_domains}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------------------- | ------------ | ------------------------------ | ------------------------ | +| `POST` | `/v1/projects/{ref}/vanity-subdomain/check-availability` | Bearer token | `{ vanity_subdomain: string }` | `{ available: boolean }` | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------- | -| `0` | success — availability result printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from availability endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyVanitySubdomainsCheckUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsCheckNetworkError`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +This command may print an upgrade suggestion for gated 4xx responses, but it does not fire +`cli_upgrade_suggested`. ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` / legacy `--output pretty` -Prints subdomain availability result to stdout. +Prints: + +```text +Subdomain available: +``` + +### Legacy `--output {json,yaml,toml,env}` + +Encodes the response object directly. ### `--output-format json` -Single JSON object emitted to stdout on success. +Single structured success event with the full response object. ### `--output-format stream-json` -One `result` event on success. - -```ndjson -{"type":"result","data":{...}} -``` +One `result` event with the full response object. ## Notes -- Requires `--desired-subdomain` flag (mandatory). -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- The legacy `--output` flag wins over TS `--output-format` when both are provided. +- `linked-project.json` is written after ref resolution, even when the API call fails. diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts index 5cc3efca89..480f568af7 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsCheckAvailability } from "./check-availability.handler.ts"; const config = { @@ -22,5 +26,11 @@ export const legacyVanitySubdomainsCheckAvailabilityCommand = Command.make( ).pipe( Command.withDescription("Checks if a desired subdomain is available for use."), Command.withShortDescription("Check subdomain availability"), - Command.withHandler((flags) => legacyVanitySubdomainsCheckAvailability(flags)), + Command.withHandler((flags) => + legacyVanitySubdomainsCheckAvailability(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "check-availability"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts index 544e016d04..549529b991 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -1,13 +1,92 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyVanitySubdomainsCheckNetworkError, + LegacyVanitySubdomainsCheckUnexpectedStatusError, +} from "../vanity-subdomains.errors.ts"; import type { LegacyVanitySubdomainsCheckAvailabilityFlags } from "./check-availability.command.ts"; +const mapCheckError = mapLegacyHttpError({ + networkError: LegacyVanitySubdomainsCheckNetworkError, + statusError: LegacyVanitySubdomainsCheckUnexpectedStatusError, + networkMessage: (cause) => `failed to check vanity subdomain: ${cause}`, + statusMessage: (status, body) => `unexpected check vanity subdomain status ${status}: ${body}`, +}); + export const legacyVanitySubdomainsCheckAvailability = Effect.fn( "legacy.vanity-subdomains.check-availability", )(function* (flags: LegacyVanitySubdomainsCheckAvailabilityFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["vanity-subdomains", "check-availability"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - args.push("--desired-subdomain", flags.desiredSubdomain); - yield* proxy.exec(args); + const output = yield* Output; + const legacyOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const response = yield* api.v1 + .checkVanitySubdomainAvailability({ + ref, + vanity_subdomain: flags.desiredSubdomain, + }) + .pipe( + Effect.catch((cause) => + Effect.gen(function* () { + const mapped = yield* Effect.flip(mapCheckError(cause)); + if (mapped._tag === "LegacyVanitySubdomainsCheckUnexpectedStatusError") { + yield* legacySuggestUpgrade({ + projectRef: ref, + featureKey: "vanity_subdomain", + statusCode: mapped.status, + trackAnalytics: false, + }); + } + return yield* Effect.fail(mapped); + }), + ), + ); + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); + + if (legacyOutput === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (legacyOutput === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (legacyOutput === "toml") { + yield* output.raw(encodeToml({ Available: response.available }) + "\n"); + return; + } + if (legacyOutput === "env") { + yield* output.raw(encodeEnv(response) + "\n"); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + yield* output.raw(`Subdomain ${flags.desiredSubdomain} available: ${response.available}\n`); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md index c6c3e522dd..2596dc4cb6 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -2,15 +2,17 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | after ref resolution, on success and failure | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | ## API Routes @@ -20,39 +22,50 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------- | -| `0` | success — vanity subdomain deleted | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from delete endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyVanitySubdomainsDeleteUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsDeleteNetworkError`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` / legacy `--output pretty` + +Prints to stderr: + +```text +Deleted vanity subdomain successfully. +``` + +### Legacy `--output {json,yaml,toml,env}` -Prints deletion result to stdout. +Ignored, matching the old Go command. The same stderr success line is printed. ### `--output-format json` -Single JSON object emitted to stdout on success. +Single structured success event when the legacy `--output` flag is unset. ### `--output-format stream-json` -One `result` event on success. - -```ndjson -{"type":"result","data":{...}} -``` +One `result` event when the legacy `--output` flag is unset. ## Notes -- Requires `--project-ref` or a linked project (`.supabase/config.json`). -- Deleting the vanity subdomain reverts to using the project ref for routing. +- The legacy `--output` flag wins over TS `--output-format` when both are provided. +- `linked-project.json` is written after ref resolution, even when the API call fails. diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts index 5af9b7a138..48e2b5908f 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsDelete } from "./delete.handler.ts"; const config = { @@ -16,5 +20,11 @@ export const legacyVanitySubdomainsDeleteCommand = Command.make("delete", config "Deletes the vanity subdomain for a project, and reverts to using the project ref for routing.", ), Command.withShortDescription("Delete the vanity subdomain"), - Command.withHandler((flags) => legacyVanitySubdomainsDelete(flags)), + Command.withHandler((flags) => + legacyVanitySubdomainsDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "delete"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts index 2ac95802c7..25a675a2e4 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts @@ -1,12 +1,51 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyVanitySubdomainsDeleteNetworkError, + LegacyVanitySubdomainsDeleteUnexpectedStatusError, +} from "../vanity-subdomains.errors.ts"; import type { LegacyVanitySubdomainsDeleteFlags } from "./delete.command.ts"; +const mapDeleteError = mapLegacyHttpError({ + networkError: LegacyVanitySubdomainsDeleteNetworkError, + statusError: LegacyVanitySubdomainsDeleteUnexpectedStatusError, + networkMessage: (cause) => `failed to delete vanity subdomain: ${cause}`, + statusMessage: (status, body) => `unexpected delete vanity subdomain status ${status}: ${body}`, +}); + export const legacyVanitySubdomainsDelete = Effect.fn("legacy.vanity-subdomains.delete")(function* ( flags: LegacyVanitySubdomainsDeleteFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["vanity-subdomains", "delete"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const legacyOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + yield* api.v1.deactivateVanitySubdomainConfig({ ref }).pipe(Effect.catch(mapDeleteError)); + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); + + if ( + legacyOutput === undefined && + (output.format === "json" || output.format === "stream-json") + ) { + yield* output.success("Deleted vanity subdomain successfully."); + return; + } + + yield* output.raw("Deleted vanity subdomain successfully.\n", "stderr"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md index 465c77e20b..15c79abde7 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -2,56 +2,73 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | after ref resolution, on success and failure | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------ | ---------------------- | -| `GET` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | `{subdomain, status}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------- | +| `GET` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | `{ status: string, custom_domain?: string }` | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------- | -| `0` | success — vanity subdomain info printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from vanity subdomain endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyVanitySubdomainsGetUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsGetNetworkError`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` / legacy `--output pretty` + +Prints: + +```text +Status: +Vanity subdomain: +``` -Prints vanity subdomain configuration to stdout. +The second line is omitted when `custom_domain` is absent. + +### Legacy `--output {json,yaml,toml,env}` + +Encodes the response object directly. ### `--output-format json` -Single JSON object emitted to stdout on success. +Single structured success event with the full response object. ### `--output-format stream-json` -One `result` event on success. - -```ndjson -{"type":"result","data":{...}} -``` +One `result` event with the full response object. ## Notes -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- The legacy `--output` flag wins over TS `--output-format` when both are provided. +- `linked-project.json` is written after ref resolution, even when the API call fails. diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts index d6f09b0ab6..1292dd87b4 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsGet } from "./get.handler.ts"; const config = { @@ -14,5 +18,11 @@ export type LegacyVanitySubdomainsGetFlags = CliCommand.Command.Config.Infer legacyVanitySubdomainsGet(flags)), + Command.withHandler((flags) => + legacyVanitySubdomainsGet(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "get"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts index d5ef119b38..8137960879 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts @@ -1,12 +1,83 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyVanitySubdomainsGetNetworkError, + LegacyVanitySubdomainsGetUnexpectedStatusError, +} from "../vanity-subdomains.errors.ts"; import type { LegacyVanitySubdomainsGetFlags } from "./get.command.ts"; +const mapGetError = mapLegacyHttpError({ + networkError: LegacyVanitySubdomainsGetNetworkError, + statusError: LegacyVanitySubdomainsGetUnexpectedStatusError, + networkMessage: (cause) => `failed to get vanity subdomain: ${cause}`, + statusMessage: (status, body) => `unexpected vanity subdomain status ${status}: ${body}`, +}); + export const legacyVanitySubdomainsGet = Effect.fn("legacy.vanity-subdomains.get")(function* ( flags: LegacyVanitySubdomainsGetFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["vanity-subdomains", "get"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const legacyOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const response = yield* api.v1 + .getVanitySubdomainConfig({ ref }) + .pipe(Effect.catch(mapGetError)); + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); + + if (legacyOutput === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (legacyOutput === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (legacyOutput === "toml") { + yield* output.raw( + encodeToml({ + Status: response.status, + ...(response.custom_domain === undefined + ? {} + : { CustomDomain: response.custom_domain }), + }) + "\n", + ); + return; + } + if (legacyOutput === "env") { + yield* output.raw(encodeEnv(response) + "\n"); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + yield* output.raw(`Status: ${response.status}\n`); + if (response.custom_domain !== undefined) { + yield* output.raw(`Vanity subdomain: ${response.custom_domain}\n`); + } + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts new file mode 100644 index 0000000000..aa849f89ef --- /dev/null +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts @@ -0,0 +1,57 @@ +import { Data } from "effect"; + +export class LegacyVanitySubdomainsGetNetworkError extends Data.TaggedError( + "LegacyVanitySubdomainsGetNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsGetUnexpectedStatusError extends Data.TaggedError( + "LegacyVanitySubdomainsGetUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsCheckNetworkError extends Data.TaggedError( + "LegacyVanitySubdomainsCheckNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsCheckUnexpectedStatusError extends Data.TaggedError( + "LegacyVanitySubdomainsCheckUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsActivateNetworkError extends Data.TaggedError( + "LegacyVanitySubdomainsActivateNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsActivateUnexpectedStatusError extends Data.TaggedError( + "LegacyVanitySubdomainsActivateUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsDeleteNetworkError extends Data.TaggedError( + "LegacyVanitySubdomainsDeleteNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsDeleteUnexpectedStatusError extends Data.TaggedError( + "LegacyVanitySubdomainsDeleteUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index d0d8d3e1f9..a1119ae31b 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -49,6 +49,7 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { readonly projectRef: string; readonly featureKey: string; readonly statusCode: number; + readonly trackAnalytics?: boolean; }) { if (opts.statusCode < 400 || opts.statusCode >= 500) { return; @@ -117,8 +118,10 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { yield* output.raw(suggestion + "\n", "stderr"); } - yield* analytics.capture(EventUpgradeSuggested, { - [PropFeatureKey]: opts.featureKey, - [PropOrgSlug]: orgSlug, - }); + if (opts.trackAnalytics !== false) { + yield* analytics.capture(EventUpgradeSuggested, { + [PropFeatureKey]: opts.featureKey, + [PropOrgSlug]: orgSlug, + }); + } }); From 20163331ddf45d2810d01e7c15843c6e9b26ab86 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Fri, 29 May 2026 19:46:22 +0530 Subject: [PATCH 2/3] test --- .../vanity-subdomains.integration.test.ts | 280 ++++++++++++++++++ .../legacy-upgrade-suggest.unit.test.ts | 14 + 2 files changed, 294 insertions(+) create mode 100644 apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts new file mode 100644 index 0000000000..cb6ae37216 --- /dev/null +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -0,0 +1,280 @@ +import type { + V1ActivateVanitySubdomainConfigOutput, + V1CheckVanitySubdomainAvailabilityOutput, + V1GetVanitySubdomainConfigOutput, +} from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + buildLegacyTestRuntime, + legacyJsonResponse, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyVanitySubdomainsActivate } from "./activate/activate.handler.ts"; +import { legacyVanitySubdomainsCheckAvailability } from "./check-availability/check-availability.handler.ts"; +import { legacyVanitySubdomainsDelete } from "./delete/delete.handler.ts"; +import { legacyVanitySubdomainsGet } from "./get/get.handler.ts"; + +type VanityConfig = typeof V1GetVanitySubdomainConfigOutput.Type; +type AvailabilityResponse = typeof V1CheckVanitySubdomainAvailabilityOutput.Type; +type ActivateResponse = typeof V1ActivateVanitySubdomainConfigOutput.Type; + +const tempRoot = useLegacyTempWorkdir("supabase-vanity-int-"); + +const SAMPLE_GET: VanityConfig = { + status: "custom-domain-used", + custom_domain: "example.com", +}; + +const SAMPLE_CHECK: AvailabilityResponse = { + available: true, +}; + +const SAMPLE_ACTIVATE: ActivateResponse = { + custom_domain: "example.com", +}; + +describe("legacy vanity-subdomains integration", () => { + type LegacyOutput = "env" | "pretty" | "json" | "toml" | "yaml"; + + function runtimeWith(opts: { + readonly out: ReturnType; + readonly api: ReturnType; + readonly analytics?: ReturnType; + readonly telemetry?: ReturnType["layer"]; + readonly linkedProjectCache?: ReturnType["layer"]; + readonly legacyOutput?: LegacyOutput; + }) { + return buildLegacyTestRuntime({ + out: opts.out, + api: opts.api, + analytics: opts.analytics, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + telemetry: opts.telemetry, + linkedProjectCache: opts.linkedProjectCache, + goOutput: opts.legacyOutput === undefined ? Option.none() : Option.some(opts.legacyOutput), + }); + } + + it.live("gets the vanity subdomain in text mode", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: SAMPLE_GET }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toBe("Status: custom-domain-used\nVanity subdomain: example.com\n"); + expect(api.requests[0]?.method).toBe("GET"); + expect(api.requests[0]?.url).toContain(`/v1/projects/${LEGACY_VALID_REF}/vanity-subdomain`); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy TOML bytes for get", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: SAMPLE_GET }, + }); + const layer = runtimeWith({ out, api, legacyOutput: "toml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toBe( + 'Status = "custom-domain-used"\nCustomDomain = "example.com"\n\n', + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("checks availability in text mode", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 201, body: SAMPLE_CHECK }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toBe("Subdomain example.com available: true\n"); + expect(api.requests[0]?.method).toBe("POST"); + expect(api.requests[0]?.url).toContain( + `/v1/projects/${LEGACY_VALID_REF}/vanity-subdomain/check-availability`, + ); + expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "example.com" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("suggests upgrade for gated availability checks without firing analytics", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "POST" && request.url.includes("/check-availability")) { + return legacyJsonResponse(request, 402, {}); + } + if ( + request.method === "GET" && + request.url.endsWith(`/v1/projects/${LEGACY_VALID_REF}`) + ) { + return legacyJsonResponse(request, 200, { organization_slug: "supabase" }); + } + if (request.method === "GET" && request.url.includes("/entitlements")) { + return legacyJsonResponse(request, 200, { + entitlements: [ + { + feature: { key: "vanity_subdomain", type: "boolean" }, + hasAccess: false, + type: "boolean", + config: { enabled: true }, + }, + ], + }); + } + return legacyJsonResponse(request, 200, null); + }), + }); + const analytics = mockAnalytics(); + const layer = runtimeWith({ out, api, analytics }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(analytics.captured).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("activates the vanity subdomain in text mode", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 201, body: SAMPLE_ACTIVATE }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toBe("Activated vanity subdomain at example.com\n"); + expect(api.requests[0]?.method).toBe("POST"); + expect(api.requests[0]?.url).toContain(`/v1/projects/${LEGACY_VALID_REF}/vanity-subdomain`); + expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "example.com" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("suggests upgrade and fires analytics for gated activation", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "POST" && request.url.includes("/vanity-subdomain")) { + return legacyJsonResponse(request, 402, {}); + } + if ( + request.method === "GET" && + request.url.endsWith(`/v1/projects/${LEGACY_VALID_REF}`) + ) { + return legacyJsonResponse(request, 200, { organization_slug: "supabase" }); + } + if (request.method === "GET" && request.url.includes("/entitlements")) { + return legacyJsonResponse(request, 200, { + entitlements: [ + { + feature: { key: "vanity_subdomain", type: "boolean" }, + hasAccess: false, + type: "boolean", + config: { enabled: true }, + }, + ], + }); + } + return legacyJsonResponse(request, 200, null); + }), + }); + const analytics = mockAnalytics(); + const layer = runtimeWith({ out, api, analytics }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "vanity_subdomain", org_slug: "supabase" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("deletes the vanity subdomain in text mode", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: null }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsDelete({ projectRef: Option.none() }); + expect(out.stderrText).toBe("Deleted vanity subdomain successfully.\n"); + expect(api.requests[0]?.method).toBe("DELETE"); + expect(api.requests[0]?.url).toContain(`/v1/projects/${LEGACY_VALID_REF}/vanity-subdomain`); + }).pipe(Effect.provide(layer)); + }); + + it.live("ignores legacy --output values on delete", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: null }, + }); + const layer = runtimeWith({ out, api, legacyOutput: "json" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsDelete({ projectRef: Option.none() }); + expect(out.stderrText).toBe("Deleted vanity subdomain successfully.\n"); + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache on success", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: SAMPLE_GET }, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = runtimeWith({ + out, + api, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts index 1ae3f1862e..103ab98348 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts @@ -210,4 +210,18 @@ describe("legacySuggestUpgrade", () => { expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); }); + + it.live("skips cli_upgrade_suggested analytics when trackAnalytics=false", () => { + const { layer, analytics, out } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_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)); + }); }); From f80324aed584d8925bb17828e53d243ef1c72452 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 29 May 2026 15:56:20 +0100 Subject: [PATCH 3/3] fix(cli): address review feedback on vanity-subdomains port - Drop `safeFlags: ["project-ref"]` from all four commands; Go's vanitySubdomains.go never calls markFlagTelemetrySafe, so project refs must be redacted in telemetry for 1:1 parity. - Wrap each API call in `output.task` so text mode shows a spinner instead of appearing to hang during network I/O. - Fix activate SIDE_EFFECTS.md API path to /vanity-subdomain/activate. - Document the `trackAnalytics` option, the `Effect.flip` error inspection, and the delete handler's legacy-output read. - Expand integration tests to 100% branch coverage across all four handlers (legacy json/yaml/toml/env, --output-format json/stream-json, absent custom_domain, network and status errors, post-run telemetry on failure) and tighten the activate endpoint assertion and gated-upgrade mock match. --- .../activate/SIDE_EFFECTS.md | 6 +- .../activate/activate.command.ts | 2 +- .../activate/activate.handler.ts | 9 + .../check-availability.command.ts | 2 +- .../check-availability.handler.ts | 11 + .../delete/delete.command.ts | 2 +- .../delete/delete.handler.ts | 12 +- .../vanity-subdomains/get/get.command.ts | 2 +- .../vanity-subdomains/get/get.handler.ts | 11 +- .../vanity-subdomains.integration.test.ts | 552 ++++++++++++++---- .../legacy/shared/legacy-upgrade-suggest.ts | 6 + 11 files changed, 502 insertions(+), 113 deletions(-) diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md index e71c0666b2..23fbe98051 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -16,9 +16,9 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------------------------ | --------------------------- | -| `POST` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------ | ------------------------------ | --------------------------- | +| `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` | ## Environment Variables diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts index 2683f2b820..c17e24138c 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts @@ -25,7 +25,7 @@ export const legacyVanitySubdomainsActivateCommand = Command.make("activate", co Command.withShortDescription("Activate a vanity subdomain"), Command.withHandler((flags) => legacyVanitySubdomainsActivate(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts index d3248f6dfb..27877ce2c5 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts @@ -40,14 +40,21 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + const activating = + output.format === "text" + ? yield* output.task("Activating vanity subdomain...") + : undefined; const response = yield* api.v1 .activateVanitySubdomainConfig({ ref, vanity_subdomain: flags.desiredSubdomain, }) .pipe( + Effect.tapError(() => activating?.fail() ?? Effect.void), Effect.catch((cause) => Effect.gen(function* () { + // Flip the always-failing mapper into a success so we can inspect the + // tagged error before deciding whether to suggest an upgrade, then re-fail. const mapped = yield* Effect.flip(mapActivateError(cause)); if (mapped._tag === "LegacyVanitySubdomainsActivateUnexpectedStatusError") { yield* legacySuggestUpgrade({ @@ -60,6 +67,8 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain }), ), ); + yield* activating?.clear() ?? Effect.void; + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); if (legacyOutput === "json") { diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts index 480f568af7..934d6f92e3 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts @@ -28,7 +28,7 @@ export const legacyVanitySubdomainsCheckAvailabilityCommand = Command.make( Command.withShortDescription("Check subdomain availability"), Command.withHandler((flags) => legacyVanitySubdomainsCheckAvailability(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts index 549529b991..d17836e693 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -41,16 +41,25 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + const checking = + output.format === "text" + ? yield* output.task("Checking vanity subdomain availability...") + : undefined; const response = yield* api.v1 .checkVanitySubdomainAvailability({ ref, vanity_subdomain: flags.desiredSubdomain, }) .pipe( + Effect.tapError(() => checking?.fail() ?? Effect.void), Effect.catch((cause) => Effect.gen(function* () { + // Flip the always-failing mapper into a success so we can inspect the + // tagged error before deciding whether to suggest an upgrade, then re-fail. const mapped = yield* Effect.flip(mapCheckError(cause)); if (mapped._tag === "LegacyVanitySubdomainsCheckUnexpectedStatusError") { + // Go's check command calls SuggestUpgradeOnError without a following + // TrackUpgradeSuggested, so suppress the analytics event for parity. yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "vanity_subdomain", @@ -62,6 +71,8 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( }), ), ); + yield* checking?.clear() ?? Effect.void; + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); if (legacyOutput === "json") { diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts index 48e2b5908f..2a6200c66e 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts @@ -22,7 +22,7 @@ export const legacyVanitySubdomainsDeleteCommand = Command.make("delete", config Command.withShortDescription("Delete the vanity subdomain"), Command.withHandler((flags) => legacyVanitySubdomainsDelete(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts index 25a675a2e4..c465dfe397 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts @@ -34,7 +34,17 @@ export const legacyVanitySubdomainsDelete = Effect.fn("legacy.vanity-subdomains. const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { - yield* api.v1.deactivateVanitySubdomainConfig({ ref }).pipe(Effect.catch(mapDeleteError)); + const deleting = + output.format === "text" ? yield* output.task("Deleting vanity subdomain...") : undefined; + yield* api.v1.deactivateVanitySubdomainConfig({ ref }).pipe( + Effect.tapError(() => deleting?.fail() ?? Effect.void), + Effect.catch(mapDeleteError), + ); + yield* deleting?.clear() ?? Effect.void; + + // Go's delete ignores --output entirely (stderr-only success). We still read + // the legacy flag so that an explicit --output suppresses the TS json/stream-json + // success event, matching Go's behavior of emitting nothing to stdout. const legacyOutput = Option.getOrUndefined(legacyOutputFlag); if ( diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts index 1292dd87b4..76c8e87f50 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts @@ -20,7 +20,7 @@ export const legacyVanitySubdomainsGetCommand = Command.make("get", config).pipe Command.withShortDescription("Get the current vanity subdomain"), Command.withHandler((flags) => legacyVanitySubdomainsGet(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts index 8137960879..47cfc2908e 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts @@ -40,9 +40,14 @@ export const legacyVanitySubdomainsGet = Effect.fn("legacy.vanity-subdomains.get const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { - const response = yield* api.v1 - .getVanitySubdomainConfig({ ref }) - .pipe(Effect.catch(mapGetError)); + const fetching = + 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(mapGetError), + ); + yield* fetching?.clear() ?? Effect.void; + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); if (legacyOutput === "json") { diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index cb6ae37216..40c484acfe 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -33,6 +33,11 @@ const SAMPLE_GET: VanityConfig = { custom_domain: "example.com", }; +// A project with no vanity subdomain configured — `custom_domain` is absent. +const SAMPLE_GET_NO_DOMAIN: VanityConfig = { + status: "not-used", +}; + const SAMPLE_CHECK: AvailabilityResponse = { available: true, }; @@ -41,33 +46,61 @@ const SAMPLE_ACTIVATE: ActivateResponse = { custom_domain: "example.com", }; -describe("legacy vanity-subdomains integration", () => { - type LegacyOutput = "env" | "pretty" | "json" | "toml" | "yaml"; - - function runtimeWith(opts: { - readonly out: ReturnType; - readonly api: ReturnType; - readonly analytics?: ReturnType; - readonly telemetry?: ReturnType["layer"]; - readonly linkedProjectCache?: ReturnType["layer"]; - readonly legacyOutput?: LegacyOutput; - }) { - return buildLegacyTestRuntime({ - out: opts.out, - api: opts.api, - analytics: opts.analytics, - cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), - telemetry: opts.telemetry, - linkedProjectCache: opts.linkedProjectCache, - goOutput: opts.legacyOutput === undefined ? Option.none() : Option.some(opts.legacyOutput), - }); - } +type LegacyOutput = "env" | "pretty" | "json" | "toml" | "yaml"; + +function runtimeWith(opts: { + readonly out: ReturnType; + readonly api: ReturnType; + readonly analytics?: ReturnType; + readonly telemetry?: ReturnType["layer"]; + readonly linkedProjectCache?: ReturnType["layer"]; + readonly legacyOutput?: LegacyOutput; +}) { + return buildLegacyTestRuntime({ + out: opts.out, + api: opts.api, + analytics: opts.analytics, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + telemetry: opts.telemetry, + linkedProjectCache: opts.linkedProjectCache, + goOutput: opts.legacyOutput === undefined ? Option.none() : Option.some(opts.legacyOutput), + }); +} - it.live("gets the vanity subdomain in text mode", () => { +// Builds an API mock where the given write endpoint is billing-gated (402) and +// the project/entitlements lookups report no access to `vanity_subdomain`. Used +// to exercise the upgrade-suggestion branch in `activate` and `check-availability`. +function gatedApi(matchWrite: (url: string) => boolean) { + return mockLegacyPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "POST" && matchWrite(request.url)) { + return legacyJsonResponse(request, 402, {}); + } + if (request.method === "GET" && request.url.endsWith(`/v1/projects/${LEGACY_VALID_REF}`)) { + return legacyJsonResponse(request, 200, { organization_slug: "supabase" }); + } + if (request.method === "GET" && request.url.includes("/entitlements")) { + return legacyJsonResponse(request, 200, { + entitlements: [ + { + feature: { key: "vanity_subdomain", type: "boolean" }, + hasAccess: false, + type: "boolean", + config: { enabled: true }, + }, + ], + }); + } + return legacyJsonResponse(request, 200, null); + }), + }); +} + +describe("legacy vanity-subdomains get", () => { + it.live("prints status and subdomain in text mode", () => { const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi({ - response: { status: 200, body: SAMPLE_GET }, - }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); const layer = runtimeWith({ out, api }); return Effect.gen(function* () { @@ -78,11 +111,44 @@ describe("legacy vanity-subdomains integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits legacy TOML bytes for get", () => { + it.live("omits the subdomain line in text mode when none is configured", () => { const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi({ - response: { status: 200, body: SAMPLE_GET }, - }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET_NO_DOMAIN } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toBe("Status: not-used\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy JSON bytes for --output json", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api, legacyOutput: "json" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toContain('"status": "custom-domain-used"'); + expect(out.stdoutText).toContain('"custom_domain": "example.com"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy YAML for --output yaml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api, legacyOutput: "yaml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("status: custom-domain-used"); + expect(out.stdoutText).toContain("custom_domain: example.com"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy TOML bytes for --output toml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); const layer = runtimeWith({ out, api, legacyOutput: "toml" }); return Effect.gen(function* () { @@ -93,11 +159,91 @@ describe("legacy vanity-subdomains integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("checks availability in text mode", () => { + it.live("omits CustomDomain in TOML when none is configured", () => { const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi({ - response: { status: 201, body: SAMPLE_CHECK }, - }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET_NO_DOMAIN } }); + const layer = runtimeWith({ out, api, legacyOutput: "toml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toBe('Status = "not-used"\n\n'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy env for --output env", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api, legacyOutput: "env" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toContain('STATUS="custom-domain-used"'); + expect(out.stdoutText).toContain('CUSTOM_DOMAIN="example.com"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event for --output-format json", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ status: "custom-domain-used" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event for --output-format stream-json", () => { + const out = mockOutput({ format: "stream-json" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ status: "custom-domain-used" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with an unexpected-status error on HTTP 503", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 503, body: {} } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsGetUnexpectedStatusError"); + expect(errorJson).toContain("unexpected vanity subdomain status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + // json mode so the spinner is suppressed — exercises the no-task error path. + it.live("fails with a network error on transport failure", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ network: "fail" }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsGetNetworkError"); + expect(errorJson).toContain("failed to get vanity subdomain"); + } + }).pipe(Effect.provide(layer)); + }); +}); + +describe("legacy vanity-subdomains check-availability", () => { + it.live("prints availability in text mode", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); const layer = runtimeWith({ out, api }); return Effect.gen(function* () { @@ -114,35 +260,80 @@ describe("legacy vanity-subdomains integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("suggests upgrade for gated availability checks without firing analytics", () => { + it.live("emits legacy JSON bytes for --output json", () => { const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi({ - handler: (request) => - Effect.sync(() => { - if (request.method === "POST" && request.url.includes("/check-availability")) { - return legacyJsonResponse(request, 402, {}); - } - if ( - request.method === "GET" && - request.url.endsWith(`/v1/projects/${LEGACY_VALID_REF}`) - ) { - return legacyJsonResponse(request, 200, { organization_slug: "supabase" }); - } - if (request.method === "GET" && request.url.includes("/entitlements")) { - return legacyJsonResponse(request, 200, { - entitlements: [ - { - feature: { key: "vanity_subdomain", type: "boolean" }, - hasAccess: false, - type: "boolean", - config: { enabled: true }, - }, - ], - }); - } - return legacyJsonResponse(request, 200, null); - }), - }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api, legacyOutput: "json" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain('"available": true'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy YAML for --output yaml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api, legacyOutput: "yaml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain("available: true"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy TOML bytes for --output toml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api, legacyOutput: "toml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toBe("Available = true\n\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy env for --output env", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api, legacyOutput: "env" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain('AVAILABLE="true"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event for --output-format json", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ available: true }); + }).pipe(Effect.provide(layer)); + }); + + it.live("suggests upgrade for gated checks without firing analytics", () => { + const out = mockOutput({ format: "text" }); + const api = gatedApi((url) => url.includes("/check-availability")); const analytics = mockAnalytics(); const layer = runtimeWith({ out, api, analytics }); @@ -159,11 +350,33 @@ describe("legacy vanity-subdomains integration", () => { }).pipe(Effect.provide(layer)); }); + // json mode so the spinner is suppressed — exercises the no-task error path. + it.live("fails with a network error on transport failure", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ network: "fail" }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsCheckNetworkError"); + expect(errorJson).toContain("failed to check vanity subdomain"); + } + }).pipe(Effect.provide(layer)); + }); +}); + +describe("legacy vanity-subdomains activate", () => { it.live("activates the vanity subdomain in text mode", () => { const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi({ - response: { status: 201, body: SAMPLE_ACTIVATE }, - }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); const layer = runtimeWith({ out, api }); return Effect.gen(function* () { @@ -173,40 +386,87 @@ describe("legacy vanity-subdomains integration", () => { }); expect(out.stdoutText).toBe("Activated vanity subdomain at example.com\n"); expect(api.requests[0]?.method).toBe("POST"); - expect(api.requests[0]?.url).toContain(`/v1/projects/${LEGACY_VALID_REF}/vanity-subdomain`); + expect(api.requests[0]?.url).toContain( + `/v1/projects/${LEGACY_VALID_REF}/vanity-subdomain/activate`, + ); expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "example.com" }); }).pipe(Effect.provide(layer)); }); + it.live("emits legacy JSON bytes for --output json", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api, legacyOutput: "json" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain('"custom_domain": "example.com"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy YAML for --output yaml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api, legacyOutput: "yaml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain("custom_domain: example.com"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy TOML bytes for --output toml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api, legacyOutput: "toml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toBe('CustomDomain = "example.com"\n\n'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy env for --output env", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api, legacyOutput: "env" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain('CUSTOM_DOMAIN="example.com"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event for --output-format json", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ custom_domain: "example.com" }); + }).pipe(Effect.provide(layer)); + }); + it.live("suggests upgrade and fires analytics for gated activation", () => { const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi({ - handler: (request) => - Effect.sync(() => { - if (request.method === "POST" && request.url.includes("/vanity-subdomain")) { - return legacyJsonResponse(request, 402, {}); - } - if ( - request.method === "GET" && - request.url.endsWith(`/v1/projects/${LEGACY_VALID_REF}`) - ) { - return legacyJsonResponse(request, 200, { organization_slug: "supabase" }); - } - if (request.method === "GET" && request.url.includes("/entitlements")) { - return legacyJsonResponse(request, 200, { - entitlements: [ - { - feature: { key: "vanity_subdomain", type: "boolean" }, - hasAccess: false, - type: "boolean", - config: { enabled: true }, - }, - ], - }); - } - return legacyJsonResponse(request, 200, null); - }), - }); + const api = gatedApi((url) => url.endsWith("/vanity-subdomain/activate")); const analytics = mockAnalytics(); const layer = runtimeWith({ out, api, analytics }); @@ -228,11 +488,36 @@ describe("legacy vanity-subdomains integration", () => { }).pipe(Effect.provide(layer)); }); + // json mode so the spinner is suppressed — exercises the no-task error path. + it.live("fails with a network error on transport failure", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ network: "fail" }); + const analytics = mockAnalytics(); + const layer = runtimeWith({ out, api, analytics }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsActivateNetworkError"); + expect(errorJson).toContain("failed activate vanity subdomain"); + } + // A network failure is not a billing gate, so no upgrade is suggested. + expect(analytics.captured).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("legacy vanity-subdomains delete", () => { it.live("deletes the vanity subdomain in text mode", () => { const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi({ - response: { status: 200, body: null }, - }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: null } }); const layer = runtimeWith({ out, api }); return Effect.gen(function* () { @@ -243,11 +528,21 @@ describe("legacy vanity-subdomains integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("ignores legacy --output values on delete", () => { + it.live("emits a JSON success event for --output-format json", () => { const out = mockOutput({ format: "json" }); - const api = mockLegacyPlatformApi({ - response: { status: 200, body: null }, - }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: null } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsDelete({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Deleted vanity subdomain successfully."); + }).pipe(Effect.provide(layer)); + }); + + it.live("ignores legacy --output values and prints to stderr", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: null } }); const layer = runtimeWith({ out, api, legacyOutput: "json" }); return Effect.gen(function* () { @@ -257,11 +552,44 @@ describe("legacy vanity-subdomains integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("fails with an unexpected-status error on HTTP 503", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 503, body: {} } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsDelete({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsDeleteUnexpectedStatusError"); + expect(errorJson).toContain("unexpected delete vanity subdomain status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + // json mode so the spinner is suppressed — exercises the no-task error path. + it.live("fails with a network error on transport failure", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ network: "fail" }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsDelete({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsDeleteNetworkError"); + expect(errorJson).toContain("failed to delete vanity subdomain"); + } + }).pipe(Effect.provide(layer)); + }); +}); + +describe("legacy vanity-subdomains PersistentPostRun parity", () => { it.live("flushes telemetry and writes linked-project cache on success", () => { const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi({ - response: { status: 200, body: SAMPLE_GET }, - }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); const layer = runtimeWith({ @@ -277,4 +605,24 @@ describe("legacy vanity-subdomains integration", () => { expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); + + it.live("flushes telemetry and writes linked-project cache on failure", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 503, body: {} } }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = runtimeWith({ + out, + api, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index a1119ae31b..0466e726ec 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -49,6 +49,12 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { readonly projectRef: string; readonly featureKey: string; readonly statusCode: number; + /** + * Whether to fire the `cli_upgrade_suggested` analytics event when a gate is + * detected. Defaults to `true`. Pass `false` for Go call-sites that invoke + * `SuggestUpgradeOnError` without a following `TrackUpgradeSuggested` + * (e.g. `vanity-subdomains check-availability`), so telemetry stays 1:1 with Go. + */ readonly trackAnalytics?: boolean; }) { if (opts.statusCode < 400 || opts.statusCode >= 500) {