From 91b4bc8ab84c715905bdf0daffc3a79e135e4ba0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 29 May 2026 13:15:17 +0100 Subject: [PATCH 1/2] feat(cli): port domains commands to native TypeScript Replace the Phase 0 Go-proxy handlers for `supabase domains` (create/get/reverify/activate/delete) with native Effect implementations in the legacy shell, as a strict 1:1 port of the Go CLI. - Each subcommand resolves the project ref, calls the typed Management API custom-hostname endpoint, and mirrors Go's PrintStatus output (status text to stderr, structured output to stdout when -o != pretty) across all --output-format and -o modes. PersistentPostRun parity (linked-project cache + telemetry flush) is preserved on success and failure. - `create` performs the Cloudflare DNS-over-HTTPS CNAME pre-check before initializing, short-circuiting before any POST on failure. - Restore the deprecated `--include-raw-output` flag (was missing from the proxy port); it forces `-o json` when -o is unset/pretty and is inert on delete, matching Go. - Add `projectHost` to LegacyCliConfig, sourced from the single-source legacy-profile.ts map (also consumed by `branches get`); add the `snap` built-in profile so CNAME targets resolve correctly. - Consolidate the five per-subcommand SIDE_EFFECTS.md files into one group-level document and flip the porting-status rows to `ported`. Unit + integration tests cover the PrintStatus state machine, the CNAME verifier, every output mode, and all error paths. --- apps/cli/docs/go-cli-porting-status.md | 10 +- .../legacy-platform-api.layer.unit.test.ts | 1 + .../legacy/commands/domains/SIDE_EFFECTS.md | 98 ++++++++ .../commands/domains/activate/SIDE_EFFECTS.md | 42 ---- .../domains/activate/activate.command.ts | 15 +- .../domains/activate/activate.handler.ts | 36 ++- .../activate/activate.integration.test.ts | 138 ++++++++++ .../commands/domains/create/SIDE_EFFECTS.md | 43 ---- .../commands/domains/create/create.command.ts | 15 +- .../commands/domains/create/create.handler.ts | 55 +++- .../domains/create/create.integration.test.ts | 236 ++++++++++++++++++ .../commands/domains/delete/SIDE_EFFECTS.md | 39 --- .../commands/domains/delete/delete.command.ts | 15 +- .../commands/domains/delete/delete.handler.ts | 49 +++- .../domains/delete/delete.integration.test.ts | 128 ++++++++++ .../legacy/commands/domains/domains.cname.ts | 94 +++++++ .../domains/domains.cname.unit.test.ts | 72 ++++++ .../legacy/commands/domains/domains.emit.ts | 66 +++++ .../legacy/commands/domains/domains.errors.ts | 50 ++++ .../legacy/commands/domains/domains.format.ts | 87 +++++++ .../domains/domains.format.unit.test.ts | 203 +++++++++++++++ .../commands/domains/get/SIDE_EFFECTS.md | 39 --- .../commands/domains/get/get.command.ts | 15 +- .../commands/domains/get/get.handler.ts | 40 ++- .../domains/get/get.integration.test.ts | 197 +++++++++++++++ .../commands/domains/reverify/SIDE_EFFECTS.md | 40 --- .../domains/reverify/reverify.command.ts | 15 +- .../domains/reverify/reverify.handler.ts | 36 ++- .../reverify/reverify.integration.test.ts | 137 ++++++++++ .../legacy/config/legacy-cli-config.layer.ts | 54 ++-- .../legacy-cli-config.layer.unit.test.ts | 20 ++ .../config/legacy-cli-config.service.ts | 9 +- .../legacy-project-ref.layer.unit.test.ts | 1 + apps/cli/src/legacy/shared/legacy-profile.ts | 1 + .../legacy/shared/legacy-profile.unit.test.ts | 1 + apps/cli/tests/helpers/legacy-mocks.ts | 2 + 36 files changed, 1838 insertions(+), 261 deletions(-) create mode 100644 apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md delete mode 100644 apps/cli/src/legacy/commands/domains/activate/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts delete mode 100644 apps/cli/src/legacy/commands/domains/create/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/domains/create/create.integration.test.ts delete mode 100644 apps/cli/src/legacy/commands/domains/delete/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/domains/delete/delete.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/domains/domains.cname.ts create mode 100644 apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/domains/domains.emit.ts create mode 100644 apps/cli/src/legacy/commands/domains/domains.errors.ts create mode 100644 apps/cli/src/legacy/commands/domains/domains.format.ts create mode 100644 apps/cli/src/legacy/commands/domains/domains.format.unit.test.ts delete mode 100644 apps/cli/src/legacy/commands/domains/get/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/domains/get/get.integration.test.ts delete mode 100644 apps/cli/src/legacy/commands/domains/reverify/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 7e8592cb43..d13d150b38 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -241,11 +241,11 @@ Legend: | `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | | `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | | `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | -| `domains create` | `wrapped` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | -| `domains get` | `wrapped` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | -| `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) | +| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | +| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | +| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | +| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | +| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/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) | diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index 1047501322..243036c1e2 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -22,6 +22,7 @@ function mockCliConfig(opts: { accessToken?: string; apiUrl?: string; userAgent? return Layer.succeed(LegacyCliConfig, { profile: "supabase", apiUrl: opts.apiUrl ?? "https://api.supabase.com", + projectHost: "supabase.co", accessToken: opts.accessToken === undefined ? Option.none() : Option.some(Redacted.make(opts.accessToken)), projectId: Option.none(), diff --git a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md new file mode 100644 index 0000000000..7cc6c40b44 --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md @@ -0,0 +1,98 @@ +# `supabase domains [create|get|reverify|activate|delete]` + +Custom hostname management. Every subcommand resolves a project ref and calls a +single Management API custom-hostname endpoint. `create` additionally performs a +Cloudflare DNS-over-HTTPS CNAME pre-check. + +## Files Read + +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | + +## Files Written + +| Path | Format | When | +| ------------------------------------------------ | ------ | -------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | always (PersistentPostRun), after the ref resolves | +| `~/.supabase/telemetry.json` | JSON | always (PersistentPostRun), success or failure | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------------------------- | ------------ | --------------------------- | -------------------------------------------------------------------------------------------- | +| `GET` | `https://1.1.1.1/dns-query?name=&type=5` | none | none | `{Answer: [{type, data}]}` — first CNAME answer (`create` only, before the POST) | +| `POST` | `/v1/projects/{ref}/custom-hostname/initialize` | Bearer token | `{custom_hostname: string}` | 201 → `{status, custom_hostname, data: {result: {ssl, custom_origin_server, …}}}` (`create`) | +| `GET` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → same response shape (`get`) | +| `POST` | `/v1/projects/{ref}/custom-hostname/reverify` | Bearer token | none | 201 → same response shape (`reverify`) | +| `POST` | `/v1/projects/{ref}/custom-hostname/activate` | Bearer token | none | 201 → same response shape (`activate`) | +| `DELETE` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → empty/void body (`delete`) | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to linked-project file) | +| `SUPABASE_PROFILE` | built-in profile name or path to a YAML profile | no (defaults to `supabase`; sets API URL + project host) | + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------------- | +| `0` | success | +| `1` | project ref cannot be resolved / is malformed | +| `1` | `create`: hostname has no matching CNAME record (Cloudflare DNS pre-check) | +| `1` | API error — unexpected (non-201/200) response from the custom-hostname endpoint | +| `1` | network / connection failure | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (all redacted — Go marks no `domains` flag telemetry-safe) | + +No custom events: the Go `internal/hostnames` package emits no `phtelemetry.*` calls. + +## Output + +`PrintStatus` text is written to **stderr** in every Go output mode; structured +output (when `-o != pretty`) is written to **stdout**. `delete` only prints a +fixed success line to stderr and ignores `-o`. + +### `--output-format text` (Go CLI compatible) + +stderr carries the status message for the hostname's current state, e.g.: + +``` +Custom hostname configuration not started. +``` + +`delete` prints `Deleted custom hostname config successfully.` to stderr. stdout is empty in text mode. + +### `--output-format json` + +Single JSON object (the full custom-hostname response) emitted via `output.success`. +`delete` emits `{}` with the success message. + +### `--output-format stream-json` + +A `result` event carrying the custom-hostname response object. + +### Go `-o {json,yaml,toml,env}` + +When the Go `--output`/`-o` flag is set (or `--include-raw-output` forces `json`), +the full response is encoded to stdout in that format, and the status text is still +written to stderr. `delete` ignores `-o`. + +## Notes + +- `--custom-hostname` is required for `create`. +- `create` validates the CNAME via Cloudflare DNS-over-HTTPS (`https://1.1.1.1`, 10s timeout) before initializing; on failure it short-circuits before any POST. +- All subcommands resolve the ref via `--project-ref` → `PROJECT_ID` env → linked-project file, matching Go. +- The project-ref fallback env var is `SUPABASE_PROJECT_ID`, matching Go (Go calls `viper.GetString("PROJECT_ID")` under `viper.SetEnvPrefix("SUPABASE")`, which resolves to the `SUPABASE_PROJECT_ID` environment variable). +- **Documented divergences from Go (intentional):** + - `--include-raw-output` is declared as a normal boolean **on each subcommand** (Go declares it as a persistent flag on the `domains` group). Two consequences: (a) it must appear after the subcommand name (`domains get --include-raw-output`) rather than before it (`domains --include-raw-output get`), matching how `--project-ref` is already handled shell-wide; (b) it cannot reproduce Cobra's help-hiding or the `Flag --include-raw-output has been deprecated` stderr warning, which Effect CLI has no hook for. It still reproduces the behavioral effect (forces `-o json` when `-o` is unset/pretty); on `delete` it is inert, matching Go. + - `-o json|yaml|toml|env` encode the decoded snake_case response, not Go's PascalCase struct keys (consistent with `backups list` / `sso add`). + - The degenerate `validation_records != 1` status message approximates Go's `%+v` struct dump (which embeds a non-deterministic pointer address). diff --git a/apps/cli/src/legacy/commands/domains/activate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/activate/SIDE_EFFECTS.md deleted file mode 100644 index 7add1ec33c..0000000000 --- a/apps/cli/src/legacy/commands/domains/activate/SIDE_EFFECTS.md +++ /dev/null @@ -1,42 +0,0 @@ -# `supabase domains activate` - -## Files Read - -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------- | -| `POST` | `/v1/projects/{ref}/custom-hostname/activate` | Bearer token | none | `{custom_hostname, status, data: {result: {ownership_verification, ssl}}}` | - -## 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`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------------- | -| `0` | success — activation result printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from activate endpoint | -| `1` | network / connection failure | - -## Notes - -- After activation, the project responds to requests on the custom hostname. -- Auth services will no longer function on the Supabase-provisioned subdomain after activation. -- This is a destructive, irreversible operation — proceed with care. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). diff --git a/apps/cli/src/legacy/commands/domains/activate/activate.command.ts b/apps/cli/src/legacy/commands/domains/activate/activate.command.ts index bc7b331f3e..331ebd49f3 100644 --- a/apps/cli/src/legacy/commands/domains/activate/activate.command.ts +++ b/apps/cli/src/legacy/commands/domains/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 { legacyDomainsActivate } from "./activate.handler.ts"; const config = { @@ -7,6 +11,9 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), + includeRawOutput: Flag.boolean("include-raw-output").pipe( + Flag.withDescription("(Deprecated) use -o json instead."), + ), } as const; export type LegacyDomainsActivateFlags = CliCommand.Command.Config.Infer; @@ -22,5 +29,11 @@ export const legacyDomainsActivateCommand = Command.make("activate", config).pip description: "Activate the custom hostname for a project", }, ]), - Command.withHandler((flags) => legacyDomainsActivate(flags)), + Command.withHandler((flags) => + legacyDomainsActivate(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["domains", "activate"])), ); diff --git a/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts index 713838414a..534050089c 100644 --- a/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts @@ -1,12 +1,36 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect } from "effect"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { emitLegacyHostnameResult } from "../domains.emit.ts"; +import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; import type { LegacyDomainsActivateFlags } from "./activate.command.ts"; +const mapActivateError = mapLegacyDomainsHttpError("activate"); + export const legacyDomainsActivate = Effect.fn("legacy.domains.activate")(function* ( flags: LegacyDomainsActivateFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["domains", "activate"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const activating = + output.format === "text" ? yield* output.task("Activating custom hostname...") : undefined; + const response = yield* api.v1.activateCustomHostname({ ref }).pipe( + Effect.tapError(() => activating?.fail() ?? Effect.void), + Effect.catch(mapActivateError), + ); + yield* activating?.clear() ?? Effect.void; + + yield* emitLegacyHostnameResult(response, flags.includeRawOutput); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts b/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts new file mode 100644 index 0000000000..950aeb2a95 --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts @@ -0,0 +1,138 @@ +import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyDomainsActivate } from "./activate.handler.ts"; + +const HOSTNAME_RESPONSE: typeof V1GetHostnameConfigOutput.Type = { + status: "5_services_reconfigured", + custom_hostname: "shop.acme.dev", + data: { + success: true, + errors: [], + messages: [], + result: { + id: "id-1", + hostname: "shop.acme.dev", + ssl: { status: "active", validation_records: [] }, + ownership_verification: { type: "txt", name: "n", value: "v" }, + custom_origin_server: "abc.supabase.co", + status: "active", + }, + }, +}; + +type GoOutput = "env" | "pretty" | "json" | "toml" | "yaml"; + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: GoOutput; + readonly status?: number; + readonly network?: "fail"; +} + +const tempRoot = useLegacyTempWorkdir("supabase-domains-activate-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 201, body: HOSTNAME_RESPONSE }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }); + return { layer, out, api, telemetry, linkedProjectCache }; +} + +const baseFlags = { projectRef: Option.none(), includeRawOutput: false }; + +describe("legacy domains activate integration", () => { + it.live("prints the completion status to stderr in text mode", () => { + const { layer, out, api, telemetry, linkedProjectCache } = setup(); + return Effect.gen(function* () { + yield* legacyDomainsActivate(baseFlags); + expect(out.stderrText).toContain( + "Custom hostname setup completed. Project is now accessible at shop.acme.dev.", + ); + expect(out.stdoutText).toBe(""); + expect(api.requests[0]?.url).toContain("/custom-hostname/activate"); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured success object for --output-format json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyDomainsActivate(baseFlags); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ status: "5_services_reconfigured" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits indented Go JSON to stdout for -o json", () => { + const { layer, out } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyDomainsActivate(baseFlags); + expect(out.stdoutText.startsWith("{")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("forces Go JSON output when --include-raw-output is set", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyDomainsActivate({ projectRef: Option.none(), includeRawOutput: true }); + expect(out.stdoutText.startsWith("{")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyDomainsUnexpectedStatusError on HTTP 503", () => { + const { layer, telemetry } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsActivate(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("unexpected activate hostname status 503"); + } + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyDomainsNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsActivate(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to activate custom hostname"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("maps an HTTP error without a spinner in json mode", () => { + const { layer, out } = setup({ format: "json", status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsActivate(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.progressEvents).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/domains/create/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/create/SIDE_EFFECTS.md deleted file mode 100644 index c9c72d0a2e..0000000000 --- a/apps/cli/src/legacy/commands/domains/create/SIDE_EFFECTS.md +++ /dev/null @@ -1,43 +0,0 @@ -# `supabase domains create` - -## Files Read - -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------ | ------------ | --------------------------- | -------------------------------------------------------------------------- | -| `POST` | `/v1/projects/{ref}/custom-hostname` | Bearer token | `{custom_hostname: string}` | `{custom_hostname, status, data: {result: {ownership_verification, ssl}}}` | - -## 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`) | - -## Exit Codes - -| Code | Condition | -| ---- | -------------------------------------------------------------- | -| `0` | success — custom hostname created and config printed | -| `1` | authentication error — no valid token found | -| `1` | validation error — hostname does not have a valid CNAME record | -| `1` | API error — non-2xx response from custom-hostname endpoint | -| `1` | network / connection failure | - -## Notes - -- `--custom-hostname` flag is required. -- Before calling the API, validates that the hostname has a CNAME record pointing to the project's subdomain via Cloudflare DNS (1.1.1.1). -- `--include-raw-output` (deprecated, use `-o json` instead) includes the raw API response. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). diff --git a/apps/cli/src/legacy/commands/domains/create/create.command.ts b/apps/cli/src/legacy/commands/domains/create/create.command.ts index 1f47d7d261..5824fac7ee 100644 --- a/apps/cli/src/legacy/commands/domains/create/create.command.ts +++ b/apps/cli/src/legacy/commands/domains/create/create.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 { legacyDomainsCreate } from "./create.handler.ts"; const config = { @@ -10,6 +14,9 @@ const config = { customHostname: Flag.string("custom-hostname").pipe( Flag.withDescription("The custom hostname to use for your Supabase project."), ), + includeRawOutput: Flag.boolean("include-raw-output").pipe( + Flag.withDescription("(Deprecated) use -o json instead."), + ), } as const; export type LegacyDomainsCreateFlags = CliCommand.Command.Config.Infer; @@ -26,5 +33,11 @@ export const legacyDomainsCreateCommand = Command.make("create", config).pipe( description: "Create a custom hostname for a project", }, ]), - Command.withHandler((flags) => legacyDomainsCreate(flags)), + Command.withHandler((flags) => + legacyDomainsCreate(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["domains", "create"])), ); diff --git a/apps/cli/src/legacy/commands/domains/create/create.handler.ts b/apps/cli/src/legacy/commands/domains/create/create.handler.ts index b7ee816068..8d3a10b585 100644 --- a/apps/cli/src/legacy/commands/domains/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/domains/create/create.handler.ts @@ -1,13 +1,54 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { verifyLegacyCname } from "../domains.cname.ts"; +import { emitLegacyHostnameResult } from "../domains.emit.ts"; +import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; import type { LegacyDomainsCreateFlags } from "./create.command.ts"; +const mapCreateError = mapLegacyDomainsHttpError("create"); + export const legacyDomainsCreate = Effect.fn("legacy.domains.create")(function* ( flags: LegacyDomainsCreateFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["domains", "create"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - args.push("--custom-hostname", flags.customHostname); - yield* proxy.exec(args); + const output = yield* Output; + const httpClient = yield* HttpClient.HttpClient; + const api = yield* LegacyPlatformApi; + const cliConfig = yield* LegacyCliConfig; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const ref = yield* resolver.resolve(flags.projectRef); + + // Mirror Go's PersistentPostRun (`apps/cli-go/cmd/root.go:176`): write the + // linked-project cache and persist the telemetry state file on success and failure. + yield* Effect.gen(function* () { + // 1. Verify the CNAME first (Go step 1) — short-circuits before any POST. + yield* verifyLegacyCname({ + httpClient, + projectHost: cliConfig.projectHost, + ref, + customHostname: flags.customHostname, + }); + + // 2. Initialize the custom hostname. + const creating = + output.format === "text" ? yield* output.task("Creating custom hostname...") : undefined; + const response = yield* api.v1 + .updateHostnameConfig({ ref, custom_hostname: flags.customHostname }) + .pipe( + Effect.tapError(() => creating?.fail() ?? Effect.void), + Effect.catch(mapCreateError), + ); + yield* creating?.clear() ?? Effect.void; + + yield* emitLegacyHostnameResult(response, flags.includeRawOutput); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts new file mode 100644 index 0000000000..f6f4b9d790 --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts @@ -0,0 +1,236 @@ +import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + buildLegacyTestRuntime, + legacyJsonResponse, + legacyTransportFailure, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyDomainsCreate } from "./create.handler.ts"; + +const CUSTOM_HOSTNAME = "shop.acme.dev"; +const EXPECTED_CNAME = `${LEGACY_VALID_REF}.supabase.co.`; + +const HOSTNAME_RESPONSE: typeof V1GetHostnameConfigOutput.Type = { + status: "4_origin_setup_completed", + custom_hostname: CUSTOM_HOSTNAME, + data: { + success: true, + errors: [], + messages: [], + result: { + id: "id-1", + hostname: CUSTOM_HOSTNAME, + ssl: { status: "active", validation_records: [] }, + ownership_verification: { type: "txt", name: "n", value: "v" }, + custom_origin_server: "abc.supabase.co", + status: "active", + }, + }, +}; + +type GoOutput = "env" | "pretty" | "json" | "toml" | "yaml"; + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: GoOutput; + readonly cname?: "ok" | "transport-fail" | "no-cname" | "mismatch" | "status-error"; + readonly apiStatus?: number; + readonly apiNetwork?: "fail"; +} + +const tempRoot = useLegacyTempWorkdir("supabase-domains-create-int-"); + +function setup(opts: SetupOpts = {}) { + const cname = opts.cname ?? "ok"; + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + if (request.url.includes("1.1.1.1")) { + if (cname === "transport-fail") { + return Effect.fail(legacyTransportFailure(request)); + } + if (cname === "status-error") { + return Effect.succeed(legacyJsonResponse(request, 500, { error: "dns down" })); + } + const answer = + cname === "no-cname" + ? [{ type: 1, data: "1.2.3.4" }] + : [{ type: 5, data: cname === "mismatch" ? "wrong.example.com." : EXPECTED_CNAME }]; + return Effect.succeed(legacyJsonResponse(request, 200, { Answer: answer })); + } + if (opts.apiNetwork === "fail") { + return Effect.fail(legacyTransportFailure(request)); + } + return Effect.succeed(legacyJsonResponse(request, opts.apiStatus ?? 201, HOSTNAME_RESPONSE)); + }, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }); + return { layer, out, api, telemetry, linkedProjectCache }; +} + +function flags(over: Partial<{ includeRawOutput: boolean }> = {}) { + return { + projectRef: Option.none(), + customHostname: CUSTOM_HOSTNAME, + includeRawOutput: over.includeRawOutput ?? false, + }; +} + +function postedToInitialize(api: { requests: ReadonlyArray<{ url: string }> }): boolean { + return api.requests.some((r) => r.url.includes("/custom-hostname/initialize")); +} + +describe("legacy domains create integration", () => { + it.live("verifies the CNAME, creates the hostname, and prints status to stderr", () => { + const { layer, out, api, telemetry, linkedProjectCache } = setup(); + return Effect.gen(function* () { + yield* legacyDomainsCreate(flags()); + expect(out.stderrText).toContain("Custom hostname configuration complete"); + expect(out.stdoutText).toBe(""); + expect(postedToInitialize(api)).toBe(true); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails before any POST when the CNAME lookup transport fails", () => { + const { layer, api } = setup({ cname: "transport-fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsCreate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyDomainsCnameError"); + expect(json).toContain("but it failed to resolve"); + } + expect(postedToInitialize(api)).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails before any POST when no CNAME record resolves", () => { + const { layer, api } = setup({ cname: "no-cname" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsCreate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to locate appropriate CNAME record"); + } + expect(postedToInitialize(api)).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails before any POST when the CNAME points elsewhere", () => { + const { layer, api, telemetry } = setup({ cname: "mismatch" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsCreate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "but it is currently set to 'wrong.example.com.'", + ); + } + expect(postedToInitialize(api)).toBe(false); + // PersistentPostRun still fires even when the CNAME check fails. + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails before any POST when the DNS query returns a non-200 status", () => { + const { layer, api } = setup({ cname: "status-error" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsCreate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("but it failed to resolve"); + expect(json).toContain("unexpected DNS query status 500"); + } + expect(postedToInitialize(api)).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits indented Go JSON to stdout for -o json", () => { + const { layer, out } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyDomainsCreate(flags()); + expect(out.stdoutText.startsWith("{")).toBe(true); + expect(out.stderrText).toContain("Custom hostname configuration complete"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits YAML to stdout for -o yaml", () => { + const { layer, out } = setup({ goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyDomainsCreate(flags()); + expect(out.stdoutText).toContain(`custom_hostname: ${CUSTOM_HOSTNAME}`); + expect(out.stderrText).toContain("Custom hostname configuration complete"); + }).pipe(Effect.provide(layer)); + }); + + it.live("forces Go JSON output when --include-raw-output is set", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyDomainsCreate(flags({ includeRawOutput: true })); + expect(out.stdoutText.startsWith("{")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured success object for --output-format json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyDomainsCreate(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ custom_hostname: CUSTOM_HOSTNAME }); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyDomainsUnexpectedStatusError when the API returns 503", () => { + const { layer } = setup({ apiStatus: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsCreate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("unexpected create hostname status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyDomainsNetworkError when the create request fails", () => { + const { layer } = setup({ apiNetwork: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsCreate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to create custom hostname"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("maps an API error without a spinner in json mode", () => { + const { layer, out } = setup({ format: "json", apiStatus: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsCreate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.progressEvents).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/domains/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/delete/SIDE_EFFECTS.md deleted file mode 100644 index 4a36a4599c..0000000000 --- a/apps/cli/src/legacy/commands/domains/delete/SIDE_EFFECTS.md +++ /dev/null @@ -1,39 +0,0 @@ -# `supabase domains delete` - -## Files Read - -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------ | ------------ | ------------ | --------------------------- | -| `DELETE` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | `{custom_hostname, status}` | - -## 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`) | - -## Exit Codes - -| Code | Condition | -| ---- | ---------------------------------------------------------- | -| `0` | success — custom hostname config deleted | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from custom-hostname endpoint | -| `1` | network / connection failure | - -## Notes - -- Requires `--project-ref` or a linked project (`.supabase/config.json`). diff --git a/apps/cli/src/legacy/commands/domains/delete/delete.command.ts b/apps/cli/src/legacy/commands/domains/delete/delete.command.ts index 22b450c932..05eeed92b6 100644 --- a/apps/cli/src/legacy/commands/domains/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/domains/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 { legacyDomainsDelete } from "./delete.handler.ts"; const config = { @@ -7,6 +11,9 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), + includeRawOutput: Flag.boolean("include-raw-output").pipe( + Flag.withDescription("(Deprecated) use -o json instead."), + ), } as const; export type LegacyDomainsDeleteFlags = CliCommand.Command.Config.Infer; @@ -20,5 +27,11 @@ export const legacyDomainsDeleteCommand = Command.make("delete", config).pipe( description: "Delete the custom hostname config for a project", }, ]), - Command.withHandler((flags) => legacyDomainsDelete(flags)), + Command.withHandler((flags) => + legacyDomainsDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["domains", "delete"])), ); diff --git a/apps/cli/src/legacy/commands/domains/delete/delete.handler.ts b/apps/cli/src/legacy/commands/domains/delete/delete.handler.ts index 63213496bc..5fca7c28a4 100644 --- a/apps/cli/src/legacy/commands/domains/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/domains/delete/delete.handler.ts @@ -1,12 +1,49 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect } from "effect"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; import type { LegacyDomainsDeleteFlags } from "./delete.command.ts"; +const mapDeleteError = mapLegacyDomainsHttpError("delete"); + +const DELETE_SUCCESS_MESSAGE = "Deleted custom hostname config successfully."; + +// `flags.includeRawOutput` is intentionally unread: Go declares `--include-raw-output` +// as a persistent flag on the `domains` group, so it is accepted on `delete` too, but +// Go's `delete.Run` ignores it (delete has no response body to encode). We mirror that — +// the flag is inert here, asserted by the "ignores --include-raw-output" integration test. export const legacyDomainsDelete = Effect.fn("legacy.domains.delete")(function* ( flags: LegacyDomainsDeleteFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["domains", "delete"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const deleting = + output.format === "text" + ? yield* output.task("Deleting custom hostname config...") + : undefined; + // Delete returns an empty (void) body; Go ignores `-o` here and only prints + // the success line to stderr. + yield* api.v1.deleteHostnameConfig({ ref }).pipe( + Effect.tapError(() => deleting?.fail() ?? Effect.void), + Effect.catch(mapDeleteError), + ); + yield* deleting?.clear() ?? Effect.void; + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success(DELETE_SUCCESS_MESSAGE, {}); + return; + } + yield* output.raw(`${DELETE_SUCCESS_MESSAGE}\n`, "stderr"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/domains/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/domains/delete/delete.integration.test.ts new file mode 100644 index 0000000000..999fd16e7c --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/delete/delete.integration.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyDomainsDelete } from "./delete.handler.ts"; + +type GoOutput = "env" | "pretty" | "json" | "toml" | "yaml"; + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: GoOutput; + readonly status?: number; + readonly network?: "fail"; +} + +const tempRoot = useLegacyTempWorkdir("supabase-domains-delete-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: {} }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }); + return { layer, out, api, telemetry, linkedProjectCache }; +} + +const baseFlags = { projectRef: Option.none(), includeRawOutput: false }; + +describe("legacy domains delete integration", () => { + it.live("prints the success line to stderr in text mode", () => { + const { layer, out, api, telemetry, linkedProjectCache } = setup(); + return Effect.gen(function* () { + yield* legacyDomainsDelete(baseFlags); + expect(out.stderrText).toBe("Deleted custom hostname config successfully.\n"); + expect(out.stdoutText).toBe(""); + expect(api.requests[0]?.method).toBe("DELETE"); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured success event for --output-format json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyDomainsDelete(baseFlags); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Deleted custom hostname config successfully."); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured success event for --output-format stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyDomainsDelete(baseFlags); + expect(out.messages.some((m) => m.type === "success")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("ignores -o json and still only prints to stderr (Go parity)", () => { + const { layer, out } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyDomainsDelete(baseFlags); + expect(out.stdoutText).toBe(""); + expect(out.stderrText).toBe("Deleted custom hostname config successfully.\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("ignores --include-raw-output (inert on delete, Go parity)", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyDomainsDelete({ projectRef: Option.none(), includeRawOutput: true }); + expect(out.stdoutText).toBe(""); + expect(out.stderrText).toBe("Deleted custom hostname config successfully.\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyDomainsUnexpectedStatusError on HTTP 503", () => { + const { layer, telemetry, linkedProjectCache } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsDelete(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("unexpected delete hostname status 503"); + } + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyDomainsNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsDelete(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to delete custom hostname"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("maps an HTTP error without a spinner in json mode", () => { + const { layer, out } = setup({ format: "json", status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsDelete(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.progressEvents).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/domains/domains.cname.ts b/apps/cli/src/legacy/commands/domains/domains.cname.ts new file mode 100644 index 0000000000..07d01833e8 --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/domains.cname.ts @@ -0,0 +1,94 @@ +import { Effect } from "effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import { LegacyDomainsCnameError } from "./domains.errors.ts"; + +// Cloudflare DNS-over-HTTPS record type for CNAME (IANA DNS parameter 5). +const CNAME_TYPE = 5; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Extract the first CNAME answer's `data` from a Cloudflare DNS-over-HTTPS JSON + * response. Mirrors Go's `utils.ResolveCNAME` + * (`apps/cli-go/internal/utils/api.go:60-79`): scan `Answer` for the first entry + * with `type === 5` and return its `data`; otherwise fail with the same + * "failed to locate" message Go embeds (4-space-indented JSON of the answers). + */ +export function parseFirstCname(payload: unknown, host: string): Effect.Effect { + const answers = isRecord(payload) && Array.isArray(payload["Answer"]) ? payload["Answer"] : []; + for (const answer of answers) { + if (isRecord(answer) && answer["type"] === CNAME_TYPE && typeof answer["data"] === "string") { + return Effect.succeed(answer["data"]); + } + } + // Cap the embedded answer dump so an oversized DNS response can't flood the + // error envelope (mirrors the 1024-byte policy in `sanitizeLegacyErrorBody`). + const dump = JSON.stringify(answers, null, 4); + const capped = dump.length > 1024 ? `${dump.slice(0, 1024)}…` : dump; + return Effect.fail( + new Error(`failed to locate appropriate CNAME record for ${host}; resolves to ${capped}`), + ); +} + +/** + * Render the `%w`-wrapped cause string for the "failed to resolve" CNAME error. + * Transport / timeout / parse failures and the locate error all flow through + * here so the outer message stays Go-shaped without leaking object internals. + */ +export function formatCnameCause(cause: unknown): string { + if (cause instanceof Error) return cause.message; + if (isRecord(cause) && typeof cause["message"] === "string") return cause["message"]; + return String(cause); +} + +/** + * Verify that `customHostname` has a CNAME record pointing at the project's + * Supabase subdomain before initializing a custom hostname. Mirrors + * `apps/cli-go/internal/hostnames/common.go:14-22` + `cloudflare/api.go`: + * queries `https://1.1.1.1/dns-query` (DNS-over-HTTPS, `accept: application/dns-json`, + * 10s timeout) and compares the resolved CNAME to `..`. + * + * The `HttpClient` is passed in (not yielded) so this helper carries no service + * requirement and composes cleanly into the create handler. + */ +export const verifyLegacyCname = Effect.fnUntraced(function* (args: { + readonly httpClient: HttpClient.HttpClient; + readonly projectHost: string; + readonly ref: string; + readonly customHostname: string; +}) { + const expected = `${args.ref}.${args.projectHost}.`; + const url = `https://1.1.1.1/dns-query?name=${encodeURIComponent(args.customHostname)}&type=${CNAME_TYPE}`; + const request = HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeader("accept", "application/dns-json"), + ); + + const resolved = yield* Effect.gen(function* () { + const response = yield* args.httpClient.execute(request); + if (response.status !== 200) { + return yield* Effect.fail(new Error(`unexpected DNS query status ${response.status}`)); + } + const payload = yield* response.json; + return yield* parseFirstCname(payload, args.customHostname); + }).pipe( + Effect.timeout("10 seconds"), + Effect.mapError( + (cause) => + new LegacyDomainsCnameError({ + message: `expected custom hostname '${args.customHostname}' to have a CNAME record pointing to your project at '${expected}', but it failed to resolve: ${formatCnameCause(cause)}`, + }), + ), + ); + + if (resolved !== expected) { + return yield* Effect.fail( + new LegacyDomainsCnameError({ + message: `expected custom hostname '${args.customHostname}' to have a CNAME record pointing to your project at '${expected}', but it is currently set to '${resolved}'`, + }), + ); + } +}); diff --git a/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts b/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts new file mode 100644 index 0000000000..c6304af928 --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts @@ -0,0 +1,72 @@ +import { Effect, Exit } from "effect"; +import { describe, expect, it } from "vitest"; + +import { formatCnameCause, parseFirstCname } from "./domains.cname.ts"; + +describe("parseFirstCname", () => { + it("returns the data of the first CNAME answer", () => { + const result = Effect.runSync( + parseFirstCname({ Answer: [{ type: 5, data: "foo.supabase.co." }] }, "foo.example.com"), + ); + expect(result).toBe("foo.supabase.co."); + }); + + it("skips non-CNAME answers and returns the first CNAME", () => { + const result = Effect.runSync( + parseFirstCname( + { + Answer: [ + { type: 1, data: "1.2.3.4" }, + { type: 5, data: "cname.target." }, + ], + }, + "foo.example.com", + ), + ); + expect(result).toBe("cname.target."); + }); + + it("ignores a CNAME answer whose data is not a string", () => { + const exit = Effect.runSyncExit( + parseFirstCname({ Answer: [{ type: 5, data: 123 }] }, "foo.example.com"), + ); + expect(Exit.isFailure(exit)).toBe(true); + }); + + it("fails with a locate error when no CNAME answer is present", () => { + const error = Effect.runSync( + Effect.flip(parseFirstCname({ Answer: [{ type: 1, data: "1.2.3.4" }] }, "host.example.com")), + ); + expect(error.message).toContain( + "failed to locate appropriate CNAME record for host.example.com", + ); + }); + + it("treats a payload without an Answer array as no records", () => { + const exit = Effect.runSyncExit(parseFirstCname({}, "host.example.com")); + expect(Exit.isFailure(exit)).toBe(true); + }); + + it("treats a non-object payload as no records", () => { + const exit = Effect.runSyncExit(parseFirstCname("not-json", "host.example.com")); + expect(Exit.isFailure(exit)).toBe(true); + }); +}); + +describe("formatCnameCause", () => { + it("uses the message of an Error", () => { + expect(formatCnameCause(new Error("boom"))).toBe("boom"); + }); + + it("uses a string message field on a plain object", () => { + expect(formatCnameCause({ message: "obj-msg" })).toBe("obj-msg"); + }); + + it("stringifies an object whose message is not a string", () => { + expect(formatCnameCause({ message: 42 })).toBe("[object Object]"); + }); + + it("stringifies a primitive cause", () => { + expect(formatCnameCause(42)).toBe("42"); + }); +}); diff --git a/apps/cli/src/legacy/commands/domains/domains.emit.ts b/apps/cli/src/legacy/commands/domains/domains.emit.ts new file mode 100644 index 0000000000..bd0741414c --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/domains.emit.ts @@ -0,0 +1,66 @@ +import { Effect, Option } from "effect"; + +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 { formatHostnameStatus, type LegacyHostnameResponse } from "./domains.format.ts"; + +/** + * Emit a custom-hostname response across all output modes, mirroring the Go + * subcommands (`apps/cli-go/internal/hostnames/{get,create,activate,reverify}`): + * + * - Go always writes the human status text to **stderr** (`PrintStatus`), + * then, when `-o != pretty`, encodes the full response to **stdout**. + * - `--include-raw-output` (deprecated) forces `-o` to `json` when it is unset + * or `pretty`. + * - For the TS-native `--output-format json|stream-json` modes (no Go `-o`), + * emit a single structured `success` event and suppress the stderr status. + */ +export const emitLegacyHostnameResult = Effect.fnUntraced(function* ( + response: LegacyHostnameResponse, + includeRawOutput: boolean, +) { + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + + const goFmt = Option.getOrUndefined(goOutputFlag); + const effectiveGoFmt = + includeRawOutput && (goFmt === undefined || goFmt === "pretty") ? "json" : goFmt; + + const statusText = formatHostnameStatus(response); + + if (effectiveGoFmt === "json") { + yield* output.raw(statusText, "stderr"); + yield* output.raw(encodeGoJson(response)); + return; + } + if (effectiveGoFmt === "yaml") { + yield* output.raw(statusText, "stderr"); + yield* output.raw(encodeYaml(response)); + return; + } + if (effectiveGoFmt === "toml") { + yield* output.raw(statusText, "stderr"); + yield* output.raw(encodeToml(response) + "\n"); + return; + } + if (effectiveGoFmt === "env") { + yield* output.raw(statusText, "stderr"); + yield* output.raw(encodeEnv(response) + "\n"); + return; + } + + // goFmt is undefined or "pretty" — defer to the TS --output-format mode. + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + // text mode (Go pretty parity): status to stderr, nothing to stdout. + yield* output.raw(statusText, "stderr"); +}); diff --git a/apps/cli/src/legacy/commands/domains/domains.errors.ts b/apps/cli/src/legacy/commands/domains/domains.errors.ts new file mode 100644 index 0000000000..11b68b3b65 --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/domains.errors.ts @@ -0,0 +1,50 @@ +import { Data } from "effect"; + +import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; + +/** + * Transport-level failure talking to the Management API custom-hostname + * endpoints. Mirrors Go's `errors.Errorf("failed to custom hostname: %w", err)` + * (`apps/cli-go/internal/hostnames/*`). + */ +class LegacyDomainsNetworkError extends Data.TaggedError("LegacyDomainsNetworkError")<{ + readonly message: string; +}> {} + +/** + * The custom-hostname endpoint returned a status the Go CLI does not treat as + * success (201 for create/reverify/activate, 200 for get/delete). Mirrors Go's + * `errors.Errorf("unexpected hostname status %d: %s", code, body)`. + */ +class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( + "LegacyDomainsUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +/** + * The CNAME pre-check in `domains create` failed — either the DNS lookup did + * not resolve to a CNAME, or it resolved to a host other than the expected + * Supabase subdomain. Mirrors `apps/cli-go/internal/hostnames/common.go:14-22`. + */ +export class LegacyDomainsCnameError extends Data.TaggedError("LegacyDomainsCnameError")<{ + readonly message: string; +}> {} + +/** + * Build the network/status error mapper for a custom-hostname subcommand. The + * Go error strings differ only by verb, so each handler supplies its verb and + * shares the dispatch + body-truncation policy from `mapLegacyHttpError`. + * + * @param verb - the Go phrasing, e.g. `"create"`, `"get"`, `"re-verify"`. + */ +export function mapLegacyDomainsHttpError(verb: string) { + return mapLegacyHttpError({ + networkError: LegacyDomainsNetworkError, + statusError: LegacyDomainsUnexpectedStatusError, + networkMessage: (cause) => `failed to ${verb} custom hostname: ${cause}`, + statusMessage: (status, body) => `unexpected ${verb} hostname status ${status}: ${body}`, + }); +} diff --git a/apps/cli/src/legacy/commands/domains/domains.format.ts b/apps/cli/src/legacy/commands/domains/domains.format.ts new file mode 100644 index 0000000000..727154512f --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/domains.format.ts @@ -0,0 +1,87 @@ +import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; + +/** + * The custom-hostname response shape. The Management API returns the same + * structure for get / create / reverify / activate, so a single type covers + * every status formatter. + */ +export type LegacyHostnameResponse = typeof V1GetHostnameConfigOutput.Type; + +type LegacyHostnameSsl = LegacyHostnameResponse["data"]["result"]["ssl"]; + +/** + * Byte-for-byte port of Go's `hostnames.PrintStatus` + * (`apps/cli-go/internal/hostnames/common.go:24-59`). Returns the exact string + * the Go CLI writes to stderr — mind the trailing-newline difference between + * `Fprintln` (adds `\n`) and `Fprintf` (does not). + */ +export function formatHostnameStatus(response: LegacyHostnameResponse): string { + switch (response.status) { + case "5_services_reconfigured": + // Fprintf — no trailing newline. + return `Custom hostname setup completed. Project is now accessible at ${response.custom_hostname}.`; + case "4_origin_setup_completed": + // Fprintf raw string literal — no trailing newline. + return `Custom hostname configuration complete, and ready for activation. + +Please ensure that your custom domain is set up as a CNAME record to your Supabase subdomain: +${response.custom_hostname} CNAME -> ${response.data.result.custom_origin_server}`; + case "3_challenge_verified": + case "2_initiated": { + const ssl = response.data.result.ssl; + if (ssl.status === "initializing") { + // Fprintln — trailing newline. + return "Custom hostname setup is being initialized; please request re-verification in a few seconds.\n"; + } + const validationErrors = ssl.validation_errors; + if (validationErrors !== undefined && validationErrors.length > 0) { + const errorMessages: string[] = []; + for (const valError of validationErrors) { + if (valError.message.includes("caa_error")) { + // Fprintln — trailing newline; Go returns immediately. + return 'CAA mismatch; please remove any existing CAA records on your domain, or add one for "digicert.com"\n'; + } + errorMessages.push(valError.message); + } + // Fprintf with explicit trailing `\n`. + return `SSL validation errors: \n\t- ${errorMessages.join("\n\t- ")}\n`; + } + if (ssl.validation_records.length !== 1) { + // Fprintf — no trailing newline. Go formats the ssl struct with `%+v`; + // not byte-reproducible (see formatSslStructDump). + return `expected a single SSL verification record, received: ${formatSslStructDump(ssl)}`; + } + // Fprintln on the two-line heading, then a tab-indented record (Fprintf, no newline). + let out = + "Custom hostname verification in-progress; please configure the appropriate DNS entries and request re-verification.\nRequired outstanding validation records:\n"; + const rec = ssl.validation_records[0]; + if (rec !== undefined && rec.txt_name !== "") { + out += `\t${rec.txt_name} TXT -> ${rec.txt_value}`; + } + return out; + } + case "1_not_started": + // Fprintln — trailing newline. + return "Custom hostname configuration not started.\n"; + default: + // Go's switch has no default arm — nothing is written. + return ""; + } +} + +/** + * Approximates Go's `fmt.Sprintf("%+v", ssl)` for the degenerate + * "validation_records != 1" branch. The Go output embeds a pointer address for + * the `ValidationErrors` field and is therefore not byte-reproducible; this + * dump is deterministic and documented as a divergence in SIDE_EFFECTS.md. + */ +export function formatSslStructDump(ssl: LegacyHostnameSsl): string { + const validationErrors = + ssl.validation_errors === undefined + ? "" + : `&[${ssl.validation_errors.map((e) => `{Message:${e.message}}`).join(" ")}]`; + const validationRecords = ssl.validation_records + .map((r) => `{TxtName:${r.txt_name} TxtValue:${r.txt_value}}`) + .join(" "); + return `{Status:${ssl.status} ValidationErrors:${validationErrors} ValidationRecords:[${validationRecords}]}`; +} diff --git a/apps/cli/src/legacy/commands/domains/domains.format.unit.test.ts b/apps/cli/src/legacy/commands/domains/domains.format.unit.test.ts new file mode 100644 index 0000000000..09b05e95ad --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/domains.format.unit.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; + +import { + formatHostnameStatus, + formatSslStructDump, + type LegacyHostnameResponse, +} from "./domains.format.ts"; + +type Status = LegacyHostnameResponse["status"]; +type Ssl = LegacyHostnameResponse["data"]["result"]["ssl"]; + +function makeResponse(args: { + readonly status: Status; + readonly customHostname?: string; + readonly customOriginServer?: string; + readonly ssl: Ssl; +}): LegacyHostnameResponse { + const hostname = args.customHostname ?? "example.com"; + return { + status: args.status, + custom_hostname: hostname, + data: { + success: true, + errors: [], + messages: [], + result: { + id: "id-1", + hostname, + ssl: args.ssl, + ownership_verification: { type: "txt", name: "n", value: "v" }, + custom_origin_server: args.customOriginServer ?? "origin.example.com", + status: "active", + }, + }, + }; +} + +describe("formatHostnameStatus", () => { + it("reports completion for 5_services_reconfigured (no trailing newline)", () => { + const out = formatHostnameStatus( + makeResponse({ + status: "5_services_reconfigured", + customHostname: "shop.acme.dev", + ssl: { status: "active", validation_records: [] }, + }), + ); + expect(out).toBe( + "Custom hostname setup completed. Project is now accessible at shop.acme.dev.", + ); + }); + + it("renders the CNAME activation instructions for 4_origin_setup_completed", () => { + const out = formatHostnameStatus( + makeResponse({ + status: "4_origin_setup_completed", + customHostname: "shop.acme.dev", + customOriginServer: "abc.supabase.co", + ssl: { status: "active", validation_records: [] }, + }), + ); + expect(out).toBe( + "Custom hostname configuration complete, and ready for activation.\n\n" + + "Please ensure that your custom domain is set up as a CNAME record to your Supabase subdomain:\n" + + "shop.acme.dev CNAME -> abc.supabase.co", + ); + }); + + it("reports an initializing SSL state during verification", () => { + const out = formatHostnameStatus( + makeResponse({ + status: "2_initiated", + ssl: { status: "initializing", validation_records: [] }, + }), + ); + expect(out).toBe( + "Custom hostname setup is being initialized; please request re-verification in a few seconds.\n", + ); + }); + + it("short-circuits to a CAA mismatch hint when a validation error mentions caa_error", () => { + const out = formatHostnameStatus( + makeResponse({ + status: "3_challenge_verified", + ssl: { + status: "pending_validation", + validation_records: [], + validation_errors: [{ message: "some unrelated error" }, { message: "boom caa_error!" }], + }, + }), + ); + expect(out).toBe( + 'CAA mismatch; please remove any existing CAA records on your domain, or add one for "digicert.com"\n', + ); + }); + + it("joins multiple non-CAA SSL validation errors", () => { + const out = formatHostnameStatus( + makeResponse({ + status: "2_initiated", + ssl: { + status: "pending_validation", + validation_records: [], + validation_errors: [{ message: "first" }, { message: "second" }], + }, + }), + ); + expect(out).toBe("SSL validation errors: \n\t- first\n\t- second\n"); + }); + + it("dumps the ssl struct when there is not exactly one validation record (none)", () => { + const out = formatHostnameStatus( + makeResponse({ + status: "2_initiated", + ssl: { status: "pending_validation", validation_records: [] }, + }), + ); + expect(out).toBe( + "expected a single SSL verification record, received: {Status:pending_validation ValidationErrors: ValidationRecords:[]}", + ); + }); + + it("dumps the ssl struct when there are multiple validation records", () => { + const out = formatHostnameStatus( + makeResponse({ + status: "3_challenge_verified", + ssl: { + status: "pending_validation", + validation_records: [ + { txt_name: "_a", txt_value: "v1" }, + { txt_name: "_b", txt_value: "v2" }, + ], + }, + }), + ); + expect(out).toBe( + "expected a single SSL verification record, received: {Status:pending_validation ValidationErrors: ValidationRecords:[{TxtName:_a TxtValue:v1} {TxtName:_b TxtValue:v2}]}", + ); + }); + + it("treats an empty validation_errors array as no errors and falls through to records", () => { + const out = formatHostnameStatus( + makeResponse({ + status: "2_initiated", + ssl: { + status: "pending_validation", + validation_records: [{ txt_name: "_acme", txt_value: "token" }], + validation_errors: [], + }, + }), + ); + expect(out).toBe( + "Custom hostname verification in-progress; please configure the appropriate DNS entries and request re-verification.\n" + + "Required outstanding validation records:\n" + + "\t_acme TXT -> token", + ); + }); + + it("omits the record line when the single record has an empty txt_name", () => { + const out = formatHostnameStatus( + makeResponse({ + status: "2_initiated", + ssl: { + status: "pending_validation", + validation_records: [{ txt_name: "", txt_value: "token" }], + }, + }), + ); + expect(out).toBe( + "Custom hostname verification in-progress; please configure the appropriate DNS entries and request re-verification.\n" + + "Required outstanding validation records:\n", + ); + }); + + it("reports the not-started state", () => { + const out = formatHostnameStatus( + makeResponse({ + status: "1_not_started", + ssl: { status: "active", validation_records: [] }, + }), + ); + expect(out).toBe("Custom hostname configuration not started.\n"); + }); +}); + +describe("formatSslStructDump", () => { + it("renders when validation_errors is absent", () => { + expect(formatSslStructDump({ status: "x", validation_records: [] })).toBe( + "{Status:x ValidationErrors: ValidationRecords:[]}", + ); + }); + + it("renders the validation errors slice when present", () => { + expect( + formatSslStructDump({ + status: "x", + validation_records: [{ txt_name: "_n", txt_value: "v" }], + validation_errors: [{ message: "oops" }], + }), + ).toBe( + "{Status:x ValidationErrors:&[{Message:oops}] ValidationRecords:[{TxtName:_n TxtValue:v}]}", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/domains/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/get/SIDE_EFFECTS.md deleted file mode 100644 index e231f4f1b1..0000000000 --- a/apps/cli/src/legacy/commands/domains/get/SIDE_EFFECTS.md +++ /dev/null @@ -1,39 +0,0 @@ -# `supabase domains get` - -## Files Read - -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | `{custom_hostname, status, data: {result: {ownership_verification, ssl}}}` | - -## 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`) | - -## Exit Codes - -| Code | Condition | -| ---- | ---------------------------------------------------------- | -| `0` | success — custom hostname config printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from custom-hostname endpoint | -| `1` | network / connection failure | - -## Notes - -- Requires `--project-ref` or a linked project (`.supabase/config.json`). diff --git a/apps/cli/src/legacy/commands/domains/get/get.command.ts b/apps/cli/src/legacy/commands/domains/get/get.command.ts index 5c57cdc114..49cece3592 100644 --- a/apps/cli/src/legacy/commands/domains/get/get.command.ts +++ b/apps/cli/src/legacy/commands/domains/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 { legacyDomainsGet } from "./get.handler.ts"; const config = { @@ -7,6 +11,9 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), + includeRawOutput: Flag.boolean("include-raw-output").pipe( + Flag.withDescription("(Deprecated) use -o json instead."), + ), } as const; export type LegacyDomainsGetFlags = CliCommand.Command.Config.Infer; @@ -22,5 +29,11 @@ export const legacyDomainsGetCommand = Command.make("get", config).pipe( description: "Get the custom hostname config for a project", }, ]), - Command.withHandler((flags) => legacyDomainsGet(flags)), + Command.withHandler((flags) => + legacyDomainsGet(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["domains", "get"])), ); diff --git a/apps/cli/src/legacy/commands/domains/get/get.handler.ts b/apps/cli/src/legacy/commands/domains/get/get.handler.ts index 71cd074412..d60e6fd13f 100644 --- a/apps/cli/src/legacy/commands/domains/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/domains/get/get.handler.ts @@ -1,12 +1,40 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect } from "effect"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { emitLegacyHostnameResult } from "../domains.emit.ts"; +import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; import type { LegacyDomainsGetFlags } from "./get.command.ts"; +const mapGetError = mapLegacyDomainsHttpError("get"); + export const legacyDomainsGet = Effect.fn("legacy.domains.get")(function* ( flags: LegacyDomainsGetFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["domains", "get"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const ref = yield* resolver.resolve(flags.projectRef); + + // Mirror Go's PersistentPostRun: write the linked-project cache and persist + // the telemetry state file on success and failure. + yield* Effect.gen(function* () { + const fetching = + output.format === "text" + ? yield* output.task("Fetching custom hostname config...") + : undefined; + const response = yield* api.v1.getHostnameConfig({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapGetError), + ); + yield* fetching?.clear() ?? Effect.void; + + yield* emitLegacyHostnameResult(response, flags.includeRawOutput); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts new file mode 100644 index 0000000000..064300a33e --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts @@ -0,0 +1,197 @@ +import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyDomainsGet } from "./get.handler.ts"; + +const HOSTNAME_RESPONSE: typeof V1GetHostnameConfigOutput.Type = { + status: "1_not_started", + custom_hostname: "shop.acme.dev", + data: { + success: true, + errors: [], + messages: [], + result: { + id: "id-1", + hostname: "shop.acme.dev", + ssl: { status: "active", validation_records: [] }, + ownership_verification: { type: "txt", name: "n", value: "v" }, + custom_origin_server: "abc.supabase.co", + status: "active", + }, + }, +}; + +type GoOutput = "env" | "pretty" | "json" | "toml" | "yaml"; + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: GoOutput; + readonly status?: number; + readonly network?: "fail"; +} + +const tempRoot = useLegacyTempWorkdir("supabase-domains-get-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: HOSTNAME_RESPONSE }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }); + return { layer, out, api, telemetry, linkedProjectCache }; +} + +const baseFlags = { projectRef: Option.none(), includeRawOutput: false }; + +describe("legacy domains get integration", () => { + it.live("prints the hostname status to stderr in text mode", () => { + const { layer, out, telemetry, linkedProjectCache } = setup(); + return Effect.gen(function* () { + yield* legacyDomainsGet(baseFlags); + expect(out.stderrText).toContain("Custom hostname configuration not started."); + expect(out.stdoutText).toBe(""); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured success object for --output-format json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyDomainsGet(baseFlags); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ custom_hostname: "shop.acme.dev" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured success object for --output-format stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyDomainsGet(baseFlags); + expect(out.messages.some((m) => m.type === "success")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits indented Go JSON to stdout and status to stderr for -o json", () => { + const { layer, out } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyDomainsGet(baseFlags); + expect(out.stdoutText.startsWith("{")).toBe(true); + expect(out.stdoutText).toContain('"custom_hostname": "shop.acme.dev"'); + expect(out.stderrText).toContain("Custom hostname configuration not started."); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits YAML to stdout for -o yaml", () => { + const { layer, out } = setup({ goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyDomainsGet(baseFlags); + expect(out.stdoutText).toContain("custom_hostname: shop.acme.dev"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits TOML to stdout for -o toml", () => { + const { layer, out } = setup({ goOutput: "toml" }); + return Effect.gen(function* () { + yield* legacyDomainsGet(baseFlags); + expect(out.stdoutText).toContain('custom_hostname = "shop.acme.dev"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits KEY=VALUE lines for -o env", () => { + const { layer, out } = setup({ goOutput: "env" }); + return Effect.gen(function* () { + yield* legacyDomainsGet(baseFlags); + expect(out.stdoutText).toContain('CUSTOM_HOSTNAME="shop.acme.dev"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("treats -o pretty as text mode (status to stderr only)", () => { + const { layer, out } = setup({ goOutput: "pretty" }); + return Effect.gen(function* () { + yield* legacyDomainsGet(baseFlags); + expect(out.stderrText).toContain("Custom hostname configuration not started."); + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("forces Go JSON output when --include-raw-output is set", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyDomainsGet({ projectRef: Option.none(), includeRawOutput: true }); + expect(out.stdoutText.startsWith("{")).toBe(true); + expect(out.stdoutText).toContain('"custom_hostname": "shop.acme.dev"'); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "forces Go JSON even when -o is explicitly pretty and --include-raw-output is set", + () => { + const { layer, out } = setup({ goOutput: "pretty" }); + return Effect.gen(function* () { + yield* legacyDomainsGet({ projectRef: Option.none(), includeRawOutput: true }); + expect(out.stdoutText.startsWith("{")).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("fails with LegacyDomainsUnexpectedStatusError on HTTP 503", () => { + const { layer, telemetry, linkedProjectCache } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsGet(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyDomainsUnexpectedStatusError"); + expect(json).toContain("unexpected get hostname status 503"); + } + // PersistentPostRun still fires on failure. + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("maps an HTTP error without a spinner in json mode", () => { + const { layer, out } = setup({ format: "json", status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsGet(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + // No spinner task is started in json mode. + expect(out.progressEvents).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyDomainsNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsGet(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyDomainsNetworkError"); + expect(json).toContain("failed to get custom hostname"); + } + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/domains/reverify/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/reverify/SIDE_EFFECTS.md deleted file mode 100644 index 8f1e2be774..0000000000 --- a/apps/cli/src/legacy/commands/domains/reverify/SIDE_EFFECTS.md +++ /dev/null @@ -1,40 +0,0 @@ -# `supabase domains reverify` - -## Files Read - -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------- | -| `POST` | `/v1/projects/{ref}/custom-hostname/reverify` | Bearer token | none | `{custom_hostname, status, data: {result: {ownership_verification, ssl}}}` | - -## 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`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------------- | -| `0` | success — reverification result printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from reverify endpoint | -| `1` | network / connection failure | - -## Notes - -- Triggers re-verification of the custom hostname ownership via Cloudflare. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). diff --git a/apps/cli/src/legacy/commands/domains/reverify/reverify.command.ts b/apps/cli/src/legacy/commands/domains/reverify/reverify.command.ts index c01275a99f..249b0ff7e1 100644 --- a/apps/cli/src/legacy/commands/domains/reverify/reverify.command.ts +++ b/apps/cli/src/legacy/commands/domains/reverify/reverify.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 { legacyDomainsReverify } from "./reverify.handler.ts"; const config = { @@ -7,6 +11,9 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), + includeRawOutput: Flag.boolean("include-raw-output").pipe( + Flag.withDescription("(Deprecated) use -o json instead."), + ), } as const; export type LegacyDomainsReverifyFlags = CliCommand.Command.Config.Infer; @@ -20,5 +27,11 @@ export const legacyDomainsReverifyCommand = Command.make("reverify", config).pip description: "Re-verify the custom hostname for a project", }, ]), - Command.withHandler((flags) => legacyDomainsReverify(flags)), + Command.withHandler((flags) => + legacyDomainsReverify(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["domains", "reverify"])), ); diff --git a/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts b/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts index 06df58624a..83a527763a 100644 --- a/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts +++ b/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts @@ -1,12 +1,36 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect } from "effect"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { emitLegacyHostnameResult } from "../domains.emit.ts"; +import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; import type { LegacyDomainsReverifyFlags } from "./reverify.command.ts"; +const mapReverifyError = mapLegacyDomainsHttpError("re-verify"); + export const legacyDomainsReverify = Effect.fn("legacy.domains.reverify")(function* ( flags: LegacyDomainsReverifyFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["domains", "reverify"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const reverifying = + output.format === "text" ? yield* output.task("Re-verifying custom hostname...") : undefined; + const response = yield* api.v1.verifyDnsConfig({ ref }).pipe( + Effect.tapError(() => reverifying?.fail() ?? Effect.void), + Effect.catch(mapReverifyError), + ); + yield* reverifying?.clear() ?? Effect.void; + + yield* emitLegacyHostnameResult(response, flags.includeRawOutput); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts b/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts new file mode 100644 index 0000000000..e5cfde8343 --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts @@ -0,0 +1,137 @@ +import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyDomainsReverify } from "./reverify.handler.ts"; + +const HOSTNAME_RESPONSE: typeof V1GetHostnameConfigOutput.Type = { + status: "2_initiated", + custom_hostname: "shop.acme.dev", + data: { + success: true, + errors: [], + messages: [], + result: { + id: "id-1", + hostname: "shop.acme.dev", + ssl: { status: "initializing", validation_records: [] }, + ownership_verification: { type: "txt", name: "n", value: "v" }, + custom_origin_server: "abc.supabase.co", + status: "active", + }, + }, +}; + +type GoOutput = "env" | "pretty" | "json" | "toml" | "yaml"; + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: GoOutput; + readonly status?: number; + readonly network?: "fail"; +} + +const tempRoot = useLegacyTempWorkdir("supabase-domains-reverify-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 201, body: HOSTNAME_RESPONSE }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }); + return { layer, out, api, telemetry, linkedProjectCache }; +} + +const baseFlags = { projectRef: Option.none(), includeRawOutput: false }; + +describe("legacy domains reverify integration", () => { + it.live("prints the initializing status to stderr in text mode", () => { + const { layer, out, api, telemetry, linkedProjectCache } = setup(); + return Effect.gen(function* () { + yield* legacyDomainsReverify(baseFlags); + expect(out.stderrText).toContain("being initialized"); + expect(out.stdoutText).toBe(""); + expect(api.requests[0]?.url).toContain("/custom-hostname/reverify"); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured success object for --output-format json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyDomainsReverify(baseFlags); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ custom_hostname: "shop.acme.dev" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits indented Go JSON to stdout for -o json", () => { + const { layer, out } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyDomainsReverify(baseFlags); + expect(out.stdoutText.startsWith("{")).toBe(true); + expect(out.stderrText).toContain("being initialized"); + }).pipe(Effect.provide(layer)); + }); + + it.live("forces Go JSON output when --include-raw-output is set", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyDomainsReverify({ projectRef: Option.none(), includeRawOutput: true }); + expect(out.stdoutText.startsWith("{")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyDomainsUnexpectedStatusError on HTTP 503", () => { + const { layer, telemetry } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsReverify(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("unexpected re-verify hostname status 503"); + } + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyDomainsNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsReverify(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to re-verify custom hostname"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("maps an HTTP error without a spinner in json mode", () => { + const { layer, out } = setup({ format: "json", status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsReverify(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.progressEvents).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 3046a1367e..9ea6c773d7 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -2,29 +2,45 @@ import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { parse as parseYaml } from "yaml"; import { CLI_VERSION } from "../../shared/cli/version.ts"; import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; +import { legacyProjectHost } from "../shared/legacy-profile.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { LegacyCliConfig, type LegacyProfileName } from "./legacy-cli-config.service.ts"; interface ResolvedProfile { readonly name: string; readonly apiUrl: string; + readonly projectHost: string; } -const BUILTIN_PROFILES: Record = { - supabase: { name: "supabase", apiUrl: "https://api.supabase.com" }, - "supabase-staging": { name: "supabase-staging", apiUrl: "https://api.supabase.green" }, - "supabase-local": { name: "supabase-local", apiUrl: "http://localhost:8080" }, +const BUILTIN_PROFILE_API_URLS: Record = { + supabase: "https://api.supabase.com", + "supabase-staging": "https://api.supabase.green", + "supabase-local": "http://localhost:8080", + snap: "https://cloudapi.snap.com", }; function isBuiltinProfileName(value: string): value is LegacyProfileName { - return value in BUILTIN_PROFILES; + return value in BUILTIN_PROFILE_API_URLS; } -function safeParseYaml(text: string): { name?: unknown; api_url?: unknown } | undefined { +// `projectHost` is sourced from `legacy-profile.ts` (the single source of truth that +// mirrors Go's `allProfiles` table and is also consumed by `branches get`), so the +// per-profile host mapping is not duplicated here. +function resolvedBuiltin(name: LegacyProfileName): ResolvedProfile { + return { + name, + apiUrl: BUILTIN_PROFILE_API_URLS[name], + projectHost: legacyProjectHost(name), + }; +} + +function safeParseYaml( + text: string, +): { name?: unknown; api_url?: unknown; project_host?: unknown } | undefined { try { const value = parseYaml(text); return value !== null && typeof value === "object" - ? (value as { name?: unknown; api_url?: unknown }) + ? (value as { name?: unknown; api_url?: unknown; project_host?: unknown }) : undefined; } catch { return undefined; @@ -41,7 +57,8 @@ function safeParseYaml(text: string): { name?: unknown; api_url?: unknown } | un * * The cli-e2e harness depends on (2) — it writes a per-test YAML profile and * sets `SUPABASE_PROFILE=` so both the Go and ts-legacy binaries - * route requests to the local replay server. + * route requests to the local replay server. YAML profiles may also carry a + * `project_host:` key (Go's `Profile.ProjectHost`); it defaults to `supabase.co`. */ function resolveProfile( flagValue: string, @@ -52,19 +69,23 @@ function resolveProfile( const token = flagValue !== "supabase" ? flagValue : (envValue ?? "supabase"); if (isBuiltinProfileName(token)) { - return BUILTIN_PROFILES[token]; + return resolvedBuiltin(token); } const content = yield* fs.readFileString(token).pipe(Effect.option); - if (Option.isNone(content)) return BUILTIN_PROFILES.supabase; + if (Option.isNone(content)) return resolvedBuiltin("supabase"); const parsed = safeParseYaml(content.value); if (parsed === undefined || typeof parsed.api_url !== "string") { - return BUILTIN_PROFILES.supabase; + return resolvedBuiltin("supabase"); } return { name: typeof parsed.name === "string" ? parsed.name : "supabase", apiUrl: parsed.api_url, + projectHost: + typeof parsed.project_host === "string" + ? parsed.project_host + : legacyProjectHost("supabase"), }; }); } @@ -112,11 +133,11 @@ export const legacyCliConfigLayer = Layer.unwrap( const runtimeInfo = yield* RuntimeInfo; const env = process.env; - const { name: profile, apiUrl } = yield* resolveProfile( - profileFlag, - env["SUPABASE_PROFILE"], - fs, - ); + const { + name: profile, + apiUrl, + projectHost, + } = yield* resolveProfile(profileFlag, env["SUPABASE_PROFILE"], fs); const rawAccessToken = env["SUPABASE_ACCESS_TOKEN"]; const accessToken = @@ -143,6 +164,7 @@ export const legacyCliConfigLayer = Layer.unwrap( return LegacyCliConfig.of({ profile, apiUrl, + projectHost, accessToken, projectId, workdir, diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index 7c311d8c5a..387a93ece4 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -45,6 +45,7 @@ describe("legacyCliConfigLayer", () => { const config = yield* LegacyCliConfig; expect(config.profile).toBe("supabase"); expect(config.apiUrl).toBe("https://api.supabase.com"); + expect(config.projectHost).toBe("supabase.co"); }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), ); @@ -53,6 +54,7 @@ describe("legacyCliConfigLayer", () => { const config = yield* LegacyCliConfig; expect(config.profile).toBe("supabase-staging"); expect(config.apiUrl).toBe("https://api.supabase.green"); + expect(config.projectHost).toBe("supabase.red"); }).pipe( Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "supabase-staging" }, cwd: tempRoot })), ), @@ -65,6 +67,14 @@ describe("legacyCliConfigLayer", () => { }).pipe(Effect.provide(makeLayer({ profileFlag: "supabase-local", cwd: tempRoot }))), ); + it.effect("resolves the snap profile API URL and project host", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.apiUrl).toBe("https://cloudapi.snap.com"); + expect(config.projectHost).toBe("snapcloud.dev"); + }).pipe(Effect.provide(makeLayer({ profileFlag: "snap", cwd: tempRoot }))), + ); + it.effect( "falls back to supabase profile when SUPABASE_PROFILE is neither a known name nor a readable file", () => @@ -87,6 +97,16 @@ describe("legacyCliConfigLayer", () => { const config = yield* LegacyCliConfig; expect(config.profile).toBe("cli-e2e"); expect(config.apiUrl).toBe("http://127.0.0.1:9999"); + expect(config.projectHost).toBe("localhost"); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); + }); + + it.effect("defaults project_host to supabase.co when a YAML profile omits it", () => { + const profilePath = join(tempRoot, "no-host.yaml"); + writeFileSync(profilePath, ["name: cli-e2e", 'api_url: "http://127.0.0.1:9999"'].join("\n")); + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.projectHost).toBe("supabase.co"); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); }); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.service.ts b/apps/cli/src/legacy/config/legacy-cli-config.service.ts index 166edbcce7..6ab55793c0 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.service.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.service.ts @@ -8,11 +8,18 @@ import { Context } from "effect"; * supports YAML profile files where `name:` is arbitrary user input. See * `legacy-cli-config.layer.ts` for the resolution semantics. */ -export type LegacyProfileName = "supabase" | "supabase-staging" | "supabase-local"; +export type LegacyProfileName = "supabase" | "supabase-staging" | "supabase-local" | "snap"; interface LegacyCliConfigShape { readonly profile: string; readonly apiUrl: string; + /** + * Project subdomain host for the active profile (Go's `Profile.ProjectHost`, + * `apps/cli-go/internal/utils/profile.go`). Used to build the expected CNAME + * target (`.`) in `domains create`. Defaults to `supabase.co` + * for the built-in `supabase` profile. + */ + readonly projectHost: string; readonly accessToken: Option.Option>; readonly projectId: Option.Option; readonly workdir: string; diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts index 5bbef78478..d614509a72 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts @@ -21,6 +21,7 @@ function mockCliConfig(opts: { workdir: string; projectId?: string }) { return Layer.succeed(LegacyCliConfig, { profile: "supabase", apiUrl: "https://api.supabase.com", + projectHost: "supabase.co", accessToken: Option.none(), projectId: opts.projectId === undefined ? Option.none() : Option.some(opts.projectId), workdir: opts.workdir, diff --git a/apps/cli/src/legacy/shared/legacy-profile.ts b/apps/cli/src/legacy/shared/legacy-profile.ts index d36d5047b2..898cad08f6 100644 --- a/apps/cli/src/legacy/shared/legacy-profile.ts +++ b/apps/cli/src/legacy/shared/legacy-profile.ts @@ -25,6 +25,7 @@ const BUILT_IN: Readonly> = { projectHost: "supabase.red", dashboardUrl: "http://localhost:8082", }, + snap: { projectHost: "snapcloud.dev", dashboardUrl: "https://cloud.snap.com/dashboard" }, }; const DEFAULT_ENDPOINTS: LegacyProfileEndpoints = BUILT_IN.supabase!; diff --git a/apps/cli/src/legacy/shared/legacy-profile.unit.test.ts b/apps/cli/src/legacy/shared/legacy-profile.unit.test.ts index d240d6a200..cbc4e9ae0d 100644 --- a/apps/cli/src/legacy/shared/legacy-profile.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-profile.unit.test.ts @@ -7,6 +7,7 @@ describe("legacyProjectHost", () => { expect(legacyProjectHost("supabase")).toBe("supabase.co"); expect(legacyProjectHost("supabase-staging")).toBe("supabase.red"); expect(legacyProjectHost("supabase-local")).toBe("supabase.red"); + expect(legacyProjectHost("snap")).toBe("snapcloud.dev"); }); it("falls back to supabase.co for unknown / YAML-mode profiles", () => { diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 548fd6c033..7271c43e40 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -116,6 +116,7 @@ export function mockLegacyCliConfig(opts: { readonly workdir: string; readonly profile?: string; readonly apiUrl?: string; + readonly projectHost?: string; readonly accessToken?: Option.Option>; readonly projectId?: Option.Option; readonly userAgent?: string; @@ -123,6 +124,7 @@ export function mockLegacyCliConfig(opts: { return Layer.succeed(LegacyCliConfig, { profile: opts.profile ?? "supabase", apiUrl: opts.apiUrl ?? LEGACY_DEFAULT_API_URL, + projectHost: opts.projectHost ?? "supabase.co", accessToken: opts.accessToken ?? Option.some(Redacted.make(LEGACY_VALID_TOKEN)), projectId: opts.projectId ?? Option.some(LEGACY_VALID_REF), workdir: opts.workdir, From 6a9e29e20eeabfc348eb84367e798eafb46b4b4a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 29 May 2026 13:33:48 +0100 Subject: [PATCH 2/2] fix(cli): suppress domains status on stderr in structured output modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `domains get --output json` e2e parity test failed: Go's `PrintStatus` writes the `5_services_reconfigured` message with no trailing newline, so it fuses with Go's version-update notice on a single line and is stripped wholesale by the e2e normalizer (normalize.ts rule 11) — making Go's observable stderr empty in machine-output mode. The TS port emitted a clean status line, so it survived normalization and diverged. Suppress the human status on stderr when emitting a structured Go `-o` format (json/yaml/toml/env); pretty/text mode still writes it. This keeps stdout clean in machine-output mode and matches the parity contract. Update the affected integration tests and SIDE_EFFECTS.md. --- .../legacy/commands/domains/SIDE_EFFECTS.md | 13 +++++++----- .../domains/create/create.integration.test.ts | 8 +++---- .../legacy/commands/domains/domains.emit.ts | 21 +++++++++++-------- .../domains/get/get.integration.test.ts | 6 ++++-- .../reverify/reverify.integration.test.ts | 4 ++-- 5 files changed, 30 insertions(+), 22 deletions(-) diff --git a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md index 7cc6c40b44..dbfd1dcf48 100644 --- a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md @@ -57,9 +57,11 @@ No custom events: the Go `internal/hostnames` package emits no `phtelemetry.*` c ## Output -`PrintStatus` text is written to **stderr** in every Go output mode; structured -output (when `-o != pretty`) is written to **stdout**. `delete` only prints a -fixed success line to stderr and ignores `-o`. +In `pretty`/text mode the `PrintStatus` text is written to **stderr** and nothing +to stdout. In a structured `-o` mode (`json`/`yaml`/`toml`/`env`) the encoded +response goes to **stdout** and the human status is suppressed on stderr (see the +divergence note below). `delete` only prints a fixed success line to stderr and +ignores `-o`. ### `--output-format text` (Go CLI compatible) @@ -83,8 +85,8 @@ A `result` event carrying the custom-hostname response object. ### Go `-o {json,yaml,toml,env}` When the Go `--output`/`-o` flag is set (or `--include-raw-output` forces `json`), -the full response is encoded to stdout in that format, and the status text is still -written to stderr. `delete` ignores `-o`. +the full response is encoded to stdout in that format and the human status is +suppressed on stderr. `delete` ignores `-o`. ## Notes @@ -96,3 +98,4 @@ written to stderr. `delete` ignores `-o`. - `--include-raw-output` is declared as a normal boolean **on each subcommand** (Go declares it as a persistent flag on the `domains` group). Two consequences: (a) it must appear after the subcommand name (`domains get --include-raw-output`) rather than before it (`domains --include-raw-output get`), matching how `--project-ref` is already handled shell-wide; (b) it cannot reproduce Cobra's help-hiding or the `Flag --include-raw-output has been deprecated` stderr warning, which Effect CLI has no hook for. It still reproduces the behavioral effect (forces `-o json` when `-o` is unset/pretty); on `delete` it is inert, matching Go. - `-o json|yaml|toml|env` encode the decoded snake_case response, not Go's PascalCase struct keys (consistent with `backups list` / `sso add`). - The degenerate `validation_records != 1` status message approximates Go's `%+v` struct dump (which embeds a non-deterministic pointer address). + - In a structured `-o` mode the human status is suppressed on stderr. Go technically still writes `PrintStatus` to stderr, but the `5_*`/`4_*` messages carry no trailing newline, so they fuse with Go's version-update notice and are stripped together by the e2e normalizer — making Go's observable machine-output stderr empty. Suppressing keeps stdout clean and matches the parity contract. diff --git a/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts index f6f4b9d790..edb4843753 100644 --- a/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts @@ -168,21 +168,21 @@ describe("legacy domains create integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits indented Go JSON to stdout for -o json", () => { + it.live("emits indented Go JSON to stdout with no status on stderr for -o json", () => { const { layer, out } = setup({ goOutput: "json" }); return Effect.gen(function* () { yield* legacyDomainsCreate(flags()); expect(out.stdoutText.startsWith("{")).toBe(true); - expect(out.stderrText).toContain("Custom hostname configuration complete"); + expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); }); - it.live("emits YAML to stdout for -o yaml", () => { + it.live("emits YAML to stdout with no status on stderr for -o yaml", () => { const { layer, out } = setup({ goOutput: "yaml" }); return Effect.gen(function* () { yield* legacyDomainsCreate(flags()); expect(out.stdoutText).toContain(`custom_hostname: ${CUSTOM_HOSTNAME}`); - expect(out.stderrText).toContain("Custom hostname configuration complete"); + expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/domains/domains.emit.ts b/apps/cli/src/legacy/commands/domains/domains.emit.ts index bd0741414c..4464cf7713 100644 --- a/apps/cli/src/legacy/commands/domains/domains.emit.ts +++ b/apps/cli/src/legacy/commands/domains/domains.emit.ts @@ -14,8 +14,17 @@ import { formatHostnameStatus, type LegacyHostnameResponse } from "./domains.for * Emit a custom-hostname response across all output modes, mirroring the Go * subcommands (`apps/cli-go/internal/hostnames/{get,create,activate,reverify}`): * - * - Go always writes the human status text to **stderr** (`PrintStatus`), - * then, when `-o != pretty`, encodes the full response to **stdout**. + * - In `pretty`/text mode the human status text goes to **stderr** (Go's + * `PrintStatus`), and nothing goes to stdout. + * - In a structured Go `-o` mode (`json`/`yaml`/`toml`/`env`) the encoded + * response goes to **stdout** and the human status is **suppressed**. Go + * technically still writes `PrintStatus` to stderr here, but because the + * `5_services_reconfigured`/`4_origin_setup_completed` messages carry no + * trailing newline they get fused with — and stripped alongside — Go's + * version-update notice (see `normalize.ts` rule 11), so the observable Go + * stderr in machine-output mode is empty. Suppressing keeps stdout clean and + * matches that contract (verified by the `domains get --output json` parity + * e2e). * - `--include-raw-output` (deprecated) forces `-o` to `json` when it is unset * or `pretty`. * - For the TS-native `--output-format json|stream-json` modes (no Go `-o`), @@ -32,25 +41,19 @@ export const emitLegacyHostnameResult = Effect.fnUntraced(function* ( const effectiveGoFmt = includeRawOutput && (goFmt === undefined || goFmt === "pretty") ? "json" : goFmt; - const statusText = formatHostnameStatus(response); - if (effectiveGoFmt === "json") { - yield* output.raw(statusText, "stderr"); yield* output.raw(encodeGoJson(response)); return; } if (effectiveGoFmt === "yaml") { - yield* output.raw(statusText, "stderr"); yield* output.raw(encodeYaml(response)); return; } if (effectiveGoFmt === "toml") { - yield* output.raw(statusText, "stderr"); yield* output.raw(encodeToml(response) + "\n"); return; } if (effectiveGoFmt === "env") { - yield* output.raw(statusText, "stderr"); yield* output.raw(encodeEnv(response) + "\n"); return; } @@ -62,5 +65,5 @@ export const emitLegacyHostnameResult = Effect.fnUntraced(function* ( } // text mode (Go pretty parity): status to stderr, nothing to stdout. - yield* output.raw(statusText, "stderr"); + yield* output.raw(formatHostnameStatus(response), "stderr"); }); diff --git a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts index 064300a33e..7559202434 100644 --- a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts @@ -93,13 +93,15 @@ describe("legacy domains get integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits indented Go JSON to stdout and status to stderr for -o json", () => { + it.live("emits indented Go JSON to stdout with no status on stderr for -o json", () => { const { layer, out } = setup({ goOutput: "json" }); return Effect.gen(function* () { yield* legacyDomainsGet(baseFlags); expect(out.stdoutText.startsWith("{")).toBe(true); expect(out.stdoutText).toContain('"custom_hostname": "shop.acme.dev"'); - expect(out.stderrText).toContain("Custom hostname configuration not started."); + // Structured -o output: human status is suppressed (Go's no-newline status + // is fused with + stripped alongside its version-update notice — see emit). + expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts b/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts index e5cfde8343..f590ce361c 100644 --- a/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts @@ -86,12 +86,12 @@ describe("legacy domains reverify integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits indented Go JSON to stdout for -o json", () => { + it.live("emits indented Go JSON to stdout with no status on stderr for -o json", () => { const { layer, out } = setup({ goOutput: "json" }); return Effect.gen(function* () { yield* legacyDomainsReverify(baseFlags); expect(out.stdoutText.startsWith("{")).toBe(true); - expect(out.stderrText).toContain("being initialized"); + expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); });