diff --git a/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md index f39c04d7c9..0298b977ae 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` — on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | ## API Routes @@ -22,26 +22,28 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success — network-restrictions status printed to stdout | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-200 (`LegacyNetworkRestrictionsGetUnexpectedStatusError`) | -| `1` | transport failure (`LegacyNetworkRestrictionsGetNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — network-restrictions status printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-200 (`LegacyNetworkRestrictionsGetUnexpectedStatusError`) | +| `1` | transport failure (`LegacyNetworkRestrictionsGetNetworkError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → ``) | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` (`--project-ref` → ``) | Matches `apps/cli-go/internal/restrictions/get/`. Go does not fire any custom telemetry event for this command. @@ -90,6 +92,8 @@ One `result` event whose `data` is the full response object. - The Go `--output` flag wins over the TS `--output-format` flag when both are provided. - `linked-project.json` is written **after** the project ref is resolved, regardless of whether the subsequent API call succeeds (mirrors Go's `PersistentPostRun`). -- `telemetry.json` is written on every invocation, including failures. +- `telemetry.json` is written on every invocation past the `--experimental` gate, including + failures. A closed gate writes nothing (Go's `PersistentPreRunE` fails before + `PersistentPostRun` runs). - Go's `restrictions/get` itself does not honor `--output`. The legacy TS port honors both `--output` and `--output-format` per the legacy CLAUDE.md output-parity rules. diff --git a/apps/cli/src/legacy/commands/network-restrictions/get/get.command.ts b/apps/cli/src/legacy/commands/network-restrictions/get/get.command.ts index b9105e5d74..3ec90f170c 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/get/get.command.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/get/get.command.ts @@ -1,9 +1,15 @@ +import { Effect } from "effect"; 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyNetworkRestrictionsGet } from "./get.handler.ts"; const config = { @@ -19,10 +25,23 @@ export const legacyNetworkRestrictionsGetCommand = Command.make("get", config).p Command.withDescription("Get the current network restrictions."), Command.withShortDescription("Get the current network restrictions"), Command.withHandler((flags) => - legacyNetworkRestrictionsGet(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `restrictionsCmd` (network-restrictions) behind `--experimental` in + // PersistentPreRunE (root.go:91-96) BEFORE the `IsManagementAPI` login check + // (root.go:105-109). `legacyManagementApiRuntimeLayer` eagerly resolves an + // access token as part of building its `LegacyPlatformApi` layer, so it must + // be provided AFTER the gate (inline here) rather than via `Command.provide` + // on the whole command — `Command.provide` would build the layer, and fail on + // a missing token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyNetworkRestrictionsGet(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["network-restrictions", "get"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["network-restrictions", "get"])), ); diff --git a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts new file mode 100644 index 0000000000..57acaad0b9 --- /dev/null +++ b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; + +import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; +import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyPlatformApi, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyNetworkRestrictionsCommand } from "./network-restrictions.command.ts"; + +// See postgres-config.experimental-gate.integration.test.ts for the full +// rationale: this proves `--experimental` is wired into the actual +// `.command.ts` handler pipeline AND runs before +// `legacyManagementApiRuntimeLayer`'s eager access-token resolution +// (Go's `IsExperimental` check precedes `IsManagementAPI` in +// `apps/cli-go/cmd/root.go:91-109`). + +const tempRoot = useLegacyTempWorkdir("supabase-network-restrictions-experimental-int-"); + +const testRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyNetworkRestrictionsCommand]), +); + +function setup() { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: { config: { dbAllowedCidrs: [] }, status: "applied" } }, + }); + const runtime = buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + // `RuntimeInfo` is ambient (not provided by `legacyManagementApiRuntimeLayer` + // itself), so the real `legacyCredentialsLayer` built inline inside the + // command for the "gate open" case resolves ITS `RuntimeInfo` from this + // layer. Point homeDir at this test's isolated tempRoot so the layer's + // file-based token fallback (`/.supabase/access-token`) can't pick + // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. + runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), + }); + const layer = Layer.mergeAll( + runtime, + CliOutput.layer(textCliOutputFormatter()), + // The "gate open" case reaches the real `legacyManagementApiRuntimeLayer` + // (provided inline inside the command, not by this test's mocked runtime), + // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, + // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the + // test would make these assertions non-deterministic. Wipe process.env + // down to just this and disable the keyring fallback. + processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: `${tempRoot.current}/.supabase`, + tracesDir: `${tempRoot.current}/.supabase/traces`, + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + identity: makeTelemetryIdentity(undefined), + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }), + ), + ); + return { layer, api }; +} + +describe("legacy network-restrictions experimental gate (Go PersistentPreRunE parity)", () => { + const leaves: ReadonlyArray<{ readonly name: string; readonly args: ReadonlyArray }> = [ + { name: "get", args: ["network-restrictions", "get"] }, + { name: "update", args: ["network-restrictions", "update"] }, + ]; + + for (const { name, args } of leaves) { + it.live( + `${name} fails with LegacyExperimentalRequiredError when --experimental is unset`, + () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })(args), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live(`${name} does not fail with the gate error once --experimental is set`, () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })([...args, "--experimental"]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = JSON.stringify(exit.cause); + expect(causeText).not.toContain("LegacyExperimentalRequiredError"); + expect(causeText).toContain("LegacyPlatformAuthRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + } +}); diff --git a/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md index f30b5ed87e..3eb0cd2630 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` — success and HTTP failure | -| `~/.supabase/telemetry.json` | JSON | always, via outermost `Effect.ensuring` — including CIDR validation failures | +| Path | Format | When | +| ------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — success and HTTP failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via outermost `Effect.ensuring` — including CIDR validation failures. Not written if closed. | ## API Routes @@ -28,28 +28,30 @@ when no `--db-allow-cidr` was supplied), matching Go's `&[]string{}` initializat ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------- | -| `0` | success — network restrictions updated and status printed to stdout | -| `1` | CIDR parse failure — `LegacyNetworkRestrictionsInvalidCidrError` (`failed to parse IP: `) | -| `1` | private-IP rejection — `LegacyNetworkRestrictionsPrivateIpError` (`private IP provided: `) | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-201 (POST) / non-200 (PATCH) — `LegacyNetworkRestrictionsUpdateUnexpectedStatusError` | -| `1` | transport failure — `LegacyNetworkRestrictionsUpdateNetworkError` | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success — network restrictions updated and status printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before CIDR validation/ref/API | +| `1` | CIDR parse failure — `LegacyNetworkRestrictionsInvalidCidrError` (`failed to parse IP: `) | +| `1` | private-IP rejection — `LegacyNetworkRestrictionsPrivateIpError` (`private IP provided: `) | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-201 (POST) / non-200 (PATCH) — `LegacyNetworkRestrictionsUpdateUnexpectedStatusError` | +| `1` | transport failure — `LegacyNetworkRestrictionsUpdateNetworkError` | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → ``) | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` (`--project-ref` → ``) | Matches `apps/cli-go/internal/restrictions/update/`. Go does not fire any custom telemetry event for this command. @@ -109,7 +111,8 @@ One `result` event whose `data` is the full response object. envelope (`{ dbAllowedCidrs, dbAllowedCidrsV6 }` → `{ add: { dbAllowedCidrs, dbAllowedCidrsV6 } }`). - `linked-project.json` writes after a successful project-ref resolution, regardless of whether the subsequent API call succeeds. -- `telemetry.json` writes on every invocation, including CIDR validation failures, ref - resolution failures, and API failures. +- `telemetry.json` writes on every invocation past the `--experimental` gate, including + CIDR validation failures, ref resolution failures, and API failures. A closed gate + writes nothing (Go's `PersistentPreRunE` fails before `PersistentPostRun` runs). - Go's `restrictions/update` itself does not honor `--output`. The legacy TS port honors both `--output` and `--output-format` per the legacy CLAUDE.md output-parity rules. diff --git a/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts b/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts index 047d17eff5..a2e3cc0667 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts @@ -1,9 +1,15 @@ +import { Effect } from "effect"; 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyNetworkRestrictionsUpdate } from "./update.handler.ts"; const config = { @@ -29,10 +35,24 @@ export const legacyNetworkRestrictionsUpdateCommand = Command.make("update", con Command.withDescription("Update network restrictions."), Command.withShortDescription("Update network restrictions"), Command.withHandler((flags) => - legacyNetworkRestrictionsUpdate(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `restrictionsCmd` (network-restrictions) behind `--experimental` in + // PersistentPreRunE (root.go:91-96) BEFORE the `IsManagementAPI` login check + // (root.go:105-109) — and before RunE, so the gate also precedes this command's + // local CIDR validation. `legacyManagementApiRuntimeLayer` eagerly resolves an + // access token as part of building its `LegacyPlatformApi` layer, so it must + // be provided AFTER the gate (inline here) rather than via `Command.provide` + // on the whole command — `Command.provide` would build the layer, and fail on + // a missing token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyNetworkRestrictionsUpdate(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["network-restrictions", "update"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["network-restrictions", "update"])), ); 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 aace0d1a0b..9ad8fa4b34 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 @@ -9,10 +9,10 @@ ## Files Written -| 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 | +| Path | Format | When | +| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes @@ -22,27 +22,30 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-2xx (`LegacyVanitySubdomainsActivateUnexpectedStatusError`) | -| `1` | transport failure (`LegacyVanitySubdomainsActivateNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | `--desired-subdomain` omitted (`LegacyDesiredSubdomainRequiredError`) — checked after gate/login/ref resolution; telemetry still fires | +| `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` | +| Event | When | Notable properties / groups | +| ----------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | +| `cli_upgrade_suggested` | gated 4xx responses only | `feature_key=vanity_subdomain`, `org_slug` | ## Output @@ -69,5 +72,15 @@ One `result` event with the full response object. ## Notes - 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. +- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), + even when the API call fails. A closed gate writes nothing (Go's `PersistentPreRunE` fails + before `PersistentPostRun` runs). - On gated 4xx responses this command prints an upgrade suggestion and fires `cli_upgrade_suggested`. +- `--desired-subdomain` is required in Go (`cmd/vanitySubdomains.go:67`) but cobra validates + required flags only after `PersistentPreRunE` (gate → login → ref resolution), so the TS flag + is optional at parse time and enforced in the handler with cobra's exact wording + (`required flag(s) "desired-subdomain" not set`). The gate/login/ref errors win over the + missing flag, matching Go's ordering, and — as in Go, where `PersistentPostRun` still runs — + telemetry and `linked-project.json` are written on this failure. An explicit empty value + (`--desired-subdomain ""`) passes the check and reaches the API, as cobra only requires the + flag to be set. 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 c17e24138c..2824618a25 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,9 +1,15 @@ +import { Effect } from "effect"; 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsActivate } from "./activate.handler.ts"; const config = { @@ -11,8 +17,15 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), + // Go marks this flag required (`cmd/vanitySubdomains.go:67`), but cobra + // validates required flags only AFTER `PersistentPreRunE` + // (`cobra@v1.10.2/command.go:985,1005`) — so the `--experimental` gate, + // login check, and project-ref resolution must all win over a missing + // `--desired-subdomain`. Optional at parse time; presence is enforced in + // the handler (after ref resolution) instead. desiredSubdomain: Flag.string("desired-subdomain").pipe( Flag.withDescription("The desired vanity subdomain to use for your Supabase project."), + Flag.optional, ), } as const; @@ -24,10 +37,23 @@ export const legacyVanitySubdomainsActivateCommand = Command.make("activate", co ), Command.withShortDescription("Activate a vanity subdomain"), Command.withHandler((flags) => - legacyVanitySubdomainsActivate(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyVanitySubdomainsActivate(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "activate"])), + ); + }).pipe(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 27877ce2c5..0b7af30cd8 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 @@ -15,6 +15,7 @@ import { } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { + LegacyDesiredSubdomainRequiredError, LegacyVanitySubdomainsActivateNetworkError, LegacyVanitySubdomainsActivateUnexpectedStatusError, } from "../vanity-subdomains.errors.ts"; @@ -40,6 +41,21 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + // Go validates the required `--desired-subdomain` only after + // `PersistentPreRunE` completes (gate → login → ref resolution, + // `cmd/root.go:93-117`; `cobra@v1.10.2/command.go:985,1005`), and + // `PersistentPostRun` still fires telemetry + the linked-project cache + // on that failure — hence this check sits inside both `Effect.ensuring` + // wrappers, after ref resolution. Cobra checks the flag was *changed*, + // not non-empty, so `--desired-subdomain ""` passes and reaches the API. + if (Option.isNone(flags.desiredSubdomain)) { + return yield* Effect.fail( + new LegacyDesiredSubdomainRequiredError({ + message: `required flag(s) "desired-subdomain" not set`, + }), + ); + } + const desiredSubdomain = flags.desiredSubdomain.value; const activating = output.format === "text" ? yield* output.task("Activating vanity subdomain...") @@ -47,7 +63,7 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain const response = yield* api.v1 .activateVanitySubdomainConfig({ ref, - vanity_subdomain: flags.desiredSubdomain, + vanity_subdomain: desiredSubdomain, }) .pipe( Effect.tapError(() => activating?.fail() ?? Effect.void), 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 9fc14225dc..180330a9c5 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 @@ -9,10 +9,10 @@ ## Files Written -| 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 | +| Path | Format | When | +| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes @@ -22,26 +22,29 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-2xx (`LegacyVanitySubdomainsCheckUnexpectedStatusError`) | -| `1` | transport failure (`LegacyVanitySubdomainsCheckNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | `--desired-subdomain` omitted (`LegacyDesiredSubdomainRequiredError`) — checked after gate/login/ref resolution; telemetry still fires | +| `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` | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | This command may print an upgrade suggestion for gated 4xx responses, but it does not fire `cli_upgrade_suggested`. @@ -71,4 +74,14 @@ One `result` event with the full response object. ## Notes - 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. +- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), + even when the API call fails. A closed gate writes nothing (Go's `PersistentPreRunE` fails + before `PersistentPostRun` runs). +- `--desired-subdomain` is required in Go (`cmd/vanitySubdomains.go:69`) but cobra validates + required flags only after `PersistentPreRunE` (gate → login → ref resolution), so the TS flag + is optional at parse time and enforced in the handler with cobra's exact wording + (`required flag(s) "desired-subdomain" not set`). The gate/login/ref errors win over the + missing flag, matching Go's ordering, and — as in Go, where `PersistentPostRun` still runs — + telemetry and `linked-project.json` are written on this failure. An explicit empty value + (`--desired-subdomain ""`) passes the check and reaches the API, as cobra only requires the + flag to be set. 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 934d6f92e3..a015585fab 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,9 +1,15 @@ +import { Effect } from "effect"; 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsCheckAvailability } from "./check-availability.handler.ts"; const config = { @@ -11,8 +17,15 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), + // Go marks this flag required (`cmd/vanitySubdomains.go:69`), but cobra + // validates required flags only AFTER `PersistentPreRunE` + // (`cobra@v1.10.2/command.go:985,1005`) — so the `--experimental` gate, + // login check, and project-ref resolution must all win over a missing + // `--desired-subdomain`. Optional at parse time; presence is enforced in + // the handler (after ref resolution) instead. desiredSubdomain: Flag.string("desired-subdomain").pipe( Flag.withDescription("The desired vanity subdomain to use for your Supabase project."), + Flag.optional, ), } as const; @@ -27,10 +40,25 @@ export const legacyVanitySubdomainsCheckAvailabilityCommand = Command.make( Command.withDescription("Checks if a desired subdomain is available for use."), Command.withShortDescription("Check subdomain availability"), Command.withHandler((flags) => - legacyVanitySubdomainsCheckAvailability(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyVanitySubdomainsCheckAvailability(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide( + legacyManagementApiRuntimeLayer(["vanity-subdomains", "check-availability"]), + ), + ); + }).pipe(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 d17836e693..f136f28298 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 @@ -15,6 +15,7 @@ import { } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { + LegacyDesiredSubdomainRequiredError, LegacyVanitySubdomainsCheckNetworkError, LegacyVanitySubdomainsCheckUnexpectedStatusError, } from "../vanity-subdomains.errors.ts"; @@ -41,6 +42,21 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + // Go validates the required `--desired-subdomain` only after + // `PersistentPreRunE` completes (gate → login → ref resolution, + // `cmd/root.go:93-117`; `cobra@v1.10.2/command.go:985,1005`), and + // `PersistentPostRun` still fires telemetry + the linked-project cache + // on that failure — hence this check sits inside both `Effect.ensuring` + // wrappers, after ref resolution. Cobra checks the flag was *changed*, + // not non-empty, so `--desired-subdomain ""` passes and reaches the API. + if (Option.isNone(flags.desiredSubdomain)) { + return yield* Effect.fail( + new LegacyDesiredSubdomainRequiredError({ + message: `required flag(s) "desired-subdomain" not set`, + }), + ); + } + const desiredSubdomain = flags.desiredSubdomain.value; const checking = output.format === "text" ? yield* output.task("Checking vanity subdomain availability...") @@ -48,7 +64,7 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( const response = yield* api.v1 .checkVanitySubdomainAvailability({ ref, - vanity_subdomain: flags.desiredSubdomain, + vanity_subdomain: desiredSubdomain, }) .pipe( Effect.tapError(() => checking?.fail() ?? Effect.void), @@ -97,7 +113,7 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( return; } - yield* output.raw(`Subdomain ${flags.desiredSubdomain} available: ${response.available}\n`); + yield* output.raw(`Subdomain ${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 5e67410233..0e9d2f1708 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 @@ -9,10 +9,10 @@ ## Files Written -| 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 | +| Path | Format | When | +| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes @@ -22,26 +22,28 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-2xx (`LegacyVanitySubdomainsDeleteUnexpectedStatusError`) | -| `1` | transport failure (`LegacyVanitySubdomainsDeleteNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `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` | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | ## Output @@ -68,4 +70,6 @@ One `result` event when the legacy `--output` flag is unset. ## Notes - 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. +- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), + even when the API call fails. A closed gate writes nothing (Go's `PersistentPreRunE` fails + before `PersistentPostRun` runs). 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 2a6200c66e..10a798500f 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,9 +1,15 @@ +import { Effect } from "effect"; 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsDelete } from "./delete.handler.ts"; const config = { @@ -21,10 +27,23 @@ export const legacyVanitySubdomainsDeleteCommand = Command.make("delete", config ), Command.withShortDescription("Delete the vanity subdomain"), Command.withHandler((flags) => - legacyVanitySubdomainsDelete(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyVanitySubdomainsDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "delete"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "delete"])), ); 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 73b367ec63..80a46f4360 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 @@ -9,10 +9,10 @@ ## Files Written -| 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 | +| Path | Format | When | +| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes @@ -22,26 +22,28 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-2xx (`LegacyVanitySubdomainsGetUnexpectedStatusError`) | -| `1` | transport failure (`LegacyVanitySubdomainsGetNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `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` | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | ## Output @@ -71,4 +73,6 @@ One `result` event with the full response object. ## Notes - 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. +- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), + even when the API call fails. A closed gate writes nothing (Go's `PersistentPreRunE` fails + before `PersistentPostRun` runs). 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 76c8e87f50..9dd58b19f3 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,9 +1,15 @@ +import { Effect } from "effect"; 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsGet } from "./get.handler.ts"; const config = { @@ -19,10 +25,23 @@ export const legacyVanitySubdomainsGetCommand = Command.make("get", config).pipe Command.withDescription("Get the current vanity subdomain."), Command.withShortDescription("Get the current vanity subdomain"), Command.withHandler((flags) => - legacyVanitySubdomainsGet(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyVanitySubdomainsGet(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "get"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "get"])), ); 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 index aa849f89ef..7a06a00976 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts @@ -1,5 +1,21 @@ import { Data } from "effect"; +/** + * Raised by the `activate` and `check-availability` handlers when + * `--desired-subdomain` is omitted. Go marks the flag required + * (`cmd/vanitySubdomains.go:67,69`) but cobra validates required flags only + * AFTER `PersistentPreRunE` (`cobra@v1.10.2/command.go:985,1005`) — i.e. after + * the `--experimental` gate, login check, and project-ref resolution + * (`cmd/root.go:93-117`) — so the flag is optional at parse time and enforced + * in the handler instead. Byte-matches cobra's required-flag wording + * (`command.go:1198`), same pattern as `LegacyProjectRefRequiredError`. + */ +export class LegacyDesiredSubdomainRequiredError extends Data.TaggedError( + "LegacyDesiredSubdomainRequiredError", +)<{ + readonly message: string; +}> {} + export class LegacyVanitySubdomainsGetNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsGetNetworkError", )<{ diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts new file mode 100644 index 0000000000..f87a5c8ebc --- /dev/null +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; + +import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; +import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyPlatformApi, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyVanitySubdomainsCommand } from "./vanity-subdomains.command.ts"; + +// See postgres-config.experimental-gate.integration.test.ts for the full +// rationale: this proves `--experimental` is wired into the actual +// `.command.ts` handler pipeline AND runs before +// `legacyManagementApiRuntimeLayer`'s eager access-token resolution +// (Go's `IsExperimental` check precedes `IsManagementAPI` in +// `apps/cli-go/cmd/root.go:91-109`). + +const tempRoot = useLegacyTempWorkdir("supabase-vanity-subdomains-experimental-int-"); + +const testRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyVanitySubdomainsCommand]), +); + +function setup() { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: { status: "not-used" } }, + }); + const runtime = buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + // `RuntimeInfo` is ambient (not provided by `legacyManagementApiRuntimeLayer` + // itself), so the real `legacyCredentialsLayer` built inline inside the + // command for the "gate open" case resolves ITS `RuntimeInfo` from this + // layer. Point homeDir at this test's isolated tempRoot so the layer's + // file-based token fallback (`/.supabase/access-token`) can't pick + // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. + runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), + }); + const layer = Layer.mergeAll( + runtime, + CliOutput.layer(textCliOutputFormatter()), + // The "gate open" case reaches the real `legacyManagementApiRuntimeLayer` + // (provided inline inside the command, not by this test's mocked runtime), + // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, + // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the + // test would make these assertions non-deterministic. Wipe process.env + // down to just this and disable the keyring fallback. + processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: `${tempRoot.current}/.supabase`, + tracesDir: `${tempRoot.current}/.supabase/traces`, + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + identity: makeTelemetryIdentity(undefined), + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }), + ), + ); + return { layer, api }; +} + +describe("legacy vanity-subdomains experimental gate (Go PersistentPreRunE parity)", () => { + // `check-availability` and `activate` deliberately OMIT `--desired-subdomain`: + // Go marks it required (`cmd/vanitySubdomains.go:67,69`) but cobra validates + // required flags only after `PersistentPreRunE` (`cobra@v1.10.2 + // command.go:985,1005`), so the gate error must win when both flags are + // missing. The TS flag is optional at parse time (enforced in the handler) + // precisely so this ordering holds — these cases assert it end to end. + const leaves: ReadonlyArray<{ readonly name: string; readonly args: ReadonlyArray }> = [ + { name: "get", args: ["vanity-subdomains", "get"] }, + { + name: "check-availability", + args: ["vanity-subdomains", "check-availability"], + }, + { + name: "activate", + args: ["vanity-subdomains", "activate"], + }, + { name: "delete", args: ["vanity-subdomains", "delete"] }, + ]; + + for (const { name, args } of leaves) { + it.live( + `${name} fails with LegacyExperimentalRequiredError when --experimental is unset`, + () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })(args), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live(`${name} does not fail with the gate error once --experimental is set`, () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })([...args, "--experimental"]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = JSON.stringify(exit.cause); + expect(causeText).not.toContain("LegacyExperimentalRequiredError"); + expect(causeText).toContain("LegacyPlatformAuthRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + } +}); 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 40c484acfe..62019cc4b9 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 @@ -249,7 +249,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toBe("Subdomain example.com available: true\n"); expect(api.requests[0]?.method).toBe("POST"); @@ -268,7 +268,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain('"available": true'); }).pipe(Effect.provide(layer)); @@ -282,7 +282,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain("available: true"); }).pipe(Effect.provide(layer)); @@ -296,7 +296,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toBe("Available = true\n\n"); }).pipe(Effect.provide(layer)); @@ -310,7 +310,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain('AVAILABLE="true"'); }).pipe(Effect.provide(layer)); @@ -324,7 +324,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ available: true }); @@ -341,7 +341,7 @@ describe("legacy vanity-subdomains check-availability", () => { const exit = yield* Effect.exit( legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }), ); expect(Exit.isFailure(exit)).toBe(true); @@ -360,7 +360,7 @@ describe("legacy vanity-subdomains check-availability", () => { const exit = yield* Effect.exit( legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }), ); expect(Exit.isFailure(exit)).toBe(true); @@ -371,6 +371,44 @@ describe("legacy vanity-subdomains check-availability", () => { } }).pipe(Effect.provide(layer)); }); + + // Go marks --desired-subdomain required but cobra validates it only after + // PersistentPreRunE (gate → login → ref resolution), so the handler enforces + // it with cobra's exact wording after the ref resolves. + it.live("fails with cobra's required-flag error when --desired-subdomain is omitted", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: Option.none(), + }), + ); + expect(error._tag).toBe("LegacyDesiredSubdomainRequiredError"); + expect(error.message).toBe('required flag(s) "desired-subdomain" not set'); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + // Cobra's MarkFlagRequired checks the flag was *changed*, not non-empty — + // an explicit empty value must pass the check and reach the API. + it.live("passes an explicit empty --desired-subdomain through to the API", () => { + 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: Option.some(""), + }); + expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "" }); + expect(out.stdoutText).toBe("Subdomain available: true\n"); + }).pipe(Effect.provide(layer)); + }); }); describe("legacy vanity-subdomains activate", () => { @@ -382,7 +420,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toBe("Activated vanity subdomain at example.com\n"); expect(api.requests[0]?.method).toBe("POST"); @@ -401,7 +439,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain('"custom_domain": "example.com"'); }).pipe(Effect.provide(layer)); @@ -415,7 +453,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain("custom_domain: example.com"); }).pipe(Effect.provide(layer)); @@ -429,7 +467,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toBe('CustomDomain = "example.com"\n\n'); }).pipe(Effect.provide(layer)); @@ -443,7 +481,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain('CUSTOM_DOMAIN="example.com"'); }).pipe(Effect.provide(layer)); @@ -457,7 +495,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ custom_domain: "example.com" }); @@ -474,7 +512,7 @@ describe("legacy vanity-subdomains activate", () => { const exit = yield* Effect.exit( legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }), ); expect(Exit.isFailure(exit)).toBe(true); @@ -499,7 +537,7 @@ describe("legacy vanity-subdomains activate", () => { const exit = yield* Effect.exit( legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }), ); expect(Exit.isFailure(exit)).toBe(true); @@ -512,6 +550,43 @@ describe("legacy vanity-subdomains activate", () => { expect(analytics.captured).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + // Go marks --desired-subdomain required but cobra validates it only after + // PersistentPreRunE (gate → login → ref resolution), so the handler enforces + // it with cobra's exact wording after the ref resolves. + it.live("fails with cobra's required-flag error when --desired-subdomain is omitted", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: Option.none(), + }), + ); + expect(error._tag).toBe("LegacyDesiredSubdomainRequiredError"); + expect(error.message).toBe('required flag(s) "desired-subdomain" not set'); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + // Cobra's MarkFlagRequired checks the flag was *changed*, not non-empty — + // an explicit empty value must pass the check and reach the API. + it.live("passes an explicit empty --desired-subdomain through to the API", () => { + 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: Option.some(""), + }); + expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "" }); + }).pipe(Effect.provide(layer)); + }); }); describe("legacy vanity-subdomains delete", () => { @@ -625,4 +700,36 @@ describe("legacy vanity-subdomains PersistentPostRun parity", () => { expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); + + // In Go the missing-required-flag failure happens AFTER PersistentPreRunE + // completes, so PersistentPostRun still fires telemetry and writes the + // linked-project cache (`cmd/root.go:171-181,212-233`). The handler-level + // check sits inside both `Effect.ensuring` wrappers to match. + it.live( + "flushes telemetry and writes linked-project cache on a missing --desired-subdomain", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = runtimeWith({ + out, + api, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: Option.none(), + }), + ); + expect(error._tag).toBe("LegacyDesiredSubdomainRequiredError"); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); });