From 0684ae6472ee40178f39383b822a2342823fdcb9 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Fri, 29 May 2026 15:43:21 +0530 Subject: [PATCH 01/16] feat(cli): migrate network bans (#5382) ## TL;DR Ports `supabase network-bans get` and `supabase network-bans remove` from the Go proxy to native TypeScript ## What's Introduced Replaces the `LegacyGoProxy` path for both `network-bans` leaf commands with native handlers wired through the existing legacy management-api runtime and instrumentation path ## Coverage Added a small integration suite covering this as well --------- Co-authored-by: Colum Ferry --- apps/cli/docs/go-cli-porting-status.md | 4 +- .../commands/network-bans/get/SIDE_EFFECTS.md | 82 ++++-- .../commands/network-bans/get/get.command.ts | 12 +- .../commands/network-bans/get/get.handler.ts | 83 +++++- .../network-bans/get/get.integration.test.ts | 258 ++++++++++++++++++ .../network-bans/network-bans.encoders.ts | 8 + .../network-bans.encoders.unit.test.ts | 19 ++ .../network-bans/network-bans.errors.ts | 46 ++++ .../network-bans/remove/SIDE_EFFECTS.md | 71 +++-- .../network-bans/remove/remove.command.ts | 12 +- .../network-bans/remove/remove.handler.ts | 69 ++++- .../remove/remove.integration.test.ts | 241 ++++++++++++++++ 12 files changed, 836 insertions(+), 69 deletions(-) create mode 100644 apps/cli/src/legacy/commands/network-bans/get/get.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/network-bans/network-bans.encoders.ts create mode 100644 apps/cli/src/legacy/commands/network-bans/network-bans.encoders.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts create mode 100644 apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 1a29409300..4071fa52d1 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -250,8 +250,8 @@ Legend: | `vanity-subdomains check-availability` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | | `vanity-subdomains activate` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | | `vanity-subdomains delete` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | -| `network-bans get` | `wrapped` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | -| `network-bans remove` | `wrapped` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | +| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | +| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | | `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | | `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | | `encryption get-root-key` | `wrapped` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | diff --git a/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md index 63244d33db..fdb5dbaf68 100644 --- a/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md @@ -2,57 +2,83 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` — on success and failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------- | ------------ | ------------ | ------------------------- | -| `GET` | `/v1/projects/{ref}/network-bans` | Bearer token | none | `{banned_ipv4_addresses}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | ------------------------- | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `{banned_ipv4_addresses}` | + +The Management API exposes this read operation as `POST .../network-bans/retrieve` (not `GET`) — see `V1ListAllNetworkBans` in `packages/api/src/generated/contracts.ts`. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------- | -| `0` | success — network bans printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from network bans endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | success — network bans printed to stdout | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyNetworkBansGetUnexpectedStatusError`) | +| `1` | transport failure (`LegacyNetworkBansGetNetworkError`) | +| `1` | `--output env` requested (`LegacyNetworkBansEnvNotSupportedError`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → ``) | + +Matches `apps/cli-go/internal/bans/get/`. Go does not fire any custom telemetry event for this command. ## Output -### `--output-format text` (Go CLI compatible) +A `DB banned IPs:` heading is written to stderr unconditionally before any stdout output (mirrors Go's `fmt.Fprintln(os.Stderr, "DB banned IPs:")`). Exception: `--output-format json` / `--output-format stream-json` (with no Go `--output` flag set) emit a structured success event and skip the stderr heading to keep machine-readable output clean. + +### `--output-format text` (default) — Go CLI compatible + +Stderr heading followed by the banned IP array rendered as Go-compatible JSON (alphabetical key order, two-space indent, trailing newline). -Prints a list of banned IP addresses to stdout. +### Go `--output {json,pretty,yaml,toml}` + +Byte-identical to the Go CLI's encoders. + +- `json` and `pretty` — Go-compatible JSON of the IP array (`pretty` aliases to `json` per `apps/cli-go/internal/bans/get/get.go:21-23`). +- `yaml` — `stringifyYaml(ipArray)`. +- `toml` — `banned_ips = ["…", "…"]\n` (matches the Go struct tag `toml:"banned_ips"`). + +### Go `--output env` + +Fails with `LegacyNetworkBansEnvNotSupportedError`, matching Go's `utils.ErrEnvNotSupported`. ### `--output-format json` -Single JSON object emitted to stdout on success. +The full `V1ListAllNetworkBansOutput` response object (`{ banned_ipv4_addresses: string[] }`) emitted as the `success` event payload. Note: the Go `--output json` mode emits only the bare array — the TS-native `--output-format json` mode wraps it in the response object for consistency with other TS-native commands. ### `--output-format stream-json` -One `result` event on success. - -```ndjson -{"type":"result","data":{...}} -``` +One `result` event whose `data` is the full response object. ## Notes -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- The Go `--output` flag wins over the TS `--output-format` flag when both are provided. +- `linked-project.json` is written **after** the project ref is resolved, regardless of whether the subsequent API call succeeds (mirrors Go's `PersistentPostRun`). +- `telemetry.json` is written on every invocation, including failures. - Network bans are temporary blocks on IPs with abusive traffic patterns (e.g. multiple failed auth attempts). diff --git a/apps/cli/src/legacy/commands/network-bans/get/get.command.ts b/apps/cli/src/legacy/commands/network-bans/get/get.command.ts index edb5255718..0789bcf660 100644 --- a/apps/cli/src/legacy/commands/network-bans/get/get.command.ts +++ b/apps/cli/src/legacy/commands/network-bans/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 { legacyNetworkBansGet } from "./get.handler.ts"; const config = { @@ -14,5 +18,11 @@ export type LegacyNetworkBansGetFlags = CliCommand.Command.Config.Infer legacyNetworkBansGet(flags)), + Command.withHandler((flags) => + legacyNetworkBansGet(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["network-bans", "get"])), ); diff --git a/apps/cli/src/legacy/commands/network-bans/get/get.handler.ts b/apps/cli/src/legacy/commands/network-bans/get/get.handler.ts index e9954b259b..0766a4aaf9 100644 --- a/apps/cli/src/legacy/commands/network-bans/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/network-bans/get/get.handler.ts @@ -1,12 +1,85 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { encodeGoJson, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { encodeBannedIpsToml } from "../network-bans.encoders.ts"; +import { + LegacyNetworkBansEnvNotSupportedError, + LegacyNetworkBansGetNetworkError, + LegacyNetworkBansGetUnexpectedStatusError, +} from "../network-bans.errors.ts"; import type { LegacyNetworkBansGetFlags } from "./get.command.ts"; +const mapGetError = mapLegacyHttpError({ + networkError: LegacyNetworkBansGetNetworkError, + statusError: LegacyNetworkBansGetUnexpectedStatusError, + networkMessage: (cause) => `failed to list network bans: ${cause}`, + statusMessage: (status, body) => `unexpected list bans status ${status}: ${body}`, +}); + export const legacyNetworkBansGet = Effect.fn("legacy.network-bans.get")(function* ( flags: LegacyNetworkBansGetFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["network-bans", "get"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const legacyOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const fetching = + output.format === "text" ? yield* output.task("Fetching network bans...") : undefined; + const response = yield* api.v1.listAllNetworkBans({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapGetError), + ); + yield* fetching?.clear() ?? Effect.void; + + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); + + // TS-native machine-readable modes skip the stderr heading for clean output. + // Go --output takes priority (CLAUDE.md item 6), so this only fires when the + // legacy flag is unset. + if ( + legacyOutput === undefined && + (output.format === "json" || output.format === "stream-json") + ) { + yield* output.success("", response); + return; + } + + // Go's `get.Run` prints `DB banned IPs:` to stderr unconditionally before + // the format switch (`apps/cli-go/internal/bans/get/get.go:19`), including + // for `--output env` (which then errors). + yield* output.raw("DB banned IPs:\n", "stderr"); + + if (legacyOutput === "env") { + return yield* new LegacyNetworkBansEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } + if (legacyOutput === "yaml") { + yield* output.raw(encodeYaml(response.banned_ipv4_addresses)); + return; + } + if (legacyOutput === "toml") { + yield* output.raw(encodeBannedIpsToml(response.banned_ipv4_addresses)); + return; + } + + // Default and `--output {json,pretty}`. Go aliases `pretty` → `json` in + // `get.go:21-23` and falls through to `EncodeOutput(format, ips)`. + yield* output.raw(encodeGoJson(response.banned_ipv4_addresses)); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/network-bans/get/get.integration.test.ts b/apps/cli/src/legacy/commands/network-bans/get/get.integration.test.ts new file mode 100644 index 0000000000..67ce998fc3 --- /dev/null +++ b/apps/cli/src/legacy/commands/network-bans/get/get.integration.test.ts @@ -0,0 +1,258 @@ +import { type V1ListAllNetworkBansOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyNetworkBansGet } from "./get.handler.ts"; + +const SAMPLE_BANS: typeof V1ListAllNetworkBansOutput.Type = { + banned_ipv4_addresses: ["192.168.0.1", "192.168.0.2"], +}; + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + legacyOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + response?: typeof V1ListAllNetworkBansOutput.Type; + status?: number; + network?: "fail"; +} + +const tempRoot = useLegacyTempWorkdir("supabase-network-bans-get-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 201, body: opts.response ?? SAMPLE_BANS }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + goOutput: opts.legacyOutput === undefined ? Option.none() : Option.some(opts.legacyOutput), + }); + return { layer, out, api }; +} + +function setupTracked(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 201, body: opts.response ?? SAMPLE_BANS }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + return { layer, out, api, telemetry, cache }; +} + +describe("legacy network-bans get integration", () => { + it.live("writes the stderr heading and JSON array bytes in text mode", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.none() }); + expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stdoutText).toBe( + `[ + "192.168.0.1", + "192.168.0.2" +] +`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits TOML bytes for --output toml", () => { + const { layer, out } = setup({ legacyOutput: "toml" }); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.none() }); + expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stdoutText).toBe('banned_ips = ["192.168.0.1", "192.168.0.2"]\n'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits YAML bytes for --output yaml", () => { + const { layer, out } = setup({ legacyOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.none() }); + expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stdoutText).toContain("192.168.0.1"); + expect(out.stdoutText).toContain("192.168.0.2"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits Go-compatible JSON bytes for --output json", () => { + const { layer, out } = setup({ legacyOutput: "json" }); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.none() }); + expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stdoutText).toBe( + `[ + "192.168.0.1", + "192.168.0.2" +] +`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("treats --output pretty as a JSON alias matching Go's get.go switch", () => { + const { layer, out } = setup({ legacyOutput: "pretty" }); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.none() }); + expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stdoutText).toBe( + `[ + "192.168.0.1", + "192.168.0.2" +] +`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with the env-not-supported error for --output env", () => { + const { layer, out } = setup({ legacyOutput: "env" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyNetworkBansGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.stderrText).toBe("DB banned IPs:\n"); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacyNetworkBansEnvNotSupportedError"); + expect(errJson).toContain("--output env flag is not supported"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured JSON success payload via --output-format=json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ + banned_ipv4_addresses: ["192.168.0.1", "192.168.0.2"], + }); + // TS-native machine-readable modes skip the stderr heading. + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event via --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ + banned_ipv4_addresses: ["192.168.0.1", "192.168.0.2"], + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("Go --output wins over TS --output-format when both are set", () => { + const { layer, out } = setup({ format: "json", legacyOutput: "toml" }); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.none() }); + expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stdoutText).toBe('banned_ips = ["192.168.0.1", "192.168.0.2"]\n'); + }).pipe(Effect.provide(layer)); + }); + + it.live("posts to the /network-bans/retrieve endpoint with the resolved ref", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.none() }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.method).toBe("POST"); + expect(api.requests[0]?.url).toContain( + `/v1/projects/${LEGACY_VALID_REF}/network-bans/retrieve`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses --project-ref flag value over LegacyCliConfig.projectId", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.some(flagRef) }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyNetworkBansGetUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyNetworkBansGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacyNetworkBansGetUnexpectedStatusError"); + expect(errJson).toContain("unexpected list bans status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("reports a network error when the API transport fails", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyNetworkBansGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacyNetworkBansGetNetworkError"); + expect(errJson).toContain("failed to list network bans:"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { + const { layer, out } = setup({ format: "json", status: 503 }); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + expect(out.messages.some((m) => m.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache on success", () => { + const { layer, telemetry, cache } = setupTracked(); + return Effect.gen(function* () { + yield* legacyNetworkBansGet({ projectRef: Option.none() }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry even on API failure", () => { + const { layer, telemetry, cache } = setupTracked({ status: 500 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyNetworkBansGet({ projectRef: Option.none() }).pipe(Effect.provide(layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(telemetry.flushed).toBe(true); + // Linked-project cache wraps the inner effect, so it still fires after ref + // resolution succeeded — matches Go's PersistentPostRun once ref is known. + expect(cache.cached).toBe(true); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/network-bans/network-bans.encoders.ts b/apps/cli/src/legacy/commands/network-bans/network-bans.encoders.ts new file mode 100644 index 0000000000..c30f84c212 --- /dev/null +++ b/apps/cli/src/legacy/commands/network-bans/network-bans.encoders.ts @@ -0,0 +1,8 @@ +/** + * Renders a `banned_ips = ["…", "…"]` TOML line, matching Go's encoding of + * `struct { BannedIPs []string `toml:"banned_ips"` }` in + * `apps/cli-go/internal/bans/get/get.go:24-29`. + */ +export function encodeBannedIpsToml(ips: ReadonlyArray): string { + return `banned_ips = [${ips.map((ip) => JSON.stringify(ip)).join(", ")}]\n`; +} diff --git a/apps/cli/src/legacy/commands/network-bans/network-bans.encoders.unit.test.ts b/apps/cli/src/legacy/commands/network-bans/network-bans.encoders.unit.test.ts new file mode 100644 index 0000000000..427d341c1c --- /dev/null +++ b/apps/cli/src/legacy/commands/network-bans/network-bans.encoders.unit.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { encodeBannedIpsToml } from "./network-bans.encoders.ts"; + +describe("encodeBannedIpsToml", () => { + it("emits a single TOML key with a JSON-escaped string array", () => { + expect(encodeBannedIpsToml(["1.2.3.4", "5.6.7.8"])).toBe( + 'banned_ips = ["1.2.3.4", "5.6.7.8"]\n', + ); + }); + + it("emits an empty array for an empty input", () => { + expect(encodeBannedIpsToml([])).toBe("banned_ips = []\n"); + }); + + it("JSON-escapes characters that would otherwise break the array literal", () => { + expect(encodeBannedIpsToml(['1.2.3.4"; evil'])).toBe('banned_ips = ["1.2.3.4\\"; evil"]\n'); + }); +}); diff --git a/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts b/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts new file mode 100644 index 0000000000..6e9459a1fb --- /dev/null +++ b/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts @@ -0,0 +1,46 @@ +import { Data } from "effect"; + +export class LegacyNetworkBansGetNetworkError extends Data.TaggedError( + "LegacyNetworkBansGetNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyNetworkBansGetUnexpectedStatusError extends Data.TaggedError( + "LegacyNetworkBansGetUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyNetworkBansRemoveNetworkError extends Data.TaggedError( + "LegacyNetworkBansRemoveNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyNetworkBansRemoveUnexpectedStatusError extends Data.TaggedError( + "LegacyNetworkBansRemoveUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyNetworkBansEnvNotSupportedError extends Data.TaggedError( + "LegacyNetworkBansEnvNotSupportedError", +)<{ + readonly message: string; +}> {} + +export class LegacyNetworkBansInvalidIpError extends Data.TaggedError( + "LegacyNetworkBansInvalidIpError", +)<{ + readonly input: string; + readonly message: string; +}> { + constructor(args: { readonly input: string }) { + super({ input: args.input, message: `invalid IP address: ${args.input}` }); + } +} diff --git a/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md index 719636034f..f1252d674b 100644 --- a/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md @@ -2,51 +2,71 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` — on success and failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | --------------------------------- | ------------ | ---------------------------- | ---------------------- | -| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ipv4_addresses: string[]}` | none | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | --------------------------------- | ------------ | --------------------------------------------------- | ---------------------- | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ipv4_addresses: string[], requester_ip: boolean}` | none | + +`requester_ip` is `true` when no `--db-unban-ip` flags are passed (self-unban mode) and `false` otherwise — matching Go's `len(addrs) == 0` derivation in `apps/cli-go/internal/utils/flags/db_url.go:UnbanIP`. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------- | -| `0` | success — network ban removed | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from network bans endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | success — network ban removed | +| `1` | invalid IP supplied via `--db-unban-ip` (`LegacyNetworkBansInvalidIpError`) | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyNetworkBansRemoveUnexpectedStatusError`) | +| `1` | transport failure (`LegacyNetworkBansRemoveNetworkError`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → ``, `--db-unban-ip` → ``) | + +Matches `apps/cli-go/internal/bans/update/`. Go does not fire any custom telemetry event for this command. ## Output -### `--output-format text` (Go CLI compatible) +Matches Go's `PostRun` hook — `Successfully removed network bans.\n` is always written to stdout regardless of the legacy `--output` flag (Go's `update.Run` does not read `OutputFormat`). + +### `--output-format text` (default) — Go CLI compatible + +Prints `Successfully removed network bans.\n` to stdout. + +### Go `--output {json,pretty,yaml,toml,env}` -Prints "Successfully removed network bans." to stdout. +Identical to text mode — Go's `update.Run` ignores `--output` and `PostRun` always prints the success line. ### `--output-format json` -Single JSON object emitted to stdout on success. +Single `success` event emitted to stdout when the Go `--output` flag is unset. When Go `--output` is set, the raw text line is emitted instead (Go priority). ### `--output-format stream-json` -One `result` event on success. +One `result` event on success when the Go `--output` flag is unset. ```ndjson {"type":"result","data":{...}} @@ -54,5 +74,8 @@ One `result` event on success. ## Notes -- Requires `--db-unban-ip` flag to specify IP(s) to unban (repeatable). +- The Go `--output` flag wins over the TS `--output-format` flag when both are provided. +- Requires `--db-unban-ip` flag to specify IP(s) to unban (repeatable). When omitted, the caller's own IP is unbanned (`requester_ip: true`). - Requires `--project-ref` or a linked project (`.supabase/config.json`). +- `linked-project.json` is written **after** the project ref is resolved, regardless of whether the subsequent API call succeeds (mirrors Go's `PersistentPostRun`). +- `telemetry.json` is written on every invocation, including failures. diff --git a/apps/cli/src/legacy/commands/network-bans/remove/remove.command.ts b/apps/cli/src/legacy/commands/network-bans/remove/remove.command.ts index fcd73389f8..dde28a344d 100644 --- a/apps/cli/src/legacy/commands/network-bans/remove/remove.command.ts +++ b/apps/cli/src/legacy/commands/network-bans/remove/remove.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 { legacyNetworkBansRemove } from "./remove.handler.ts"; const config = { @@ -18,5 +22,11 @@ export type LegacyNetworkBansRemoveFlags = CliCommand.Command.Config.Infer legacyNetworkBansRemove(flags)), + Command.withHandler((flags) => + legacyNetworkBansRemove(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["network-bans", "remove"])), ); diff --git a/apps/cli/src/legacy/commands/network-bans/remove/remove.handler.ts b/apps/cli/src/legacy/commands/network-bans/remove/remove.handler.ts index 8a53362706..4825dcfc89 100644 --- a/apps/cli/src/legacy/commands/network-bans/remove/remove.handler.ts +++ b/apps/cli/src/legacy/commands/network-bans/remove/remove.handler.ts @@ -1,15 +1,68 @@ +import { isIP } from "node:net"; import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyNetworkBansInvalidIpError, + LegacyNetworkBansRemoveNetworkError, + LegacyNetworkBansRemoveUnexpectedStatusError, +} from "../network-bans.errors.ts"; import type { LegacyNetworkBansRemoveFlags } from "./remove.command.ts"; +const mapRemoveError = mapLegacyHttpError({ + networkError: LegacyNetworkBansRemoveNetworkError, + statusError: LegacyNetworkBansRemoveUnexpectedStatusError, + networkMessage: (cause) => `failed to remove network bans: ${cause}`, + statusMessage: (status, body) => `unexpected unban status ${status}: ${body}`, +}); + export const legacyNetworkBansRemove = Effect.fn("legacy.network-bans.remove")(function* ( flags: LegacyNetworkBansRemoveFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["network-bans", "remove"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - for (const ip of flags.dbUnbanIp) { - args.push("--db-unban-ip", ip); - } - yield* proxy.exec(args); + const output = yield* Output; + const legacyOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + for (const ip of flags.dbUnbanIp) { + if (isIP(ip) === 0) { + return yield* new LegacyNetworkBansInvalidIpError({ input: ip }); + } + } + + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + yield* api.v1 + .deleteNetworkBans({ + ref, + ipv4_addresses: [...flags.dbUnbanIp], + requester_ip: flags.dbUnbanIp.length === 0, + }) + .pipe(Effect.catch(mapRemoveError)); + + // Go's `bansRemoveCmd.PostRun` always prints the success line to stdout + // regardless of `--output` (`apps/cli-go/cmd/bans.go:28-30`). The TS-native + // `--output-format json/stream-json` modes emit a structured success event + // instead, but only when Go `--output` is unset (Go priority — CLAUDE.md item 6). + if ( + Option.isNone(legacyOutputFlag) && + (output.format === "json" || output.format === "stream-json") + ) { + yield* output.success("Successfully removed network bans."); + return; + } + + yield* output.raw("Successfully removed network bans.\n"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts b/apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts new file mode 100644 index 0000000000..f3f426de20 --- /dev/null +++ b/apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyNetworkBansRemove } from "./remove.handler.ts"; + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + legacyOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + status?: number; + network?: "fail"; +} + +const tempRoot = useLegacyTempWorkdir("supabase-network-bans-remove-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: null }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + goOutput: opts.legacyOutput === undefined ? Option.none() : Option.some(opts.legacyOutput), + }); + return { layer, out, api }; +} + +function setupTracked(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: null }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + return { layer, out, api, telemetry, cache }; +} + +describe("legacy network-bans remove integration", () => { + it.live("removes bans and prints the success line in text mode", () => { + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: [], + }); + expect(out.stdoutText).toBe("Successfully removed network bans.\n"); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.method).toBe("DELETE"); + expect(api.requests[0]?.url).toContain(`/v1/projects/${LEGACY_VALID_REF}/network-bans`); + expect(api.requests[0]?.body).toEqual({ ipv4_addresses: [], requester_ip: true }); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends the expected request body when explicit IPs are provided", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: ["12.3.4.5", "2001:db8:abcd:0012::0"], + }); + expect(api.requests[0]?.body).toEqual({ + ipv4_addresses: ["12.3.4.5", "2001:db8:abcd:0012::0"], + requester_ip: false, + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("ignores legacy --output values and still prints the success line", () => { + const { layer, out } = setup({ legacyOutput: "json" }); + return Effect.gen(function* () { + yield* legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: [], + }); + expect(out.stdoutText).toBe("Successfully removed network bans.\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("ignores legacy --output yaml and still prints the success line", () => { + const { layer, out } = setup({ legacyOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: [], + }); + expect(out.stdoutText).toBe("Successfully removed network bans.\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event for --output-format=json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: [], + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Successfully removed network bans."); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event for --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: [], + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Successfully removed network bans."); + }).pipe(Effect.provide(layer)); + }); + + it.live("Go --output wins over TS --output-format when both are set", () => { + const { layer, out } = setup({ format: "json", legacyOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: [], + }); + expect(out.stdoutText).toBe("Successfully removed network bans.\n"); + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails before any API call when an IP is invalid", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: ["12.3.4"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(0); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacyNetworkBansInvalidIpError"); + expect(errJson).toContain("invalid IP address: 12.3.4"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyNetworkBansRemoveUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: [], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacyNetworkBansRemoveUnexpectedStatusError"); + expect(errJson).toContain("unexpected unban status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("reports a network error when the API transport fails", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: [], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacyNetworkBansRemoveNetworkError"); + expect(errJson).toContain("failed to remove network bans:"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { + const { layer, out } = setup({ format: "json", status: 503 }); + return Effect.gen(function* () { + yield* legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: [], + }).pipe(withJsonErrorHandling); + expect(out.messages.some((m) => m.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache on success", () => { + const { layer, telemetry, cache } = setupTracked(); + return Effect.gen(function* () { + yield* legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: [], + }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry even on API failure", () => { + const { layer, telemetry, cache } = setupTracked({ status: 500 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: [], + }).pipe(Effect.provide(layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }); + }); +}); From 98fc0e151562bb3e9f6d9e8e16028cbaaa380c5d Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Fri, 29 May 2026 12:21:19 +0200 Subject: [PATCH 02/16] chore(ci): gh action hardening (#5388) ## What kind of change does this PR introduce? chore ## Additional context Applies most of zizmor's hardening suggestions. Brings some action usage up to date (client-id instead of app-id). And does some action pinning. Co-authored-by: Julien Goux --- .github/actions/setup/action.yml | 10 ++-- .github/dependabot.yml | 8 ++++ .github/workflows/automerge.yml | 17 ++++--- .github/workflows/cli-go-api-sync.yml | 18 +++++--- .github/workflows/cli-go-ci.yml | 10 ++++ .github/workflows/cli-go-codeql.yml | 2 + .github/workflows/cli-go-mirror-image.yml | 6 ++- .github/workflows/cli-go-mirror.yml | 7 ++- .github/workflows/cli-go-pg-prove.yml | 5 +- .github/workflows/cli-go-publish-migra.yml | 5 +- .github/workflows/cli-go-tag-pkg.yml | 5 +- .github/workflows/deploy.yml | 9 ++-- .github/workflows/release-shared.yml | 54 +++++++++++++++------- .github/workflows/release.yml | 11 ++++- .github/workflows/setup-cli-smoke-test.yml | 8 ++-- .github/workflows/smoke-test-pr.yml | 3 +- .github/workflows/test.yml | 24 ++++++---- 17 files changed, 139 insertions(+), 63 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 90c0795049..bf7bd89085 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -11,21 +11,21 @@ runs: - name: Restore Bun toolchain cache id: bun-toolchain-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: /opt/hostedtoolcache/bun key: bun-toolchain-${{ runner.os }}-${{ runner.arch }}-${{ env.BUN_VERSION }} - name: Install Bun id: install-bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 continue-on-error: true with: bun-version: ${{ env.BUN_VERSION }} - name: Install Bun (fallback with retries) if: steps.install-bun.outcome == 'failure' - uses: nick-fields/retry@v3 + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 with: timeout_minutes: 3 max_attempts: 5 @@ -39,7 +39,7 @@ runs: run: bun --version - name: Install Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .nvmrc package-manager-cache: false @@ -49,7 +49,7 @@ runs: run: npm install --global --force corepack && corepack enable - name: Configure dependency cache - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: cache: pnpm diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cb2ebe6d72..0889e6501e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,8 @@ updates: actions-major: patterns: - "*" + cooldown: + default-days: 7 - package-ecosystem: "gomod" directories: - "/" @@ -23,6 +25,8 @@ updates: - patch exclude-patterns: - github.com/compose-spec/compose-go/v2 + cooldown: + default-days: 7 - package-ecosystem: "npm" directory: "/" schedule: @@ -32,6 +36,8 @@ updates: npm-major: patterns: - "*" + cooldown: + default-days: 7 - package-ecosystem: "docker" directory: "/apps/cli-go/pkg/config/templates" schedule: @@ -49,3 +55,5 @@ updates: - dependency-name: "axllent/mailpit" - dependency-name: "darthsim/imgproxy" - dependency-name: "timberio/vector" + cooldown: + default-days: 7 diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index 48fa2ee1ab..cafc5eedca 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -4,15 +4,14 @@ name: Dependabot auto-merge on: pull_request permissions: - pull-requests: write - contents: write + contents: read jobs: dependabot: runs-on: ubuntu-latest # Checking the actor will prevent your Action run failing on non-Dependabot # PRs but also ensures that it only does work for Dependabot PRs. - if: ${{ github.actor == 'dependabot[bot]' }} + if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == github.event.pull_request.head.repo.full_name steps: # This first step will fail if there's no metadata and so the approval # will not occur. @@ -27,20 +26,24 @@ jobs: if: ${{ steps.meta.outputs.update-type == null || steps.meta.outputs.update-type == 'version-update:semver-patch' || (!startsWith(steps.meta.outputs.previous-version, '0.') && steps.meta.outputs.update-type == 'version-update:semver-minor') }} uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-pull-requests: write + permission-contents: write # Here the PR gets approved. - name: Approve a PR if: ${{ steps.meta.outputs.update-type == null || steps.meta.outputs.update-type == 'version-update:semver-patch' || (!startsWith(steps.meta.outputs.previous-version, '0.') && steps.meta.outputs.update-type == 'version-update:semver-minor') }} - run: gh pr review --approve "${{ github.event.pull_request.html_url }}" + run: gh pr review --approve "${GITHUB_EVENT_PULL_REQUEST_HTML_URL}" env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + GITHUB_EVENT_PULL_REQUEST_HTML_URL: ${{ github.event.pull_request.html_url }} # Finally, this sets the PR to allow auto-merging for patch and minor # updates if all checks pass - name: Enable auto-merge for Dependabot PRs if: ${{ steps.meta.outputs.update-type == null || steps.meta.outputs.update-type == 'version-update:semver-patch' || (!startsWith(steps.meta.outputs.previous-version, '0.') && steps.meta.outputs.update-type == 'version-update:semver-minor') }} - run: gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}" + run: gh pr merge --auto --squash "${GITHUB_EVENT_PULL_REQUEST_HTML_URL}" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + GITHUB_EVENT_PULL_REQUEST_HTML_URL: ${{ github.event.pull_request.html_url }} diff --git a/.github/workflows/cli-go-api-sync.yml b/.github/workflows/cli-go-api-sync.yml index 6e3f7fc03c..7bd7f4bf87 100644 --- a/.github/workflows/cli-go-api-sync.yml +++ b/.github/workflows/cli-go-api-sync.yml @@ -5,9 +5,9 @@ on: types: - api-sync workflow_dispatch: # allow manual triggering + permissions: - contents: write - pull-requests: write + contents: read jobs: sync: @@ -15,6 +15,8 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -39,8 +41,10 @@ jobs: id: app-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-pull-requests: write + permission-contents: write - name: Create Pull Request if: steps.check.outputs.has_changes == 'true' @@ -60,15 +64,17 @@ jobs: - name: Approve a PR if: steps.check.outputs.has_changes == 'true' && steps.cpr.outputs.pull-request-operation == 'created' continue-on-error: true - run: gh pr review --approve --repo "${{ github.repository }}" "${{ steps.cpr.outputs.pull-request-number }}" + run: gh pr review --approve --repo "${{ github.repository }}" "${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} - name: Enable Pull Request Automerge if: steps.check.outputs.has_changes == 'true' - run: gh pr merge --auto --squash --repo "${{ github.repository }}" "${{ steps.cpr.outputs.pull-request-number }}" + run: gh pr merge --auto --squash --repo "${{ github.repository }}" "${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} defaults: run: working-directory: apps/cli-go diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml index 07edcdc37b..58482c2e39 100644 --- a/.github/workflows/cli-go-ci.yml +++ b/.github/workflows/cli-go-ci.yml @@ -22,6 +22,8 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: @@ -59,6 +61,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: @@ -78,6 +82,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: apps/cli-go/go.mod @@ -102,6 +108,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: apps/cli-go/go.mod @@ -117,6 +125,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index c43b57572b..9b5f9168e7 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -64,6 +64,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/cli-go-mirror-image.yml index d3e34f2bf3..541f89cc3d 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -9,6 +9,9 @@ on: image: required: true type: string + secrets: + PROD_AWS_ROLE: + required: true workflow_dispatch: inputs: image: @@ -26,8 +29,9 @@ jobs: runs-on: ubuntu-latest steps: - id: strip + env: + TAG: ${{ github.event.client_payload.image || inputs.image }} run: | - TAG=${{ github.event.client_payload.image || inputs.image }} echo "image=${TAG##*/}" >> $GITHUB_OUTPUT - name: configure aws credentials uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 diff --git a/.github/workflows/cli-go-mirror.yml b/.github/workflows/cli-go-mirror.yml index 30ad074580..03eaf4bfd5 100644 --- a/.github/workflows/cli-go-mirror.yml +++ b/.github/workflows/cli-go-mirror.yml @@ -28,6 +28,8 @@ jobs: curr: ${{ steps.curr.outputs.tags }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: apps/cli-go/go.mod @@ -55,10 +57,11 @@ jobs: matrix: src: ${{ fromJson(needs.setup.outputs.tags) }} # Call workflow explicitly because events from actions cannot trigger more actions - uses: ./.github/workflows/mirror-image.yml + uses: ./.github/workflows/cli-go-mirror-image.yml with: image: ${{ matrix.src }} - secrets: inherit + secrets: + PROD_AWS_ROLE: ${{ secrets.PROD_AWS_ROLE }} defaults: run: working-directory: apps/cli-go diff --git a/.github/workflows/cli-go-pg-prove.yml b/.github/workflows/cli-go-pg-prove.yml index 4d732d3b78..1dff403bfc 100644 --- a/.github/workflows/cli-go-pg-prove.yml +++ b/.github/workflows/cli-go-pg-prove.yml @@ -86,10 +86,11 @@ jobs: - settings - merge_manifest # Call workflow explicitly because events from actions cannot trigger more actions - uses: ./.github/workflows/mirror-image.yml + uses: ./.github/workflows/cli-go-mirror-image.yml with: image: ${{ needs.settings.outputs.image_tag }} - secrets: inherit + secrets: + PROD_AWS_ROLE: ${{ secrets.PROD_AWS_ROLE }} defaults: run: working-directory: apps/cli-go diff --git a/.github/workflows/cli-go-publish-migra.yml b/.github/workflows/cli-go-publish-migra.yml index cacfdbff64..d62bc19ebc 100644 --- a/.github/workflows/cli-go-publish-migra.yml +++ b/.github/workflows/cli-go-publish-migra.yml @@ -86,10 +86,11 @@ jobs: - settings - merge_manifest # Call workflow explicitly because events from actions cannot trigger more actions - uses: ./.github/workflows/mirror-image.yml + uses: ./.github/workflows/cli-go-mirror-image.yml with: image: ${{ needs.settings.outputs.image_tag }} - secrets: inherit + secrets: + PROD_AWS_ROLE: ${{ secrets.PROD_AWS_ROLE }} defaults: run: working-directory: apps/cli-go diff --git a/.github/workflows/cli-go-tag-pkg.yml b/.github/workflows/cli-go-tag-pkg.yml index bf6c625585..b1658e3127 100644 --- a/.github/workflows/cli-go-tag-pkg.yml +++ b/.github/workflows/cli-go-tag-pkg.yml @@ -22,10 +22,11 @@ jobs: with: ref: develop fetch-depth: 0 + persist-credentials: false - name: Create and push pkg tag run: | - VERSION="${{ inputs.version }}" + VERSION="${INPUTS_VERSION}" if ! [[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "Error: version '$VERSION' does not match semver format (e.g. v1.2.2)" exit 1 @@ -37,6 +38,8 @@ jobs: fi git tag "$TAG" git push origin "$TAG" + env: + INPUTS_VERSION: ${{ inputs.version }} defaults: run: working-directory: apps/cli-go diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ad7c8a0368..94d46b7686 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,8 +7,7 @@ on: workflow_dispatch: permissions: - pull-requests: write - contents: write + contents: read jobs: deploy: @@ -17,11 +16,15 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + persist-credentials: false - id: app-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - run: "gh pr create -B main -H develop --title 'chore: production deploy' --label 'do not merge' --fill" env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 1a6f841e7b..4c9fe3b8f7 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -42,7 +42,15 @@ on: required: false type: string default: supabase - + secrets: + SENTRY_DSN: + required: false + POSTHOG_API_KEY: + required: false + POSTHOG_ENDPOINT: + required: false + GH_APP_PRIVATE_KEY: + required: false jobs: build: runs-on: blacksmith-32vcpu-ubuntu-2404 @@ -54,13 +62,15 @@ jobs: POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: apps/cli-go/go.mod cache: true @@ -92,7 +102,7 @@ jobs: ls -la dist/ - name: Upload build artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} path: | @@ -118,13 +128,15 @@ jobs: VERSION: ${{ inputs.version }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} @@ -144,7 +156,7 @@ jobs: - name: Setup QEMU for cross-platform Docker if: runner.os == 'Linux' - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 # Cache the smoke-test base images across runs. Without this, eight # parallel `docker run` calls in smoke-test-linux.ts race on first-time @@ -154,7 +166,7 @@ jobs: - name: Cache smoke-test docker images if: runner.os == 'Linux' id: smoke-docker-cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cache/smoke-docker-images.tar key: smoke-docker-images-debian-bookworm-slim-amazonlinux-2023-alpine-3.21-v1 @@ -223,13 +235,15 @@ jobs: id-token: write steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: true - name: Setup uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} @@ -298,7 +312,7 @@ jobs: done - name: Create draft GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: tag_name: v${{ inputs.version }} name: v${{ inputs.version }} @@ -352,13 +366,15 @@ jobs: VERSION: ${{ inputs.version }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} @@ -366,11 +382,12 @@ jobs: id: app-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: | homebrew-tap + permission-contents: write - name: Configure git for tap push env: @@ -394,13 +411,15 @@ jobs: VERSION: ${{ inputs.version }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} @@ -408,11 +427,12 @@ jobs: id: app-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: | scoop-bucket + permission-contents: write - name: Configure git for bucket push env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dfa2a75ed..bb209b899b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,10 +53,12 @@ jobs: - id: app-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-contents: write - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 with: + persist-credentials: true fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} - name: Fast-forward main @@ -92,6 +94,7 @@ jobs: with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-contents: write # `persist-credentials: false` is required: otherwise checkout caches the # default GITHUB_TOKEN as an `http.extraheader` in git config, and that # Authorization header overrides the App token semantic-release puts in @@ -200,4 +203,8 @@ jobs: scoop_name: ${{ needs.plan.outputs.scoop_name }} dry_run: ${{ needs.plan.outputs.dry_run == 'true' }} channel: ${{ needs.plan.outputs.channel }} - secrets: inherit + secrets: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/setup-cli-smoke-test.yml b/.github/workflows/setup-cli-smoke-test.yml index 0fd423f907..11b8139ebb 100644 --- a/.github/workflows/setup-cli-smoke-test.yml +++ b/.github/workflows/setup-cli-smoke-test.yml @@ -48,12 +48,12 @@ jobs: steps: - name: Install Supabase CLI via setup-cli@v1 if: matrix.major-version == 'v1' - uses: supabase/setup-cli@v1 + uses: supabase/setup-cli@ab058987d8d6c725971f6cf9d0b5c98467e30bd1 # v1.7.1 with: version: ${{ inputs.version }} - name: Install Supabase CLI via setup-cli@v2 if: matrix.major-version == 'v2' - uses: supabase/setup-cli@v2 + uses: supabase/setup-cli@3c2f5e2ae34c34e428e8e206e2c4d21fa2d20fbf # v2.1.1 with: version: ${{ inputs.version }} - name: Verify supabase --version matches the expected version @@ -100,12 +100,12 @@ jobs: run: apk add --no-cache bash curl tar - name: Install Supabase CLI via setup-cli@v1 if: matrix.major-version == 'v1' - uses: supabase/setup-cli@v1 + uses: supabase/setup-cli@ab058987d8d6c725971f6cf9d0b5c98467e30bd1 # v1.7.1 with: version: ${{ inputs.version }} - name: Install Supabase CLI via setup-cli@v2 if: matrix.major-version == 'v2' - uses: supabase/setup-cli@v2 + uses: supabase/setup-cli@3c2f5e2ae34c34e428e8e206e2c4d21fa2d20fbf # v2.1.1 with: version: ${{ inputs.version }} - name: Verify supabase --version matches the expected version diff --git a/.github/workflows/smoke-test-pr.yml b/.github/workflows/smoke-test-pr.yml index 4879139593..0dfd0fb19c 100644 --- a/.github/workflows/smoke-test-pr.yml +++ b/.github/workflows/smoke-test-pr.yml @@ -58,4 +58,5 @@ jobs: # `!inputs.dry_run` and never execute here, but GitHub validates secret # references at startup, so the called workflow needs the secrets bag # propagated even when the jobs that use them are skipped. - secrets: inherit + secrets: + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b98860a0e7..850bc66c3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,12 +28,14 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: apps/cli-go/go.mod cache-dependency-path: apps/cli-go/go.sum @@ -41,7 +43,7 @@ jobs: run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest && echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Unlock keyring (for cli-go keyring tests) - uses: t1m0thyj/unlock-keyring@cbcf205c879ebd86add70bab3a6abfcce59a5cae + uses: t1m0thyj/unlock-keyring@cbcf205c879ebd86add70bab3a6abfcce59a5cae # v1.2.0 - name: Check code quality run: pnpm run check:all @@ -52,12 +54,14 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup uses: ./.github/actions/setup - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: apps/cli-go/go.mod cache-dependency-path: apps/cli-go/go.sum @@ -65,7 +69,7 @@ jobs: run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest && echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Unlock keyring (for cli-go keyring tests) - uses: t1m0thyj/unlock-keyring@cbcf205c879ebd86add70bab3a6abfcce59a5cae + uses: t1m0thyj/unlock-keyring@cbcf205c879ebd86add70bab3a6abfcce59a5cae # v1.2.0 - name: Run unit and integration tests run: pnpm run test:core @@ -80,13 +84,13 @@ jobs: shard: [ 1, 2, 3 ] steps: - name: Checkout - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1.0.0-beta with: fetch-depth: 0 - name: Set base and head SHAs for affected if: github.event_name == 'pull_request' - uses: nrwl/nx-set-shas@v4 + uses: nrwl/nx-set-shas@3e9ad7370203c1e93d109be57f3b72eb0eb511b1 # v4.4.0 - name: Setup uses: ./.github/actions/setup @@ -114,7 +118,7 @@ jobs: - name: Cache Go CLI binary if: steps.detect.outputs.cli_e2e == 'true' id: cache-go-binary - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: apps/cli-go/supabase-go key: go-cli-${{ runner.os }}-${{ hashFiles('apps/cli-go/**/*.go', @@ -123,7 +127,7 @@ jobs: - name: Setup Go if: steps.detect.outputs.cli_e2e == 'true' && steps.cache-go-binary.outputs.cache-hit != 'true' - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: apps/cli-go/go.mod cache-dependency-path: apps/cli-go/go.sum From e4649dd380850e034f7ac5264e099629866d2d3f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 10:26:46 +0000 Subject: [PATCH 03/16] fix(docker): bump the docker-minor group across 1 directory with 2 updates (#5385) Bumps the docker-minor group with 2 updates in the /apps/cli-go/pkg/config/templates directory: supabase/realtime and supabase/storage-api. Updates `supabase/realtime` from v2.100.0 to v2.101.0 Updates `supabase/storage-api` from v1.60.0 to v1.60.2 Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index fa434fad4a..cc05eef99a 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -11,8 +11,8 @@ FROM supabase/edge-runtime:v1.74.0 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.5 AS supavisor FROM supabase/gotrue:v2.189.0 AS gotrue -FROM supabase/realtime:v2.100.0 AS realtime -FROM supabase/storage-api:v1.60.0 AS storage +FROM supabase/realtime:v2.101.0 AS realtime +FROM supabase/storage-api:v1.60.2 AS storage FROM supabase/logflare:1.42.0 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ From 1c5942c278b57a2066c176b7d4cdfb622c6f176d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 29 May 2026 11:44:58 +0100 Subject: [PATCH 04/16] ci: notify Slack on beta and stable CLI releases (#5389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Adds a Slack release notifier to the CLI release pipeline. - **New `.github/workflows/slack-notify.yml`** — a reusable workflow (`on: workflow_call`) that posts a release message to a Slack **Incoming Webhook** via `curl`, mirroring the pattern used in `supabase-js`. Takes `version` + `channel` inputs and a required `SLACK_RELEASE_WEBHOOK` secret, and renders a Block Kit message with links to the changelog (GitHub release), the commit, and the workflow run. - **`.github/workflows/release.yml`** — adds a `notify-slack` job that calls the reusable workflow once the release succeeds. ## Why / behaviour - `needs: [plan, release]` with an `if:` that contains no status function keeps the implicit `success()` gate, so the notification only fires when both `plan` and `release` succeeded. - The `if:` further restricts to **non-dry-run beta/stable** cuts — alpha and dry runs stay silent. - Nothing depends on `notify-slack`, so a webhook/Slack failure cannot affect the already-completed release. - Reuses existing `plan` outputs (`version`, `channel`); `secrets: inherit` forwards the webhook, same as the existing `release` job. ## Reviewer note: required repo configuration This needs a new repository secret before it can post: 1. Create a Slack **Incoming Webhook** for the target channel. 2. Add it as a repo secret named **`SLACK_RELEASE_WEBHOOK`** (Settings → Secrets and variables → Actions). Must be a secret (not a variable) so `secrets: inherit` can forward it. A `workflow_dispatch` dry-run will not exercise the Slack job (gated on `dry_run != 'true'` by design); the first real signal is the next merged beta cut from `develop`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .github/workflows/release.yml | 19 ++++++++ .github/workflows/slack-notify.yml | 72 ++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 .github/workflows/slack-notify.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb209b899b..fbe5f0d64a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -208,3 +208,22 @@ jobs: POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }} GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + + # Posts to the release Slack channel once the pipeline succeeds. Listing + # `release` in `needs` without a status function in `if:` keeps the implicit + # success() gate, so this only runs when both plan and release succeeded. + # The `if:` then filters to real (non-dry-run) beta/stable cuts; alpha and + # dry runs stay silent. Nothing depends on this job, so a Slack/webhook + # failure can't affect the already-completed release. + notify-slack: + name: Notify Slack + needs: [plan, release] + if: >- + needs.plan.outputs.dry_run != 'true' && + (needs.plan.outputs.channel == 'beta' || needs.plan.outputs.channel == 'stable') + uses: ./.github/workflows/slack-notify.yml + with: + version: ${{ needs.plan.outputs.version }} + channel: ${{ needs.plan.outputs.channel }} + secrets: + SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} diff --git a/.github/workflows/slack-notify.yml b/.github/workflows/slack-notify.yml new file mode 100644 index 0000000000..d92968c8b2 --- /dev/null +++ b/.github/workflows/slack-notify.yml @@ -0,0 +1,72 @@ +name: Reusable Slack Notification + +on: + workflow_call: + inputs: + version: + description: Released version (without the leading v, e.g. 1.2.3) + required: true + type: string + channel: + description: Release channel (alpha | beta | stable), used to label the message + required: false + type: string + secrets: + SLACK_RELEASE_WEBHOOK: + required: true + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Send Slack notification + # Values flow in through env so the payload heredoc never interpolates + # untrusted strings into the shell — only github.run_id/github.sha are + # inlined, and those are GitHub-controlled. + env: + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + SHA: ${{ github.sha }} + SLACK_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + run: | + set -euo pipefail + + SHORT_SHA="${SHA:0:7}" + HEADER="🚀 Supabase CLI v${VERSION} released" + if [[ "$CHANNEL" == "beta" ]]; then + HEADER="🚀 Supabase CLI v${VERSION} released (beta)" + fi + + CHANGELOG_URL="https://github.com/${REPO}/releases/tag/v${VERSION}" + COMMIT_URL="https://github.com/${REPO}/commit/${SHA}" + RUN_URL="https://github.com/${REPO}/actions/runs/${RUN_ID}" + + payload=$(cat <\n*Commit:* <${COMMIT_URL}|${SHORT_SHA}>\n*Workflow run:* <${RUN_URL}|view run>" + } + }, + { + "type": "context", + "elements": [ + { "type": "mrkdwn", "text": "Channel: ${CHANNEL:-n/a} • ${REPO}" } + ] + } + ] + } + EOF + ) + + curl -fsSL -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK" From 507200333b6ced01f25761c1260a1ebc94b3e4a3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 29 May 2026 11:49:14 +0100 Subject: [PATCH 05/16] feat(cli): port snippets commands to native TypeScript (#5381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replaces the Phase 0 Go-binary proxies for `supabase snippets list` and `supabase snippets download` with native Effect handlers. Mirrors Go's output bytes, error messages, exit codes, and lifecycle (linked-project cache + telemetry flush on every exit). Adds TS-only `--output-format json|stream-json` events on top, exposing the full Management-API payload to scripted callers. ## Changes - **`snippets list`**: native handler against `GET /v1/snippets?project_ref=` with Glamour ASCII table, `--output env` rejection after ref resolution (Go-bytes), Go-compatible JSON/YAML/TOML encoders, `nullForEmptyArrays: ["data"]` for empty-list parity - **`snippets download`**: native handler against `GET /v1/snippets/{id}` with UUID pre-validation reproducing google/uuid v1.6.0's two-flavor error surface (`invalid UUID length: N` / `invalid UUID format`); raw SQL + trailing `\n` in text mode; full response payload under `--output-format json` - **Lifecycle parity**: both handlers wrap in nested `Effect.gen` blocks so `telemetryState.flush` (Go `Execute`) fires on every exit and `linkedProjectCache.cache(ref)` (Go `PersistentPostRun`) fires for every path where the ref resolved — including `--output env` and invalid-UUID early-exits - **Hoist**: `formatBackupTimestamp` → `legacy/shared/legacy-timestamp.format.ts::formatLegacyTimestamp` (snippets is the second consumer, per `apps/cli/CLAUDE.md` "Hoist Before You Duplicate") - **Test helpers**: `LegacyRecordedRequest` now captures `urlParams` + `urlWithParams` so tests can assert on GET query parameters - **Docs**: `SIDE_EFFECTS.md` rewritten for both commands; `docs/go-cli-porting-status.md` flips both from `wrapped` → `ported` ## Go parity contract | Surface | Verified against | | --- | --- | | `--output env` rejection | `apps/cli-go/internal/utils/output.go:41` (byte-exact `--output env flag is not supported`) | | UUID error prefix | `apps/cli-go/internal/snippets/download/download.go:17` + google/uuid v1.6.0 | | Table columns / pipe escape | `apps/cli-go/internal/snippets/list/list.go:27-41` | | JSON `"data": null` for empty list | `list_test.go::encodes json output` fixture | | `download` ignores `--output` | `download.go:25` (`fmt.Println` unconditional) | | Telemetry safe-flag policy | `apps/cli-go/cmd/snippets.go` has no `markFlagTelemetrySafe` — `--project-ref` value is redacted | Fixes CLI-1299 --- apps/cli/docs/go-cli-porting-status.md | 4 +- .../legacy/commands/backups/backups.format.ts | 27 -- .../backups/backups.format.unit.test.ts | 25 +- .../commands/backups/list/list.handler.ts | 5 +- .../snippets/download/SIDE_EFFECTS.md | 75 ++-- .../snippets/download/download.command.ts | 15 +- .../snippets/download/download.handler.ts | 131 ++++++- .../download/download.integration.test.ts | 277 +++++++++++++++ .../commands/snippets/list/SIDE_EFFECTS.md | 90 +++-- .../commands/snippets/list/list.command.ts | 15 +- .../commands/snippets/list/list.handler.ts | 162 ++++++++- .../snippets/list/list.integration.test.ts | 319 ++++++++++++++++++ .../commands/snippets/snippets.e2e.test.ts | 27 ++ .../commands/snippets/snippets.errors.ts | 43 +++ .../commands/snippets/snippets.format.ts | 50 +++ .../snippets/snippets.format.unit.test.ts | 48 +++ .../legacy/shared/legacy-timestamp.format.ts | 26 ++ .../legacy-timestamp.format.unit.test.ts | 26 ++ apps/cli/tests/helpers/legacy-mocks.ts | 14 + 19 files changed, 1261 insertions(+), 118 deletions(-) create mode 100644 apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/snippets/snippets.errors.ts create mode 100644 apps/cli/src/legacy/commands/snippets/snippets.format.ts create mode 100644 apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-timestamp.format.ts create mode 100644 apps/cli/src/legacy/shared/legacy-timestamp.format.unit.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 4071fa52d1..7055959346 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -233,8 +233,8 @@ Legend: | `config push` | `wrapped` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | | `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | | `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | -| `snippets list` | `wrapped` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `wrapped` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | | `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | | `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | | `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | diff --git a/apps/cli/src/legacy/commands/backups/backups.format.ts b/apps/cli/src/legacy/commands/backups/backups.format.ts index a04ce811d7..61beb4c00e 100644 --- a/apps/cli/src/legacy/commands/backups/backups.format.ts +++ b/apps/cli/src/legacy/commands/backups/backups.format.ts @@ -22,30 +22,3 @@ const REGION_NAMES: Readonly> = { export function formatRegion(region: string): string { return REGION_NAMES[region] ?? region; } - -function pad2(value: number): string { - return value.toString().padStart(2, "0"); -} - -/** - * Reproduces `utils.FormatTimestamp` from `apps/cli-go/internal/utils/render.go:17`: - * parse RFC3339; on success format as UTC "YYYY-MM-DD HH:MM:SS"; on failure - * return the input verbatim. - */ -export function formatBackupTimestamp(value: string): string { - if (value.length === 0) return value; - // Go uses time.Parse(time.RFC3339, value). Date.parse accepts a broader format - // surface, so we additionally require the year-month-day prefix to weed out - // values like "2026-02-08 16:44:07" (already-formatted) that Date.parse would - // happily accept but Go's strict RFC3339 parser would reject. - if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) { - return value; - } - const parsed = Date.parse(value); - if (Number.isNaN(parsed)) return value; - const date = new Date(parsed); - return ( - `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` + - `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}` - ); -} diff --git a/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts b/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts index 4d270cdc21..a0f13e9e1f 100644 --- a/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatBackupTimestamp, formatRegion } from "./backups.format.ts"; +import { formatRegion } from "./backups.format.ts"; describe("formatRegion", () => { it.each([ @@ -30,26 +30,3 @@ describe("formatRegion", () => { expect(formatRegion("xx-unknown-9")).toBe("xx-unknown-9"); }); }); - -describe("formatBackupTimestamp", () => { - it("formats valid RFC3339 to YYYY-MM-DD HH:MM:SS UTC", () => { - expect(formatBackupTimestamp("2026-02-08T16:44:07Z")).toBe("2026-02-08 16:44:07"); - }); - - it("handles offsets by normalizing to UTC", () => { - expect(formatBackupTimestamp("2026-02-08T18:44:07+02:00")).toBe("2026-02-08 16:44:07"); - }); - - it("falls back to the original value for already-formatted timestamps", () => { - // Go's time.Parse(time.RFC3339, ...) rejects "2026-02-08 16:44:07" (space, not T). - expect(formatBackupTimestamp("2026-02-08 16:44:07")).toBe("2026-02-08 16:44:07"); - }); - - it("falls back for malformed input", () => { - expect(formatBackupTimestamp("not-a-timestamp")).toBe("not-a-timestamp"); - }); - - it("returns empty string unchanged", () => { - expect(formatBackupTimestamp("")).toBe(""); - }); -}); diff --git a/apps/cli/src/legacy/commands/backups/list/list.handler.ts b/apps/cli/src/legacy/commands/backups/list/list.handler.ts index 85892779e7..496fa0f5b8 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.handler.ts @@ -19,7 +19,8 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; -import { formatBackupTimestamp, formatRegion } from "../backups.format.ts"; +import { formatLegacyTimestamp } from "../../../shared/legacy-timestamp.format.ts"; +import { formatRegion } from "../backups.format.ts"; import type { LegacyBackupsListFlags } from "./list.command.ts"; type BackupsResponse = typeof V1ListAllBackupsOutput.Type; @@ -56,7 +57,7 @@ function renderLogicalTable(response: BackupsResponse): string { region, backup.is_physical_backup ? "PHYSICAL" : "LOGICAL", backup.status, - formatBackupTimestamp(backup.inserted_at), + formatLegacyTimestamp(backup.inserted_at), ]); return renderGlamourTable(LOGICAL_HEADERS, rows); } diff --git a/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md index 1226c2938b..19f3323f9e 100644 --- a/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md @@ -2,59 +2,84 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | +| `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves (Go `PersistentPostRun`) | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------- | ------------ | ------------ | -------------------------- | -| `GET` | `/v1/snippets/{id}` | Bearer token | none | `{content: {sql: string}}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/snippets/{id}` | Bearer token | none | `{content: {sql, schema_version, favorite?}, id, name, visibility, owner, project, inserted_at, updated_at}` | + +Only `content.sql` is rendered in text mode. The full payload is exposed via `--output-format json`. ## Environment Variables | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | | `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------- | -| `0` | success — SQL content printed to stdout | -| `1` | invalid snippet ID argument (empty or not a valid UUID) | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from `/v1/snippets/{id}` | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------- | +| `0` | success — SQL written to stdout | +| `1` | `LegacySnippetsInvalidIdError` — `` is not a valid UUID | +| `1` | `LegacyInvalidProjectRefError` / `LegacyProjectNotLinkedError` | +| `1` | `LegacySnippetsDownloadUnexpectedStatusError` — non-2xx response | +| `1` | `LegacySnippetsDownloadNetworkError` — transport-level failure | + +## Telemetry Events Fired + +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | ## Output ### `--output-format text` (Go CLI compatible) -Prints the raw SQL content of the snippet to stdout, followed by a newline. +The raw SQL `content.sql` followed by a trailing `\n`. ``` -select 1 +select 1; ``` -### `--output-format json` +### `--output-format json` (TS extension) -Not applicable — download writes SQL directly to stdout. +Single `success` event with the full `V1GetASnippetOutput` payload as `data`. This includes `id`, `name`, `visibility`, `owner`, `project`, `inserted_at`, `updated_at`, `favorite`, and `content` (with `sql`, `schema_version`, and optional `favorite`). Agents that only need the SQL can read `data.content.sql`; agents reconstructing a snippet in a new project have everything they need. + +```json +{ + "id": "0b0d48f6-…", + "name": "Create table", + "visibility": "user", + "owner": { "id": 7, "username": "supaseed" }, + "content": { "schema_version": "1.0.0", "sql": "select 1;" } +} +``` -### `--output-format stream-json` +### `--output-format stream-json` (TS extension) -Not applicable — download writes SQL directly to stdout. +NDJSON `success` event with the same full payload as `--output-format json`. ## Notes -- Requires a `` positional argument (UUID). -- Requires `--project-ref` or a linked project. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. +- Go's `--output` flag is **ignored** by `download.Run` — `fmt.Println(resp.JSON200.Content.Sql)` runs regardless of `pretty|json|yaml|toml|env`. The TS port mirrors this exactly: Go-style `--output` values do not change text-mode rendering. Only the TS-extension `--output-format json|stream-json` produces a structured payload. +- UUID validation runs **after** project-ref resolution but **before** the API call, matching Go's lifecycle: `PersistentPreRunE` resolves the ref first, then `download.Run` validates via `uuid.Parse`. Error messages mirror google/uuid v1.6.0: `invalid snippet ID: invalid UUID length: N` for malformed lengths, `invalid snippet ID: invalid UUID format` for length-36 inputs with wrong dash positions or hex chars. +- The linked-project cache fires after project-ref resolves (Go `PersistentPostRun`); the telemetry state always flushes (Go `Execute`). Both run on success and on every error path — including invalid-UUID early-exit — via the two `Effect.ensuring` blocks in the handler. diff --git a/apps/cli/src/legacy/commands/snippets/download/download.command.ts b/apps/cli/src/legacy/commands/snippets/download/download.command.ts index c26691bd70..724d5afd68 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.command.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.command.ts @@ -1,5 +1,9 @@ import { Argument, 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 { legacySnippetsDownload } from "./download.handler.ts"; const config = { @@ -22,5 +26,14 @@ export const legacySnippetsDownloadCommand = Command.make("download", config).pi description: "Download the SQL contents of the given snippet", }, ]), - Command.withHandler((flags) => legacySnippetsDownload(flags)), + Command.withHandler((flags) => + legacySnippetsDownload(flags).pipe( + // No `safeFlags` — Go's `cmd/snippets.go` does not call + // `markFlagTelemetrySafe` for `--project-ref`, so the telemetry payload + // redacts the value. + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["snippets", "download"])), ); diff --git a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts index bffb5ce890..086709b41e 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts @@ -1,12 +1,133 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +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 { resolveLegacyAccessToken } from "../../../shared/legacy-resolve-token.ts"; +import { sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + LegacySnippetsDownloadNetworkError, + LegacySnippetsDownloadUnexpectedStatusError, + LegacySnippetsInvalidIdError, +} from "../snippets.errors.ts"; import type { LegacySnippetsDownloadFlags } from "./download.command.ts"; +// Load-bearing for error-message parity. The generated `V1GetASnippetInput` +// schema (contracts.ts:1539-1545) already pattern-checks UUIDs, so if this +// pre-check is removed, a non-UUID input would surface as a `SchemaError` +// routed through `mapDownloadError` to `LegacySnippetsDownloadNetworkError` +// with a `failed to download snippet:` prefix — losing the Go-canonical +// `invalid snippet ID:` prefix from `apps/cli-go/internal/snippets/download/download.go:17`. +const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +// Mirrors Go's `uuid.Parse` (google/uuid v1.6.0) error surface: +// - len(s) not in {32, 36, 38, 41} → `invalid UUID length: N` +// - len(s) == 36 but dashes/hex chars wrong → `invalid UUID format` +// We accept only the canonical 36-char form (`8-4-4-4-12`), so the two +// branches collapse to length-vs-format. The outer wrap mirrors +// `fmt.Errorf("invalid snippet ID: %w", err)` from download.go:17. +function uuidErrorMessage(value: string): string { + if (value.length !== 36) { + return `invalid snippet ID: invalid UUID length: ${value.length}`; + } + return "invalid snippet ID: invalid UUID format"; +} + +// Tolerant body parse — see `list.handler.ts` for the rationale. The real +// `/v1/snippets/{id}` payload omits `description`, which the generated +// `V1GetASnippetOutput` schema declares as `Union[String, Null]` (required). +// Routing through the typed client surfaces `SchemaError: Missing key …` on +// every non-test response. Same workaround as `legacy-linked-project-cache.layer.ts`. +function asRecord(obj: unknown): Record { + return typeof obj === "object" && obj !== null ? (obj as Record) : {}; +} + +function readSql(body: unknown): string { + const content = asRecord(asRecord(body)["content"]); + const sql = content["sql"]; + return typeof sql === "string" ? sql : ""; +} + export const legacySnippetsDownload = Effect.fn("legacy.snippets.download")(function* ( flags: LegacySnippetsDownloadFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["snippets", "download", flags.snippetId]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const httpClient = yield* HttpClient.HttpClient; + const cliConfig = yield* LegacyCliConfig; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + if (!UUID_RE.test(flags.snippetId)) { + return yield* new LegacySnippetsInvalidIdError({ + message: uuidErrorMessage(flags.snippetId), + }); + } + + const tokenOpt = yield* resolveLegacyAccessToken; + const authHeader: ( + req: HttpClientRequest.HttpClientRequest, + ) => HttpClientRequest.HttpClientRequest = Option.isSome(tokenOpt) + ? HttpClientRequest.bearerToken(tokenOpt.value) + : (req) => req; + const request = HttpClientRequest.get( + `${cliConfig.apiUrl}/v1/snippets/${flags.snippetId}`, + ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); + + const fetching = + output.format === "text" ? yield* output.task("Downloading snippet...") : undefined; + const response = yield* httpClient.execute(request).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch( + (cause) => + new LegacySnippetsDownloadNetworkError({ + message: `failed to download snippet: ${cause.reason.description ?? cause.reason._tag}`, + }), + ), + ); + + if (response.status !== 200) { + yield* fetching?.fail() ?? Effect.void; + const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + const body = sanitizeLegacyErrorBody(rawBody); + return yield* new LegacySnippetsDownloadUnexpectedStatusError({ + status: response.status, + body, + message: `unexpected download snippet status ${response.status}: ${body}`, + }); + } + + const rawBody = yield* response.json.pipe( + Effect.catch( + (cause) => + new LegacySnippetsDownloadNetworkError({ + message: `failed to download snippet: ${String(cause)}`, + }), + ), + ); + yield* fetching?.clear() ?? Effect.void; + + // TS-only structured output. Expose the full payload so scripted callers + // and agents can read snippet identity (`id`, `name`, `owner`, …) + // alongside `content.sql`, matching the SIDE_EFFECTS.md contract and the + // shape `snippets list --output-format json` uses for its response. + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", asRecord(rawBody)); + return; + } + + // Go's `download.Run` ignores `--output` entirely and always runs + // `fmt.Println(resp.JSON200.Content.Sql)` (download.go:25). Mirror that: + // no branching on `LegacyOutputFlag`. + yield* output.raw(readSql(rawBody) + "\n"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts b/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts new file mode 100644 index 0000000000..05db086767 --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts @@ -0,0 +1,277 @@ +import { type V1GetASnippetOutput } 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 { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacySnippetsDownload } from "./download.handler.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_ID = "0b0d48f6-878b-4190-88d7-2ca33ed800bc"; +const INVALID_ID = "not-a-uuid"; // length 10 → "invalid UUID length: 10" +const TOO_LONG_ID = "0b0d48f6-878b-4190-88d7-2ca33ed800bc-extra"; // length 42 (3 ungrouped: 32, 36, 38, 41) +const WRONG_FORMAT_ID = "0b0d48f6.878b.4190.88d7.2ca33ed800bc"; // length 36, no dashes in canonical positions +const SQL = "select 1;"; + +type SnippetResponse = typeof V1GetASnippetOutput.Type; + +const SNIPPET_RESPONSE: SnippetResponse = { + id: VALID_ID, + inserted_at: "2023-10-13T17:48:58.491Z", + updated_at: "2023-10-13T17:48:58.491Z", + type: "sql", + visibility: "user", + name: "Create table", + description: null, + project: { id: 1, name: "Proj" }, + owner: { id: 7, username: "supaseed" }, + updated_by: { id: 7, username: "supaseed" }, + favorite: false, + content: { schema_version: "1.0.0", sql: SQL }, +}; + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +// `goOutput` is intentionally absent: the download handler does not consume +// `LegacyOutputFlag` at all (matches Go's `download.Run`, which calls +// `fmt.Println(resp.JSON200.Content.Sql)` unconditionally). Threading a value +// through here would suggest a behaviour difference that does not exist. +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + status?: number; + network?: "fail"; + response?: SnippetResponse; +} + +const tempRoot = useLegacyTempWorkdir("supabase-snippets-download-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: opts.response ?? SNIPPET_RESPONSE }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + return { layer, out, api, telemetry, cache }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy snippets download integration", () => { + it.live("prints raw SQL with a trailing newline in text mode", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + expect(out.stdoutText).toBe(`${SQL}\n`); + }).pipe(Effect.provide(layer)); + }); + + // Go's `download.Run` ignores `--output` entirely (download.go:25). The TS + // handler must reproduce that: no read of `LegacyOutputFlag`, no branching. + // This regression guards against a future refactor that adds branch-on-goOutput + // logic by mistake — if the flag is consumed, this assertion will diverge. + it.live("text mode is unaffected by any Go `--output` value (Go parity)", () => { + const out = mockOutput({ format: "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SNIPPET_RESPONSE } }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + goOutput: Option.some("json"), + }); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + expect(out.stdoutText).toBe(`${SQL}\n`); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with the full response under --output-format=json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + const data = success?.data as SnippetResponse | undefined; + expect(data?.id).toBe(VALID_ID); + expect(data?.name).toBe("Create table"); + expect(data?.content.sql).toBe(SQL); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event with the full response under --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + const data = success?.data as SnippetResponse | undefined; + expect(data?.content.sql).toBe(SQL); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "non-UUID input emits Go-format `invalid UUID length: N`, flushes telemetry+cache, skips API", + () => { + const { layer, api, telemetry, cache } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySnippetsDownload({ snippetId: INVALID_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsInvalidIdError"); + // Go's `uuid.Parse` returns `invalid UUID length: 10` for "not-a-uuid" + // (length 10), wrapped by download.go:17 as `invalid snippet ID: %w`. + expect(dump).toContain("invalid snippet ID: invalid UUID length: 10"); + } + expect(api.requests).toHaveLength(0); + // Go's PersistentPostRun + Execute both still fire on this error path. + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("a 42-char input also produces the length error", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySnippetsDownload({ snippetId: TOO_LONG_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("invalid snippet ID: invalid UUID length: 42"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("a 36-char input with wrong dash positions emits `invalid UUID format`", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySnippetsDownload({ snippetId: WRONG_FORMAT_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("invalid snippet ID: invalid UUID format"); + // The offending value must NOT be embedded (Go does not include it). + expect(dump).not.toContain(WRONG_FORMAT_ID); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("calls GET /v1/snippets/{id} with the validated UUID and no project_ref query", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.method).toBe("GET"); + expect(api.requests[0]?.url).toContain(`/v1/snippets/${VALID_ID}`); + expect(api.requests[0]?.urlParams).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses --project-ref flag value when resolving the linked-project cache", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, cache } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.some(flagRef) }); + // The download endpoint itself takes only the snippet ID, but the + // resolved project ref still flows into the linked-project cache write + // (Go's PersistentPostRun behaviour). + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySnippetsDownloadUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsDownloadUnexpectedStatusError"); + expect(dump).toContain("unexpected download snippet status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySnippetsDownloadNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsDownloadNetworkError"); + expect(dump).toContain("failed to download snippet"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache on success", () => { + const { layer, telemetry, cache } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache even on API failure", () => { + const { layer, telemetry, cache } = setup({ status: 500 }); + return Effect.gen(function* () { + yield* Effect.exit( + legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }), + ); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { + const { layer, out } = setup({ format: "json", status: 503 }); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }).pipe( + withJsonErrorHandling, + ); + expect(out.messages.some((m) => m.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md index cb6aa1bd6a..867b2331ac 100644 --- a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md @@ -2,62 +2,100 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | +| `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves (Go `PersistentPostRun`) | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------- | ------------ | ------------ | ------------------------------------------------------------------------------ | -| `GET` | `/v1/snippets` | Bearer token | none | `{data: [{id, name, visibility, owner: {username}, inserted_at, updated_at}]}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------- | +| `GET` | `/v1/snippets?project_ref=` | Bearer token | none | `{data: [{id, name, visibility, owner: {username}, inserted_at, updated_at, ...}], cursor?}` | ## Environment Variables | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | | `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------ | -| `0` | success — snippet list printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from `/v1/snippets` | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------- | +| `0` | success | +| `1` | `LegacySnippetsEnvNotSupportedError` — `--output env` was requested | +| `1` | `LegacyInvalidProjectRefError` / `LegacyProjectNotLinkedError` | +| `1` | `LegacySnippetsListUnexpectedStatusError` — non-2xx response | +| `1` | `LegacySnippetsListNetworkError` — transport-level failure | + +## Telemetry Events Fired + +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` / Go `--output pretty` (Go CLI compatible) + +Glamour-styled ASCII table with columns `ID`, `NAME`, `VISIBILITY`, `OWNER`, `CREATED AT (UTC)`, `UPDATED AT (UTC)`. Literal `|` characters in `name`, `visibility`, or `owner.username` are passed through verbatim (Go applies a `strings.ReplaceAll` markdown-intermediate escape that glamour decodes back; the final ASCII bytes carry the raw `|`). -Prints a Markdown-style table with columns: `ID`, `NAME`, `VISIBILITY`, `OWNER`, `CREATED AT (UTC)`, `UPDATED AT (UTC)`. +API-supplied strings are not stripped of ANSI / terminal control sequences before rendering — matches Go's glamour pass-through. ``` - ID | NAME | VISIBILITY | OWNER | CREATED AT (UTC) | UPDATED AT (UTC) - test-snippet | Create table | user | supaseed | 2023-10-13 17:48:58 | 2023-10-13 17:48:58 + ID | NAME | VISIBILITY | OWNER | CREATED AT (UTC) | UPDATED AT (UTC) + --------------|--------------|------------|----------|---------------------|--------------------- + test-snippet | Create table | user | supaseed | 2023-10-13 17:48:58 | 2023-10-13 17:48:58 ``` -### `--output-format json` +### Go `--output json` -Single JSON object with the full snippets list response. +Indented JSON with alphabetically-sorted keys and a trailing newline. Empty `data` is rendered as `null` to match Go's `encoding/json` nil-slice serialization. ```json -{"data": [{"id": "…", "name": "…", "visibility": "user", …}]} +{ + "data": [ + { "favorite": false, "id": "…", "inserted_at": "…", "name": "…", "owner": { … }, ... } + ] +} ``` -### `--output-format stream-json` +### Go `--output yaml` + +YAML rendering of the full `V1ListAllSnippetsOutput` response. + +### Go `--output toml` + +TOML rendering of the full `V1ListAllSnippetsOutput` response, with a trailing newline. + +### Go `--output env` + +Not supported — fails with `--output env flag is not supported`. Byte-exact match against Go's `utils.ErrEnvNotSupported` (`apps/cli-go/internal/utils/output.go:41`). + +### `--output-format json` (TS extension) + +Single `success` event whose `data` is the full `V1ListAllSnippetsOutput` payload. + +### `--output-format stream-json` (TS extension) -One `result` event on success. +NDJSON `success` event with the full response as `data`. ## Notes -- Requires `--project-ref` or a linked project. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. +- When both Go `--output` and TS `--output-format` are set, Go's flag wins (matches the precedence used elsewhere in legacy ports). +- `--output env` is rejected **after** project-ref resolution but **before** the API call, matching Go's lifecycle: cobra resolves `--project-ref` in `PersistentPreRunE`, `list.Run` checks `OutputFormat.Value` before invoking the encoder. The error message is byte-exact with `utils.ErrEnvNotSupported`. +- The linked-project cache fires after project-ref resolves (Go `PersistentPostRun`); the telemetry state always flushes (Go `Execute`). Both run on success and on every error path — the two `Effect.ensuring` blocks in the handler model the post-run order exactly. diff --git a/apps/cli/src/legacy/commands/snippets/list/list.command.ts b/apps/cli/src/legacy/commands/snippets/list/list.command.ts index 99136b5877..a97ba0a55b 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.command.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.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 { legacySnippetsList } from "./list.handler.ts"; const config = { @@ -23,5 +27,14 @@ export const legacySnippetsListCommand = Command.make("list", config).pipe( description: "List snippets for a specific project", }, ]), - Command.withHandler((flags) => legacySnippetsList(flags)), + Command.withHandler((flags) => + legacySnippetsList(flags).pipe( + // No `safeFlags` — Go's `cmd/snippets.go` does not call + // `markFlagTelemetrySafe` for `--project-ref`, so the telemetry payload + // redacts the value (matches Go's default behavior for unmarked flags). + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["snippets", "list"])), ); diff --git a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts index 35e44a55bc..fbbf5212dd 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts @@ -1,12 +1,164 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; +import { resolveLegacyAccessToken } from "../../../shared/legacy-resolve-token.ts"; +import { sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + LegacySnippetsEnvNotSupportedError, + LegacySnippetsListNetworkError, + LegacySnippetsListUnexpectedStatusError, +} from "../snippets.errors.ts"; +import { renderSnippetsTable, type SnippetRow } from "../snippets.format.ts"; import type { LegacySnippetsListFlags } from "./list.command.ts"; +// Tolerant accessors for the API response body. The real `/v1/snippets` +// payload regularly omits optional fields like `description` that the +// generated `V1ListAllSnippetsOutput` schema declares as `Union[String, Null]` +// (present-but-nullable). Routing through the typed client therefore fails +// with a `SchemaError: Missing key …` on any real-world response — see the +// cli-e2e `snippets-download-prints-sql-content-to-stdout` failure that +// prompted the bypass. Same workaround pattern as +// `legacy-linked-project-cache.layer.ts` and `legacySuggestUpgrade`. +function readString(obj: unknown, key: string): string { + if (typeof obj === "object" && obj !== null && key in obj) { + const value = (obj as Record)[key]; + return typeof value === "string" ? value : ""; + } + return ""; +} + +function asRecord(obj: unknown): Record { + return typeof obj === "object" && obj !== null ? (obj as Record) : {}; +} + +interface SnippetsResponseBody { + readonly data: ReadonlyArray; +} + +function parseSnippetsResponse(body: unknown): SnippetsResponseBody { + const root = asRecord(body); + const data = Array.isArray(root["data"]) ? root["data"] : []; + return { data }; +} + +function toSnippetRow(raw: unknown): SnippetRow { + const item = asRecord(raw); + const owner = asRecord(item["owner"]); + return { + id: readString(item, "id"), + name: readString(item, "name"), + visibility: readString(item, "visibility"), + owner: { username: readString(owner, "username") }, + inserted_at: readString(item, "inserted_at"), + updated_at: readString(item, "updated_at"), + }; +} + export const legacySnippetsList = Effect.fn("legacy.snippets.list")(function* ( flags: LegacySnippetsListFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["snippets", "list"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const httpClient = yield* HttpClient.HttpClient; + const cliConfig = yield* LegacyCliConfig; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + // Mirror Go's lifecycle (apps/cli-go/cmd/root.go:93-167 + 175-183): + // PersistentPreRunE → resolve project ref + // Run → reject --output env / call API / render + // PersistentPostRun → write linked-project cache (needs `ref`) + // Execute → flush telemetry (no `ref` required) + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + if (Option.getOrUndefined(goOutputFlag) === "env") { + return yield* new LegacySnippetsEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } + + const tokenOpt = yield* resolveLegacyAccessToken; + const authHeader: ( + req: HttpClientRequest.HttpClientRequest, + ) => HttpClientRequest.HttpClientRequest = Option.isSome(tokenOpt) + ? HttpClientRequest.bearerToken(tokenOpt.value) + : (req) => req; + const request = HttpClientRequest.get(`${cliConfig.apiUrl}/v1/snippets`).pipe( + HttpClientRequest.setUrlParams({ project_ref: ref }), + authHeader, + HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent), + ); + + const fetching = + output.format === "text" ? yield* output.task("Fetching snippets...") : undefined; + const response = yield* httpClient.execute(request).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch( + (cause) => + new LegacySnippetsListNetworkError({ + message: `failed to list snippets: ${cause.reason.description ?? cause.reason._tag}`, + }), + ), + ); + + if (response.status !== 200) { + yield* fetching?.fail() ?? Effect.void; + const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + const body = sanitizeLegacyErrorBody(rawBody); + return yield* new LegacySnippetsListUnexpectedStatusError({ + status: response.status, + body, + message: `unexpected list snippets status ${response.status}: ${body}`, + }); + } + + const rawBody = yield* response.json.pipe( + Effect.catch( + (cause) => + new LegacySnippetsListNetworkError({ + message: `failed to list snippets: ${String(cause)}`, + }), + ), + ); + yield* fetching?.clear() ?? Effect.void; + + const parsed = parseSnippetsResponse(rawBody); + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "json") { + // Round-trip the raw body so a real API `data: []` stays `data: []` + // (and a hypothetical `data: null` would stay null). Go's + // `encoding/json` preserves nil-vs-empty; bypassing the typed client + // means we can faithfully mirror that here too. + yield* output.raw(encodeGoJson(rawBody)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(rawBody)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(asRecord(rawBody)) + "\n"); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", asRecord(rawBody)); + return; + } + + yield* output.raw(renderSnippetsTable(parsed.data.map(toSnippetRow))); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts b/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts new file mode 100644 index 0000000000..ec48c7fa33 --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts @@ -0,0 +1,319 @@ +import { type V1ListAllSnippetsOutput } 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, + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacySnippetsList } from "./list.handler.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +type SnippetsResponse = typeof V1ListAllSnippetsOutput.Type; + +const SNIPPET_ID = "00000000-0000-0000-0000-000000000001"; + +const SNIPPET_BASE = { + id: SNIPPET_ID, + inserted_at: "2023-10-13T17:48:58.491Z", + updated_at: "2023-10-13T17:48:58.491Z", + type: "sql" as const, + visibility: "user" as const, + name: "Create table", + description: null, + project: { id: 1, name: "Proj" }, + owner: { id: 7, username: "supaseed" }, + updated_by: { id: 7, username: "supaseed" }, + favorite: false, +}; + +const SINGLE_RESPONSE: SnippetsResponse = { + data: [SNIPPET_BASE], +}; + +const PIPE_RESPONSE: SnippetsResponse = { + data: [ + { + ...SNIPPET_BASE, + // Go's `strings.ReplaceAll(value, "|", "\\|")` is a markdown-intermediate + // escape that glamour decodes back to literal `|` in the rendered ASCII + // bytes. `renderGlamourTable` bypasses glamour, so we pass raw values — + // any `|` in `name` / `owner.username` must appear literally in stdout. + name: "name|with|pipes", + owner: { id: 7, username: "user|name" }, + }, + ], +}; + +const RAW_TIMESTAMP_RESPONSE: SnippetsResponse = { + data: [ + { + ...SNIPPET_BASE, + inserted_at: "not-an-rfc3339", + updated_at: "2023-10-13T17:48:58.491Z", + }, + ], +}; + +const EMPTY_RESPONSE: SnippetsResponse = { + data: [], +}; + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + response?: SnippetsResponse; + status?: number; + network?: "fail"; +} + +const tempRoot = useLegacyTempWorkdir("supabase-snippets-list-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: opts.response ?? SINGLE_RESPONSE }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }); + return { layer, out, api, telemetry, cache }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy snippets list integration", () => { + it.live("renders an ASCII table in text mode with all six columns", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("ID"); + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).toContain("VISIBILITY"); + expect(out.stdoutText).toContain("OWNER"); + expect(out.stdoutText).toContain("CREATED AT (UTC)"); + expect(out.stdoutText).toContain("UPDATED AT (UTC)"); + expect(out.stdoutText).toContain(SNIPPET_ID); + expect(out.stdoutText).toContain("Create table"); + expect(out.stdoutText).toContain("supaseed"); + }).pipe(Effect.provide(layer)); + }); + + it.live("preserves literal `|` characters in snippet name and owner username (Go parity)", () => { + const { layer, out } = setup({ response: PIPE_RESPONSE }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("name|with|pipes"); + expect(out.stdoutText).toContain("user|name"); + // No `\|` escape — Go's intermediate escape is round-tripped by glamour. + expect(out.stdoutText).not.toContain("\\|"); + }).pipe(Effect.provide(layer)); + }); + + it.live("formats RFC3339 timestamps as UTC YYYY-MM-DD HH:MM:SS", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("2023-10-13 17:48:58"); + }).pipe(Effect.provide(layer)); + }); + + it.live("leaves a non-RFC3339 inserted_at string untouched", () => { + const { layer, out } = setup({ response: RAW_TIMESTAMP_RESPONSE }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("not-an-rfc3339"); + // The valid updated_at is still formatted. + expect(out.stdoutText).toContain("2023-10-13 17:48:58"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with the full response under --output-format=json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + const data = success?.data as SnippetsResponse | undefined; + expect(data?.data).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event under --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.messages.some((m) => m.type === "success")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("Go --output=json emits alphabetically-keyed JSON, preserving empty arrays", () => { + const { layer, out } = setup({ goOutput: "json", response: EMPTY_RESPONSE }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + // The API returns `{"data": []}`; Go's `encoding/json` round-trip + // preserves nil-vs-empty (real responses always send `[]`, never null). + // Our raw-HTTP bypass means we faithfully echo whatever the API sent — + // no `nullForEmptyArrays` coercion. + expect(out.stdoutText).toBe(`{ + "data": [] +} +`); + }).pipe(Effect.provide(layer)); + }); + + it.live("Go --output=yaml emits a `data:` block", () => { + const { layer, out } = setup({ goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("data:"); + expect(out.stdoutText).toContain(SNIPPET_ID); + }).pipe(Effect.provide(layer)); + }); + + it.live("Go --output=toml emits the response", () => { + const { layer, out } = setup({ goOutput: "toml" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText.length).toBeGreaterThan(0); + expect(out.stdoutText).toContain(SNIPPET_ID); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "Go --output=env fails with LegacySnippetsEnvNotSupportedError, flushes telemetry+cache, and does not call the API", + () => { + const { layer, api, telemetry, cache } = setup({ goOutput: "env" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySnippetsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsEnvNotSupportedError"); + // Byte-exact match against Go's `ErrEnvNotSupported` + // (apps/cli-go/internal/utils/output.go:41). + expect(dump).toContain("--output env flag is not supported"); + } + expect(api.requests).toHaveLength(0); + // Go's PersistentPostRun + Execute both fire on this error path. + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("Go --output=pretty falls through to the text renderer", () => { + const { layer, out } = setup({ goOutput: "pretty" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("VISIBILITY"); + expect(out.stdoutText).toContain(SNIPPET_ID); + }).pipe(Effect.provide(layer)); + }); + + it.live("Go --output wins over --output-format when both are set", () => { + const { layer, out } = setup({ format: "json", goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("data:"); + expect(out.stdoutText.startsWith("{")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("passes the resolved project_ref as a `project_ref` query parameter", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.urlWithParams).toContain( + `/v1/snippets?project_ref=${LEGACY_VALID_REF}`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses --project-ref flag value over the resolver's linked-project default", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.some(flagRef) }); + expect(api.requests[0]?.urlWithParams).toContain(`project_ref=${flagRef}`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySnippetsListUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySnippetsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsListUnexpectedStatusError"); + expect(dump).toContain("unexpected list snippets status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySnippetsListNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySnippetsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsListNetworkError"); + expect(dump).toContain("failed to list snippets"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache on success", () => { + const { layer, telemetry, cache } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache even on API failure", () => { + const { layer, telemetry, cache } = setup({ status: 500 }); + return Effect.gen(function* () { + yield* Effect.exit(legacySnippetsList({ projectRef: Option.none() })); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { + const { layer, out } = setup({ format: "json", status: 503 }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + expect(out.messages.some((m) => m.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts b/apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts new file mode 100644 index 0000000000..587b8371f6 --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const TEST_PROJECT_REF = "abcdefghijklmnopqrst"; +const TEST_TOKEN = "sbp_" + "a".repeat(40); + +describe("supabase snippets (legacy)", () => { + // Golden-path e2e: exercises the real subprocess boundary for the only + // API-free code path in `snippets download` — the UUID pre-check in + // `download.handler.ts`. This validates that the compiled-binary wiring + // (Command.provide, runtime layer, withJsonErrorHandling) correctly + // surfaces the Go-format `invalid snippet ID:` prefix to stdout/stderr + // with exit code 1. + test( + "download with invalid UUID exits 1 with Go-format message", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["snippets", "download", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], + { entrypoint: "legacy", env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, + ); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("invalid snippet ID"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts new file mode 100644 index 0000000000..e030544b4b --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts @@ -0,0 +1,43 @@ +import { Data } from "effect"; + +export class LegacySnippetsListNetworkError extends Data.TaggedError( + "LegacySnippetsListNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacySnippetsListUnexpectedStatusError extends Data.TaggedError( + "LegacySnippetsListUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +// Mirrors Go's `utils.ErrEnvNotSupported` ("--output env is not supported"), +// returned from `list.Run` when `OutputFormat.Value == OutputEnv`. +export class LegacySnippetsEnvNotSupportedError extends Data.TaggedError( + "LegacySnippetsEnvNotSupportedError", +)<{ + readonly message: string; +}> {} + +// Wraps `uuid.Parse` failure in `download.Run`; message preserves Go's +// `invalid snippet ID: ` prefix so callers see the same string. +export class LegacySnippetsInvalidIdError extends Data.TaggedError("LegacySnippetsInvalidIdError")<{ + readonly message: string; +}> {} + +export class LegacySnippetsDownloadNetworkError extends Data.TaggedError( + "LegacySnippetsDownloadNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacySnippetsDownloadUnexpectedStatusError extends Data.TaggedError( + "LegacySnippetsDownloadUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/snippets/snippets.format.ts b/apps/cli/src/legacy/commands/snippets/snippets.format.ts new file mode 100644 index 0000000000..9b4b5f6ab0 --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/snippets.format.ts @@ -0,0 +1,50 @@ +import { renderGlamourTable } from "../../output/legacy-glamour-table.ts"; +import { formatLegacyTimestamp } from "../../shared/legacy-timestamp.format.ts"; + +// --------------------------------------------------------------------------- +// Pure formatter — no Effect / no service dependencies, kept unit-testable. +// Reproduces Go's `snippets/list/list.go:27-41` markdown-table + glamour pipeline. +// +// Go writes each cell wrapped in backticks with `strings.ReplaceAll(value, "|", "\\|")` +// applied; glamour then decodes the `\|` escape and strips the backticks, so the +// final ASCII bytes contain raw `|` (not `\|`). `renderGlamourTable` lays out +// cells directly without the markdown round-trip, so we pass raw values — any +// `|` in `name`, `visibility`, or `owner.username` appears literally in stdout, +// byte-matching the Go binary. (Same parity rule documented in orgs.format.ts.) +// +// Note (Go parity): API-supplied strings are NOT stripped of ANSI escape +// sequences or other terminal control bytes before rendering. Go's glamour +// has identical pass-through behaviour. If a future security review decides +// to sanitize, it should land at the renderer (`legacy-glamour-table.ts`), +// not per-command. +// --------------------------------------------------------------------------- + +const HEADERS = [ + "ID", + "NAME", + "VISIBILITY", + "OWNER", + "CREATED AT (UTC)", + "UPDATED AT (UTC)", +] as const; + +export interface SnippetRow { + readonly id: string; + readonly name: string; + readonly visibility: string; + readonly owner: { readonly username: string }; + readonly inserted_at: string; + readonly updated_at: string; +} + +export function renderSnippetsTable(items: ReadonlyArray): string { + const rows = items.map((snippet) => [ + snippet.id, + snippet.name, + snippet.visibility, + snippet.owner.username, + formatLegacyTimestamp(snippet.inserted_at), + formatLegacyTimestamp(snippet.updated_at), + ]); + return renderGlamourTable(HEADERS, rows); +} diff --git a/apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts b/apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts new file mode 100644 index 0000000000..ddfe7e6607 --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { renderSnippetsTable } from "./snippets.format.ts"; + +describe("renderSnippetsTable", () => { + it("renders headers in a Glamour ASCII table when the list is empty", () => { + const out = renderSnippetsTable([]); + expect(out).toContain("ID"); + expect(out).toContain("NAME"); + expect(out).toContain("VISIBILITY"); + expect(out).toContain("OWNER"); + expect(out).toContain("CREATED AT (UTC)"); + expect(out).toContain("UPDATED AT (UTC)"); + }); + + it("preserves literal `|` characters in name, visibility, and owner (Glamour decodes Go's escape back)", () => { + const out = renderSnippetsTable([ + { + id: "00000000-0000-0000-0000-000000000001", + name: "name|here", + visibility: "user|public", + owner: { username: "user|name" }, + inserted_at: "2023-10-13T17:48:58.491Z", + updated_at: "2023-10-13T17:48:58.491Z", + }, + ]); + expect(out).toContain("name|here"); + expect(out).toContain("user|public"); + expect(out).toContain("user|name"); + // No `\|` escape — Go's `strings.ReplaceAll` is a markdown intermediate + // that glamour decodes; the final bytes carry the raw `|`. + expect(out).not.toContain("\\|"); + }); + + it("formats RFC3339 timestamps as UTC YYYY-MM-DD HH:MM:SS", () => { + const out = renderSnippetsTable([ + { + id: "00000000-0000-0000-0000-000000000001", + name: "n", + visibility: "user", + owner: { username: "u" }, + inserted_at: "2023-10-13T17:48:58.491Z", + updated_at: "2023-10-13T17:48:58.491Z", + }, + ]); + expect(out).toContain("2023-10-13 17:48:58"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-timestamp.format.ts b/apps/cli/src/legacy/shared/legacy-timestamp.format.ts new file mode 100644 index 0000000000..1d164e02d5 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-timestamp.format.ts @@ -0,0 +1,26 @@ +function pad2(value: number): string { + return value.toString().padStart(2, "0"); +} + +/** + * Reproduces `utils.FormatTimestamp` from `apps/cli-go/internal/utils/render.go:17`: + * parse RFC3339; on success format as UTC "YYYY-MM-DD HH:MM:SS"; on failure + * return the input verbatim. + */ +export function formatLegacyTimestamp(value: string): string { + if (value.length === 0) return value; + // Go uses time.Parse(time.RFC3339, value). Date.parse accepts a broader format + // surface, so we additionally require the year-month-day prefix to weed out + // values like "2026-02-08 16:44:07" (already-formatted) that Date.parse would + // happily accept but Go's strict RFC3339 parser would reject. + if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) { + return value; + } + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + const date = new Date(parsed); + return ( + `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` + + `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}` + ); +} diff --git a/apps/cli/src/legacy/shared/legacy-timestamp.format.unit.test.ts b/apps/cli/src/legacy/shared/legacy-timestamp.format.unit.test.ts new file mode 100644 index 0000000000..a7b9166563 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-timestamp.format.unit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { formatLegacyTimestamp } from "./legacy-timestamp.format.ts"; + +describe("formatLegacyTimestamp", () => { + it("formats valid RFC3339 to YYYY-MM-DD HH:MM:SS UTC", () => { + expect(formatLegacyTimestamp("2026-02-08T16:44:07Z")).toBe("2026-02-08 16:44:07"); + }); + + it("handles offsets by normalizing to UTC", () => { + expect(formatLegacyTimestamp("2026-02-08T18:44:07+02:00")).toBe("2026-02-08 16:44:07"); + }); + + it("falls back to the original value for already-formatted timestamps", () => { + // Go's time.Parse(time.RFC3339, ...) rejects "2026-02-08 16:44:07" (space, not T). + expect(formatLegacyTimestamp("2026-02-08 16:44:07")).toBe("2026-02-08 16:44:07"); + }); + + it("falls back for malformed input", () => { + expect(formatLegacyTimestamp("not-a-timestamp")).toBe("not-a-timestamp"); + }); + + it("returns empty string unchanged", () => { + expect(formatLegacyTimestamp("")).toBe(""); + }); +}); diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 88ac993331..548fd6c033 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -10,6 +10,7 @@ import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as HttpClientRequestModule from "effect/unstable/http/HttpClientRequest"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as UrlParams from "effect/unstable/http/UrlParams"; import { afterEach, beforeEach } from "vitest"; import { LegacyCredentials } from "../../src/legacy/auth/legacy-credentials.service.ts"; @@ -199,6 +200,16 @@ export interface LegacyRecordedRequest { readonly method: string; readonly headers: Readonly>; readonly body?: unknown; + // Captured separately because Effect's HttpClient keeps `urlParams` on the + // request struct and only merges it into the final URL inside the real + // transport layer (`HttpClient.ts:747`). Tests that need to assert on + // GET-style query parameters (e.g. `/v1/snippets?project_ref=…`) read this + // serialized form instead of `url`. + readonly urlParams: string; + // Convenience: `url + "?" + urlParams` (or just `url` when there are none). + // Use this when an assertion wants to check the path and query in one + // string, mirroring what `curl -v` would print as the request line. + readonly urlWithParams: string; } export interface LegacyApiResponse { @@ -250,11 +261,14 @@ export function mockLegacyPlatformApi( body = decoded; } } + const params = UrlParams.toString(request.urlParams); const recorded: LegacyRecordedRequest = { url: request.url, method: request.method, headers: request.headers, body, + urlParams: params, + urlWithParams: params === "" ? request.url : `${request.url}?${params}`, }; requests.push(recorded); From 9cb026041b557618cdd59103fc2fcac991344fe0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 29 May 2026 13:52:41 +0100 Subject: [PATCH 06/16] ci(cli): only notify Slack for stable releases (#5393) ## What changed The `notify-slack` job in the Release workflow now only fires for **stable** releases. Previously it ran for both beta and stable cuts. ## Why Beta release notifications were adding noise to the release Slack channel. Stable releases are the only ones we want to broadcast there. Alpha, beta, and dry-run cuts now stay silent. The reusable `slack-notify.yml` workflow is left untouched (still channel-agnostic) in case it's reused for another channel later. --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbe5f0d64a..57c7418c22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,7 +212,7 @@ jobs: # Posts to the release Slack channel once the pipeline succeeds. Listing # `release` in `needs` without a status function in `if:` keeps the implicit # success() gate, so this only runs when both plan and release succeeded. - # The `if:` then filters to real (non-dry-run) beta/stable cuts; alpha and + # The `if:` then filters to real (non-dry-run) stable cuts; alpha, beta, and # dry runs stay silent. Nothing depends on this job, so a Slack/webhook # failure can't affect the already-completed release. notify-slack: @@ -220,7 +220,7 @@ jobs: needs: [plan, release] if: >- needs.plan.outputs.dry_run != 'true' && - (needs.plan.outputs.channel == 'beta' || needs.plan.outputs.channel == 'stable') + needs.plan.outputs.channel == 'stable' uses: ./.github/workflows/slack-notify.yml with: version: ${{ needs.plan.outputs.version }} From f6f2a47bb6d17a297f6926201a7f1e10898ea57c Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Fri, 29 May 2026 21:37:27 +0530 Subject: [PATCH 07/16] feat(cli): migrate vanity-subdomains (#5394) ## TL;DR Ports `supabase vanity-subdomains get`, `check-availability`, `activate`, and `delete` from the legacy Go proxy to native TypeScript ## What's Introduced This replaces the `LegacyGoProxy` path for the `vanity-subdomains` command family with native handlers wired through the existing legacy management-api runtime, instrumentation, and JSON error handling path ## Ref: closes CLI-1294 --------- Co-authored-by: Colum Ferry --- apps/cli/docs/go-cli-porting-status.md | 8 +- .../activate/SIDE_EFFECTS.md | 74 ++- .../activate/activate.command.ts | 12 +- .../activate/activate.handler.ts | 99 ++- .../check-availability/SIDE_EFFECTS.md | 74 ++- .../check-availability.command.ts | 12 +- .../check-availability.handler.ts | 102 ++- .../vanity-subdomains/delete/SIDE_EFFECTS.md | 65 +- .../delete/delete.command.ts | 12 +- .../delete/delete.handler.ts | 59 +- .../vanity-subdomains/get/SIDE_EFFECTS.md | 73 +- .../vanity-subdomains/get/get.command.ts | 12 +- .../vanity-subdomains/get/get.handler.ts | 86 ++- .../vanity-subdomains.errors.ts | 57 ++ .../vanity-subdomains.integration.test.ts | 628 ++++++++++++++++++ .../legacy/shared/legacy-upgrade-suggest.ts | 17 +- .../legacy-upgrade-suggest.unit.test.ts | 14 + 17 files changed, 1257 insertions(+), 147 deletions(-) create mode 100644 apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts create mode 100644 apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 7055959346..7e8592cb43 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -246,10 +246,10 @@ Legend: | `domains reverify` | `wrapped` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | | `domains activate` | `wrapped` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | | `domains delete` | `wrapped` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | -| `vanity-subdomains activate` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | +| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | | `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | | `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | | `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md index 1b6ce9c4ee..23fbe98051 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -2,58 +2,72 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | after ref resolution, on success and failure | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ---------------------------- | ---------------------- | -| `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{vanity_subdomain: string}` | `{subdomain, status}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------ | ------------------------------ | --------------------------- | +| `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------- | -| `0` | success — vanity subdomain activated | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from activate endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyVanitySubdomainsActivateUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsActivateNetworkError`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ----------------------- | ------------------------------------------ | ------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| `cli_upgrade_suggested` | gated 4xx responses only | `feature_key=vanity_subdomain`, `org_slug` | ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` / legacy `--output pretty` + +Prints: + +```text +Activated vanity subdomain at +``` + +### Legacy `--output {json,yaml,toml,env}` -Prints activation result to stdout. +Encodes the response object directly. ### `--output-format json` -Single JSON object emitted to stdout on success. +Single structured success event with the full response object. ### `--output-format stream-json` -One `result` event on success. - -```ndjson -{"type":"result","data":{...}} -``` +One `result` event with the full response object. ## Notes -- Requires `--desired-subdomain` flag (mandatory). -- Requires `--project-ref` or a linked project (`.supabase/config.json`). -- After activation, the project's auth services will no longer function on the `{project-ref}.{supabase-domain}` hostname. +- The legacy `--output` flag wins over TS `--output-format` when both are provided. +- `linked-project.json` is written after ref resolution, even when the API call fails. +- On gated 4xx responses this command prints an upgrade suggestion and fires `cli_upgrade_suggested`. diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts index 57ba773e97..c17e24138c 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsActivate } from "./activate.handler.ts"; const config = { @@ -19,5 +23,11 @@ export const legacyVanitySubdomainsActivateCommand = Command.make("activate", co "Activate a vanity subdomain for your Supabase project. This reconfigures your Supabase project to respond to requests on your vanity subdomain. After the vanity subdomain is activated, your project's auth services will no longer function on the {project-ref}.{supabase-domain} hostname.", ), Command.withShortDescription("Activate a vanity subdomain"), - Command.withHandler((flags) => legacyVanitySubdomainsActivate(flags)), + Command.withHandler((flags) => + legacyVanitySubdomainsActivate(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "activate"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts index f4f4d3690f..27877ce2c5 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts @@ -1,13 +1,100 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyVanitySubdomainsActivateNetworkError, + LegacyVanitySubdomainsActivateUnexpectedStatusError, +} from "../vanity-subdomains.errors.ts"; import type { LegacyVanitySubdomainsActivateFlags } from "./activate.command.ts"; +const mapActivateError = mapLegacyHttpError({ + networkError: LegacyVanitySubdomainsActivateNetworkError, + statusError: LegacyVanitySubdomainsActivateUnexpectedStatusError, + networkMessage: (cause) => `failed activate vanity subdomain: ${cause}`, + statusMessage: (status, body) => `unexpected activate vanity subdomain status ${status}: ${body}`, +}); + export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomains.activate")( function* (flags: LegacyVanitySubdomainsActivateFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["vanity-subdomains", "activate"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - args.push("--desired-subdomain", flags.desiredSubdomain); - yield* proxy.exec(args); + const output = yield* Output; + const legacyOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const activating = + output.format === "text" + ? yield* output.task("Activating vanity subdomain...") + : undefined; + const response = yield* api.v1 + .activateVanitySubdomainConfig({ + ref, + vanity_subdomain: flags.desiredSubdomain, + }) + .pipe( + Effect.tapError(() => activating?.fail() ?? Effect.void), + Effect.catch((cause) => + Effect.gen(function* () { + // Flip the always-failing mapper into a success so we can inspect the + // tagged error before deciding whether to suggest an upgrade, then re-fail. + const mapped = yield* Effect.flip(mapActivateError(cause)); + if (mapped._tag === "LegacyVanitySubdomainsActivateUnexpectedStatusError") { + yield* legacySuggestUpgrade({ + projectRef: ref, + featureKey: "vanity_subdomain", + statusCode: mapped.status, + }); + } + return yield* Effect.fail(mapped); + }), + ), + ); + yield* activating?.clear() ?? Effect.void; + + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); + + if (legacyOutput === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (legacyOutput === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (legacyOutput === "toml") { + yield* output.raw(encodeToml({ CustomDomain: response.custom_domain }) + "\n"); + return; + } + if (legacyOutput === "env") { + yield* output.raw(encodeEnv(response) + "\n"); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + yield* output.raw(`Activated vanity subdomain at ${response.custom_domain}\n`); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }, ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md index bd3ed8d04f..dc361d9fbc 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md @@ -2,57 +2,73 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | after ref resolution, on success and failure | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------------------- | ------------ | ------------ | -------------------------------- | -| `GET` | `/v1/projects/{ref}/vanity-subdomain/check-availability` | Bearer token | none | `{available, available_domains}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------------------- | ------------ | ------------------------------ | ------------------------ | +| `POST` | `/v1/projects/{ref}/vanity-subdomain/check-availability` | Bearer token | `{ vanity_subdomain: string }` | `{ available: boolean }` | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------- | -| `0` | success — availability result printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from availability endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyVanitySubdomainsCheckUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsCheckNetworkError`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +This command may print an upgrade suggestion for gated 4xx responses, but it does not fire +`cli_upgrade_suggested`. ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` / legacy `--output pretty` -Prints subdomain availability result to stdout. +Prints: + +```text +Subdomain available: +``` + +### Legacy `--output {json,yaml,toml,env}` + +Encodes the response object directly. ### `--output-format json` -Single JSON object emitted to stdout on success. +Single structured success event with the full response object. ### `--output-format stream-json` -One `result` event on success. - -```ndjson -{"type":"result","data":{...}} -``` +One `result` event with the full response object. ## Notes -- Requires `--desired-subdomain` flag (mandatory). -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- The legacy `--output` flag wins over TS `--output-format` when both are provided. +- `linked-project.json` is written after ref resolution, even when the API call fails. diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts index 5cc3efca89..934d6f92e3 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsCheckAvailability } from "./check-availability.handler.ts"; const config = { @@ -22,5 +26,11 @@ export const legacyVanitySubdomainsCheckAvailabilityCommand = Command.make( ).pipe( Command.withDescription("Checks if a desired subdomain is available for use."), Command.withShortDescription("Check subdomain availability"), - Command.withHandler((flags) => legacyVanitySubdomainsCheckAvailability(flags)), + Command.withHandler((flags) => + legacyVanitySubdomainsCheckAvailability(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "check-availability"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts index 544e016d04..d17836e693 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -1,13 +1,103 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyVanitySubdomainsCheckNetworkError, + LegacyVanitySubdomainsCheckUnexpectedStatusError, +} from "../vanity-subdomains.errors.ts"; import type { LegacyVanitySubdomainsCheckAvailabilityFlags } from "./check-availability.command.ts"; +const mapCheckError = mapLegacyHttpError({ + networkError: LegacyVanitySubdomainsCheckNetworkError, + statusError: LegacyVanitySubdomainsCheckUnexpectedStatusError, + networkMessage: (cause) => `failed to check vanity subdomain: ${cause}`, + statusMessage: (status, body) => `unexpected check vanity subdomain status ${status}: ${body}`, +}); + export const legacyVanitySubdomainsCheckAvailability = Effect.fn( "legacy.vanity-subdomains.check-availability", )(function* (flags: LegacyVanitySubdomainsCheckAvailabilityFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["vanity-subdomains", "check-availability"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - args.push("--desired-subdomain", flags.desiredSubdomain); - yield* proxy.exec(args); + const output = yield* Output; + const legacyOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const checking = + output.format === "text" + ? yield* output.task("Checking vanity subdomain availability...") + : undefined; + const response = yield* api.v1 + .checkVanitySubdomainAvailability({ + ref, + vanity_subdomain: flags.desiredSubdomain, + }) + .pipe( + Effect.tapError(() => checking?.fail() ?? Effect.void), + Effect.catch((cause) => + Effect.gen(function* () { + // Flip the always-failing mapper into a success so we can inspect the + // tagged error before deciding whether to suggest an upgrade, then re-fail. + const mapped = yield* Effect.flip(mapCheckError(cause)); + if (mapped._tag === "LegacyVanitySubdomainsCheckUnexpectedStatusError") { + // Go's check command calls SuggestUpgradeOnError without a following + // TrackUpgradeSuggested, so suppress the analytics event for parity. + yield* legacySuggestUpgrade({ + projectRef: ref, + featureKey: "vanity_subdomain", + statusCode: mapped.status, + trackAnalytics: false, + }); + } + return yield* Effect.fail(mapped); + }), + ), + ); + yield* checking?.clear() ?? Effect.void; + + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); + + if (legacyOutput === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (legacyOutput === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (legacyOutput === "toml") { + yield* output.raw(encodeToml({ Available: response.available }) + "\n"); + return; + } + if (legacyOutput === "env") { + yield* output.raw(encodeEnv(response) + "\n"); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + yield* output.raw(`Subdomain ${flags.desiredSubdomain} available: ${response.available}\n`); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md index c6c3e522dd..2596dc4cb6 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -2,15 +2,17 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | after ref resolution, on success and failure | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | ## API Routes @@ -20,39 +22,50 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------- | -| `0` | success — vanity subdomain deleted | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from delete endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyVanitySubdomainsDeleteUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsDeleteNetworkError`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` / legacy `--output pretty` + +Prints to stderr: + +```text +Deleted vanity subdomain successfully. +``` + +### Legacy `--output {json,yaml,toml,env}` -Prints deletion result to stdout. +Ignored, matching the old Go command. The same stderr success line is printed. ### `--output-format json` -Single JSON object emitted to stdout on success. +Single structured success event when the legacy `--output` flag is unset. ### `--output-format stream-json` -One `result` event on success. - -```ndjson -{"type":"result","data":{...}} -``` +One `result` event when the legacy `--output` flag is unset. ## Notes -- Requires `--project-ref` or a linked project (`.supabase/config.json`). -- Deleting the vanity subdomain reverts to using the project ref for routing. +- The legacy `--output` flag wins over TS `--output-format` when both are provided. +- `linked-project.json` is written after ref resolution, even when the API call fails. diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts index 5af9b7a138..2a6200c66e 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsDelete } from "./delete.handler.ts"; const config = { @@ -16,5 +20,11 @@ export const legacyVanitySubdomainsDeleteCommand = Command.make("delete", config "Deletes the vanity subdomain for a project, and reverts to using the project ref for routing.", ), Command.withShortDescription("Delete the vanity subdomain"), - Command.withHandler((flags) => legacyVanitySubdomainsDelete(flags)), + Command.withHandler((flags) => + legacyVanitySubdomainsDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "delete"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts index 2ac95802c7..c465dfe397 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.handler.ts @@ -1,12 +1,61 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyVanitySubdomainsDeleteNetworkError, + LegacyVanitySubdomainsDeleteUnexpectedStatusError, +} from "../vanity-subdomains.errors.ts"; import type { LegacyVanitySubdomainsDeleteFlags } from "./delete.command.ts"; +const mapDeleteError = mapLegacyHttpError({ + networkError: LegacyVanitySubdomainsDeleteNetworkError, + statusError: LegacyVanitySubdomainsDeleteUnexpectedStatusError, + networkMessage: (cause) => `failed to delete vanity subdomain: ${cause}`, + statusMessage: (status, body) => `unexpected delete vanity subdomain status ${status}: ${body}`, +}); + export const legacyVanitySubdomainsDelete = Effect.fn("legacy.vanity-subdomains.delete")(function* ( flags: LegacyVanitySubdomainsDeleteFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["vanity-subdomains", "delete"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const legacyOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const deleting = + output.format === "text" ? yield* output.task("Deleting vanity subdomain...") : undefined; + yield* api.v1.deactivateVanitySubdomainConfig({ ref }).pipe( + Effect.tapError(() => deleting?.fail() ?? Effect.void), + Effect.catch(mapDeleteError), + ); + yield* deleting?.clear() ?? Effect.void; + + // Go's delete ignores --output entirely (stderr-only success). We still read + // the legacy flag so that an explicit --output suppresses the TS json/stream-json + // success event, matching Go's behavior of emitting nothing to stdout. + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); + + if ( + legacyOutput === undefined && + (output.format === "json" || output.format === "stream-json") + ) { + yield* output.success("Deleted vanity subdomain successfully."); + return; + } + + yield* output.raw("Deleted vanity subdomain successfully.\n", "stderr"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md index 465c77e20b..15c79abde7 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -2,56 +2,73 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | after ref resolution, on success and failure | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------ | ---------------------- | -| `GET` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | `{subdomain, status}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------- | +| `GET` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | `{ status: string, custom_domain?: string }` | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------- | -| `0` | success — vanity subdomain info printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from vanity subdomain endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyVanitySubdomainsGetUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsGetNetworkError`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` / legacy `--output pretty` + +Prints: + +```text +Status: +Vanity subdomain: +``` -Prints vanity subdomain configuration to stdout. +The second line is omitted when `custom_domain` is absent. + +### Legacy `--output {json,yaml,toml,env}` + +Encodes the response object directly. ### `--output-format json` -Single JSON object emitted to stdout on success. +Single structured success event with the full response object. ### `--output-format stream-json` -One `result` event on success. - -```ndjson -{"type":"result","data":{...}} -``` +One `result` event with the full response object. ## Notes -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- The legacy `--output` flag wins over TS `--output-format` when both are provided. +- `linked-project.json` is written after ref resolution, even when the API call fails. diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts index d6f09b0ab6..76c8e87f50 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsGet } from "./get.handler.ts"; const config = { @@ -14,5 +18,11 @@ export type LegacyVanitySubdomainsGetFlags = CliCommand.Command.Config.Infer legacyVanitySubdomainsGet(flags)), + Command.withHandler((flags) => + legacyVanitySubdomainsGet(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "get"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts index d5ef119b38..47cfc2908e 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts @@ -1,12 +1,88 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyVanitySubdomainsGetNetworkError, + LegacyVanitySubdomainsGetUnexpectedStatusError, +} from "../vanity-subdomains.errors.ts"; import type { LegacyVanitySubdomainsGetFlags } from "./get.command.ts"; +const mapGetError = mapLegacyHttpError({ + networkError: LegacyVanitySubdomainsGetNetworkError, + statusError: LegacyVanitySubdomainsGetUnexpectedStatusError, + networkMessage: (cause) => `failed to get vanity subdomain: ${cause}`, + statusMessage: (status, body) => `unexpected vanity subdomain status ${status}: ${body}`, +}); + export const legacyVanitySubdomainsGet = Effect.fn("legacy.vanity-subdomains.get")(function* ( flags: LegacyVanitySubdomainsGetFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["vanity-subdomains", "get"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const legacyOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const fetching = + output.format === "text" ? yield* output.task("Getting vanity subdomain...") : undefined; + const response = yield* api.v1.getVanitySubdomainConfig({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapGetError), + ); + yield* fetching?.clear() ?? Effect.void; + + const legacyOutput = Option.getOrUndefined(legacyOutputFlag); + + if (legacyOutput === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (legacyOutput === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (legacyOutput === "toml") { + yield* output.raw( + encodeToml({ + Status: response.status, + ...(response.custom_domain === undefined + ? {} + : { CustomDomain: response.custom_domain }), + }) + "\n", + ); + return; + } + if (legacyOutput === "env") { + yield* output.raw(encodeEnv(response) + "\n"); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + yield* output.raw(`Status: ${response.status}\n`); + if (response.custom_domain !== undefined) { + yield* output.raw(`Vanity subdomain: ${response.custom_domain}\n`); + } + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts new file mode 100644 index 0000000000..aa849f89ef --- /dev/null +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts @@ -0,0 +1,57 @@ +import { Data } from "effect"; + +export class LegacyVanitySubdomainsGetNetworkError extends Data.TaggedError( + "LegacyVanitySubdomainsGetNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsGetUnexpectedStatusError extends Data.TaggedError( + "LegacyVanitySubdomainsGetUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsCheckNetworkError extends Data.TaggedError( + "LegacyVanitySubdomainsCheckNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsCheckUnexpectedStatusError extends Data.TaggedError( + "LegacyVanitySubdomainsCheckUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsActivateNetworkError extends Data.TaggedError( + "LegacyVanitySubdomainsActivateNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsActivateUnexpectedStatusError extends Data.TaggedError( + "LegacyVanitySubdomainsActivateUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsDeleteNetworkError extends Data.TaggedError( + "LegacyVanitySubdomainsDeleteNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyVanitySubdomainsDeleteUnexpectedStatusError extends Data.TaggedError( + "LegacyVanitySubdomainsDeleteUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts new file mode 100644 index 0000000000..40c484acfe --- /dev/null +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -0,0 +1,628 @@ +import type { + V1ActivateVanitySubdomainConfigOutput, + V1CheckVanitySubdomainAvailabilityOutput, + V1GetVanitySubdomainConfigOutput, +} from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + buildLegacyTestRuntime, + legacyJsonResponse, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyVanitySubdomainsActivate } from "./activate/activate.handler.ts"; +import { legacyVanitySubdomainsCheckAvailability } from "./check-availability/check-availability.handler.ts"; +import { legacyVanitySubdomainsDelete } from "./delete/delete.handler.ts"; +import { legacyVanitySubdomainsGet } from "./get/get.handler.ts"; + +type VanityConfig = typeof V1GetVanitySubdomainConfigOutput.Type; +type AvailabilityResponse = typeof V1CheckVanitySubdomainAvailabilityOutput.Type; +type ActivateResponse = typeof V1ActivateVanitySubdomainConfigOutput.Type; + +const tempRoot = useLegacyTempWorkdir("supabase-vanity-int-"); + +const SAMPLE_GET: VanityConfig = { + status: "custom-domain-used", + custom_domain: "example.com", +}; + +// A project with no vanity subdomain configured — `custom_domain` is absent. +const SAMPLE_GET_NO_DOMAIN: VanityConfig = { + status: "not-used", +}; + +const SAMPLE_CHECK: AvailabilityResponse = { + available: true, +}; + +const SAMPLE_ACTIVATE: ActivateResponse = { + custom_domain: "example.com", +}; + +type LegacyOutput = "env" | "pretty" | "json" | "toml" | "yaml"; + +function runtimeWith(opts: { + readonly out: ReturnType; + readonly api: ReturnType; + readonly analytics?: ReturnType; + readonly telemetry?: ReturnType["layer"]; + readonly linkedProjectCache?: ReturnType["layer"]; + readonly legacyOutput?: LegacyOutput; +}) { + return buildLegacyTestRuntime({ + out: opts.out, + api: opts.api, + analytics: opts.analytics, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + telemetry: opts.telemetry, + linkedProjectCache: opts.linkedProjectCache, + goOutput: opts.legacyOutput === undefined ? Option.none() : Option.some(opts.legacyOutput), + }); +} + +// Builds an API mock where the given write endpoint is billing-gated (402) and +// the project/entitlements lookups report no access to `vanity_subdomain`. Used +// to exercise the upgrade-suggestion branch in `activate` and `check-availability`. +function gatedApi(matchWrite: (url: string) => boolean) { + return mockLegacyPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "POST" && matchWrite(request.url)) { + return legacyJsonResponse(request, 402, {}); + } + if (request.method === "GET" && request.url.endsWith(`/v1/projects/${LEGACY_VALID_REF}`)) { + return legacyJsonResponse(request, 200, { organization_slug: "supabase" }); + } + if (request.method === "GET" && request.url.includes("/entitlements")) { + return legacyJsonResponse(request, 200, { + entitlements: [ + { + feature: { key: "vanity_subdomain", type: "boolean" }, + hasAccess: false, + type: "boolean", + config: { enabled: true }, + }, + ], + }); + } + return legacyJsonResponse(request, 200, null); + }), + }); +} + +describe("legacy vanity-subdomains get", () => { + it.live("prints status and subdomain in text mode", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toBe("Status: custom-domain-used\nVanity subdomain: example.com\n"); + expect(api.requests[0]?.method).toBe("GET"); + expect(api.requests[0]?.url).toContain(`/v1/projects/${LEGACY_VALID_REF}/vanity-subdomain`); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits the subdomain line in text mode when none is configured", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET_NO_DOMAIN } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toBe("Status: not-used\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy JSON bytes for --output json", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api, legacyOutput: "json" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toContain('"status": "custom-domain-used"'); + expect(out.stdoutText).toContain('"custom_domain": "example.com"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy YAML for --output yaml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api, legacyOutput: "yaml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("status: custom-domain-used"); + expect(out.stdoutText).toContain("custom_domain: example.com"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy TOML bytes for --output toml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api, legacyOutput: "toml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toBe( + 'Status = "custom-domain-used"\nCustomDomain = "example.com"\n\n', + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits CustomDomain in TOML when none is configured", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET_NO_DOMAIN } }); + const layer = runtimeWith({ out, api, legacyOutput: "toml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toBe('Status = "not-used"\n\n'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy env for --output env", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api, legacyOutput: "env" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(out.stdoutText).toContain('STATUS="custom-domain-used"'); + expect(out.stdoutText).toContain('CUSTOM_DOMAIN="example.com"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event for --output-format json", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ status: "custom-domain-used" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event for --output-format stream-json", () => { + const out = mockOutput({ format: "stream-json" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ status: "custom-domain-used" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with an unexpected-status error on HTTP 503", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 503, body: {} } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsGetUnexpectedStatusError"); + expect(errorJson).toContain("unexpected vanity subdomain status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + // json mode so the spinner is suppressed — exercises the no-task error path. + it.live("fails with a network error on transport failure", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ network: "fail" }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsGetNetworkError"); + expect(errorJson).toContain("failed to get vanity subdomain"); + } + }).pipe(Effect.provide(layer)); + }); +}); + +describe("legacy vanity-subdomains check-availability", () => { + it.live("prints availability in text mode", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toBe("Subdomain example.com available: true\n"); + expect(api.requests[0]?.method).toBe("POST"); + expect(api.requests[0]?.url).toContain( + `/v1/projects/${LEGACY_VALID_REF}/vanity-subdomain/check-availability`, + ); + expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "example.com" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy JSON bytes for --output json", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api, legacyOutput: "json" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain('"available": true'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy YAML for --output yaml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api, legacyOutput: "yaml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain("available: true"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy TOML bytes for --output toml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api, legacyOutput: "toml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toBe("Available = true\n\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy env for --output env", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api, legacyOutput: "env" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain('AVAILABLE="true"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event for --output-format json", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ available: true }); + }).pipe(Effect.provide(layer)); + }); + + it.live("suggests upgrade for gated checks without firing analytics", () => { + const out = mockOutput({ format: "text" }); + const api = gatedApi((url) => url.includes("/check-availability")); + const analytics = mockAnalytics(); + const layer = runtimeWith({ out, api, analytics }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(analytics.captured).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + // json mode so the spinner is suppressed — exercises the no-task error path. + it.live("fails with a network error on transport failure", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ network: "fail" }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsCheckNetworkError"); + expect(errorJson).toContain("failed to check vanity subdomain"); + } + }).pipe(Effect.provide(layer)); + }); +}); + +describe("legacy vanity-subdomains activate", () => { + it.live("activates the vanity subdomain in text mode", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toBe("Activated vanity subdomain at example.com\n"); + expect(api.requests[0]?.method).toBe("POST"); + expect(api.requests[0]?.url).toContain( + `/v1/projects/${LEGACY_VALID_REF}/vanity-subdomain/activate`, + ); + expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "example.com" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy JSON bytes for --output json", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api, legacyOutput: "json" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain('"custom_domain": "example.com"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy YAML for --output yaml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api, legacyOutput: "yaml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain("custom_domain: example.com"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy TOML bytes for --output toml", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api, legacyOutput: "toml" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toBe('CustomDomain = "example.com"\n\n'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits legacy env for --output env", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api, legacyOutput: "env" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + expect(out.stdoutText).toContain('CUSTOM_DOMAIN="example.com"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event for --output-format json", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ custom_domain: "example.com" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("suggests upgrade and fires analytics for gated activation", () => { + const out = mockOutput({ format: "text" }); + const api = gatedApi((url) => url.endsWith("/vanity-subdomain/activate")); + const analytics = mockAnalytics(); + const layer = runtimeWith({ out, api, analytics }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "vanity_subdomain", org_slug: "supabase" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + // json mode so the spinner is suppressed — exercises the no-task error path. + it.live("fails with a network error on transport failure", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ network: "fail" }); + const analytics = mockAnalytics(); + const layer = runtimeWith({ out, api, analytics }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: "example.com", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsActivateNetworkError"); + expect(errorJson).toContain("failed activate vanity subdomain"); + } + // A network failure is not a billing gate, so no upgrade is suggested. + expect(analytics.captured).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("legacy vanity-subdomains delete", () => { + it.live("deletes the vanity subdomain in text mode", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: null } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsDelete({ projectRef: Option.none() }); + expect(out.stderrText).toBe("Deleted vanity subdomain successfully.\n"); + expect(api.requests[0]?.method).toBe("DELETE"); + expect(api.requests[0]?.url).toContain(`/v1/projects/${LEGACY_VALID_REF}/vanity-subdomain`); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event for --output-format json", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: null } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsDelete({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Deleted vanity subdomain successfully."); + }).pipe(Effect.provide(layer)); + }); + + it.live("ignores legacy --output values and prints to stderr", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: null } }); + const layer = runtimeWith({ out, api, legacyOutput: "json" }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsDelete({ projectRef: Option.none() }); + expect(out.stderrText).toBe("Deleted vanity subdomain successfully.\n"); + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with an unexpected-status error on HTTP 503", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 503, body: {} } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsDelete({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsDeleteUnexpectedStatusError"); + expect(errorJson).toContain("unexpected delete vanity subdomain status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + // json mode so the spinner is suppressed — exercises the no-task error path. + it.live("fails with a network error on transport failure", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ network: "fail" }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsDelete({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyVanitySubdomainsDeleteNetworkError"); + expect(errorJson).toContain("failed to delete vanity subdomain"); + } + }).pipe(Effect.provide(layer)); + }); +}); + +describe("legacy vanity-subdomains PersistentPostRun parity", () => { + it.live("flushes telemetry and writes linked-project cache on success", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET } }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = runtimeWith({ + out, + api, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache on failure", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 503, body: {} } }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = runtimeWith({ + out, + api, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index d0d8d3e1f9..0466e726ec 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -49,6 +49,13 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { readonly projectRef: string; readonly featureKey: string; readonly statusCode: number; + /** + * Whether to fire the `cli_upgrade_suggested` analytics event when a gate is + * detected. Defaults to `true`. Pass `false` for Go call-sites that invoke + * `SuggestUpgradeOnError` without a following `TrackUpgradeSuggested` + * (e.g. `vanity-subdomains check-availability`), so telemetry stays 1:1 with Go. + */ + readonly trackAnalytics?: boolean; }) { if (opts.statusCode < 400 || opts.statusCode >= 500) { return; @@ -117,8 +124,10 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { yield* output.raw(suggestion + "\n", "stderr"); } - yield* analytics.capture(EventUpgradeSuggested, { - [PropFeatureKey]: opts.featureKey, - [PropOrgSlug]: orgSlug, - }); + if (opts.trackAnalytics !== false) { + yield* analytics.capture(EventUpgradeSuggested, { + [PropFeatureKey]: opts.featureKey, + [PropOrgSlug]: orgSlug, + }); + } }); diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts index 1ae3f1862e..103ab98348 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts @@ -210,4 +210,18 @@ describe("legacySuggestUpgrade", () => { expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); }); + + it.live("skips cli_upgrade_suggested analytics when trackAnalytics=false", () => { + const { layer, analytics, out } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + featureKey: "branching_limit", + statusCode: 402, + trackAnalytics: false, + }); + expect(analytics.captured).toHaveLength(0); + expect(out.stderrText).toContain("Upgrade your plan:"); + }).pipe(Effect.provide(layer)); + }); }); From dfc9d1effbb9e8a63588cccbbd6106ed8454d2df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 01:00:03 +0000 Subject: [PATCH 08/16] fix(docker): bump supabase/realtime from v2.101.0 to v2.102.1 in /apps/cli-go/pkg/config/templates in the docker-minor group (#5399) Bumps the docker-minor group in /apps/cli-go/pkg/config/templates with 1 update: supabase/realtime. Updates `supabase/realtime` from v2.101.0 to v2.102.1 [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=supabase/realtime&package-manager=docker&previous-version=v2.101.0&new-version=v2.102.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index cc05eef99a..40e987e98f 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -11,7 +11,7 @@ FROM supabase/edge-runtime:v1.74.0 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.5 AS supavisor FROM supabase/gotrue:v2.189.0 AS gotrue -FROM supabase/realtime:v2.101.0 AS realtime +FROM supabase/realtime:v2.102.1 AS realtime FROM supabase/storage-api:v1.60.2 AS storage FROM supabase/logflare:1.42.0 AS logflare # Append to JobImages when adding new dependencies below From 7a126394d6e11f04d93d77fe5c33c7af6e162e48 Mon Sep 17 00:00:00 2001 From: fadymak Date: Sun, 31 May 2026 00:06:22 +0200 Subject: [PATCH 09/16] fix(auth): propagate passkey config to env (#5401) --- apps/cli-go/internal/start/start.go | 19 ++++++++++++++++ apps/cli-go/internal/start/start_test.go | 28 ++++++++++++++++++++++++ apps/cli-go/pkg/config/auth.go | 16 +++++++------- apps/cli-go/pkg/config/auth_test.go | 12 +++++----- 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/apps/cli-go/internal/start/start.go b/apps/cli-go/internal/start/start.go index 88ab8e7ed3..881f8dab73 100644 --- a/apps/cli-go/internal/start/start.go +++ b/apps/cli-go/internal/start/start.go @@ -747,6 +747,7 @@ EOF ) } + env = appendGotruePasskeyEnv(env) env = appendGotrueExternalProviderEnv(env) env = append(env, fmt.Sprintf("GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED=%v", utils.Config.Auth.Web3.Solana.Enabled), @@ -1354,6 +1355,24 @@ func buildGotrueEnv(dbConfig pgconn.Config) []string { } } +// appendGotruePasskeyEnv wires the Auth container with passkey/WebAuthn +// settings from the [auth.passkey] and [auth.webauthn] config sections. Both +// sections are optional (nil when unset), so each block is guarded. +func appendGotruePasskeyEnv(env []string) []string { + if utils.Config.Auth.Passkey != nil { + env = append(env, fmt.Sprintf("GOTRUE_PASSKEY_ENABLED=%v", utils.Config.Auth.Passkey.Enabled)) + } + if w := utils.Config.Auth.Webauthn; w != nil { + env = append( + env, + "GOTRUE_WEBAUTHN_RP_ID="+w.RpId, + "GOTRUE_WEBAUTHN_RP_DISPLAY_NAME="+w.RpDisplayName, + "GOTRUE_WEBAUTHN_RP_ORIGINS="+strings.Join(w.RpOrigins, ","), + ) + } + return env +} + func appendGotrueExternalProviderEnv(env []string) []string { for name, config := range utils.Config.Auth.External { redirectUri := config.RedirectUri diff --git a/apps/cli-go/internal/start/start_test.go b/apps/cli-go/internal/start/start_test.go index de6ddda9be..d8d4deb982 100644 --- a/apps/cli-go/internal/start/start_test.go +++ b/apps/cli-go/internal/start/start_test.go @@ -353,6 +353,34 @@ func TestBuildGotrueEnv(t *testing.T) { assert.Equal(t, "http://127.0.0.1:54321/auth/v1/verify", env["GOTRUE_MAILER_URLPATHS_INVITE"]) assert.Equal(t, "https://example.com/custom/callback", env["GOTRUE_EXTERNAL_AZURE_REDIRECT_URI"]) }) + + t.Run("wires passkey and webauthn settings", func(t *testing.T) { + utils.Config = config.NewConfig() + utils.Config.Auth.Passkey = &config.Passkey{Enabled: true} + utils.Config.Auth.Webauthn = &config.Webauthn{ + RpDisplayName: "Supabase", + RpId: "localhost", + RpOrigins: []string{"http://127.0.0.1:5173", "http://localhost:5173"}, + } + + env := envToMap(appendGotruePasskeyEnv(buildGotrueEnv(pgconn.Config{}))) + + assert.Equal(t, "true", env["GOTRUE_PASSKEY_ENABLED"]) + assert.Equal(t, "localhost", env["GOTRUE_WEBAUTHN_RP_ID"]) + assert.Equal(t, "Supabase", env["GOTRUE_WEBAUTHN_RP_DISPLAY_NAME"]) + assert.Equal(t, "http://127.0.0.1:5173,http://localhost:5173", env["GOTRUE_WEBAUTHN_RP_ORIGINS"]) + }) + + t.Run("omits passkey and webauthn env when sections are unset", func(t *testing.T) { + utils.Config = config.NewConfig() + + env := envToMap(appendGotruePasskeyEnv(buildGotrueEnv(pgconn.Config{}))) + + _, hasPasskey := env["GOTRUE_PASSKEY_ENABLED"] + _, hasRpId := env["GOTRUE_WEBAUTHN_RP_ID"] + assert.False(t, hasPasskey) + assert.False(t, hasRpId) + }) } func TestFormatMapForEnvConfig(t *testing.T) { diff --git a/apps/cli-go/pkg/config/auth.go b/apps/cli-go/pkg/config/auth.go index 4517affb5b..c8d2e58b4e 100644 --- a/apps/cli-go/pkg/config/auth.go +++ b/apps/cli-go/pkg/config/auth.go @@ -163,8 +163,8 @@ type ( PasswordRequirements PasswordRequirements `toml:"password_requirements" json:"password_requirements"` SigningKeysPath string `toml:"signing_keys_path" json:"signing_keys_path"` SigningKeys []JWK `toml:"-" json:"-"` - Passkey *passkey `toml:"passkey" json:"passkey"` - Webauthn *webauthn `toml:"webauthn" json:"webauthn"` + Passkey *Passkey `toml:"passkey" json:"passkey"` + Webauthn *Webauthn `toml:"webauthn" json:"webauthn"` RateLimit rateLimit `toml:"rate_limit" json:"rate_limit"` Captcha *captcha `toml:"captcha" json:"captcha"` @@ -381,11 +381,11 @@ type ( Ethereum ethereum `toml:"ethereum" json:"ethereum"` } - passkey struct { + Passkey struct { Enabled bool `toml:"enabled" json:"enabled"` } - webauthn struct { + Webauthn struct { RpDisplayName string `toml:"rp_display_name" json:"rp_display_name"` RpId string `toml:"rp_id" json:"rp_id"` RpOrigins []string `toml:"rp_origins" json:"rp_origins"` @@ -517,11 +517,11 @@ func (c *captcha) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { c.Enabled = ValOrDefault(remoteConfig.SecurityCaptchaEnabled, false) } -func (p passkey) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { +func (p Passkey) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { body.PasskeyEnabled = cast.Ptr(p.Enabled) } -func (p *passkey) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { +func (p *Passkey) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { // When local config is not set, we assume platform defaults should not change if p == nil { return @@ -529,13 +529,13 @@ func (p *passkey) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { p.Enabled = remoteConfig.PasskeyEnabled } -func (w webauthn) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { +func (w Webauthn) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { body.WebauthnRpDisplayName = nullable.NewNullableWithValue(w.RpDisplayName) body.WebauthnRpId = nullable.NewNullableWithValue(w.RpId) body.WebauthnRpOrigins = nullable.NewNullableWithValue(strings.Join(w.RpOrigins, ",")) } -func (w *webauthn) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { +func (w *Webauthn) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { // When local config is not set, we assume platform defaults should not change if w == nil { return diff --git a/apps/cli-go/pkg/config/auth_test.go b/apps/cli-go/pkg/config/auth_test.go index 61ba5b429c..ddfaca4008 100644 --- a/apps/cli-go/pkg/config/auth_test.go +++ b/apps/cli-go/pkg/config/auth_test.go @@ -215,8 +215,8 @@ func TestCaptchaDiff(t *testing.T) { func TestPasskeyConfigMapping(t *testing.T) { t.Run("serializes passkey config to update body", func(t *testing.T) { c := newWithDefaults() - c.Passkey = &passkey{Enabled: true} - c.Webauthn = &webauthn{ + c.Passkey = &Passkey{Enabled: true} + c.Webauthn = &Webauthn{ RpDisplayName: "Supabase CLI", RpId: "localhost", RpOrigins: []string{ @@ -237,7 +237,7 @@ func TestPasskeyConfigMapping(t *testing.T) { t.Run("does not serialize rp fields when webauthn is undefined", func(t *testing.T) { c := newWithDefaults() - c.Passkey = &passkey{Enabled: false} + c.Passkey = &Passkey{Enabled: false} // Run test body := c.ToUpdateAuthConfigBody() // Check result @@ -254,7 +254,7 @@ func TestPasskeyConfigMapping(t *testing.T) { t.Run("serializes webauthn fields independently of passkey", func(t *testing.T) { c := newWithDefaults() - c.Webauthn = &webauthn{ + c.Webauthn = &Webauthn{ RpDisplayName: "Supabase CLI", RpId: "localhost", RpOrigins: []string{"http://127.0.0.1:3000"}, @@ -270,8 +270,8 @@ func TestPasskeyConfigMapping(t *testing.T) { t.Run("hydrates passkey and webauthn config from remote", func(t *testing.T) { c := newWithDefaults() - c.Passkey = &passkey{Enabled: true} - c.Webauthn = &webauthn{} + c.Passkey = &Passkey{Enabled: true} + c.Webauthn = &Webauthn{} // Run test c.FromRemoteAuthConfig(v1API.AuthConfigResponse{ PasskeyEnabled: true, From 2dfacb9382faf95a0768363eef29822e57f9c021 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 00:11:44 +0000 Subject: [PATCH 10/16] fix(docker): bump supabase/postgres from 17.6.1.131 to 17.6.1.132 in /apps/cli-go/pkg/config/templates (#5402) Bumps supabase/postgres from 17.6.1.131 to 17.6.1.132. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=supabase/postgres&package-manager=docker&previous-version=17.6.1.131&new-version=17.6.1.132)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 40e987e98f..76c42d1bbd 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -1,5 +1,5 @@ # Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.131 AS pg +FROM supabase/postgres:17.6.1.132 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.22.3 AS mailpit From 34c3ce95a2754e530903c4a13a6d92bd6d8e012a Mon Sep 17 00:00:00 2001 From: Sean Oliver <882952+seanoliver@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:28:19 -0700 Subject: [PATCH 11/16] fix(cli): drop redundant identify from identity stitch (#5396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Both the Go CLI's `StitchLogin` and the TS CLI's `X-Gotrue-Id` interception fire `$create_alias` AND `$identify(distinctID, nil)` back-to-back on a user's first successful stitch. The `$identify` carries no person properties, so `$create_alias` already owns the entire device→user merge in the person graph. The Identify is pure event-volume waste. This is the follow-up to #5366. That PR gated stitching in ephemeral environments (CI, Docker, npx) to stop the identify spike. This one tightens the path that still runs — persistent developer laptops — by dropping the redundant call. We verified the redundancy empirically: of 638K `posthog-go` `$identify` events on 2026-05-26, zero carried `$anon_distinct_id` linkage, confirming the merge comes entirely from the alias. ## Changes - Go: remove `s.analytics.Identify(distinctID, nil)` from `StitchLogin` in `apps/cli-go/internal/telemetry/service.go`. - TS: remove `analytics.identify(gotrueId)` from the stitch in `apps/cli/src/legacy/auth/legacy-platform-api.layer.ts`. The TS `identify()` may have set default person properties (`cli_version`, `os`, `arch`); accepting that small loss since subsequent `cli_*` capture events carry the same data as event properties. - Tests updated on both surfaces to assert `Alias` fires and `Identify` does not. Kept the surfaces 1:1 so the Go/TS telemetry parity holds. `identify` stays on the `Analytics` interface — the `login` command still uses it for `cli_login_completed`, which is a separate path. Halves the remaining persistent-laptop stitch volume (~15K/day → ~7K/day). No effect on the spike population already handled by #5366. GROWTH-890 --------- Co-authored-by: Julien Goux --- apps/cli-go/internal/login/login_test.go | 6 ++++-- apps/cli-go/internal/telemetry/service.go | 3 --- apps/cli-go/internal/telemetry/service_test.go | 3 +-- apps/cli/src/legacy/auth/legacy-platform-api.layer.ts | 1 - .../src/legacy/auth/legacy-platform-api.layer.unit.test.ts | 4 ++-- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/cli-go/internal/login/login_test.go b/apps/cli-go/internal/login/login_test.go index 65c6a2605f..1ce936d196 100644 --- a/apps/cli-go/internal/login/login_test.go +++ b/apps/cli-go/internal/login/login_test.go @@ -170,8 +170,7 @@ func TestLoginTelemetryStitching(t *testing.T) { require.NoError(t, err) require.Len(t, analytics.aliases, 1) assert.Equal(t, "user-123", analytics.aliases[0].distinctID) - require.Len(t, analytics.identifies, 1) - assert.Equal(t, "user-123", analytics.identifies[0].distinctID) + assert.Empty(t, analytics.identifies) require.Len(t, analytics.captures, 1) assert.Equal(t, phtelemetry.EventLoginCompleted, analytics.captures[0].event) assert.Equal(t, "user-123", analytics.captures[0].distinctID) @@ -211,6 +210,9 @@ func TestLoginTelemetryStitching(t *testing.T) { }) require.NoError(t, err) + require.Len(t, analytics.aliases, 1) + assert.Equal(t, "user-456", analytics.aliases[0].distinctID) + assert.Empty(t, analytics.identifies) require.Len(t, analytics.captures, 1) assert.Equal(t, "user-456", analytics.captures[0].distinctID) }) diff --git a/apps/cli-go/internal/telemetry/service.go b/apps/cli-go/internal/telemetry/service.go index 26869ad5a8..6b237f7e8c 100644 --- a/apps/cli-go/internal/telemetry/service.go +++ b/apps/cli-go/internal/telemetry/service.go @@ -137,9 +137,6 @@ func (s *Service) StitchLogin(distinctID string) error { if err := s.analytics.Alias(distinctID, s.state.DeviceID); err != nil { return err } - if err := s.analytics.Identify(distinctID, nil); err != nil { - return err - } } s.state.DistinctID = distinctID return SaveState(s.state, s.fsys) diff --git a/apps/cli-go/internal/telemetry/service_test.go b/apps/cli-go/internal/telemetry/service_test.go index 5a19198c83..39df2bd0e2 100644 --- a/apps/cli-go/internal/telemetry/service_test.go +++ b/apps/cli-go/internal/telemetry/service_test.go @@ -151,8 +151,7 @@ func TestServiceStitchLoginPersistsDistinctID(t *testing.T) { require.Len(t, analytics.aliases, 1) assert.Equal(t, "user-123", analytics.aliases[0].distinctID) assert.Equal(t, deviceID, analytics.aliases[0].alias) - require.Len(t, analytics.identifies, 1) - assert.Equal(t, "user-123", analytics.identifies[0].distinctID) + assert.Empty(t, analytics.identifies) require.Len(t, analytics.captures, 1) assert.Equal(t, "user-123", analytics.captures[0].distinctID) diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts index ff16c8afcf..654c97fb5e 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -97,7 +97,6 @@ const makeLegacyPlatformApiServices = Effect.gen(function* () { stitchAttempted = true; yield* analytics.alias(gotrueId, runtime.deviceId); - yield* analytics.identify(gotrueId); const state: LegacyTelemetryState = { enabled, 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..a33d241ebc 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 @@ -296,7 +296,7 @@ describe("legacyPlatformApiLayer", () => { yield* api.v1.listAllProjects(); expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); - expect(analytics.identifies).toEqual([{ distinctId: "user-123", properties: {} }]); + expect(analytics.identifies).toEqual([]); const telemetry = readTelemetryConfig(configDir); expect(telemetry.distinct_id).toBe("user-123"); expect(telemetry.enabled).toBe(true); @@ -374,7 +374,7 @@ describe("legacyPlatformApiLayer", () => { yield* api.v1.listAllProjects(); expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); - expect(analytics.identifies).toEqual([{ distinctId: "user-123", properties: {} }]); + expect(analytics.identifies).toEqual([]); expect(readTelemetryConfig(configDir).distinct_id).toBe("user-123"); } finally { rmSync(configDir, { recursive: true, force: true }); From 2a8a08781ef78d7db3f506b1f4c4e83652517104 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 1 Jun 2026 08:34:55 +0100 Subject: [PATCH 12/16] feat(cli): port domains commands to native TypeScript (#5391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports `supabase domains` (custom hostname management) from the Phase 0 Go-binary proxy to a native TypeScript/Effect implementation in the legacy shell, as a strict 1:1 port of the Go CLI. Closes CLI-1293. ## What changed - **5 native handlers** (`create`, `get`, `reverify`, `activate`, `delete`) replacing the Go-proxy wrappers. Each resolves the project ref, calls the typed Management API custom-hostname endpoint, and reproduces Go's `PrintStatus` contract: status text to **stderr**, structured output to **stdout** when `-o != pretty`. All three `--output-format` modes (text/json/stream-json) and Go's `-o {pretty,json,yaml,toml,env}` are honored, and PersistentPostRun parity (linked-project cache + telemetry flush) runs on success and failure. - **`create` CNAME pre-check** — Cloudflare DNS-over-HTTPS (`https://1.1.1.1`, 10s timeout) verifying the hostname's CNAME points at `..` before initializing; short-circuits before any POST on failure. - **Restored `--include-raw-output`** (deprecated) — was missing from the proxy port. Forces `-o json` when `-o` is unset/pretty; inert on `delete`, matching Go. - **`LegacyCliConfig.projectHost`** — sourced from the single-source `legacy-profile.ts` map (also used by `branches get`), with the YAML `project_host` override. Added the `snap` built-in profile so its CNAME targets resolve to `snapcloud.dev`. - **Docs** — consolidated the 5 per-subcommand `SIDE_EFFECTS.md` files into one group-level document; flipped the 5 `domains` rows to `ported` in the porting-status tracker. ## Reviewer notes - `--include-raw-output` is declared per-subcommand (Effect CLI has no persistent-group-flag support, consistent with how `--project-ref` is handled shell-wide) and cannot reproduce Cobra's help-hiding or deprecation warning. Documented as an intentional divergence in `SIDE_EFFECTS.md`, alongside the snake-case encoder output and the `%+v` SSL-struct dump approximation. - New colocated modules: `domains.errors.ts`, `domains.format.ts` (pure `PrintStatus` port), `domains.cname.ts` (DNS verifier), `domains.emit.ts` (shared output-mode branching). The per-profile host map lives only in `legacy-profile.ts` (single source of truth). --------- Co-authored-by: Julien Goux --- apps/cli/docs/go-cli-porting-status.md | 10 +- .../legacy-platform-api.layer.unit.test.ts | 1 + .../legacy/commands/domains/SIDE_EFFECTS.md | 101 ++++++++ .../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 | 69 +++++ .../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 | 199 +++++++++++++++ .../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, 1846 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 a33d241ebc..dca599d975 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..dbfd1dcf48 --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md @@ -0,0 +1,101 @@ +# `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 + +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) + +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 human status is +suppressed on 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). + - 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/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..edb4843753 --- /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 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).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + 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).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* 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..4464cf7713 --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/domains.emit.ts @@ -0,0 +1,69 @@ +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}`): + * + * - 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`), + * 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; + + if (effectiveGoFmt === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (effectiveGoFmt === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (effectiveGoFmt === "toml") { + yield* output.raw(encodeToml(response) + "\n"); + return; + } + if (effectiveGoFmt === "env") { + 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(formatHostnameStatus(response), "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..7559202434 --- /dev/null +++ b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts @@ -0,0 +1,199 @@ +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 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"'); + // 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)); + }); + + 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..f590ce361c --- /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 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).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* 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 cbf20ccb3dc79f58919388fa71fb8347c022e0f6 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 1 Jun 2026 08:41:22 +0100 Subject: [PATCH 13/16] feat(cli): port projects commands to native TypeScript (#5392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Replaces the four Phase-0 Go proxies in the legacy `projects` command group with native TypeScript Effect implementations that call the Management API directly, and flips their rows in `go-cli-porting-status.md` from `wrapped` to `ported`. - **`list`** — `GET /v1/projects`; soft linked-ref marker via the new `resolveOptional` (never prompts/fails); two-axis output (Go `--output pretty|json|yaml|toml|env` × TS `--output-format text|json|stream-json`), with `--output env` unsupported, matching Go. - **`create`** — interactive/non-interactive gating on `--interactive` + TTY + output mode; prompts for name/org/region/password when omitted; crypto-RNG 16-char password fallback; `desired_instance_size` only when `--size` set; `--plan` accepted-but-ignored (hidden, vestigial in Go); dashboard URL resolved per profile. - **`delete`** — arg-or-prompt ref resolution (never reads the linked file as a source); confirmation defaulting to **No** and honouring `--yes`; 404 → does-not-exist, other non-2xx → failed; best-effort `supabase/.temp` unlink when the deleted ref matches the linked one. - **`api-keys`** — ref resolution through the shared resolver (flag → env → linked file → prompt on a TTY → error); `NAME | KEY VALUE` table with `******` masking; `SUPABASE__KEY` env/toml encoding; raw `ApiKeyResponse[]` for json/yaml. ## Shared infrastructure - `LegacyProjectRefResolver` gains `resolveOptional` (soft marker for `list`) and `promptProjectRef(title)` (per-command prompt label for `delete`); existing `resolve` unchanged. - Hoists `apiKeysToEnv` into `legacy/shared/legacy-api-keys.format.ts` and reuses the existing `legacy-timestamp.format.ts` instead of duplicating a timestamp formatter (per the hoist-before-duplicate policy). ## Reviewer notes - **Strict Go parity** for stdout/stderr text, flags, exit codes, API routes, and error-message phrasing. Refs are validated against `^[a-z]{20}$` before reaching any API path. - **Intentional divergences** (documented in `SIDE_EFFECTS.md`): terminal ANSI color is omitted (Go disables color on non-TTY output, so piped output matches byte-for-byte); the non-interactive `create` error consolidates cobra's per-flag "required" errors into one message; `DB_PASSWORD` is not consumed (Go only mirrors `--db-password` into viper for local-stack reuse). - New unit + integration coverage across all four commands and the resolver; per-command and group-level `SIDE_EFFECTS.md`. Closes CLI-1288. --------- Co-authored-by: Julien Goux --- apps/cli/docs/go-cli-porting-status.md | 8 +- .../commands/branches/branches.format.ts | 42 +- .../branches/branches.format.unit.test.ts | 21 +- .../legacy/commands/projects/SIDE_EFFECTS.md | 98 ++++ .../projects/api-keys/SIDE_EFFECTS.md | 48 +- .../projects/api-keys/api-keys.command.ts | 11 +- .../projects/api-keys/api-keys.handler.ts | 83 +++- .../api-keys/api-keys.integration.test.ts | 169 +++++++ .../commands/projects/create/SIDE_EFFECTS.md | 24 +- .../projects/create/create.command.ts | 11 +- .../projects/create/create.handler.ts | 190 +++++++- .../create/create.integration.test.ts | 423 ++++++++++++++++++ .../commands/projects/delete/SIDE_EFFECTS.md | 38 +- .../projects/delete/delete.command.ts | 11 +- .../projects/delete/delete.handler.ts | 153 ++++++- .../delete/delete.integration.test.ts | 276 ++++++++++++ .../commands/projects/list/SIDE_EFFECTS.md | 53 ++- .../commands/projects/list/list.command.ts | 12 +- .../commands/projects/list/list.handler.ts | 133 +++++- .../projects/list/list.integration.test.ts | 268 +++++++++++ .../commands/projects/projects.errors.ts | 129 ++++++ .../commands/projects/projects.format.ts | 142 ++++++ .../projects/projects.format.unit.test.ts | 152 +++++++ .../commands/projects/projects.prompt.ts | 138 ++++++ .../legacy/config/legacy-project-ref.layer.ts | 17 +- .../legacy-project-ref.layer.unit.test.ts | 71 ++- .../config/legacy-project-ref.service.ts | 24 + .../legacy/shared/legacy-api-keys.format.ts | 32 ++ apps/cli/tests/helpers/legacy-mocks.ts | 6 +- apps/cli/tests/helpers/mocks.ts | 11 +- packages/api/src/effect.ts | 15 +- packages/api/src/internal/client.ts | 41 +- packages/cli-test-helpers/src/normalize.ts | 10 + 33 files changed, 2694 insertions(+), 166 deletions(-) create mode 100644 apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/projects/create/create.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/projects/list/list.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/projects/projects.errors.ts create mode 100644 apps/cli/src/legacy/commands/projects/projects.format.ts create mode 100644 apps/cli/src/legacy/commands/projects/projects.format.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/projects/projects.prompt.ts create mode 100644 apps/cli/src/legacy/shared/legacy-api-keys.format.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index d13d150b38..5cdd1aa2fa 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -215,10 +215,10 @@ Legend: | -------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | | `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | -| `projects list` | `wrapped` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | -| `projects create` | `wrapped` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | -| `projects delete` | `wrapped` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | -| `projects api-keys` | `wrapped` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | +| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | +| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | +| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | +| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | | `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | | `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | | `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | diff --git a/apps/cli/src/legacy/commands/branches/branches.format.ts b/apps/cli/src/legacy/commands/branches/branches.format.ts index 4b4dc5293f..c410c5dcd7 100644 --- a/apps/cli/src/legacy/commands/branches/branches.format.ts +++ b/apps/cli/src/legacy/commands/branches/branches.format.ts @@ -6,6 +6,8 @@ import type { } from "@supabase/api/effect"; import { renderGlamourTable } from "../../output/legacy-glamour-table.ts"; +import { apiKeysToEnv } from "../../shared/legacy-api-keys.format.ts"; +import { formatLegacyTimestamp } from "../../shared/legacy-timestamp.format.ts"; // --------------------------------------------------------------------------- // Pure formatters — no Effect / no service dependencies, kept unit-testable. @@ -33,26 +35,6 @@ const GET_HEADERS = [ "STATUS", ] as const; -function pad2(value: number): string { - return value.toString().padStart(2, "0"); -} - -/** - * Reproduces Go's `utils.FormatTime`: parse the ISO date-time and re-render - * as UTC "YYYY-MM-DD HH:MM:SS". Used for the CREATED AT / UPDATED AT columns - * of `branches list`. - */ -export function formatUtcDateTime(value: string): string { - if (value.length === 0) return value; - const parsed = Date.parse(value); - if (Number.isNaN(parsed)) return value; - const d = new Date(parsed); - return ( - `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ` + - `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}` - ); -} - type Branch = typeof BranchResponse.Type; /** @@ -72,8 +54,8 @@ export function renderBranchesListTable(branches: ReadonlyArray): string b.git_branch ?? " ", b.with_data ? "true" : "false", b.status, - formatUtcDateTime(b.created_at), - formatUtcDateTime(b.updated_at), + formatLegacyTimestamp(b.created_at), + formatLegacyTimestamp(b.updated_at), ]); return renderGlamourTable(LIST_HEADERS, rows); } @@ -202,22 +184,6 @@ type ApiKey = typeof ApiKeyResponse.Type; type Pooler = typeof SupavisorConfigResponse.Type; type Detail = typeof V1GetABranchConfigOutput.Type; -/** - * Reproduces Go's `apiKeys.ToEnv` (`api_keys.go:51-66`): - * uppercase the name, wrap as `SUPABASE__KEY`, fall back to `"******"` - * when the api_key value is nullable-null. - */ -export function apiKeysToEnv(keys: ReadonlyArray): Record { - const envs: Record = {}; - for (const entry of keys) { - const name = entry.name.toUpperCase(); - const key = `SUPABASE_${name}_KEY`; - const value = entry.api_key === undefined || entry.api_key === null ? "******" : entry.api_key; - envs[key] = value; - } - return envs; -} - export interface StandardEnvsResult { readonly envs: Record; /** diff --git a/apps/cli/src/legacy/commands/branches/branches.format.unit.test.ts b/apps/cli/src/legacy/commands/branches/branches.format.unit.test.ts index f7c8052753..680d60b489 100644 --- a/apps/cli/src/legacy/commands/branches/branches.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/branches/branches.format.unit.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from "vitest"; +import { apiKeysToEnv } from "../../shared/legacy-api-keys.format.ts"; import { - apiKeysToEnv, - formatUtcDateTime, parsePoolerConnectionString, renderBranchGetTable, renderBranchesListTable, @@ -10,24 +9,6 @@ import { toStandardEnvs, } from "./branches.format.ts"; -describe("formatUtcDateTime", () => { - it("formats ISO date-time as UTC YYYY-MM-DD HH:MM:SS", () => { - expect(formatUtcDateTime("2026-05-27T03:04:05Z")).toBe("2026-05-27 03:04:05"); - }); - - it("zero-pads single-digit months and minutes", () => { - expect(formatUtcDateTime("2026-01-02T03:04:05Z")).toBe("2026-01-02 03:04:05"); - }); - - it("returns the empty string unchanged", () => { - expect(formatUtcDateTime("")).toBe(""); - }); - - it("returns garbage input unchanged when Date.parse cannot decode it", () => { - expect(formatUtcDateTime("not-a-date")).toBe("not-a-date"); - }); -}); - describe("renderBranchesListTable", () => { it("renders all 8 columns in declared order", () => { const out = renderBranchesListTable([ diff --git a/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md new file mode 100644 index 0000000000..23e0b9711e --- /dev/null +++ b/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md @@ -0,0 +1,98 @@ +# `supabase projects` (group) + +Group-level side-effect summary for the natively-ported `projects` commands: +`list`, `create`, `delete`, `api-keys`. Per-subcommand detail lives in each +subcommand's own `SIDE_EFFECTS.md`. + +## 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 (ref string) | `list` (linked marker), `api-keys` (ref source), `delete` (unlink match) | + +## Files Written / Removed + +| Path | Action | When | +| --------------------------- | ------- | ---------------------------------------------------------------- | +| `/supabase/.temp/` | removed | `delete` only — when the deleted ref matches the linked ref file | + +> Go best-effort deletes the per-ref keyring credential on `delete`, but Go only +> ever stores the profile-scoped access token in the keyring (never a per-ref +> entry), so that delete always targets a non-existent entry — a no-op for both +> CLIs. Go's only observable output there is its keyring-backend _availability_ +> error (`Keyring is not supported on WSL`, emitted when the system keyring is +> down, e.g. on headless CI); that environment noise is normalized away in the +> cli-e2e parity harness. + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------- | ------------ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | — | `[{id, ref, organization_slug, name, region, created_at, status, database}]` | +| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?}` (JSON) | `{id, ref, organization_slug, name, region, created_at, status}` | +| `DELETE` | `/v1/projects/{ref}` | Bearer token | — | `{id, ref, name}`; `404` → does-not-exist | +| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | — | `[{name, api_key?}]` | +| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — `create` interactive org prompt only | + +## 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_REF` | linked project ref (via the config layer) | no (used by `list` marker / `api-keys` ref / `delete` unlink) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | + +> `DB_PASSWORD` is **not** consumed. In Go it only mirrors `--db-password` via a +> viper binding for downstream local-stack use; `projects create` never reads it. + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------------ | +| `0` | success | +| `1` | auth / network / non-2xx status (incl. decode failure) / invalid ref | +| `1` | `create`: required params missing in non-interactive mode / empty project name | +| `1` | `delete`: declined confirmation (cancellation) / no ref on a non-TTY | +| `1` | `list`: `--output env` is unsupported | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +`safeFlags` (Go `markFlagTelemetrySafe`): `--org-id` (`create`), `--project-ref` +(`api-keys`). No custom events beyond `cli_command_executed`. + +## Output (two-axis: Go `--output` × TS `--output-format`) + +Go's `--output {pretty|json|yaml|toml|env}` takes priority when set; otherwise the +TS `--output-format {text|json|stream-json}` applies. + +- **list** — pretty/text: Glamour table `LINKED | ORG ID | REFERENCE ID | NAME | REGION | CREATED AT (UTC)`; go json/yaml/toml encode the `linkedProject[]` (`{projects=[...]}` for toml); go `env` is an error; TS json/stream-json `success("", {projects})`. +- **create** — stderr `Created a new project at /project/` for all formats; pretty/text: table `ORG ID | REFERENCE ID | NAME | REGION | CREATED AT (UTC)`; go json/yaml/toml/env encode the created project (env supported here); TS json/stream-json `success("Created project", {...project})`. +- **delete** — text: confirmation prompt (default No, honours `--yes`) then stdout `Deleted project: `; TS json/stream-json `success("Deleted project", {name})`. +- **api-keys** — pretty/text: Glamour table `NAME | KEY VALUE` (`******` masks null keys); go toml/env encode the `SUPABASE__KEY` map; go json/yaml encode `ApiKeyResponse[]`; TS json/stream-json `success("", {keys})`. + +## Notes + +- **Terminal color:** Go wraps refs / project names / dashboard URLs in ANSI + (`utils.Aqua`, `utils.Bold`); the TS port emits plain text. Go's lipgloss + renderer disables color when stdout/stderr is not a TTY, so piped / CI output + matches byte-for-byte; only the interactive-terminal appearance differs. +- **`create` linked cache:** the new project ref is cached on success; + `delete` also caches the resolved ref (Go's `PersistentPostRun` parity), even + though the project is gone — the cache is a telemetry-group record, separate + from the `supabase/.temp` link removed during unlink. +- **`create` non-interactive errors:** TS consolidates cobra's per-flag + "required flag(s) … not set" errors into a single `LegacyProjectsCreateMissingArgError` + that lists every missing item at once (a deliberate UX improvement over Go's + fail-on-first behavior). +- `--plan` on `create` is accepted but ignored (no-op, hidden) — vestigial in Go too. +- `create` interactivity is gated on `--interactive` (default true) **and** a TTY stdin + **and** an interactive (text-mode) `Output`. +- `delete` confirmation defaults to **No** and honours the global `--yes`. +- `api-keys` resolves `--project-ref` via the shared resolver (flag → env → + `.temp/project-ref` → prompt on a TTY → error when unlinked), matching Go's root + `ParseProjectRef`. diff --git a/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md index b30981c9cf..2071a7aefc 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| --------------------------------- | ------------------------- | ------------------------------------------------------------------------ | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | when `--project-ref` flag is not provided, to resolve linked project ref | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ----------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (ref string) | when `--project-ref` is not provided, to resolve the linked project ref | ## Files Written @@ -42,28 +42,40 @@ | `1` | network / connection failure | | `1` | project ref not provided and no linked project found | +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` is telemetry-safe) | + ## Output +Two-axis: Go's `--output {pretty|json|yaml|toml|env}` wins when set; otherwise the TS +`--output-format`. go toml/env encode a `SUPABASE__KEY` env map; go json/yaml +encode the raw `ApiKeyResponse[]`. + ### `--output-format text` (Go CLI compatible) -Prints a Markdown-style table to stdout with a header row and one row per API key. -Column order: `NAME`, `API KEY`. Null API keys are shown as empty. +Glamour ASCII table. Column order: `NAME`, `KEY VALUE`. A null api key renders as `******`. ``` - NAME API KEY - anon eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... - service_role eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + NAME | KEY VALUE + -------------|------------------------------------------- + anon | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + service_role | ****** ``` ### `--output-format json` -Single JSON array emitted to stdout on success. +`success("", { keys })` — the raw `ApiKeyResponse[]` under a `keys` field. ```json -[ - { "name": "anon", "api_key": "eyJ..." }, - { "name": "service_role", "api_key": "eyJ..." } -] +{ + "keys": [ + { "name": "anon", "api_key": "eyJ..." }, + { "name": "service_role", "api_key": null } + ] +} ``` ### `--output-format stream-json` @@ -71,7 +83,7 @@ Single JSON array emitted to stdout on success. One `result` event on success. ```ndjson -{"type":"result","data":[{"name":"anon","api_key":"eyJ..."},{"name":"service_role","api_key":"eyJ..."}]} +{"type":"result","data":{"keys":[{"name":"anon","api_key":"eyJ..."},{"name":"service_role","api_key":null}]}} ``` On failure, an `error` event is emitted instead: @@ -82,6 +94,8 @@ On failure, an `error` event is emitted instead: ## Notes -- API keys with null values (as returned by the API for redacted keys) are shown as - empty strings in text mode output. +- API keys with null values (redacted by the API) render as `******` in text mode and + in the toml/env env map; the json/yaml encodings preserve the raw `null`. - The `--project-ref` flag is optional when the CLI is linked to a project via `supabase link`. + When omitted, the ref is resolved flag → env → `.temp/project-ref` → prompt on a TTY, + failing with a not-linked error otherwise. diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts index 05a5ec562d..eba15b639b 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts @@ -1,5 +1,8 @@ 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 { legacyProjectsApiKeys } from "./api-keys.handler.ts"; const config = { @@ -19,5 +22,11 @@ export const legacyProjectsApiKeysCommand = Command.make("api-keys", config).pip description: "List all API keys for a project", }, ]), - Command.withHandler((flags) => legacyProjectsApiKeys(flags)), + Command.withHandler((flags) => + legacyProjectsApiKeys(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["projects", "api-keys"])), ); diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts index 7b7d8082ec..4eb3908984 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts @@ -1,12 +1,85 @@ +import type { V1GetProjectApiKeysOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { apiKeysToEnv } from "../../../shared/legacy-api-keys.format.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyProjectsApiKeysNetworkError, + LegacyProjectsApiKeysUnexpectedStatusError, +} from "../projects.errors.ts"; +import { renderProjectApiKeysTable } from "../projects.format.ts"; import type { LegacyProjectsApiKeysFlags } from "./api-keys.command.ts"; +type ApiKeys = typeof V1GetProjectApiKeysOutput.Type; + +const mapApiKeysError = mapLegacyHttpError({ + networkError: LegacyProjectsApiKeysNetworkError, + statusError: LegacyProjectsApiKeysUnexpectedStatusError, + networkMessage: (cause) => `failed to get api keys: ${cause}`, + statusMessage: (status, body) => `unexpected get api keys status ${status}: ${body}`, +}); + export const legacyProjectsApiKeys = Effect.fn("legacy.projects.api-keys")(function* ( flags: LegacyProjectsApiKeysFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["projects", "api-keys"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + // Go's root PersistentPreRun resolves `--project-ref` via `ParseProjectRef` + // (`root.go:112-115`), which prompts on a TTY and fails when unlinked. + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const fetching = + output.format === "text" ? yield* output.task("Fetching API keys...") : undefined; + const keys: ApiKeys = yield* api.v1.getProjectApiKeys({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapApiKeysError), + ); + yield* fetching?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + // Go encodes the `SUPABASE__KEY` env map for both toml and env + // (`api_keys.go:34-36`). + if (goFmt === "toml") { + yield* output.raw(encodeToml(apiKeysToEnv(keys)) + "\n"); + return; + } + if (goFmt === "env") { + yield* output.raw(encodeEnv(apiKeysToEnv(keys)) + "\n"); + return; + } + if (goFmt === "json") { + yield* output.raw(encodeGoJson(keys)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(keys)); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", { keys }); + return; + } + + yield* output.raw(renderProjectApiKeysTable(keys)); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts new file mode 100644 index 0000000000..2865fcde89 --- /dev/null +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts @@ -0,0 +1,169 @@ +import type { V1GetProjectApiKeysOutput } 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, + mockLegacyCliConfig, + mockLegacyPlatformApi, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyProjectsApiKeys } from "./api-keys.handler.ts"; + +type ApiKeys = typeof V1GetProjectApiKeysOutput.Type; + +const SAMPLE_KEYS: ApiKeys = [ + { name: "anon", api_key: "anon-secret" }, + { name: "service_role", api_key: null }, +]; + +const FLAG_REF = "qrstuvwxyzabcdefghij"; + +const tempRoot = useLegacyTempWorkdir("supabase-projects-apikeys-int-"); + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly response?: ApiKeys; + readonly status?: number; + readonly network?: "fail"; + readonly projectId?: Option.Option; +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: opts.response ?? SAMPLE_KEYS }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: opts.projectId ?? Option.some(LEGACY_VALID_REF), + }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }); + return { layer, out, api }; +} + +describe("legacy projects api-keys integration", () => { + it.live("lists api keys as a NAME / KEY VALUE table and masks null values", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyProjectsApiKeys({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).toContain("KEY VALUE"); + expect(out.stdoutText).toContain("anon-secret"); + expect(out.stdoutText).toContain("******"); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves the ref from --project-ref", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyProjectsApiKeys({ projectRef: Option.some(FLAG_REF) }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${FLAG_REF}/api-keys`); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves the ref from the linked project when --project-ref is omitted", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyProjectsApiKeys({ projectRef: Option.none() }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${LEGACY_VALID_REF}/api-keys`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectNotLinkedError when no ref can be resolved", () => { + const { layer } = setup({ projectId: Option.none() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsApiKeys({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with { keys } for --output-format json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyProjectsApiKeys({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ keys: SAMPLE_KEYS }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event for --output-format stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyProjectsApiKeys({ projectRef: Option.none() }); + expect(out.messages.find((m) => m.type === "success")).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("encodes the SUPABASE__KEY map for --output env", () => { + const { layer, out } = setup({ goOutput: "env" }); + return Effect.gen(function* () { + yield* legacyProjectsApiKeys({ projectRef: Option.none() }); + expect(out.stdoutText).toContain('SUPABASE_ANON_KEY="anon-secret"'); + expect(out.stdoutText).toContain('SUPABASE_SERVICE_ROLE_KEY="******"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("encodes the SUPABASE__KEY map for --output toml", () => { + const { layer, out } = setup({ goOutput: "toml" }); + return Effect.gen(function* () { + yield* legacyProjectsApiKeys({ projectRef: Option.none() }); + expect(out.stdoutText).toContain('SUPABASE_ANON_KEY = "anon-secret"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON array of api keys for --output json", () => { + const { layer, out } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyProjectsApiKeys({ projectRef: Option.none() }); + expect(out.stdoutText).toContain('"name": "anon"'); + expect(out.stdoutText.startsWith("[\n")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a YAML array for --output yaml", () => { + const { layer, out } = setup({ goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyProjectsApiKeys({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("name: anon"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectsApiKeysNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsApiKeys({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectsApiKeysNetworkError"); + expect(json).toContain("failed to get api keys"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("maps HTTP 503 to `unexpected get api keys status 503`", () => { + const { layer } = setup({ status: 503, response: [] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsApiKeys({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectsApiKeysUnexpectedStatusError"); + expect(json).toContain("unexpected get api keys status 503"); + } + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md index 78ec97bd69..46350c6f81 100644 --- a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md @@ -14,17 +14,18 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------- | ------------ | --------------------------------------------------------------------------- | --------------------------------------------------- | -| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region, desired_instance_size?}` (JSON) | `{id, name, organization_slug, region, created_at}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------- | ------------ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — interactive org prompt only | +| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?}` (JSON) | `{id, ref, name, organization_slug, region, created_at, 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`) | -| `DB_PASSWORD` | database password (can be set via env var or flag) | required in non-interactive mode (via `--db-password`) | +| 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`) | +| `DB_PASSWORD` | **not consumed** — Go only mirrors `--db-password` into viper for local-stack reuse; `projects create` never reads it | n/a | ## Exit Codes @@ -35,6 +36,13 @@ | `1` | API error — non-2xx response from `/v1/projects` | | `1` | network / connection failure | | `1` | required flags missing in non-interactive mode | +| `1` | empty project name (interactive prompt left blank) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--org-id` is telemetry-safe) | ## Flags diff --git a/apps/cli/src/legacy/commands/projects/create/create.command.ts b/apps/cli/src/legacy/commands/projects/create/create.command.ts index 3f75a95df9..1c487a4cad 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.command.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.command.ts @@ -1,6 +1,9 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withHidden, withHiddenFromConfig } from "../../../../shared/cli/hidden-flag.ts"; +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 { legacyProjectsCreate } from "./create.handler.ts"; const AWS_REGIONS = [ @@ -94,5 +97,11 @@ export const legacyProjectsCreateCommand = Command.make("create", config).pipe( }, ]), withHiddenFromConfig(config), - Command.withHandler((flags) => legacyProjectsCreate(flags)), + Command.withHandler((flags) => + legacyProjectsCreate(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: ["org-id"] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["projects", "create"])), ); diff --git a/apps/cli/src/legacy/commands/projects/create/create.handler.ts b/apps/cli/src/legacy/commands/projects/create/create.handler.ts index 8c1f0060f2..2e0af17b74 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.handler.ts @@ -1,19 +1,185 @@ +import { type V1CreateAProjectInput, operationDefinitions } from "@supabase/api/effect"; import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyProjectsCreateMissingArgError, + LegacyProjectsCreateNetworkError, + LegacyProjectsCreateUnexpectedStatusError, +} from "../projects.errors.ts"; +import { + dashboardUrlForProfile, + readProjectField, + renderProjectCreateTable, +} from "../projects.format.ts"; +import { + legacyPromptDbPassword, + legacyPromptOrgId, + legacyPromptProjectName, + legacyPromptProjectRegion, +} from "../projects.prompt.ts"; import type { LegacyProjectsCreateFlags } from "./create.command.ts"; +type CreateInput = typeof V1CreateAProjectInput.Type; + +/** Go's `printKeyValue` (`create.go:52-56`): `key` + `:` + pad to width 20 + value. */ +function printKeyValue(key: string, value: string): string { + return `${key}:${" ".repeat(Math.max(0, 20 - key.length))}${value}`; +} + export const legacyProjectsCreate = Effect.fn("legacy.projects.create")(function* ( flags: LegacyProjectsCreateFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["projects", "create"]; - if (Option.isSome(flags.name)) args.push(flags.name.value); - if (Option.isSome(flags.orgId)) args.push("--org-id", flags.orgId.value); - if (Option.isSome(flags.dbPassword)) args.push("--db-password", flags.dbPassword.value); - if (Option.isSome(flags.region)) args.push("--region", flags.region.value); - if (Option.isSome(flags.size)) args.push("--size", flags.size.value); - if (Option.isSome(flags.interactive)) - args.push(`--interactive=${flags.interactive.value ? "true" : "false"}`); - if (Option.isSome(flags.plan)) args.push("--plan", flags.plan.value); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const cliConfig = yield* LegacyCliConfig; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const tty = yield* Tty; + + let createdRef: string | undefined; + + yield* Effect.gen(function* () { + // Go gates interactivity on `term.IsTerminal(stdin) && interactive` + // (`projects.go:63`); `--interactive` defaults to true. We additionally + // require a text-mode `Output` so json/stream-json never prompt. + const interactive = Option.getOrElse(flags.interactive, () => true); + const effectiveInteractive = interactive && tty.stdinIsTty && output.interactive; + + let name = Option.getOrElse(flags.name, () => ""); + let orgId = Option.getOrElse(flags.orgId, () => ""); + let region: CreateInput["region"] = Option.getOrUndefined(flags.region); + let dbPassword = Option.getOrElse(flags.dbPassword, () => ""); + const size = Option.getOrUndefined(flags.size); + + // Non-interactive: Go's PreRunE marks `--org-id`, `--db-password`, + // `--region` required and the project name positional `ExactArgs(1)`. + if (!effectiveInteractive) { + const missing: Array = []; + if (name.length === 0) missing.push("project name"); + if (orgId.length === 0) missing.push("--org-id"); + if (dbPassword.length === 0) missing.push("--db-password"); + if (region === undefined) missing.push("--region"); + if (missing.length > 0) { + return yield* new LegacyProjectsCreateMissingArgError({ + message: `non-interactive mode requires the following to be set: ${missing.join(", ")}`, + }); + } + } + + // promptMissingParams (`create.go:58-85`): prompt for each empty value and + // echo the resolved value to stderr in text mode. + if (name.length === 0) { + name = yield* legacyPromptProjectName(); + } else if (output.format === "text") { + yield* output.raw(printKeyValue("Creating project", name) + "\n", "stderr"); + } + if (orgId.length === 0) { + orgId = yield* legacyPromptOrgId(); + if (output.format === "text") { + yield* output.raw(printKeyValue("Selected org-id", orgId) + "\n", "stderr"); + } + } + if (region === undefined) { + const chosenRegion = yield* legacyPromptProjectRegion(); + region = chosenRegion; + if (output.format === "text") { + yield* output.raw(printKeyValue("Selected region", chosenRegion) + "\n", "stderr"); + } + } + if (dbPassword.length === 0) { + dbPassword = yield* legacyPromptDbPassword(); + } + + const input: CreateInput = { + name, + organization_slug: orgId, + db_pass: dbPassword, + ...(region !== undefined ? { region } : {}), + ...(size !== undefined ? { desired_instance_size: size } : {}), + }; + + const creating = + output.format === "text" ? yield* output.task("Creating project...") : undefined; + + // `executeRaw` sends the body with Go-sorted keys (matching `json.Marshal`) + // and skips output decoding: the 201 response's `ref` can be the cli-e2e + // `__PROJECT_REF__` placeholder, which the generated schema rejects. + const response = yield* api.executeRaw(operationDefinitions.v1CreateAProject, input).pipe( + Effect.tapError(() => creating?.fail() ?? Effect.void), + Effect.mapError( + (cause) => + new LegacyProjectsCreateNetworkError({ message: `failed to create project: ${cause}` }), + ), + ); + + if (response.status !== 201) { + const body = sanitizeLegacyErrorBody( + yield* response.text.pipe(Effect.orElseSucceed(() => "")), + ); + yield* creating?.fail() ?? Effect.void; + return yield* new LegacyProjectsCreateUnexpectedStatusError({ + status: response.status, + body, + message: `Unexpected error creating project: ${body}`, + }); + } + + const created = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); + yield* creating?.clear() ?? Effect.void; + + const id = readProjectField(created, "id"); + createdRef = id.length > 0 ? id : undefined; + + // Go prints this to stderr for every output format (`create.go:33-34`). + const projectUrl = `${dashboardUrlForProfile(cliConfig.profile)}/project/${id}`; + yield* output.raw(`Created a new project at ${projectUrl}\n`, "stderr"); + + const goFmt = Option.getOrUndefined(goOutputFlag); + if (goFmt === "json") { + yield* output.raw(encodeGoJson(created)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(created)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(created) + "\n"); + return; + } + if (goFmt === "env") { + yield* output.raw(encodeEnv(created) + "\n"); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + const data = typeof created === "object" && created !== null ? created : {}; + yield* output.success("Created project", { ...data }); + return; + } + + yield* output.raw(renderProjectCreateTable(created)); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + createdRef === undefined ? Effect.void : linkedProjectCache.cache(createdRef), + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts new file mode 100644 index 0000000000..bac779f8f0 --- /dev/null +++ b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts @@ -0,0 +1,423 @@ +import type { OrganizationResponseV1, V1CreateAProjectOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { + type LegacyApiResponse, + type LegacyHttpMethod, + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import type { LegacyProjectsCreateFlags } from "./create.command.ts"; +import { legacyProjectsCreate } from "./create.handler.ts"; + +const CREATED: typeof V1CreateAProjectOutput.Type = { + id: "abcdefghijklmnopqrst", + ref: "abcdefghijklmnopqrst", + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "COMING_UP", +}; + +const ORGS: ReadonlyArray = [ + { id: "org-abc", slug: "acme", name: "Acme Inc" }, +]; + +const BASE_FLAGS: LegacyProjectsCreateFlags = { + name: Option.none(), + orgId: Option.none(), + dbPassword: Option.none(), + region: Option.none(), + size: Option.none(), + interactive: Option.none(), + plan: Option.none(), +}; + +const tempRoot = useLegacyTempWorkdir("supabase-projects-create-int-"); + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly stdinIsTty?: boolean; + readonly byMethod?: Partial>; + readonly network?: "fail"; + readonly promptTextResponses?: ReadonlyArray; + readonly promptSelectResponses?: ReadonlyArray; + readonly promptPasswordResponses?: ReadonlyArray; + readonly tracked?: boolean; +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ + format: opts.format ?? "text", + promptTextResponses: opts.promptTextResponses, + promptSelectResponses: opts.promptSelectResponses, + promptPasswordResponses: opts.promptPasswordResponses, + }); + const api = mockLegacyPlatformApi({ + network: opts.network, + byMethod: opts.byMethod ?? { + POST: { status: 201, body: CREATED }, + GET: { status: 200, body: ORGS }, + }, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const tty = mockTty({ + stdinIsTty: opts.stdinIsTty ?? false, + stdoutIsTty: opts.stdinIsTty ?? false, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + tty, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }); + return { layer, out, api, telemetry, cache }; +} + +function postBody(api: { requests: ReadonlyArray<{ method: string; body?: unknown }> }) { + return api.requests.find((r) => r.method === "POST")?.body as Record | undefined; +} + +describe("legacy projects create integration", () => { + it.live("creates a project non-interactively from flags", () => { + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }); + expect(postBody(api)).toEqual({ + name: "alpha", + organization_slug: "acme", + db_pass: "s3cret-pass", + region: "us-east-1", + }); + expect(out.stderrText).toContain("Creating project:"); + expect(out.stderrText).toContain( + "Created a new project at https://supabase.com/dashboard/project/abcdefghijklmnopqrst", + ); + expect(out.stdoutText).toContain("REFERENCE ID"); + expect(out.stdoutText).toContain("East US (North Virginia)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("includes desired_instance_size only when --size is set", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + size: Option.some("medium"), + }); + expect(postBody(api)?.desired_instance_size).toBe("medium"); + }).pipe(Effect.provide(layer)); + }); + + it.live("ignores the hidden --plan flag (no-op)", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + plan: Option.some("pro"), + }); + expect(postBody(api)).not.toHaveProperty("plan"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails non-interactively when required flags are missing", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyProjectsCreate({ ...BASE_FLAGS, name: Option.some("alpha") }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectsCreateMissingArgError"); + expect(json).toContain("--org-id"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("treats --interactive=false as non-interactive even on a TTY", () => { + const { layer, api } = setup({ stdinIsTty: true }); + return Effect.gen(function* () { + // On a TTY but with --interactive=false and a required flag missing, Go's + // PreRunE marks the flags required and never prompts. + const exit = yield* Effect.exit( + legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + interactive: Option.some(false), + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsCreateMissingArgError"); + } + // No prompts and no org fetch happened. + expect(api.requests.some((r) => r.method === "GET")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("prompts for name, org, region and password when interactive", () => { + const { layer, out, api } = setup({ + stdinIsTty: true, + promptTextResponses: ["my-proj"], + promptSelectResponses: ["org-abc", "us-west-2"], + promptPasswordResponses: [""], + }); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ ...BASE_FLAGS }); + // org list was fetched for the interactive prompt + expect(api.requests.some((r) => r.method === "GET")).toBe(true); + const body = postBody(api); + expect(body?.name).toBe("my-proj"); + expect(body?.organization_slug).toBe("org-abc"); + expect(body?.region).toBe("us-west-2"); + // blank password prompt generates a 16-char password + expect(String(body?.db_pass)).toHaveLength(16); + expect(out.stderrText).toContain("Selected org-id:"); + expect(out.stderrText).toContain("Selected region:"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectsCreateNameEmptyError when the name prompt is blank", () => { + const { layer } = setup({ stdinIsTty: true, promptTextResponses: [""] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsCreate({ ...BASE_FLAGS })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsCreateNameEmptyError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the interactive organization list request errors", () => { + const { layer } = setup({ + stdinIsTty: true, + byMethod: { GET: { status: 500, body: {} }, POST: { status: 201, body: CREATED } }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyProjectsCreate({ ...BASE_FLAGS, name: Option.some("alpha") }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsOrgsListUnexpectedStatusError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event for --output-format json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Created project"); + expect(success?.data).toMatchObject({ id: "abcdefghijklmnopqrst", name: "alpha" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("encodes the created project for --output env", () => { + const { layer, out } = setup({ goOutput: "env" }); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }); + expect(out.stdoutText).toContain('NAME="alpha"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("encodes the created project for --output yaml", () => { + const { layer, out } = setup({ goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }); + expect(out.stdoutText).toContain("name: alpha"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits Go-byte-exact indented JSON for --output json", () => { + const { layer, out } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }); + expect(out.stdoutText).toContain('"name": "alpha"'); + expect(out.stdoutText.endsWith("}\n")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("wraps the created project under [project]-style toml output", () => { + const { layer, out } = setup({ goOutput: "toml" }); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }); + expect(out.stdoutText).toContain('name = "alpha"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectsCreateNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectsCreateNetworkError"); + expect(json).toContain("failed to create project"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectsCreateUnexpectedStatusError on HTTP 500", () => { + const { layer } = setup({ byMethod: { POST: { status: 500, body: {} } } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsCreateUnexpectedStatusError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("sends the request body with Go-sorted keys", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + size: Option.some("micro"), + }); + // Go's `json.Marshal` serializes struct fields alphabetically; the + // cli-e2e replay server byte-compares the request body. JSON.parse → + // stringify round-trips key order, so this asserts the on-the-wire order. + const body = api.requests.find((r) => r.method === "POST")?.body; + expect(JSON.stringify(body)).toBe( + '{"db_pass":"s3cret-pass","desired_instance_size":"micro","name":"alpha","organization_slug":"acme","region":"us-east-1"}', + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("tolerates a 201 response with a placeholder/short ref (lenient parse)", () => { + // The typed client rejects refs shorter than 20 chars; `executeRaw` must + // render the placeholder verbatim (cli-e2e fixtures embed `__PROJECT_REF__`). + const { layer, out } = setup({ + byMethod: { + POST: { status: 201, body: { ...CREATED, id: "__PROJECT_REF__", ref: "__PROJECT_REF__" } }, + }, + }); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }); + expect(out.stderrText).toContain( + "Created a new project at https://supabase.com/dashboard/project/__PROJECT_REF__", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes linked-project cache + telemetry state on success", () => { + const { layer, telemetry, cache } = setup(); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry but skips cache when creation fails", () => { + const { layer, telemetry, cache } = setup({ network: "fail" }); + return Effect.gen(function* () { + yield* Effect.exit( + legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }), + ); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md index c76bfa582a..f4e46eab50 100644 --- a/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md @@ -2,15 +2,22 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (ref string) | after a successful delete, to decide whether to unlink | -## Files Written +## Files Written / Removed -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Action | When | +| --------------------------- | ------- | -------------------------------------------------------------- | +| `/supabase/.temp/` | removed | when the deleted ref matches the linked ref file (best-effort) | + +> Go best-effort deletes the per-ref keyring credential, but it only ever stores +> the profile-scoped access token in the keyring (never a per-ref entry), so the +> delete always targets a non-existent entry — a no-op for both CLIs. Go's +> "Keyring is not supported on WSL" stderr line (system keyring unavailable, e.g. +> headless CI) is keyring-backend noise normalized away in the parity harness. ## API Routes @@ -34,6 +41,13 @@ | `1` | project not found — 404 response from `/v1/projects/{ref}` | | `1` | API error — non-2xx/404 response from `/v1/projects/{ref}` | | `1` | network / connection failure | +| `1` | declined confirmation (cancellation) / no ref on a non-TTY | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Flags @@ -45,14 +59,16 @@ ### `--output-format text` (Go CLI compatible) -Prints a confirmation message on successful deletion. +Prompts `Do you want to delete project ? This action is irreversible.` (default +**No**, honours the global `--yes`), then on success prints `Deleted project: ` +to stdout. ### `--output-format json` -Single JSON object emitted to stdout on success. +`success("Deleted project", { name })`. ```json -{ "ref": "abcdefghijklmnopqrst", "name": "my-project" } +{ "name": "my-project" } ``` ### `--output-format stream-json` @@ -60,7 +76,7 @@ Single JSON object emitted to stdout on success. One `result` event on success. ```ndjson -{"type":"result","data":{"ref":"abcdefghijklmnopqrst","name":"my-project"}} +{"type":"result","data":{"name":"my-project"}} ``` On failure, an `error` event is emitted instead: diff --git a/apps/cli/src/legacy/commands/projects/delete/delete.command.ts b/apps/cli/src/legacy/commands/projects/delete/delete.command.ts index daa27b5d7a..6c97b2d0f9 100644 --- a/apps/cli/src/legacy/commands/projects/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/projects/delete/delete.command.ts @@ -1,5 +1,8 @@ import { Argument, Command } 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 { legacyProjectsDelete } from "./delete.handler.ts"; const config = { @@ -19,5 +22,11 @@ export const legacyProjectsDeleteCommand = Command.make("delete", config).pipe( description: "Delete a project by ref", }, ]), - Command.withHandler((flags) => legacyProjectsDelete(flags)), + Command.withHandler((flags) => + legacyProjectsDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: [] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["projects", "delete"])), ); diff --git a/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts b/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts index 91ec3dec97..a3b7855b72 100644 --- a/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts @@ -1,12 +1,153 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import type { V1DeleteAProjectOutput } from "@supabase/api/effect"; +import { Effect, FileSystem, Option, Path } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyInvalidProjectRefError } from "../../../config/legacy-project-ref.errors.ts"; +import { + INVALID_PROJECT_REF_MESSAGE, + LegacyProjectRefResolver, + PROJECT_REF_PATTERN, +} from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyProjectsDeleteCancelledError, + LegacyProjectsDeleteNetworkError, + LegacyProjectsDeleteNotFoundError, + LegacyProjectsDeleteRefRequiredError, + LegacyProjectsDeleteUnexpectedStatusError, +} from "../projects.errors.ts"; import type { LegacyProjectsDeleteFlags } from "./delete.command.ts"; +type DeletedProject = typeof V1DeleteAProjectOutput.Type; + export const legacyProjectsDelete = Effect.fn("legacy.projects.delete")(function* ( flags: LegacyProjectsDeleteFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["projects", "delete"]; - if (Option.isSome(flags.ref)) args.push(flags.ref.value); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const cliConfig = yield* LegacyCliConfig; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const yes = yield* LegacyYesFlag; + const tty = yield* Tty; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // Captured for the PersistentPostRun-parity cache write — Go's + // `ensureProjectGroupsCached` caches whatever `flags.ProjectRef` resolved to + // (`root.go:213-217`), which delete sets from the arg/prompt before deleting. + let resolvedRef: string | undefined; + + yield* Effect.gen(function* () { + // Ref resolution (Go `projects.go:117-122`): explicit arg, else prompt on a + // TTY, else fail. Delete never reads the linked ref file as a source. + let ref: string; + if (Option.isSome(flags.ref) && flags.ref.value.length > 0) { + ref = flags.ref.value; + } else if (tty.stdinIsTty && output.interactive) { + // Go passes this exact title (`projects.go:118`). + ref = yield* resolver.promptProjectRef("Which project do you want to delete?"); + } else { + return yield* new LegacyProjectsDeleteRefRequiredError({ + message: "accepts 1 arg(s), received 0", + }); + } + resolvedRef = ref; + + // delete.PreRun (`delete.go:17-28`): validate the ref, then confirm. + if (!PROJECT_REF_PATTERN.test(ref)) { + return yield* new LegacyInvalidProjectRefError({ ref, message: INVALID_PROJECT_REF_MESSAGE }); + } + + const title = `Do you want to delete project ${ref}? This action is irreversible.`; + let confirmed: boolean; + if (yes) { + // Mirror Go's `PromptYesNo` confirm-by-flag UX (`console.go:64-78`): the + // default is No, so the choices render `[y/N]` and the auto-answer is `y`. + yield* output.raw(`${title} [y/N] y\n`, "stderr"); + confirmed = true; + } else if (!tty.stdinIsTty) { + // Non-TTY with no `--yes`: `PromptYesNo` returns the `false` default. + confirmed = false; + } else { + confirmed = yield* output.promptConfirm(title).pipe(Effect.orElseSucceed(() => false)); + } + if (!confirmed) { + return yield* new LegacyProjectsDeleteCancelledError({ message: "context canceled" }); + } + + const mapDeleteError = mapLegacyHttpError({ + networkError: LegacyProjectsDeleteNetworkError, + statusError: LegacyProjectsDeleteUnexpectedStatusError, + networkMessage: (cause) => `failed to delete project: ${cause}`, + statusMessage: (_status, body) => `Failed to delete project ${ref}: ${body}`, + }); + + const deleting = + output.format === "text" ? yield* output.task("Deleting project...") : undefined; + const deleted: DeletedProject = yield* api.v1.deleteAProject({ ref }).pipe( + Effect.tapError(() => deleting?.fail() ?? Effect.void), + Effect.catch((cause) => + Effect.gen(function* () { + if ( + HttpClientError.isHttpClientError(cause) && + cause.response !== undefined && + cause.response.status === 404 + ) { + return yield* new LegacyProjectsDeleteNotFoundError({ + message: `Project does not exist:${ref}`, + }); + } + return yield* mapDeleteError(cause); + }), + ), + ); + yield* deleting?.clear() ?? Effect.void; + + // Go best-effort deletes the per-ref keyring credential (`delete.go:46-48`), + // but Go only ever *stores* the profile-scoped access token in the keyring + // (`StoreProvider.Set` is only called with `CurrentProfile.Name`, never a + // ref). So that delete always targets a non-existent entry — a functional + // no-op for both CLIs. The only thing it can emit is Go's keyring-backend + // *availability* error ("Keyring is not supported on WSL", e.g. on a + // headless CI runner with no D-Bus session); that is environment noise the + // cli-e2e parity harness normalizes away (the TS `@napi-rs/keyring` kernel + // keyutils backend never hits it). We therefore skip the no-op entirely. + + // Best-effort unlink (`delete.go:49-56`): when the linked ref file matches + // the deleted ref, remove the `supabase/.temp` directory. + const tempDir = path.join(cliConfig.workdir, "supabase", ".temp"); + const refPath = path.join(tempDir, "project-ref"); + // Go uses `afero.FileContainsBytes` (substring), but the link file written by + // `supabase link` holds exactly the ref. Compare against the trimmed content + // so a corrupt/multi-ref file can't trigger an unintended `.temp` removal. + const matches = yield* fs + .readFileString(refPath) + .pipe(Effect.map((content) => content.trim() === ref)) + .pipe(Effect.orElseSucceed(() => false)); + if (matches) { + yield* fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore); + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("Deleted project", { name: deleted.name }); + return; + } + yield* output.raw(`Deleted project: ${deleted.name}\n`); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + resolvedRef === undefined ? Effect.void : linkedProjectCache.cache(resolvedRef), + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts new file mode 100644 index 0000000000..58438255c3 --- /dev/null +++ b/apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts @@ -0,0 +1,276 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { + type LegacyApiResponse, + type LegacyHttpMethod, + LEGACY_VALID_REF, + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyProjectsDelete } from "./delete.handler.ts"; + +const OTHER_REF = "qrstuvwxyzabcdefghij"; + +const DELETED = { id: 1, ref: LEGACY_VALID_REF, name: "alpha" }; + +const SAMPLE_PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: LEGACY_VALID_REF, + ref: LEGACY_VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +const tempRoot = useLegacyTempWorkdir("supabase-projects-delete-int-"); + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly stdinIsTty?: boolean; + readonly yes?: boolean; + readonly byMethod?: Partial>; + readonly network?: "fail"; + readonly promptConfirmResponses?: ReadonlyArray; + readonly promptSelectResponses?: ReadonlyArray; +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ + format: opts.format ?? "text", + promptConfirmResponses: opts.promptConfirmResponses, + promptSelectResponses: opts.promptSelectResponses, + }); + const api = mockLegacyPlatformApi({ + network: opts.network, + byMethod: opts.byMethod ?? { DELETE: { status: 200, body: DELETED } }, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current, projectId: Option.none() }); + const tty = mockTty({ + stdinIsTty: opts.stdinIsTty ?? false, + stdoutIsTty: opts.stdinIsTty ?? false, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig, + tty, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + ); + return { layer, out, api, telemetry, cache }; +} + +function writeRefFile(content: string) { + const tempDir = join(tempRoot.current, "supabase", ".temp"); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(join(tempDir, "project-ref"), content); +} + +function hasMethod( + api: { requests: ReadonlyArray<{ method: string }> }, + method: LegacyHttpMethod, +): boolean { + return api.requests.some((r) => r.method === method); +} + +describe("legacy projects delete integration", () => { + it.live("deletes a project by positional ref after confirmation", () => { + const { layer, out, api } = setup({ stdinIsTty: true, promptConfirmResponses: [true] }); + return Effect.gen(function* () { + yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); + expect(hasMethod(api, "DELETE")).toBe(true); + expect(out.stdoutText).toContain("Deleted project: alpha"); + }).pipe(Effect.provide(layer)); + }); + + it.live("respects --yes and skips the confirmation prompt", () => { + const { layer, out, api } = setup({ yes: true }); + return Effect.gen(function* () { + yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); + expect(out.stderrText).toContain("[y/N] y"); + expect(hasMethod(api, "DELETE")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("cancels without deleting when the user declines confirmation", () => { + const { layer, api } = setup({ stdinIsTty: true, promptConfirmResponses: [false] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsDeleteCancelledError"); + } + expect(hasMethod(api, "DELETE")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("prompts to select a project when no ref is given on a TTY", () => { + const { layer, api } = setup({ + stdinIsTty: true, + promptSelectResponses: [LEGACY_VALID_REF], + byMethod: { + GET: { status: 200, body: [SAMPLE_PROJECT] }, + DELETE: { status: 200, body: DELETED }, + }, + }); + return Effect.gen(function* () { + yield* legacyProjectsDelete({ ref: Option.none() }); + expect(api.requests.find((r) => r.method === "DELETE")?.url).toContain( + `/v1/projects/${LEGACY_VALID_REF}`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when no ref is given on a non-TTY", () => { + const { layer, cache } = setup({ stdinIsTty: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsDeleteRefRequiredError"); + } + // No ref resolved → no linked-project cache write. + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("cancels on a non-TTY when a ref is provided but --yes is unset", () => { + const { layer, api } = setup({ stdinIsTty: false, yes: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsDeleteCancelledError"); + } + expect(hasMethod(api, "DELETE")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result for --output-format stream-json", () => { + const { layer, out } = setup({ format: "stream-json", yes: true }); + return Effect.gen(function* () { + yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Deleted project"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails on an invalid project-ref format", () => { + const { layer } = setup({ yes: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some("BADREF") })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("removes the linked supabase/.temp dir when the deleted ref matches", () => { + writeRefFile(LEGACY_VALID_REF); + const { layer } = setup({ yes: true }); + return Effect.gen(function* () { + yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); + expect(existsSync(join(tempRoot.current, "supabase", ".temp"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("leaves the linked dir intact when the deleted ref differs", () => { + writeRefFile(OTHER_REF); + const { layer } = setup({ yes: true }); + return Effect.gen(function* () { + yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); + expect(existsSync(join(tempRoot.current, "supabase", ".temp", "project-ref"))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("maps HTTP 404 to project-does-not-exist", () => { + const { layer } = setup({ yes: true, byMethod: { DELETE: { status: 404, body: {} } } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectsDeleteNotFoundError"); + expect(json).toContain(`Project does not exist:${LEGACY_VALID_REF}`); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("maps HTTP 503 to delete-failed", () => { + const { layer } = setup({ yes: true, byMethod: { DELETE: { status: 503, body: {} } } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectsDeleteUnexpectedStatusError"); + expect(json).toContain(`Failed to delete project ${LEGACY_VALID_REF}`); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectsDeleteNetworkError on transport failure", () => { + const { layer } = setup({ yes: true, network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectsDeleteNetworkError"); + expect(json).toContain("failed to delete project"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event for --output-format json", () => { + const { layer, out } = setup({ format: "json", yes: true }); + return Effect.gen(function* () { + yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Deleted project"); + expect(success?.data).toMatchObject({ name: "alpha" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes linked-project cache + telemetry state on success", () => { + const { layer, telemetry, cache } = setup({ yes: true }); + return Effect.gen(function* () { + yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry even when the delete fails", () => { + const { layer, telemetry } = setup({ yes: true, network: "fail" }); + return Effect.gen(function* () { + yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md index 1f8230d3ad..f4785c0f14 100644 --- a/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md @@ -2,9 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (ref string) | always (soft) — used only to flag the linked project | ## Files Written @@ -34,34 +35,48 @@ | `1` | API error — non-2xx response from `/v1/projects` | | `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` | + ## Output +Two-axis: Go's `--output {pretty|json|yaml|toml|env}` wins when set; otherwise the TS +`--output-format`. `--output env` is **unsupported** (errors). go json/yaml encode the +`linkedProject[]`; go toml wraps them as `{projects=[...]}`. + ### `--output-format text` (Go CLI compatible) -Prints a Markdown-style table to stdout with a header row and one row per project. -Column order: `ID`, `NAME`, `REGION`, `ORGANIZATION ID`, `CREATED AT`. Columns are -separated by two spaces and left-aligned. +Glamour ASCII table. Column order: `LINKED`, `ORG ID`, `REFERENCE ID`, `NAME`, `REGION`, +`CREATED AT (UTC)`. The `LINKED` cell shows ` ●` for the linked project (else blank), +`REGION` is the human-readable region name, and `CREATED AT (UTC)` is `YYYY-MM-DD HH:MM:SS`. ``` - ID NAME REGION ORGANIZATION ID CREATED AT - abcdefghijklmnopqrst Test Project us-west-1 combined-fuchsia-lion 2022-04-25T02:14:55.906498Z + LINKED | ORG ID | REFERENCE ID | NAME | REGION | CREATED AT (UTC) + -------|-----------------------|----------------------|--------------|-------------------------|-------------------- + ● | combined-fuchsia-lion | abcdefghijklmnopqrst | Test Project | East US (North Virginia)| 2022-04-25 02:14:55 ``` ### `--output-format json` -Single JSON array emitted to stdout on success. Each element contains the full -project object as returned by the Management API. +`success("", { projects })` — each project is the Management API object plus a +`linked` boolean. ```json -[ - { - "id": "abcdefghijklmnopqrst", - "organization_slug": "combined-fuchsia-lion", - "name": "Test Project", - "region": "us-west-1", - "created_at": "2022-04-25T02:14:55.906498Z" - } -] +{ + "projects": [ + { + "id": "abcdefghijklmnopqrst", + "organization_slug": "combined-fuchsia-lion", + "name": "Test Project", + "region": "us-west-1", + "created_at": "2022-04-25T02:14:55.906498Z", + "linked": true + } + ] +} ``` ### `--output-format stream-json` diff --git a/apps/cli/src/legacy/commands/projects/list/list.command.ts b/apps/cli/src/legacy/commands/projects/list/list.command.ts index 4d38c68e29..0b81e6c940 100644 --- a/apps/cli/src/legacy/commands/projects/list/list.command.ts +++ b/apps/cli/src/legacy/commands/projects/list/list.command.ts @@ -1,5 +1,9 @@ import { Command } 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 { legacyProjectsList } from "./list.handler.ts"; const config = {}; @@ -18,5 +22,11 @@ export const legacyProjectsListCommand = Command.make("list", config).pipe( description: "Machine-readable JSON output", }, ]), - Command.withHandler((flags) => legacyProjectsList(flags)), + Command.withHandler((flags) => + legacyProjectsList(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: [] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["projects", "list"])), ); diff --git a/apps/cli/src/legacy/commands/projects/list/list.handler.ts b/apps/cli/src/legacy/commands/projects/list/list.handler.ts index 9c2b173c58..3fc4e0b63f 100644 --- a/apps/cli/src/legacy/commands/projects/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/projects/list/list.handler.ts @@ -1,10 +1,135 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { operationDefinitions } from "@supabase/api/effect"; +import { Effect, Option } from "effect"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; +import { sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyProjectsEnvNotSupportedError, + LegacyProjectsListNetworkError, + LegacyProjectsListUnexpectedStatusError, +} from "../projects.errors.ts"; +import { + type LegacyLinkedProject, + readProjectField, + renderProjectsListTable, +} from "../projects.format.ts"; import type { LegacyProjectsListFlags } from "./list.command.ts"; export const legacyProjectsList = Effect.fn("legacy.projects.list")(function* ( _flags: LegacyProjectsListFlags, ) { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["projects", "list"]); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + // Go's `list.go:31-33` loads the linked ref purely as a marker — `ErrNotLinked` + // is ignored, no prompt fires. `resolveOptional` never fails or prompts. + const linkedRef = yield* resolver.resolveOptional(Option.none()); + + yield* Effect.gen(function* () { + const fetching = + output.format === "text" ? yield* output.task("Fetching projects...") : undefined; + + // `executeRaw` returns the undecoded response: the generated + // `V1ProjectWithDatabaseResponse.ref` schema enforces `isMinLength(20)` + + // `^[a-z]+$`, which the cli-e2e replay fixtures (literal `__PROJECT_REF__`) + // cannot satisfy. Auth / URL / headers are still handled by the API client. + const response = yield* api.executeRaw(operationDefinitions.v1ListAllProjects, {}).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.mapError( + (cause) => + new LegacyProjectsListNetworkError({ message: `failed to list projects: ${cause}` }), + ), + ); + + if (response.status !== 200) { + const body = sanitizeLegacyErrorBody( + yield* response.text.pipe(Effect.orElseSucceed(() => "")), + ); + yield* fetching?.fail() ?? Effect.void; + return yield* new LegacyProjectsListUnexpectedStatusError({ + status: response.status, + body, + message: `Unexpected error retrieving projects: ${body}`, + }); + } + + const parsed = yield* response.json.pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.mapError( + (cause) => + new LegacyProjectsListUnexpectedStatusError({ + status: response.status, + body: "", + message: `Unexpected error retrieving projects: ${cause}`, + }), + ), + ); + if (!Array.isArray(parsed)) { + yield* fetching?.fail() ?? Effect.void; + return yield* new LegacyProjectsListUnexpectedStatusError({ + status: response.status, + body: "", + message: "Unexpected error retrieving projects: response was not an array", + }); + } + yield* fetching?.clear() ?? Effect.void; + + // Go's `list.go:31-33` prints the `LoadProjectRef` error to stderr when no + // ref resolves (the `errors.New(ErrNotLinked)` wrapper is never `==` the + // sentinel, so the guard always fires), then renders the table anyway. + // `ErrNotLinked` colours "supabase link" via `Aqua` — plain on a non-TTY — + // and uses no backticks, unlike the resolver's hard-fail message. + if (Option.isNone(linkedRef)) { + yield* output.raw("Cannot find project ref. Have you run supabase link?\n", "stderr"); + } + + const projects: ReadonlyArray = parsed.map((project) => ({ + ...(typeof project === "object" && project !== null ? project : {}), + linked: Option.isSome(linkedRef) && readProjectField(project, "id") === linkedRef.value, + })); + + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "env") { + return yield* new LegacyProjectsEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } + if (goFmt === "json") { + yield* output.raw(encodeGoJson(projects)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(projects)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml({ projects }) + "\n"); + return; + } + + // goFmt is undefined or "pretty" — defer to TS --output-format for + // JSON/stream-json, otherwise render the Glamour-styled table. + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", { projects }); + return; + } + + yield* output.raw(renderProjectsListTable(projects)); + }).pipe( + Effect.ensuring( + Option.isSome(linkedRef) ? linkedProjectCache.cache(linkedRef.value) : Effect.void, + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/projects/list/list.integration.test.ts b/apps/cli/src/legacy/commands/projects/list/list.integration.test.ts new file mode 100644 index 0000000000..d8cc3286d0 --- /dev/null +++ b/apps/cli/src/legacy/commands/projects/list/list.integration.test.ts @@ -0,0 +1,268 @@ +import type { V1ListAllProjectsOutput } 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, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyProjectsList } from "./list.handler.ts"; + +type Projects = typeof V1ListAllProjectsOutput.Type; + +const SAMPLE_PROJECT: Projects[number] = { + id: LEGACY_VALID_REF, + ref: LEGACY_VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +const OTHER_PROJECT: Projects[number] = { + ...SAMPLE_PROJECT, + id: "qrstuvwxyzabcdefghij", + ref: "qrstuvwxyzabcdefghij", + name: "beta", + region: "eu-west-1", +}; + +const tempRoot = useLegacyTempWorkdir("supabase-projects-list-int-"); + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly response?: Projects; + readonly status?: number; + readonly network?: "fail"; + // When `false`, the linked project ref is unset so no bullet renders. + readonly linked?: boolean; +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: opts.response ?? [SAMPLE_PROJECT] }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: opts.linked === false ? Option.none() : Option.some(LEGACY_VALID_REF), + }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }); + return { layer, out, api }; +} + +function setupTracked(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: opts.response ?? [SAMPLE_PROJECT] }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: opts.linked === false ? Option.none() : Option.some(LEGACY_VALID_REF), + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + return { layer, out, telemetry, cache }; +} + +describe("legacy projects list integration", () => { + it.live("renders a Glamour table with all six columns in text mode", () => { + const { layer, out } = setup({ response: [SAMPLE_PROJECT, OTHER_PROJECT] }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(out.stdoutText).toContain("LINKED"); + expect(out.stdoutText).toContain("ORG ID"); + expect(out.stdoutText).toContain("REFERENCE ID"); + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).toContain("REGION"); + expect(out.stdoutText).toContain("CREATED AT (UTC)"); + expect(out.stdoutText).toContain("East US (North Virginia)"); + expect(out.stdoutText).toContain("2026-05-27 01:02:03"); + expect(out.stdoutText).toContain("alpha"); + }).pipe(Effect.provide(layer)); + }); + + it.live("marks the linked project with a bullet", () => { + const { layer, out } = setup({ response: [SAMPLE_PROJECT], linked: true }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(out.stdoutText).toContain("●"); + }).pipe(Effect.provide(layer)); + }); + + it.live("renders no bullet when nothing is linked", () => { + const { layer, out } = setup({ response: [SAMPLE_PROJECT], linked: false }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(out.stdoutText).not.toContain("●"); + }).pipe(Effect.provide(layer)); + }); + + it.live("warns on stderr when no project is linked (Go parity)", () => { + const { layer, out } = setup({ response: [SAMPLE_PROJECT], linked: false }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(out.stderrText).toContain("Cannot find project ref. Have you run supabase link?"); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not warn on stderr when a project is linked", () => { + const { layer, out } = setup({ response: [SAMPLE_PROJECT], linked: true }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(out.stderrText).not.toContain("Cannot find project ref"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with { projects } for --output-format json", () => { + const { layer, out } = setup({ format: "json", response: [SAMPLE_PROJECT], linked: true }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ projects: [{ linked: true }] }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event for --output-format stream-json", () => { + const { layer, out } = setup({ format: "stream-json", response: [SAMPLE_PROJECT] }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(out.messages.find((m) => m.type === "success")).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits Go-byte-exact indented JSON including `linked` for --output json", () => { + const { layer, out } = setup({ goOutput: "json", response: [SAMPLE_PROJECT], linked: true }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(out.stdoutText.startsWith("[\n {\n")).toBe(true); + expect(out.stdoutText.endsWith("]\n")).toBe(true); + expect(out.stdoutText).toContain('"linked": true'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a YAML array for --output yaml", () => { + const { layer, out } = setup({ goOutput: "yaml", response: [SAMPLE_PROJECT] }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(out.stdoutText).toContain("name: alpha"); + expect(out.stdoutText).toContain("linked:"); + }).pipe(Effect.provide(layer)); + }); + + it.live("wraps the result as { projects = [...] } for --output toml", () => { + const { layer, out } = setup({ goOutput: "toml", response: [SAMPLE_PROJECT] }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(out.stdoutText).toContain("[[projects]]"); + expect(out.stdoutText).toContain('name = "alpha"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectsEnvNotSupportedError for --output env", () => { + const { layer } = setup({ goOutput: "env", response: [SAMPLE_PROJECT] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsList({})); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectsEnvNotSupportedError"); + expect(json).toContain("--output env flag is not supported"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectsListNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsList({})); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectsListNetworkError"); + expect(json).toContain("failed to list projects"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectsListUnexpectedStatusError on HTTP 500", () => { + const { layer } = setup({ status: 500, response: [] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsList({})); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsListUnexpectedStatusError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with an unexpected-status error when the body is not an array", () => { + const { layer } = setup({ response: {} as unknown as Projects }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsList({})); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsListUnexpectedStatusError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("tolerates placeholder/short refs in the response (lenient parse)", () => { + // The typed client rejects refs shorter than 20 chars; the raw-HTTP path + // must render them verbatim (cli-e2e fixtures embed `__PROJECT_REF__`). + const placeholder = { ...SAMPLE_PROJECT, id: "__PROJECT_REF__", ref: "__PROJECT_REF__" }; + const { layer, out } = setup({ response: [placeholder as unknown as Projects[number]] }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(out.stdoutText).toContain("__PROJECT_REF__"); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes linked-project cache + telemetry state on success", () => { + const { layer, telemetry, cache } = setupTracked({ linked: true }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry but skips the cache write when nothing is linked", () => { + const { layer, telemetry, cache } = setupTracked({ linked: false }); + return Effect.gen(function* () { + yield* legacyProjectsList({}); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/projects/projects.errors.ts b/apps/cli/src/legacy/commands/projects/projects.errors.ts new file mode 100644 index 0000000000..791e931075 --- /dev/null +++ b/apps/cli/src/legacy/commands/projects/projects.errors.ts @@ -0,0 +1,129 @@ +import { Data } from "effect"; + +// --------------------------------------------------------------------------- +// HTTP-bound errors — one (Network + UnexpectedStatus) pair per Go errorf site. +// Names trace back to `apps/cli-go/internal/projects//` for grepability. +// Templates match Go's `errors.Errorf(...)` phrasing byte-for-byte. +// --------------------------------------------------------------------------- + +export class LegacyProjectsListNetworkError extends Data.TaggedError( + "LegacyProjectsListNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyProjectsListUnexpectedStatusError extends Data.TaggedError( + "LegacyProjectsListUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyProjectsCreateNetworkError extends Data.TaggedError( + "LegacyProjectsCreateNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyProjectsCreateUnexpectedStatusError extends Data.TaggedError( + "LegacyProjectsCreateUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +// Interactive org list fetched by `create` when `--org-id` is omitted +// (`create.go:97-105`). +export class LegacyProjectsOrgsListNetworkError extends Data.TaggedError( + "LegacyProjectsOrgsListNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyProjectsOrgsListUnexpectedStatusError extends Data.TaggedError( + "LegacyProjectsOrgsListUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyProjectsDeleteNetworkError extends Data.TaggedError( + "LegacyProjectsDeleteNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyProjectsDeleteUnexpectedStatusError extends Data.TaggedError( + "LegacyProjectsDeleteUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +// 404 branch of `delete.Run` (`delete.go:37-38`): "Project does not exist:". +export class LegacyProjectsDeleteNotFoundError extends Data.TaggedError( + "LegacyProjectsDeleteNotFoundError", +)<{ + readonly message: string; +}> {} + +export class LegacyProjectsApiKeysNetworkError extends Data.TaggedError( + "LegacyProjectsApiKeysNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyProjectsApiKeysUnexpectedStatusError extends Data.TaggedError( + "LegacyProjectsApiKeysUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +// --------------------------------------------------------------------------- +// Pure-path errors (validation, prompt-time semantics, user cancellation). +// --------------------------------------------------------------------------- + +// `list` rejects Go's `--output env` (`list.go:66-67`, `utils.ErrEnvNotSupported`). +export class LegacyProjectsEnvNotSupportedError extends Data.TaggedError( + "LegacyProjectsEnvNotSupportedError", +)<{ + readonly message: string; +}> {} + +// Non-interactive `create` missing required params — mirrors Go's PreRunE +// marking `--org-id`, `--db-password`, `--region` required + ExactArgs(1) +// (`projects.go:62-69`). +export class LegacyProjectsCreateMissingArgError extends Data.TaggedError( + "LegacyProjectsCreateMissingArgError", +)<{ + readonly message: string; +}> {} + +// Interactive `create` name prompt returned blank (`create.go:94`). +export class LegacyProjectsCreateNameEmptyError extends Data.TaggedError( + "LegacyProjectsCreateNameEmptyError", +)<{ + readonly message: string; +}> {} + +// `delete` non-interactive with no positional ref — mirrors Go's +// `cobra.ExactArgs(1)` on a non-TTY (`projects.go:109-113`). +export class LegacyProjectsDeleteRefRequiredError extends Data.TaggedError( + "LegacyProjectsDeleteRefRequiredError", +)<{ + readonly message: string; +}> {} + +// User declined the delete confirmation prompt (`delete.go:24-25`, +// `errors.New(context.Canceled)`). +export class LegacyProjectsDeleteCancelledError extends Data.TaggedError( + "LegacyProjectsDeleteCancelledError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/projects/projects.format.ts b/apps/cli/src/legacy/commands/projects/projects.format.ts new file mode 100644 index 0000000000..872c9d0843 --- /dev/null +++ b/apps/cli/src/legacy/commands/projects/projects.format.ts @@ -0,0 +1,142 @@ +import type { ApiKeyResponse } from "@supabase/api/effect"; + +import { renderGlamourTable } from "../../output/legacy-glamour-table.ts"; +import { apiKeyValue } from "../../shared/legacy-api-keys.format.ts"; +import { formatLegacyTimestamp } from "../../shared/legacy-timestamp.format.ts"; + +// --------------------------------------------------------------------------- +// Pure formatters — no Effect / no service dependencies, kept unit-testable. +// Match Go's byte output for `projects list`, `projects create`, `projects +// api-keys`. +// --------------------------------------------------------------------------- + +type ApiKey = typeof ApiKeyResponse.Type; + +/** + * Lenient project record. `projects list` / `create` parse the `/v1/projects` + * response via the raw HTTP client because the typed client's `ref: + * isMinLength(20)` + `^[a-z]+$` schema rejects the cli-e2e `__PROJECT_REF__` + * placeholder fixtures (the same reason `legacySuggestUpgrade` and the + * linked-project cache bypass the typed client). Projects therefore flow + * through as plain JSON objects. + */ +export type LegacyLinkedProject = Readonly> & { readonly linked: boolean }; + +/** Read a string field from a parsed JSON value (empty string when absent/non-string). */ +export function readProjectField(project: unknown, key: string): string { + if (typeof project !== "object" || project === null) return ""; + const value = Reflect.get(project, key); + return typeof value === "string" ? value : ""; +} + +// --------------------------------------------------------------------------- +// Region display names. Mirrors Go's `utils.regionMap` / +// `utils.FormatRegion` (`apps/cli-go/internal/utils/render.go:34-60`): +// known region codes render as a human-readable name; unknown codes pass +// through unchanged. +// --------------------------------------------------------------------------- + +const REGION_MAP: Readonly> = { + "ap-east-1": "East Asia (Hong Kong)", + "ap-northeast-1": "Northeast Asia (Tokyo)", + "ap-northeast-2": "Northeast Asia (Seoul)", + "ap-south-1": "South Asia (Mumbai)", + "ap-southeast-1": "Southeast Asia (Singapore)", + "ap-southeast-2": "Oceania (Sydney)", + "ca-central-1": "Canada (Central)", + "eu-central-1": "Central EU (Frankfurt)", + "eu-central-2": "Central Europe (Zurich)", + "eu-north-1": "North EU (Stockholm)", + "eu-west-1": "West EU (Ireland)", + "eu-west-2": "West Europe (London)", + "eu-west-3": "West EU (Paris)", + "sa-east-1": "South America (São Paulo)", + "us-east-1": "East US (North Virginia)", + "us-east-2": "East US (Ohio)", + "us-west-1": "West US (North California)", + "us-west-2": "West US (Oregon)", +}; + +export function formatRegion(region: string): string { + return REGION_MAP[region] ?? region; +} + +// --------------------------------------------------------------------------- +// Dashboard URL per profile. Mirrors Go's `utils.GetSupabaseDashboardURL` -> +// `CurrentProfile.DashboardURL` (`apps/cli-go/internal/utils/profile.go:30-91`). +// Defaults to the production dashboard for unknown / file-based profiles. +// --------------------------------------------------------------------------- + +const DASHBOARD_URLS: Readonly> = { + supabase: "https://supabase.com/dashboard", + "supabase-staging": "https://supabase.green/dashboard", + "supabase-local": "http://localhost:8082", +}; + +export function dashboardUrlForProfile(profile: string): string { + return DASHBOARD_URLS[profile] ?? DASHBOARD_URLS.supabase!; +} + +// --------------------------------------------------------------------------- +// Tables. `renderGlamourTable` lays out cells directly, so literal `|` in a +// project name flows through unescaped and matches Go's glamour byte output +// (the markdown `\|` escape is decoded back to `|` by glamour upstream). +// --------------------------------------------------------------------------- + +const LIST_HEADERS = [ + "LINKED", + "ORG ID", + "REFERENCE ID", + "NAME", + "REGION", + "CREATED AT (UTC)", +] as const; + +const CREATE_HEADERS = ["ORG ID", "REFERENCE ID", "NAME", "REGION", "CREATED AT (UTC)"] as const; + +const API_KEYS_HEADERS = ["NAME", "KEY VALUE"] as const; + +/** Go's `formatBullet` (`list.go:73-78`): bullet for the linked project. */ +function formatBullet(linked: boolean): string { + return linked ? " ●" : " "; +} + +/** + * Reproduces Go's `projects list` pretty table (`list.go:44-59`). The REFERENCE + * ID and LINKED-marker comparison both use the project `id` field, matching + * Go's use of `project.Id`. + */ +export function renderProjectsListTable(projects: ReadonlyArray): string { + const rows = projects.map((project) => [ + formatBullet(project.linked), + readProjectField(project, "organization_slug"), + readProjectField(project, "id"), + readProjectField(project, "name"), + formatRegion(readProjectField(project, "region")), + formatLegacyTimestamp(readProjectField(project, "created_at")), + ]); + return renderGlamourTable(LIST_HEADERS, rows); +} + +/** Reproduces Go's `projects create` pretty table (`create.go:36-47`). */ +export function renderProjectCreateTable(project: unknown): string { + const rows = [ + [ + readProjectField(project, "organization_slug"), + readProjectField(project, "id"), + readProjectField(project, "name"), + formatRegion(readProjectField(project, "region")), + formatLegacyTimestamp(readProjectField(project, "created_at")), + ], + ]; + return renderGlamourTable(CREATE_HEADERS, rows); +} + +/** + * Reproduces Go's `projects api-keys` pretty table (`api_keys.go:23-33`): + * the KEY VALUE column shows `******` when the api key is nullable-null. + */ +export function renderProjectApiKeysTable(keys: ReadonlyArray): string { + const rows = keys.map((entry) => [entry.name, apiKeyValue(entry.api_key)]); + return renderGlamourTable(API_KEYS_HEADERS, rows); +} diff --git a/apps/cli/src/legacy/commands/projects/projects.format.unit.test.ts b/apps/cli/src/legacy/commands/projects/projects.format.unit.test.ts new file mode 100644 index 0000000000..6e9385f4aa --- /dev/null +++ b/apps/cli/src/legacy/commands/projects/projects.format.unit.test.ts @@ -0,0 +1,152 @@ +import type { ApiKeyResponse, V1CreateAProjectOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "vitest"; + +import { apiKeyValue, apiKeysToEnv } from "../../shared/legacy-api-keys.format.ts"; +import { + type LegacyLinkedProject, + dashboardUrlForProfile, + formatRegion, + renderProjectApiKeysTable, + renderProjectCreateTable, + renderProjectsListTable, +} from "./projects.format.ts"; +import { generateDbPassword } from "./projects.prompt.ts"; + +type ApiKey = typeof ApiKeyResponse.Type; +type CreatedProject = typeof V1CreateAProjectOutput.Type; + +const PROJECT: LegacyLinkedProject = { + id: "abcdefghijklmnopqrst", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15", + postgres_engine: "15", + release_channel: "ga", + }, + linked: false, +}; + +const CREATED: CreatedProject = { + id: "abcdefghijklmnopqrst", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "acme", + name: "alpha", + region: "eu-west-1", + created_at: "2026-05-27T01:02:03Z", + status: "COMING_UP", +}; + +describe("formatRegion", () => { + it("maps a known region code to its display name", () => { + expect(formatRegion("us-east-1")).toBe("East US (North Virginia)"); + expect(formatRegion("ap-southeast-2")).toBe("Oceania (Sydney)"); + }); + + it("passes an unknown region code through unchanged", () => { + expect(formatRegion("mars-west-9")).toBe("mars-west-9"); + }); +}); + +describe("dashboardUrlForProfile", () => { + it("resolves the built-in profile dashboard URLs", () => { + expect(dashboardUrlForProfile("supabase")).toBe("https://supabase.com/dashboard"); + expect(dashboardUrlForProfile("supabase-staging")).toBe("https://supabase.green/dashboard"); + expect(dashboardUrlForProfile("supabase-local")).toBe("http://localhost:8082"); + }); + + it("defaults to the production dashboard for unknown profiles", () => { + expect(dashboardUrlForProfile("/path/to/profile.yaml")).toBe("https://supabase.com/dashboard"); + }); +}); + +describe("apiKeyValue / apiKeysToEnv", () => { + it("masks a null or absent api key value", () => { + expect(apiKeyValue(null)).toBe("******"); + expect(apiKeyValue(undefined)).toBe("******"); + expect(apiKeyValue("secret")).toBe("secret"); + }); + + it("uppercases names and builds SUPABASE__KEY entries", () => { + const keys: ReadonlyArray = [ + { name: "anon", api_key: "anon-key" }, + { name: "service_role", api_key: null }, + ]; + expect(apiKeysToEnv(keys)).toEqual({ + SUPABASE_ANON_KEY: "anon-key", + SUPABASE_SERVICE_ROLE_KEY: "******", + }); + }); +}); + +describe("generateDbPassword", () => { + it("produces a 16-character alphanumeric password with no colon", () => { + const password = generateDbPassword(); + expect(password).toHaveLength(16); + expect(password).toMatch(/^[a-zA-Z0-9]{16}$/); + expect(password).not.toContain(":"); + }); + + it("is non-deterministic across calls", () => { + const a = generateDbPassword(); + const b = generateDbPassword(); + expect(a).not.toBe(b); + }); +}); + +describe("renderProjectsListTable", () => { + it("renders all six columns and a bullet for the linked project", () => { + const table = renderProjectsListTable([ + { ...PROJECT, linked: true }, + { ...PROJECT, id: "qrstuvwxyzabcdefghij", name: "beta", linked: false }, + ]); + expect(table).toContain("LINKED"); + expect(table).toContain("ORG ID"); + expect(table).toContain("REFERENCE ID"); + expect(table).toContain("NAME"); + expect(table).toContain("REGION"); + expect(table).toContain("CREATED AT (UTC)"); + expect(table).toContain("●"); + expect(table).toContain("East US (North Virginia)"); + expect(table).toContain("2026-05-27 01:02:03"); + expect(table).toContain("abcdefghijklmnopqrst"); + }); + + it("renders no bullet when nothing is linked", () => { + const table = renderProjectsListTable([{ ...PROJECT, linked: false }]); + expect(table).not.toContain("●"); + }); +}); + +describe("renderProjectCreateTable", () => { + it("renders the five create columns", () => { + const table = renderProjectCreateTable(CREATED); + expect(table).toContain("ORG ID"); + expect(table).toContain("REFERENCE ID"); + expect(table).toContain("NAME"); + expect(table).toContain("REGION"); + expect(table).toContain("CREATED AT (UTC)"); + expect(table).toContain("West EU (Ireland)"); + expect(table).not.toContain("LINKED"); + }); +}); + +describe("renderProjectApiKeysTable", () => { + it("renders the NAME / KEY VALUE columns and masks null keys", () => { + const table = renderProjectApiKeysTable([ + { name: "anon", api_key: "anon-key" }, + { name: "service_role", api_key: null }, + ]); + expect(table).toContain("NAME"); + expect(table).toContain("KEY VALUE"); + expect(table).toContain("anon-key"); + expect(table).toContain("******"); + }); +}); diff --git a/apps/cli/src/legacy/commands/projects/projects.prompt.ts b/apps/cli/src/legacy/commands/projects/projects.prompt.ts new file mode 100644 index 0000000000..9e035999c8 --- /dev/null +++ b/apps/cli/src/legacy/commands/projects/projects.prompt.ts @@ -0,0 +1,138 @@ +import { randomInt } from "node:crypto"; + +import { Effect } from "effect"; + +import { LegacyPlatformApi } from "../../auth/legacy-platform-api.service.ts"; +import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { + LegacyProjectsCreateNameEmptyError, + LegacyProjectsOrgsListNetworkError, + LegacyProjectsOrgsListUnexpectedStatusError, +} from "./projects.errors.ts"; +import { formatRegion } from "./projects.format.ts"; + +const mapOrgsListError = mapLegacyHttpError({ + networkError: LegacyProjectsOrgsListNetworkError, + statusError: LegacyProjectsOrgsListUnexpectedStatusError, + networkMessage: (cause) => `failed to retrieve organizations: ${cause}`, + statusMessage: (status, body) => `Unexpected error retrieving organizations: ${body} (${status})`, +}); + +// Region codes offered in the interactive prompt. Mirrors the `supabase` +// profile's `ProjectRegions` (`apps/cli-go/internal/utils/profile.go:37-56`), +// in the same order, which also matches the `--region` enum. +const REGION_CODES = [ + "ap-east-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-south-1", + "ap-southeast-1", + "ap-southeast-2", + "ca-central-1", + "eu-central-1", + "eu-central-2", + "eu-north-1", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "sa-east-1", + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", +] as const; + +/** + * Reproduces Go's `promptProjectName` (`create.go:87-95`): read a line; a + * non-empty value is the project name, otherwise fail with "project name + * cannot be empty". + */ +export const legacyPromptProjectName = Effect.fnUntraced(function* () { + const output = yield* Output; + const name = yield* output.promptText("Enter your project name: "); + if (name.length > 0) { + return name; + } + return yield* new LegacyProjectsCreateNameEmptyError({ + message: "project name cannot be empty", + }); +}); + +/** + * Reproduces Go's `promptOrgId` (`create.go:97-115`): list the user's + * organizations and prompt for one. Go's `PromptItem` uses `Summary: org.Name`, + * `Details: org.Id` and returns `Details` (the org id), which is then sent as + * `organization_slug`. + */ +export const legacyPromptOrgId = Effect.fnUntraced(function* () { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const orgs = yield* api.v1.listAllOrganizations().pipe(Effect.catch(mapOrgsListError)); + const options = orgs.map((org) => ({ + value: org.id, + label: org.name, + hint: org.id, + })); + return yield* output.promptSelect( + "Which organisation do you want to create the project for?", + options, + ); +}); + +/** + * Reproduces Go's `promptProjectRegion` (`create.go:117-131`): prompt for a + * region; the selection value is the region code, the display detail is the + * human-readable name. + */ +export const legacyPromptProjectRegion = Effect.fnUntraced(function* () { + const output = yield* Output; + // Go's `PromptItem{Summary: code, Details: human-name}` renders the region + // code as the primary label and the friendly name as the description + // (`create.go:117-131`). Mirror that ordering. + const options = REGION_CODES.map((code) => ({ + value: code, + label: code, + hint: formatRegion(code), + })); + const chosen = yield* output.promptSelect( + "Which region do you want to host the project in?", + options, + ); + // Narrow the `string` choice back to a region literal so it satisfies the + // typed create-project input. The chosen value always comes from the options, + // so the fallback is never reached in practice. + const matched = REGION_CODES.find((code) => code === chosen); + return matched ?? "us-east-1"; +}); + +const PASSWORD_LENGTH = 16; +// Go's `config.LowerUpperLettersDigits.ToChar()` is +// "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789"; `db_url.go` +// strips the `:` separators, leaving lower + upper + digits (62 chars). +const PASSWORD_CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +/** + * Reproduces Go's blank-password fallback in `flags.PromptPassword` + * (`db_url.go:238-257`): generate a 16-character password from the + * lower+upper+digits charset using a CSPRNG. + */ +export function generateDbPassword(): string { + let password = ""; + for (let i = 0; i < PASSWORD_LENGTH; i++) { + password += PASSWORD_CHARSET[randomInt(PASSWORD_CHARSET.length)]; + } + return password; +} + +/** + * Reproduces Go's `flags.PromptPassword` (`db_url.go:238-257`): prompt for a + * masked database password; a blank entry generates one. + */ +export const legacyPromptDbPassword = Effect.fnUntraced(function* () { + const output = yield* Output; + const entered = yield* output.promptPassword( + "Enter your database password (or leave blank to generate one): ", + ); + return entered.length > 0 ? entered : generateDbPassword(); +}); diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.ts index f729eb13d9..39e50d835f 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.layer.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.ts @@ -44,7 +44,7 @@ export const legacyProjectRefLayer = Layer.effect( return trimmed.length === 0 ? Option.none() : Option.some(trimmed); }); - const promptForProjectRef = Effect.gen(function* () { + const promptForProjectRef = Effect.fnUntraced(function* (title: string) { const projects = yield* api.v1.listAllProjects().pipe( Effect.mapError( (cause) => @@ -60,7 +60,7 @@ export const legacyProjectRefLayer = Layer.effect( label: project.id, hint: `name: ${project.name}, org: ${project.organization_slug}, region: ${project.region}`, })); - const chosen = yield* output.promptSelect("Select a project:", options).pipe( + const chosen = yield* output.promptSelect(title, options).pipe( Effect.mapError( (cause) => new LegacyProjectNotLinkedError({ @@ -88,13 +88,24 @@ export const legacyProjectRefLayer = Layer.effect( return yield* assertValid(fileValue.value); } if (tty.stdinIsTty && output.interactive) { - const chosen = yield* promptForProjectRef; + const chosen = yield* promptForProjectRef("Select a project:"); return yield* assertValid(chosen); } return yield* Effect.fail( new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), ); }), + resolveOptional: (flagValue) => + Effect.gen(function* () { + if (Option.isSome(flagValue) && flagValue.value.length > 0) { + return Option.some(flagValue.value); + } + if (Option.isSome(cliConfig.projectId)) { + return cliConfig.projectId; + } + return yield* readRefFile; + }), + promptProjectRef: promptForProjectRef, }); }), ); 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 d614509a72..2499b05f6f 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 @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -172,11 +172,7 @@ describe("legacyProjectRefLayer", () => { const { resolve } = yield* LegacyProjectRefResolver; yield* resolve(Option.none()); // The resolver must not write the file — only `supabase link` does. - const exists = yield* Effect.tryPromise({ - try: () => import("node:fs").then((m) => m.existsSync(refPath)), - catch: () => false, - }); - expect(exists).toBe(false); + expect(existsSync(refPath)).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -226,4 +222,67 @@ describe("legacyProjectRefLayer", () => { expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(layer)); }); + + describe("resolveOptional", () => { + it.effect("prefers the flag value", () => { + writeRefFile(tempRoot, ANOTHER_REF); + const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + return Effect.gen(function* () { + const { resolveOptional } = yield* LegacyProjectRefResolver; + const ref = yield* resolveOptional(Option.some(VALID_REF)); + expect(ref).toEqual(Option.some(VALID_REF)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("falls back to projectId then the ref file", () => { + const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + return Effect.gen(function* () { + const { resolveOptional } = yield* LegacyProjectRefResolver; + const ref = yield* resolveOptional(Option.none()); + expect(ref).toEqual(Option.some(VALID_REF)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reads the ref file when flag and projectId are unset", () => { + writeRefFile(tempRoot, VALID_REF); + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveOptional } = yield* LegacyProjectRefResolver; + const ref = yield* resolveOptional(Option.none()); + expect(ref).toEqual(Option.some(VALID_REF)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("returns None and never fails when nothing resolves", () => { + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveOptional } = yield* LegacyProjectRefResolver; + const ref = yield* resolveOptional(Option.none()); + expect(Option.isNone(ref)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + }); + + describe("promptProjectRef", () => { + it.effect("prompts with the given title, returns the choice, and echoes it", () => { + const projects = [ + { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, + { id: ANOTHER_REF, name: "beta", organization_slug: "acme", region: "eu-west-1" }, + ]; + const { layer, out } = makeLayer({ + workdir: tempRoot, + stdinIsTty: true, + projects, + promptSelectResponses: [ANOTHER_REF], + }); + return Effect.gen(function* () { + const { promptProjectRef } = yield* LegacyProjectRefResolver; + const ref = yield* promptProjectRef("Which project do you want to delete?"); + expect(ref).toBe(ANOTHER_REF); + expect(out.promptSelectCalls[0]?.message).toBe("Which project do you want to delete?"); + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos).toContain(`Selected project: ${ANOTHER_REF}`); + }).pipe(Effect.provide(layer)); + }); + }); }); diff --git a/apps/cli/src/legacy/config/legacy-project-ref.service.ts b/apps/cli/src/legacy/config/legacy-project-ref.service.ts index 647233038e..cd2e32d53c 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.service.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.service.ts @@ -10,6 +10,30 @@ interface LegacyProjectRefResolverShape { readonly resolve: ( flagValue: Option.Option, ) => Effect.Effect; + /** + * Soft resolution chain (flag -> `cliConfig.projectId` -> ref file) with **no + * prompt and no failure**. Mirrors Go's `flags.LoadProjectRef` as used by + * `projects list` (`list.go:31-33`), which ignores `ErrNotLinked` and only + * uses the value as a "linked" marker. Returns `None` when nothing resolves. + * + * Unlike `resolve`, the returned value is **not** format-validated — Go's + * soft load also skips validation here, and the value is only used as a + * display marker, never injected into an API path. + */ + readonly resolveOptional: ( + flagValue: Option.Option, + ) => Effect.Effect, never, never>; + /** + * Lists all projects and prompts the user to select one with the given title, + * writing "Selected project: " to stderr (text mode). Mirrors Go's + * `flags.PromptProjectRef(ctx, title)` (`project_ref.go:30-52`). The `title` + * lets callers match Go's per-command prompt label (e.g. `projects delete` + * uses "Which project do you want to delete?"). Used on a TTY when no + * positional ref is supplied; never reads the linked ref file. + */ + readonly promptProjectRef: ( + title: string, + ) => Effect.Effect; } export class LegacyProjectRefResolver extends Context.Service< diff --git a/apps/cli/src/legacy/shared/legacy-api-keys.format.ts b/apps/cli/src/legacy/shared/legacy-api-keys.format.ts new file mode 100644 index 0000000000..1c36d1de47 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-api-keys.format.ts @@ -0,0 +1,32 @@ +import type { ApiKeyResponse } from "@supabase/api/effect"; + +type ApiKey = typeof ApiKeyResponse.Type; + +/** + * Masking placeholder Go substitutes for a nullable-null api key value + * (`apps/cli-go/internal/projects/apiKeys/api_keys.go:61-66`). + */ +const API_KEY_MASK = "******"; + +/** + * Reproduces Go's `apiKeys.toValue` (`api_keys.go:61-66`): return the api key + * value, or the `******` mask when the value is nullable-null / absent. + */ +export function apiKeyValue(value: string | null | undefined): string { + return value === undefined || value === null ? API_KEY_MASK : value; +} + +/** + * Reproduces Go's `apiKeys.ToEnv` (`api_keys.go:51-66`): + * uppercase the name, wrap as `SUPABASE__KEY`, fall back to `"******"` + * when the api_key value is nullable-null. Shared by `branches get` and + * `projects api-keys`. + */ +export function apiKeysToEnv(keys: ReadonlyArray): Record { + const envs: Record = {}; + for (const entry of keys) { + const key = `SUPABASE_${entry.name.toUpperCase()}_KEY`; + envs[key] = apiKeyValue(entry.api_key); + } + return envs; +} diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 7271c43e40..eebc95ef92 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -361,7 +361,11 @@ export function mockLegacyPlatformApiService( }, }); - const layer = Layer.succeed(LegacyPlatformApi, { v1: v1Proxy } as ApiClient); + const layer = Layer.succeed(LegacyPlatformApi, { + v1: v1Proxy, + // Direct-service consumers don't exercise the raw-execute escape hatch. + executeRaw: () => Effect.die("Unmocked LegacyPlatformApi.executeRaw"), + } as ApiClient); return { layer, requests }; } diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index aeb12f0083..cbab509eef 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -224,6 +224,8 @@ export function mockOutput( promptTextFail?: boolean; promptTextResponses?: ReadonlyArray; promptSelectResponses?: ReadonlyArray; + promptPasswordResponses?: ReadonlyArray; + promptConfirmResponses?: ReadonlyArray; } = {}, ) { const messages: OutputMessage[] = []; @@ -248,6 +250,8 @@ export function mockOutput( }> = []; const promptTextResponses = [...(opts.promptTextResponses ?? [])]; const promptSelectResponses = [...(opts.promptSelectResponses ?? [])]; + const promptPasswordResponses = [...(opts.promptPasswordResponses ?? [])]; + const promptConfirmResponses = [...(opts.promptConfirmResponses ?? [])]; return { layer: Layer.succeed(Output, { format: opts.format ?? "text", @@ -369,8 +373,11 @@ export function mockOutput( return Effect.succeed(promptTextResponses.shift() ?? "123456"); }; })(), - promptPassword: () => Effect.succeed(""), - promptConfirm: () => Effect.succeed(opts.confirmLogout ?? opts.confirmRelogin ?? true), + promptPassword: () => Effect.succeed(promptPasswordResponses.shift() ?? ""), + promptConfirm: () => + Effect.succeed( + promptConfirmResponses.shift() ?? opts.confirmLogout ?? opts.confirmRelogin ?? true, + ), promptSelect: (message, options, behavior) => Effect.sync(() => { promptSelectCalls.push({ message, options, behavior }); diff --git a/packages/api/src/effect.ts b/packages/api/src/effect.ts index 8d470b5d59..0cb0a4d4fe 100644 --- a/packages/api/src/effect.ts +++ b/packages/api/src/effect.ts @@ -2,6 +2,7 @@ import { Effect } from "effect"; import { makeSupabaseApiClient, type SupabaseApiClientOptions, + type SupabaseApiClientShape, type SupabaseApiConfig, } from "./internal/client.ts"; import { makeEffectApiClient, type EffectClient } from "./internal/effect-client.ts"; @@ -27,8 +28,14 @@ export * from "./generated/contracts.ts"; export { executeApiClientOperation } from "./generated/effect-client.ts"; export const makeApiClient = (config: SupabaseApiConfig = {}, options?: SupabaseApiClientOptions) => - Effect.map(makeSupabaseApiClient(config, options), (client) => - makeEffectApiClient(client, versionedEffectOperations), - ); + Effect.map(makeSupabaseApiClient(config, options), (client) => ({ + ...makeEffectApiClient(client, versionedEffectOperations), + // Expose the raw-execute escape hatch alongside the typed operations so + // callers can opt out of strict output decoding without hand-building + // requests (reuses the client's URL/auth/header/body handling). + executeRaw: client.executeRaw, + })); -export type ApiClient = EffectClient; +export type ApiClient = EffectClient & { + readonly executeRaw: SupabaseApiClientShape["executeRaw"]; +}; diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index 4aff9c50e7..9f4c0e954a 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -54,6 +54,18 @@ export interface SupabaseApiClientShape { definition: OperationDefinition, input: OperationInput, ) => Effect.Effect, SupabaseApiError>; + /** + * Execute an operation but return the raw HTTP response without decoding the + * output schema or filtering on status. Use this when the response body + * cannot satisfy the strict generated schema (e.g. cli-e2e replay fixtures + * embed a `__PROJECT_REF__` placeholder that violates `ref`'s 20-char + * pattern), so the caller can parse the body leniently. Request building — + * URL, auth, headers, body serialization — is identical to `execute`. + */ + readonly executeRaw: ( + definition: OperationDefinition, + input: OperationInput, + ) => Effect.Effect; } export class SupabaseApiClient extends Context.Service()( @@ -326,6 +338,25 @@ function asBinaryRequestBody(value: unknown): Effect.Effect = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = sortJsonKeysDeep(value[key]); + } + return sorted; +} + function encodeBody( request: HttpClientRequest.HttpClientRequest, definition: OperationDefinition, @@ -343,7 +374,7 @@ function encodeBody( payload[field] = revealRedactedValue(value); } } - return HttpClientRequest.bodyJson(request, payload); + return HttpClientRequest.bodyJson(request, sortJsonKeysDeep(payload)); } const body = revealRedactedValue(Reflect.get(input, definition.requestBody.field)); @@ -358,7 +389,7 @@ function encodeBody( switch (definition.requestBody.contentType) { case "application/json": - return HttpClientRequest.bodyJson(request, body); + return HttpClientRequest.bodyJson(request, sortJsonKeysDeep(body)); case "application/x-www-form-urlencoded": return Effect.succeed(HttpClientRequest.bodyUrlParams(request, asUrlParamsInput(body))); case "multipart/form-data": @@ -497,6 +528,12 @@ export function makeSupabaseApiClient( } return yield* Effect.die(`Unsupported response kind: ${definition.response.kind}`); }), + executeRaw: (definition, input) => + Effect.gen(function* () { + const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input); + const request = yield* buildRequest(definition, validated); + return yield* prepared.execute(request); + }), }; }); } diff --git a/packages/cli-test-helpers/src/normalize.ts b/packages/cli-test-helpers/src/normalize.ts index 6fafaf3cec..12435a0dba 100644 --- a/packages/cli-test-helpers/src/normalize.ts +++ b/packages/cli-test-helpers/src/normalize.ts @@ -94,6 +94,16 @@ export function normalize(output: string): string { // 17. Docker shadow-DB endpoint lines emitted when a container starts: // "endpoint (<64-hex>)" — both parts are random per container. .replace(/\bendpoint \w+_\w+ \([0-9a-f]{64}\)/g, "endpoint ()") + // 17b. System-keyring availability noise. The Go CLI uses an OS keyring + // (dbus Secret Service on Linux) and prints "Keyring is not supported + // on WSL" to stderr when it is unavailable — e.g. on headless CI + // runners with no D-Bus session. The ts-legacy keyring + // (`@napi-rs/keyring`) uses the kernel keyutils backend, which is + // always available, so it never prints this. The line is a + // keyring-backend implementation detail, not command behavior, so + // strip it from both sides. (Same class of divergence that defers the + // login/logout parity tests in auth.e2e.test.ts.) + .replace(/^Keyring is not supported on WSL\n?/gm, "") // 18. Trailing whitespace on each line .replace(/[ \t]+$/gm, "") // 19. Collapse 3+ consecutive blank lines to two newlines From d401de560720132d732c6b79686649542c760ac6 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:33:55 +0530 Subject: [PATCH 14/16] feat(cli): replace legacy hidden flags (#5403) ## TL;DR replaces the remaining legacy hidden flag shim with native `Flag.withHidden` ## What's Introduced This switches the remaining hidden legacy flags to Effect's native `Flag.withHidden` support and removes the old local hidden-flag shim entirely also dropped the formatter side help stripping workaround, since hidden flags are now handled natively by the upstream layer added tests around it as well..... ## ref: - follows up the earlier hidden subcommands support: https://github.com/supabase/cli/pull/5378 - extends: https://github.com/Effect-TS/effect-smol/pull/2227 --------- Co-authored-by: Julien Goux --- .../functions/deploy/deploy.command.ts | 14 +- .../functions/download/download.command.ts | 14 +- .../commands/functions/serve/serve.command.ts | 8 +- .../src/legacy/commands/init/init.command.ts | 21 +- .../projects/create/create.command.ts | 22 +- .../legacy/commands/start/start.command.ts | 7 +- .../src/legacy/commands/stop/stop.command.ts | 11 +- apps/cli/src/shared/cli/hidden-flag.ts | 83 ------ .../src/shared/cli/hidden-flag.unit.test.ts | 249 ++++++++++-------- apps/cli/src/shared/output/json-formatter.ts | 4 +- apps/cli/src/shared/output/text-formatter.ts | 2 - 11 files changed, 175 insertions(+), 260 deletions(-) delete mode 100644 apps/cli/src/shared/cli/hidden-flag.ts diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts index 68edbdc053..407e699cfb 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts @@ -1,5 +1,4 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; -import { withHidden, withHiddenFromConfig } from "../../../../shared/cli/hidden-flag.ts"; import { legacyFunctionsDeploy } from "./deploy.handler.ts"; const config = { @@ -29,20 +28,19 @@ const config = { Flag.withDescription("Maximum number of parallel jobs."), Flag.optional, ), - useDocker: withHidden( - Flag.boolean("use-docker").pipe( - Flag.withDescription("Use Docker to bundle functions locally."), - ), + useDocker: Flag.boolean("use-docker").pipe( + Flag.withDescription("Use Docker to bundle functions locally."), + Flag.withHidden, ), - legacyBundle: withHidden( - Flag.boolean("legacy-bundle").pipe(Flag.withDescription("Use legacy bundling.")), + legacyBundle: Flag.boolean("legacy-bundle").pipe( + Flag.withDescription("Use legacy bundling."), + Flag.withHidden, ), } as const; export const legacyFunctionsDeployCommand = Command.make("deploy", config).pipe( Command.withDescription("Deploy a Function to the linked Supabase project."), Command.withShortDescription("Deploy a Function to Supabase"), - withHiddenFromConfig(config), Command.withHandler((flags) => legacyFunctionsDeploy({ functionNames: flags.functionNames.map(String), diff --git a/apps/cli/src/legacy/commands/functions/download/download.command.ts b/apps/cli/src/legacy/commands/functions/download/download.command.ts index 276ac82163..71c9d42639 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.command.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.command.ts @@ -1,6 +1,5 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { withHidden, withHiddenFromConfig } from "../../../../shared/cli/hidden-flag.ts"; import { legacyFunctionsDownload } from "./download.handler.ts"; const config = { @@ -15,13 +14,13 @@ const config = { useApi: Flag.boolean("use-api").pipe( Flag.withDescription("Unbundle functions server-side without using Docker."), ), - useDocker: withHidden( - Flag.boolean("use-docker").pipe( - Flag.withDescription("Use Docker to unbundle functions locally."), - ), + useDocker: Flag.boolean("use-docker").pipe( + Flag.withDescription("Use Docker to unbundle functions locally."), + Flag.withHidden, ), - legacyBundle: withHidden( - Flag.boolean("legacy-bundle").pipe(Flag.withDescription("Use legacy bundling.")), + legacyBundle: Flag.boolean("legacy-bundle").pipe( + Flag.withDescription("Use legacy bundling."), + Flag.withHidden, ), } as const; @@ -32,6 +31,5 @@ export const legacyFunctionsDownloadCommand = Command.make("download", config).p "Download the source code for a Function from the linked Supabase project. If no function name is provided, downloads all functions.", ), Command.withShortDescription("Download a Function from Supabase"), - withHiddenFromConfig(config), Command.withHandler((flags) => legacyFunctionsDownload(flags)), ); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts index 3fe425b32d..12183d612c 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts @@ -1,6 +1,5 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { withHidden, withHiddenFromConfig } from "../../../../shared/cli/hidden-flag.ts"; import { legacyFunctionsServe } from "./serve.handler.ts"; const INSPECT_MODES = ["run", "brk", "wait"] as const; @@ -25,8 +24,10 @@ const config = { inspectMain: Flag.boolean("inspect-main").pipe( Flag.withDescription("Allow inspecting the main worker."), ), - all: withHidden( - Flag.boolean("all").pipe(Flag.withDescription("Serve all Functions."), Flag.optional), + all: Flag.boolean("all").pipe( + Flag.withDescription("Serve all Functions."), + Flag.optional, + Flag.withHidden, ), } as const; @@ -35,6 +36,5 @@ export type LegacyFunctionsServeFlags = CliCommand.Command.Config.Infer legacyFunctionsServe(flags)), ); diff --git a/apps/cli/src/legacy/commands/init/init.command.ts b/apps/cli/src/legacy/commands/init/init.command.ts index 84e84a7e66..8c80348b1f 100644 --- a/apps/cli/src/legacy/commands/init/init.command.ts +++ b/apps/cli/src/legacy/commands/init/init.command.ts @@ -1,6 +1,5 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { withHidden, withHiddenFromConfig } from "../../../shared/cli/hidden-flag.ts"; import { legacyInit } from "./init.handler.ts"; const config = { @@ -14,18 +13,17 @@ const config = { force: Flag.boolean("force").pipe( Flag.withDescription("Overwrite existing supabase/config.toml."), ), - withVscodeWorkspace: withHidden( - Flag.boolean("with-vscode-workspace").pipe(Flag.withDescription("Generate VS Code workspace.")), + withVscodeWorkspace: Flag.boolean("with-vscode-workspace").pipe( + Flag.withDescription("Generate VS Code workspace."), + Flag.withHidden, ), - withVscodeSettings: withHidden( - Flag.boolean("with-vscode-settings").pipe( - Flag.withDescription("Generate VS Code settings for Deno."), - ), + withVscodeSettings: Flag.boolean("with-vscode-settings").pipe( + Flag.withDescription("Generate VS Code settings for Deno."), + Flag.withHidden, ), - withIntellijSettings: withHidden( - Flag.boolean("with-intellij-settings").pipe( - Flag.withDescription("Generate IntelliJ IDEA settings for Deno."), - ), + withIntellijSettings: Flag.boolean("with-intellij-settings").pipe( + Flag.withDescription("Generate IntelliJ IDEA settings for Deno."), + Flag.withHidden, ), } as const; @@ -34,6 +32,5 @@ export type LegacyInitFlags = CliCommand.Command.Config.Infer; export const legacyInitCommand = Command.make("init", config).pipe( Command.withDescription("Initialize a local project."), Command.withShortDescription("Initialize a local project"), - withHiddenFromConfig(config), Command.withHandler((flags) => legacyInit(flags)), ); diff --git a/apps/cli/src/legacy/commands/projects/create/create.command.ts b/apps/cli/src/legacy/commands/projects/create/create.command.ts index 1c487a4cad..acbca6b148 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.command.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.command.ts @@ -1,6 +1,5 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { withHidden, withHiddenFromConfig } from "../../../../shared/cli/hidden-flag.ts"; 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"; @@ -70,18 +69,16 @@ const config = { Flag.withDescription("Select a desired instance size for your project."), Flag.optional, ), - interactive: withHidden( - Flag.boolean("interactive").pipe( - Flag.withDescription("Enables interactive mode."), - Flag.withAlias("i"), - Flag.optional, - ), + interactive: Flag.boolean("interactive").pipe( + Flag.withDescription("Enables interactive mode."), + Flag.withAlias("i"), + Flag.optional, + Flag.withHidden, ), - plan: withHidden( - Flag.string("plan").pipe( - Flag.withDescription("Select a plan that suits your needs."), - Flag.optional, - ), + plan: Flag.string("plan").pipe( + Flag.withDescription("Select a plan that suits your needs."), + Flag.optional, + Flag.withHidden, ), }; export type LegacyProjectsCreateFlags = CliCommand.Command.Config.Infer; @@ -96,7 +93,6 @@ export const legacyProjectsCreateCommand = Command.make("create", config).pipe( description: "Create a new project", }, ]), - withHiddenFromConfig(config), Command.withHandler((flags) => legacyProjectsCreate(flags).pipe( withLegacyCommandInstrumentation({ flags, safeFlags: ["org-id"] }), diff --git a/apps/cli/src/legacy/commands/start/start.command.ts b/apps/cli/src/legacy/commands/start/start.command.ts index a381fd254a..652128002e 100644 --- a/apps/cli/src/legacy/commands/start/start.command.ts +++ b/apps/cli/src/legacy/commands/start/start.command.ts @@ -1,6 +1,5 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { withHidden, withHiddenFromConfig } from "../../../shared/cli/hidden-flag.ts"; import { legacyStart } from "./start.handler.ts"; const config = { @@ -15,8 +14,9 @@ const config = { ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe( Flag.withDescription("Ignore unhealthy services and exit 0"), ), - preview: withHidden( - Flag.boolean("preview").pipe(Flag.withDescription("Connect to feature preview branch")), + preview: Flag.boolean("preview").pipe( + Flag.withDescription("Connect to feature preview branch"), + Flag.withHidden, ), } as const; @@ -25,6 +25,5 @@ export type LegacyStartFlags = CliCommand.Command.Config.Infer; export const legacyStartCommand = Command.make("start", config).pipe( Command.withDescription("Start containers for Supabase local development."), Command.withShortDescription("Start local Supabase stack"), - withHiddenFromConfig(config), Command.withHandler((flags) => legacyStart(flags)), ); diff --git a/apps/cli/src/legacy/commands/stop/stop.command.ts b/apps/cli/src/legacy/commands/stop/stop.command.ts index 128ddbf4ef..c89e0c3d1d 100644 --- a/apps/cli/src/legacy/commands/stop/stop.command.ts +++ b/apps/cli/src/legacy/commands/stop/stop.command.ts @@ -1,6 +1,5 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { withHidden, withHiddenFromConfig } from "../../../shared/cli/hidden-flag.ts"; import { legacyStop } from "./stop.handler.ts"; const config = { @@ -10,11 +9,10 @@ const config = { ), // Hidden boolean kept for Go CLI parity: `--backup=false` is the historical // way to skip the backup and is functionally identical to `--no-backup`. - backup: withHidden( - Flag.boolean("backup").pipe( - Flag.withDescription("Backs up the current database before stopping."), - Flag.withDefault(true), - ), + backup: Flag.boolean("backup").pipe( + Flag.withDescription("Backs up the current database before stopping."), + Flag.withDefault(true), + Flag.withHidden, ), noBackup: Flag.boolean("no-backup").pipe( Flag.withDescription("Deletes all data volumes after stopping."), @@ -29,6 +27,5 @@ export type LegacyStopFlags = CliCommand.Command.Config.Infer; export const legacyStopCommand = Command.make("stop", config).pipe( Command.withDescription("Stop all local Supabase containers."), Command.withShortDescription("Stop all local Supabase containers"), - withHiddenFromConfig(config), Command.withHandler((flags) => legacyStop(flags)), ); diff --git a/apps/cli/src/shared/cli/hidden-flag.ts b/apps/cli/src/shared/cli/hidden-flag.ts deleted file mode 100644 index ba0d4f7636..0000000000 --- a/apps/cli/src/shared/cli/hidden-flag.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Context } from "effect"; -import { Command, type Flag, type HelpDoc } from "effect/unstable/cli"; -import * as Param from "effect/unstable/cli/Param"; - -/** - * Per-command set of hidden flag names. Attached to a `Command` via - * `Command.annotate` so each command carries its own list, then read off - * `HelpDoc.annotations` by `stripHiddenFlagsFromHelpDoc`. - */ -export const LegacyHiddenFlags: Context.Reference> = Context.Reference< - ReadonlySet ->("supabase/legacy/LegacyHiddenFlags", { - defaultValue: () => new Set(), -}); - -const hiddenFlagNames = new WeakMap>(); - -const collectSingleNames = (param: Param.Param): Array => { - const node = param as - | Param.Single - | Param.Map - | Param.Transform - | Param.Optional - | Param.Variadic; - switch (node._tag) { - case "Single": - return [node.name]; - case "Map": - case "Transform": - case "Optional": - case "Variadic": - return collectSingleNames(node.param); - } -}; - -/** - * Marks a flag as hidden so that it is parsed normally but omitted from - * `--help` output. This mirrors Cobra's `MarkHidden` from the Go CLI, which - * the upstream Effect CLI does not yet expose natively. - * - * The flag reference is recorded in a module-local `WeakMap`; the - * per-command list is materialised by `withHiddenFromConfig` so flags with - * the same name in unrelated commands do not collide. - */ -export const withHidden = (flag: Flag.Flag): Flag.Flag => { - hiddenFlagNames.set(flag, collectSingleNames(flag)); - return flag; -}; - -/** - * Pipe step for a `Command` that walks the command's flag config, finds - * every flag previously wrapped with `withHidden`, and attaches the - * resulting set of hidden flag names to the command via `Command.annotate`. - * Apply directly after `Command.make(name, config)` so the same `config` - * object is in scope. - */ -export const withHiddenFromConfig = - (config: Record) => - ( - cmd: Command.Command, - ): Command.Command => { - const hidden = new Set(); - for (const value of Object.values(config)) { - if (value === null || typeof value !== "object") continue; - const names = hiddenFlagNames.get(value); - if (names === undefined) continue; - for (const name of names) hidden.add(name); - } - if (hidden.size === 0) return cmd; - return Command.annotate(cmd, LegacyHiddenFlags, hidden); - }; - -export const stripHiddenFlagsFromHelpDoc = (doc: HelpDoc.HelpDoc): HelpDoc.HelpDoc => { - const hiddenFlags = Context.get(doc.annotations, LegacyHiddenFlags); - if (hiddenFlags.size === 0) return doc; - const filteredFlags = doc.flags.filter((flag) => !hiddenFlags.has(flag.name)); - const filteredGlobalFlags = doc.globalFlags?.filter((flag) => !hiddenFlags.has(flag.name)); - return { - ...doc, - flags: filteredFlags, - ...(filteredGlobalFlags !== undefined && { globalFlags: filteredGlobalFlags }), - }; -}; diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index e70a6bd656..f0a1f60b56 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -1,48 +1,24 @@ -import { Context, Effect, Layer, Option } from "effect"; -import { CliOutput, Command, Flag, type HelpDoc } from "effect/unstable/cli"; +import { Effect, Layer } from "effect"; +import { CliOutput, Command, type HelpDoc } from "effect/unstable/cli"; import { describe, expect, it } from "vitest"; import { legacyBranchesCommand } from "../../legacy/commands/branches/branches.command.ts"; import { legacyDbCommand } from "../../legacy/commands/db/db.command.ts"; +import { legacyFunctionsCommand } from "../../legacy/commands/functions/functions.command.ts"; +import { legacyFunctionsDeployCommand } from "../../legacy/commands/functions/deploy/deploy.command.ts"; +import { legacyFunctionsDownloadCommand } from "../../legacy/commands/functions/download/download.command.ts"; +import { legacyFunctionsServeCommand } from "../../legacy/commands/functions/serve/serve.command.ts"; +import { legacyInitCommand } from "../../legacy/commands/init/init.command.ts"; +import { legacyProjectsCommand } from "../../legacy/commands/projects/projects.command.ts"; +import { legacyProjectsCreateCommand } from "../../legacy/commands/projects/create/create.command.ts"; +import { legacyStartCommand } from "../../legacy/commands/start/start.command.ts"; +import { legacyStopCommand } from "../../legacy/commands/stop/stop.command.ts"; import { LegacyGoProxy } from "../legacy/go-proxy.service.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; -import { - LegacyHiddenFlags, - stripHiddenFlagsFromHelpDoc, - withHidden, - withHiddenFromConfig, -} from "./hidden-flag.ts"; - -const flagDoc = (name: string): HelpDoc.FlagDoc => ({ - name, - aliases: [`--${name}`], - type: "boolean", - description: Option.none(), - required: false, -}); - -const helpDoc = (overrides: Partial): HelpDoc.HelpDoc => ({ - description: "", - usage: "", - flags: [], - annotations: Context.empty(), - ...overrides, -}); -const helpDocWithHidden = ( - hidden: ReadonlyArray, - overrides: Partial, -): HelpDoc.HelpDoc => - helpDoc({ - ...overrides, - annotations: Context.make(LegacyHiddenFlags, new Set(hidden)), - }); - -// Reach into the internal command shape to obtain the help doc the formatter -// would render. Effect builds this from `Command.annotations`, which is the -// contract `withHiddenFromConfig` relies on. interface CommandImpl { readonly buildHelpDoc: (path: ReadonlyArray) => HelpDoc.HelpDoc; } + const buildHelpDoc = ( cmd: Command.Command, ): HelpDoc.HelpDoc => (cmd as unknown as CommandImpl).buildHelpDoc([]); @@ -60,99 +36,143 @@ function mockLegacyGoProxy() { } const legacyTestRoot = Command.make("supabase").pipe( - Command.withSubcommands([legacyBranchesCommand, legacyDbCommand]), + Command.withSubcommands([ + legacyStartCommand, + legacyStopCommand, + legacyInitCommand, + legacyFunctionsCommand, + legacyProjectsCommand, + legacyBranchesCommand, + legacyDbCommand, + ]), ); -describe("withHidden", () => { - it("returns the same flag instance", () => { - const flag = Flag.boolean("legacy-bundle"); - expect(withHidden(flag)).toBe(flag); - }); - - it("does not register flag names globally — only commands wired via withHiddenFromConfig hide them", () => { - withHidden(Flag.boolean("stray")); - - const stripped = stripHiddenFlagsFromHelpDoc(helpDoc({ flags: [flagDoc("stray")] })); - expect(stripped.flags.map((f) => f.name)).toEqual(["stray"]); - }); -}); - -describe("withHiddenFromConfig", () => { - it("strips wrapped flags from the command's help doc", () => { - const config = { - plan: withHidden(Flag.string("plan").pipe(Flag.optional)), - visible: Flag.boolean("visible"), - } as const; - - const cmd = Command.make("demo", config).pipe(withHiddenFromConfig(config)); - const doc = stripHiddenFlagsFromHelpDoc(buildHelpDoc(cmd)); - - expect(doc.flags.map((f) => f.name)).toEqual(["visible"]); - }); +const silentCliOutputFormatter: CliOutput.Formatter = { + formatCliError: () => "", + formatError: () => "", + formatErrors: () => "", + formatHelpDoc: () => "", + formatVersion: () => "", +}; + +describe("native hidden flags", () => { + it("omits hidden flags from help docs for every legacy command that still carries one", () => { + expect(buildHelpDoc(legacyStartCommand).flags.map((flag) => flag.name)).toEqual([ + "exclude", + "ignore-health-check", + ]); - it("scopes hidden-ness to the wrapping command — same flag name in another command stays visible", () => { - const hiddenConfig = { - interactive: withHidden(Flag.boolean("interactive")), - } as const; - const visibleConfig = { - interactive: Flag.boolean("interactive"), - } as const; - - const hiddenCmd = Command.make("create", hiddenConfig).pipe(withHiddenFromConfig(hiddenConfig)); - const visibleCmd = Command.make("init", visibleConfig).pipe( - withHiddenFromConfig(visibleConfig), - ); + expect(buildHelpDoc(legacyStopCommand).flags.map((flag) => flag.name)).toEqual([ + "project-id", + "no-backup", + "all", + ]); - expect(stripHiddenFlagsFromHelpDoc(buildHelpDoc(hiddenCmd)).flags.map((f) => f.name)).toEqual( - [], - ); - expect(stripHiddenFlagsFromHelpDoc(buildHelpDoc(visibleCmd)).flags.map((f) => f.name)).toEqual([ + expect(buildHelpDoc(legacyInitCommand).flags.map((flag) => flag.name)).toEqual([ "interactive", + "use-orioledb", + "force", ]); - }); - it("is a no-op when the config contains no hidden flags", () => { - const config = { visible: Flag.boolean("visible") } as const; - const cmd = Command.make("demo", config); - const piped = cmd.pipe(withHiddenFromConfig(config)); + expect(buildHelpDoc(legacyFunctionsDownloadCommand).flags.map((flag) => flag.name)).toEqual([ + "project-ref", + "use-api", + ]); - expect(piped).toBe(cmd); - }); + expect(buildHelpDoc(legacyFunctionsDeployCommand).flags.map((flag) => flag.name)).toEqual([ + "project-ref", + "no-verify-jwt", + "use-api", + "import-map", + "prune", + "jobs", + ]); - it("collects names through Flag combinators like optional", () => { - const config = { - plan: withHidden(Flag.string("plan").pipe(Flag.optional)), - } as const; - const cmd = Command.make("demo", config).pipe(withHiddenFromConfig(config)); - const annotated = Context.get(buildHelpDoc(cmd).annotations, LegacyHiddenFlags); + expect(buildHelpDoc(legacyFunctionsServeCommand).flags.map((flag) => flag.name)).toEqual([ + "no-verify-jwt", + "env-file", + "import-map", + "inspect", + "inspect-mode", + "inspect-main", + ]); - expect([...annotated]).toEqual(["plan"]); + expect(buildHelpDoc(legacyProjectsCreateCommand).flags.map((flag) => flag.name)).toEqual([ + "org-id", + "db-password", + "region", + "size", + ]); }); -}); -describe("stripHiddenFlagsFromHelpDoc", () => { - it("returns the doc unchanged when annotations are empty", () => { - const doc = helpDoc({ flags: [flagDoc("foo")] }); - expect(stripHiddenFlagsFromHelpDoc(doc)).toBe(doc); - }); + it("still parses and forwards every hidden flag by exact name", async () => { + const proxy = mockLegacyGoProxy(); - it("filters both flags and globalFlags by the doc's annotation", () => { - const doc = helpDocWithHidden(["preview", "plan"], { - flags: [flagDoc("plan"), flagDoc("visible")], - globalFlags: [flagDoc("preview"), flagDoc("verbose")], - }); + await Effect.runPromise( + Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["start", "--preview"]); + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "stop", + "--backup=false", + ]); + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "init", + "--with-vscode-workspace", + "--with-vscode-settings", + "--with-intellij-settings", + ]); + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "functions", + "download", + "hello", + "--use-docker", + "--legacy-bundle", + ]); + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "functions", + "deploy", + "hello", + "--use-docker", + "--legacy-bundle", + ]); + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "functions", + "serve", + "--all=false", + ]); + }).pipe( + Effect.provide(Layer.mergeAll(proxy.layer, CliOutput.layer(textCliOutputFormatter()))), + ) as Effect.Effect, + ); - const stripped = stripHiddenFlagsFromHelpDoc(doc); - expect(stripped.flags.map((f) => f.name)).toEqual(["visible"]); - expect(stripped.globalFlags?.map((f) => f.name)).toEqual(["verbose"]); + expect(proxy.calls).toEqual([ + ["start", "--preview"], + ["stop", "--backup=false"], + ["init", "--with-vscode-workspace", "--with-vscode-settings", "--with-intellij-settings"], + ["functions", "download", "hello", "--use-docker", "--legacy-bundle"], + ["functions", "deploy", "hello", "--use-docker", "--legacy-bundle"], + ["functions", "serve", "--all=false"], + ]); }); - it("leaves docs without globalFlags untouched in that field", () => { - const doc = helpDocWithHidden(["foo"], { flags: [flagDoc("foo"), flagDoc("bar")] }); - const stripped = stripHiddenFlagsFromHelpDoc(doc); + it("does not leak hidden flag names through unknown-flag suggestions", async () => { + const proxy = mockLegacyGoProxy(); + + const exit = await Effect.runPromise( + Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "projects", + "create", + "demo", + "--pla", + ]).pipe( + Effect.provide(Layer.mergeAll(proxy.layer, CliOutput.layer(silentCliOutputFormatter))), + Effect.exit, + ) as Effect.Effect, + ); - expect(stripped.globalFlags).toBeUndefined(); - expect(stripped.flags.map((f) => f.name)).toEqual(["bar"]); + expect((exit as { _tag: string })._tag).toBe("Failure"); + expect(JSON.stringify(exit)).toContain('"suggestions":[]'); + expect(JSON.stringify(exit)).not.toContain("--plan"); }); }); @@ -185,11 +205,8 @@ describe("legacy hidden subcommands", () => { }); it("still executes hidden subcommands by exact name", async () => { - // `branches disable` was a hidden proxy before the native port (CLI-1289). - // It is now a native handler — `branches/disable/disable.integration.test.ts` - // covers its execution path. The other three remain Go proxies, so this - // test continues to verify the hidden-by-name dispatch via `LegacyGoProxy`. const proxy = mockLegacyGoProxy(); + await Effect.runPromise( Effect.gen(function* () { yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["db", "test"]); diff --git a/apps/cli/src/shared/output/json-formatter.ts b/apps/cli/src/shared/output/json-formatter.ts index 4947399139..e5fb754aa9 100644 --- a/apps/cli/src/shared/output/json-formatter.ts +++ b/apps/cli/src/shared/output/json-formatter.ts @@ -1,10 +1,8 @@ import type { CliOutput, HelpDoc } from "effect/unstable/cli"; -import { stripHiddenFlagsFromHelpDoc } from "../cli/hidden-flag.ts"; export function jsonCliOutputFormatter(): CliOutput.Formatter { return { - formatHelpDoc: (doc: HelpDoc.HelpDoc) => - JSON.stringify({ _tag: "Help", doc: stripHiddenFlagsFromHelpDoc(doc) }), + formatHelpDoc: (doc: HelpDoc.HelpDoc) => JSON.stringify({ _tag: "Help", doc }), formatCliError: (error) => JSON.stringify({ _tag: "Error", error: { code: error._tag, message: error.message } }), formatError: (error) => diff --git a/apps/cli/src/shared/output/text-formatter.ts b/apps/cli/src/shared/output/text-formatter.ts index 706e7d28aa..4b497742ed 100644 --- a/apps/cli/src/shared/output/text-formatter.ts +++ b/apps/cli/src/shared/output/text-formatter.ts @@ -1,11 +1,9 @@ import { CliOutput } from "effect/unstable/cli"; -import { stripHiddenFlagsFromHelpDoc } from "../cli/hidden-flag.ts"; export function textCliOutputFormatter(): CliOutput.Formatter { const base = CliOutput.defaultFormatter({ colors: false }); return { ...base, - formatHelpDoc: (doc) => base.formatHelpDoc(stripHiddenFlagsFromHelpDoc(doc)), formatVersion: (_name, version) => version, }; } From 9c4359d2ff826ef968df48cb94922c38be38fa5f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 1 Jun 2026 10:16:47 +0200 Subject: [PATCH 15/16] chore(deps): harden supply chain policy (#5387) ## What changed - Bumps the workspace package manager metadata to pnpm 11.4.0 and updates the lockfile accordingly. - Makes exotic transitive source blocking explicit with `blockExoticSubdeps: true`. - Temporarily disables pnpm release-age enforcement with `minimumReleaseAge: 0`, because the current lockfile contains packages newer than pnpm v11's default 24-hour window. - Keeps the branch rebased on the latest develop changes, including the existing 7-day Dependabot cooldowns. ## Context Recent npm supply-chain incidents make dependency freshness delay and stricter lockfile verification worth encoding as repository policy. Dependabot now carries the 7-day cooldown policy, while pnpm release-age enforcement can be enabled in a follow-up once the current lockfile has aged enough. The stricter pnpm trust downgrade policy is intentionally left out for now because it needs more review before enforcement. --- package.json | 2 +- pnpm-lock.yaml | 1101 ++++++++++++++++--------------------------- pnpm-workspace.yaml | 5 + 3 files changed, 401 insertions(+), 707 deletions(-) diff --git a/package.json b/package.json index 11e61aadf1..7845c78455 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "local-registry": "bun tools/release/local-registry.ts", "cli-release": "bun tools/release/local-release.ts" }, - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@11.4.0", "devDependencies": { "@swc-node/register": "catalog:", "@swc/core": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e408d7ab9..ef026fe268 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,13 +67,13 @@ importers: devDependencies: '@swc-node/register': specifier: 'catalog:' - version: 1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3) + version: 1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3) '@swc/core': specifier: 'catalog:' version: 1.15.40 nx: specifier: 'catalog:' - version: 22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40) + version: 22.7.5(@swc-node/register@1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40) verdaccio: specifier: ^6.7.2 version: 6.7.2(typanion@3.14.0) @@ -142,7 +142,7 @@ importers: version: 5.0.0(ink@7.0.4(@types/react@19.2.15)(react-devtools-core@7.0.1)(react@19.2.6))(react@19.2.6) knip: specifier: 'catalog:' - version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + version: 6.14.2 oxfmt: specifier: 'catalog:' version: 0.52.0 @@ -169,7 +169,7 @@ importers: version: 1.6.1 vitest: specifier: 'catalog:' - version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) yaml: specifier: ^2.9.0 version: 2.9.0 @@ -219,7 +219,7 @@ importers: version: 4.1.7(vitest@4.1.7) knip: specifier: 'catalog:' - version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + version: 6.14.2 oxfmt: specifier: 'catalog:' version: 0.52.0 @@ -231,19 +231,19 @@ importers: version: 0.23.0 vitest: specifier: 'catalog:' - version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) apps/docs: dependencies: fumadocs-core: specifier: ^16.9.1 - version: 16.9.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) + version: 16.9.2(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) fumadocs-mdx: specifier: ^15.0.9 - version: 15.0.9(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + version: 15.0.9(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.2(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) fumadocs-ui: specifier: ^16.9.1 - version: 16.9.1(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 16.9.2(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.2(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) next: specifier: ^16.2.6 version: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -277,7 +277,7 @@ importers: version: 4.0.0-beta.74(effect@4.0.0-beta.74) '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.10.1) + version: 4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.11.0) effect: specifier: 'catalog:' version: 4.0.0-beta.74 @@ -299,7 +299,7 @@ importers: version: 4.1.7(vitest@4.1.7) knip: specifier: 'catalog:' - version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + version: 6.14.2 oxfmt: specifier: 'catalog:' version: 0.52.0 @@ -311,7 +311,7 @@ importers: version: 0.23.0 vitest: specifier: 'catalog:' - version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) packages/cli-darwin-arm64: {} @@ -341,7 +341,7 @@ importers: version: 4.1.7(vitest@4.1.7) knip: specifier: 'catalog:' - version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + version: 6.14.2 oxfmt: specifier: 'catalog:' version: 0.52.0 @@ -353,7 +353,7 @@ importers: version: 0.23.0 vitest: specifier: 'catalog:' - version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) packages/cli-windows-arm64: {} @@ -366,7 +366,7 @@ importers: version: 4.0.0-beta.74(effect@4.0.0-beta.74) '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.10.1) + version: 4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.11.0) dedent: specifier: ^1.7.2 version: 1.7.2 @@ -391,7 +391,7 @@ importers: version: 4.1.7(vitest@4.1.7) knip: specifier: 'catalog:' - version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + version: 6.14.2 oxfmt: specifier: 'catalog:' version: 0.52.0 @@ -403,7 +403,7 @@ importers: version: 0.23.0 vitest: specifier: 'catalog:' - version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) packages/process-compose: dependencies: @@ -431,7 +431,7 @@ importers: version: 4.1.7(vitest@4.1.7) knip: specifier: 'catalog:' - version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + version: 6.14.2 oxfmt: specifier: 'catalog:' version: 0.52.0 @@ -443,7 +443,7 @@ importers: version: 0.23.0 vitest: specifier: 'catalog:' - version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) packages/stack: dependencies: @@ -452,7 +452,7 @@ importers: version: 4.0.0-beta.74(effect@4.0.0-beta.74) '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.10.1) + version: 4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.11.0) '@supabase/config': specifier: workspace:* version: link:../config @@ -483,7 +483,7 @@ importers: version: 4.1.7(vitest@4.1.7) knip: specifier: 'catalog:' - version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + version: 6.14.2 oxfmt: specifier: 'catalog:' version: 0.52.0 @@ -495,16 +495,16 @@ importers: version: 0.23.0 vitest: specifier: 'catalog:' - version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) tools/nx-plugins: dependencies: '@nx/devkit': specifier: 'catalog:' - version: 22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40)) + version: 22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40)) vitest: specifier: 'catalog:' - version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -528,67 +528,67 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} '@clack/core@1.3.1': @@ -656,312 +656,156 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.0': resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.0': resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.0': resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.0': resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.0': resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.0': resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.0': resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.0': resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.0': resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.0': resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.0': resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.0': resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.0': resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.0': resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.0': resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.0': resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.0': resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.0': resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.0': resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.0': resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.0': resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.0': resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.0': resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} engines: {node: '>=18'} @@ -1147,8 +991,8 @@ packages: cpu: [x64] os: [win32] - '@ioredis/commands@1.5.1': - resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} '@istanbuljs/schema@0.1.6': resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} @@ -1177,33 +1021,33 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': - resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] os: [darwin] - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': - resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} cpu: [x64] os: [darwin] - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': - resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} cpu: [arm64] os: [linux] - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': - resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} cpu: [arm] os: [linux] - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': - resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} cpu: [x64] os: [linux] - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': - resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} cpu: [x64] os: [win32] @@ -1602,117 +1446,112 @@ packages: cpu: [x64] os: [win32] - '@oxc-project/types@0.122.0': - resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} - '@oxc-project/types@0.130.0': resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} - '@oxc-resolver/binding-android-arm-eabi@11.19.1': - resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + + '@oxc-resolver/binding-android-arm-eabi@11.20.0': + resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm64@11.19.1': - resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==} + '@oxc-resolver/binding-android-arm64@11.20.0': + resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==} cpu: [arm64] os: [android] - '@oxc-resolver/binding-darwin-arm64@11.19.1': - resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==} + '@oxc-resolver/binding-darwin-arm64@11.20.0': + resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.19.1': - resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==} + '@oxc-resolver/binding-darwin-x64@11.20.0': + resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==} cpu: [x64] os: [darwin] - '@oxc-resolver/binding-freebsd-x64@11.19.1': - resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==} + '@oxc-resolver/binding-freebsd-x64@11.20.0': + resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': - resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': + resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': - resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': + resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': - resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==} + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': + resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-arm64-musl@11.19.1': - resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==} + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': + resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': - resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': + resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': - resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': + resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': - resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==} + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': + resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': - resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==} + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': + resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-gnu@11.19.1': - resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==} + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': + resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-musl@11.19.1': - resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==} + '@oxc-resolver/binding-linux-x64-musl@11.20.0': + resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==} cpu: [x64] os: [linux] libc: [musl] - '@oxc-resolver/binding-openharmony-arm64@11.19.1': - resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==} + '@oxc-resolver/binding-openharmony-arm64@11.20.0': + resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==} cpu: [arm64] os: [openharmony] - '@oxc-resolver/binding-wasm32-wasi@11.19.1': - resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==} + '@oxc-resolver/binding-wasm32-wasi@11.20.0': + resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': - resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==} + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': + resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': - resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==} - cpu: [ia32] - os: [win32] - - '@oxc-resolver/binding-win32-x64-msvc@11.19.1': - resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==} + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': + resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==} cpu: [x64] os: [win32] @@ -2464,103 +2303,103 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@rolldown/binding-android-arm64@1.0.0-rc.11': - resolution: {integrity: sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==} + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.11': - resolution: {integrity: sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==} + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.11': - resolution: {integrity: sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==} + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.11': - resolution: {integrity: sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==} + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11': - resolution: {integrity: sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11': - resolution: {integrity: sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==} + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.11': - resolution: {integrity: sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==} + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11': - resolution: {integrity: sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==} + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11': - resolution: {integrity: sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==} + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.11': - resolution: {integrity: sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==} + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.11': - resolution: {integrity: sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==} + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.11': - resolution: {integrity: sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==} + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.11': - resolution: {integrity: sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==} - engines: {node: '>=14.0.0'} + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11': - resolution: {integrity: sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==} + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.11': - resolution: {integrity: sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==} + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.11': - resolution: {integrity: sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==} + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -3176,8 +3015,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.20: - resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==} + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} engines: {node: '>=6.0.0'} hasBin: true @@ -3258,8 +3097,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001790: - resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==} + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -3373,8 +3212,8 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - cluster-key-slot@1.1.2: - resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} code-excerpt@4.0.0: @@ -3633,8 +3472,8 @@ packages: engines: {node: '>=0.12.18'} hasBin: true - electron-to-chromium@1.5.343: - resolution: {integrity: sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==} + electron-to-chromium@1.5.363: + resolution: {integrity: sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3688,8 +3527,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -3708,11 +3547,6 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.0: resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} @@ -3825,8 +3659,8 @@ packages: fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - fast-wrap-ansi@0.2.0: - resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3940,8 +3774,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fumadocs-core@16.9.1: - resolution: {integrity: sha512-8VW4aD1iG5SrCChRq84QpNyjKY69i5WfUFFgtx/LSUuX1RewWb0qps7DhKLSjOWraemhZatzsZ7iceivJmYRTg==} + fumadocs-core@16.9.2: + resolution: {integrity: sha512-jyzdPmi5WbW9LucJoZEEM7TvnOnUlkm00Rab8UZyooZ+y5Ez75fqkziqmtZ4NED5eOStFBRjyIE3NUdZQRSTwQ==} peerDependencies: '@mdx-js/mdx': '*' '@mixedbread/sdk': 0.x.x @@ -4030,13 +3864,13 @@ packages: vite: optional: true - fumadocs-ui@16.9.1: - resolution: {integrity: sha512-2A8wO/RuoV0eOgabKMlcjBEPQKynbE4wkxoTfAj/E6bYCid/zY3DG+hhSI1Ssen5ws/73mFPacIEBsWhdAAsnw==} + fumadocs-ui@16.9.2: + resolution: {integrity: sha512-0lfm+KOXmmj88RWeuFXbTCbJPzqkYkN/V34j0Z9cW02mzChtrruYz0c8F5i9AA0A2jWZ79Rvn/PV+9hV07/FZA==} peerDependencies: '@takumi-rs/image-response': '*' '@types/mdx': '*' '@types/react': '*' - fumadocs-core: 16.9.1 + fumadocs-core: 16.9.2 next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 @@ -4321,8 +4155,8 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - ioredis@5.10.1: - resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + ioredis@5.11.0: + resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} engines: {node: '>=12.22.0'} ipaddr.js@1.9.1: @@ -4626,18 +4460,12 @@ packages: lodash.capitalize@4.2.1: resolution: {integrity: sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==} - lodash.defaults@4.2.0: - resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - lodash.isarguments@3.1.0: - resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} - lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} @@ -4680,8 +4508,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.5.0: - resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -4691,16 +4519,16 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} - lucide-react@1.16.0: - resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==} + lucide-react@1.17.0: + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.2: - resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} make-asynchronous@1.1.0: resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} @@ -4996,12 +4824,12 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msgpackr-extract@3.0.3: - resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} hasBin: true - msgpackr@2.0.1: - resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==} + msgpackr@2.0.2: + resolution: {integrity: sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ==} multipasta@0.2.7: resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} @@ -5009,11 +4837,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5080,8 +4903,9 @@ packages: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} normalize-package-data@6.0.2: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} @@ -5111,8 +4935,8 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} - npm@11.15.0: - resolution: {integrity: sha512-+k0tk7lRnpMUPnC7kTuU/yrV/mnFoPhJQ75VfLtZ6fwbzOVXaPsTE/Il9Pn1DHi482byMyqkHv/XsQ76mNjXLw==} + npm@11.16.0: + resolution: {integrity: sha512-A74XL8OxmcegZDMWPkWb5bEQppg8HdYwW3rBD2sPoS4UQHVajfaxBkqyzLeJ3wR0kZ+5xoTjItxXaF7eIXUsyw==} engines: {node: ^20.17.0 || >=22.9.0} hasBin: true bundledDependencies: @@ -5246,8 +5070,8 @@ packages: resolution: {integrity: sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==} engines: {node: ^20.19.0 || >=22.12.0} - oxc-resolver@11.19.1: - resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} + oxc-resolver@11.20.0: + resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} oxfmt@0.52.0: resolution: {integrity: sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==} @@ -5486,8 +5310,8 @@ packages: pumpify@1.5.1: resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} - pure-rand@8.3.0: - resolution: {integrity: sha512-1ws1Ab8fnsf4bvpL+SujgBnr3KFs5abgCLVzavBp+f2n8Ld5YTOZlkv/ccYPhu3X9s+MEeqPRMqKlJz/kWDK8A==} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} @@ -5702,8 +5526,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.0.0-rc.11: - resolution: {integrity: sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==} + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5783,8 +5607,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} shiki@4.1.0: @@ -5917,8 +5741,8 @@ packages: stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} - streamx@2.25.0: - resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + streamx@2.26.0: + resolution: {integrity: sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==} string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} @@ -6332,14 +6156,14 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@8.0.2: - resolution: {integrity: sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==} + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 - esbuild: ^0.27.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 sass: ^1.70.0 @@ -6467,8 +6291,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@7.5.10: - resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + ws@7.5.11: + resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==} engines: {node: '>=8.3.0'} peerDependencies: bufferutil: ^4.0.1 @@ -6573,19 +6397,19 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0': + '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -6595,88 +6419,88 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': + '@babel/generator@7.29.7': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@7.29.7': {} - '@babel/helpers@7.29.2': + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 - '@babel/parser@7.29.2': + '@babel/parser@7.29.7': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 - '@babel/template@7.28.6': + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': + '@babel/types@7.29.7': dependencies: - '@babel/helper-string-parser': 7.27.1 + '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 '@clack/core@1.3.1': dependencies: - fast-wrap-ansi: 0.2.0 + fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 '@clack/prompts@1.4.0': dependencies: '@clack/core': 1.3.1 fast-string-width: 3.0.2 - fast-wrap-ansi: 0.2.0 + fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 '@colors/colors@1.5.0': @@ -6726,11 +6550,11 @@ snapshots: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.10.1)': + '@effect/platform-node@4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.11.0)': dependencies: '@effect/platform-node-shared': 4.0.0-beta.74(effect@4.0.0-beta.74) effect: 4.0.0-beta.74 - ioredis: 5.10.1 + ioredis: 5.11.0 mime: 4.1.0 undici: 8.3.0 transitivePeerDependencies: @@ -6740,7 +6564,7 @@ snapshots: '@effect/vitest@4.0.0-beta.74(effect@4.0.0-beta.74)(vitest@4.1.7)': dependencies: effect: 4.0.0-beta.74 - vitest: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) '@emnapi/core@1.10.0': dependencies: @@ -6771,159 +6595,81 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.27.4': - optional: true - '@esbuild/aix-ppc64@0.28.0': optional: true - '@esbuild/android-arm64@0.27.4': - optional: true - '@esbuild/android-arm64@0.28.0': optional: true - '@esbuild/android-arm@0.27.4': - optional: true - '@esbuild/android-arm@0.28.0': optional: true - '@esbuild/android-x64@0.27.4': - optional: true - '@esbuild/android-x64@0.28.0': optional: true - '@esbuild/darwin-arm64@0.27.4': - optional: true - '@esbuild/darwin-arm64@0.28.0': optional: true - '@esbuild/darwin-x64@0.27.4': - optional: true - '@esbuild/darwin-x64@0.28.0': optional: true - '@esbuild/freebsd-arm64@0.27.4': - optional: true - '@esbuild/freebsd-arm64@0.28.0': optional: true - '@esbuild/freebsd-x64@0.27.4': - optional: true - '@esbuild/freebsd-x64@0.28.0': optional: true - '@esbuild/linux-arm64@0.27.4': - optional: true - '@esbuild/linux-arm64@0.28.0': optional: true - '@esbuild/linux-arm@0.27.4': - optional: true - '@esbuild/linux-arm@0.28.0': optional: true - '@esbuild/linux-ia32@0.27.4': - optional: true - '@esbuild/linux-ia32@0.28.0': optional: true - '@esbuild/linux-loong64@0.27.4': - optional: true - '@esbuild/linux-loong64@0.28.0': optional: true - '@esbuild/linux-mips64el@0.27.4': - optional: true - '@esbuild/linux-mips64el@0.28.0': optional: true - '@esbuild/linux-ppc64@0.27.4': - optional: true - '@esbuild/linux-ppc64@0.28.0': optional: true - '@esbuild/linux-riscv64@0.27.4': - optional: true - '@esbuild/linux-riscv64@0.28.0': optional: true - '@esbuild/linux-s390x@0.27.4': - optional: true - '@esbuild/linux-s390x@0.28.0': optional: true - '@esbuild/linux-x64@0.27.4': - optional: true - '@esbuild/linux-x64@0.28.0': optional: true - '@esbuild/netbsd-arm64@0.27.4': - optional: true - '@esbuild/netbsd-arm64@0.28.0': optional: true - '@esbuild/netbsd-x64@0.27.4': - optional: true - '@esbuild/netbsd-x64@0.28.0': optional: true - '@esbuild/openbsd-arm64@0.27.4': - optional: true - '@esbuild/openbsd-arm64@0.28.0': optional: true - '@esbuild/openbsd-x64@0.27.4': - optional: true - '@esbuild/openbsd-x64@0.28.0': optional: true - '@esbuild/openharmony-arm64@0.27.4': - optional: true - '@esbuild/openharmony-arm64@0.28.0': optional: true - '@esbuild/sunos-x64@0.27.4': - optional: true - '@esbuild/sunos-x64@0.28.0': optional: true - '@esbuild/win32-arm64@0.27.4': - optional: true - '@esbuild/win32-arm64@0.28.0': optional: true - '@esbuild/win32-ia32@0.27.4': - optional: true - '@esbuild/win32-ia32@0.28.0': optional: true - '@esbuild/win32-x64@0.27.4': - optional: true - '@esbuild/win32-x64@0.28.0': optional: true @@ -7043,7 +6789,7 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@ioredis/commands@1.5.1': {} + '@ioredis/commands@1.10.0': {} '@istanbuljs/schema@0.1.6': {} @@ -7098,22 +6844,22 @@ snapshots: transitivePeerDependencies: - supports-color - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': optional: true '@napi-rs/keyring-darwin-arm64@1.3.0': @@ -7218,13 +6964,13 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nx/devkit@22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40))': + '@nx/devkit@22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40))': dependencies: '@zkochan/js-yaml': 0.0.7 ejs: 5.0.1 enquirer: 2.3.6 minimatch: 10.2.5 - nx: 22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40) + nx: 22.7.5(@swc-node/register@1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40) semver: 7.8.1 tslib: 2.8.1 yargs-parser: 21.1.1 @@ -7385,73 +7131,69 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.130.0': optional: true - '@oxc-project/types@0.122.0': {} - '@oxc-project/types@0.130.0': {} - '@oxc-resolver/binding-android-arm-eabi@11.19.1': + '@oxc-project/types@0.132.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.20.0': optional: true - '@oxc-resolver/binding-android-arm64@11.19.1': + '@oxc-resolver/binding-android-arm64@11.20.0': optional: true - '@oxc-resolver/binding-darwin-arm64@11.19.1': + '@oxc-resolver/binding-darwin-arm64@11.20.0': optional: true - '@oxc-resolver/binding-darwin-x64@11.19.1': + '@oxc-resolver/binding-darwin-x64@11.20.0': optional: true - '@oxc-resolver/binding-freebsd-x64@11.19.1': + '@oxc-resolver/binding-freebsd-x64@11.20.0': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.19.1': + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.19.1': + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.19.1': + '@oxc-resolver/binding-linux-x64-musl@11.20.0': optional: true - '@oxc-resolver/binding-openharmony-arm64@11.19.1': + '@oxc-resolver/binding-openharmony-arm64@11.20.0': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@oxc-resolver/binding-wasm32-wasi@11.20.0': dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': optional: true - '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': - optional: true - - '@oxc-resolver/binding-win32-x64-msvc@11.19.1': + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true '@oxfmt/binding-android-arm-eabi@0.52.0': @@ -8022,57 +7764,56 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@rolldown/binding-android-arm64@1.0.0-rc.11': + '@rolldown/binding-android-arm64@1.0.2': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.11': + '@rolldown/binding-darwin-arm64@1.0.2': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.11': + '@rolldown/binding-darwin-x64@1.0.2': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.11': + '@rolldown/binding-freebsd-x64@1.0.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11': + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11': + '@rolldown/binding-linux-arm64-gnu@1.0.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.11': + '@rolldown/binding-linux-arm64-musl@1.0.2': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11': + '@rolldown/binding-linux-ppc64-gnu@1.0.2': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11': + '@rolldown/binding-linux-s390x-gnu@1.0.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.11': + '@rolldown/binding-linux-x64-gnu@1.0.2': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.11': + '@rolldown/binding-linux-x64-musl@1.0.2': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.11': + '@rolldown/binding-openharmony-arm64@1.0.2': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@rolldown/binding-wasm32-wasi@1.0.2': dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11': + '@rolldown/binding-win32-arm64-msvc@1.0.2': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.11': + '@rolldown/binding-win32-x64-msvc@1.0.2': optional: true - '@rolldown/pluginutils@1.0.0-rc.11': {} + '@rolldown/pluginutils@1.0.1': {} '@sec-ant/readable-stream@0.4.1': {} @@ -8126,7 +7867,7 @@ snapshots: lodash-es: 4.18.1 nerf-dart: 1.0.0 normalize-url: 9.0.1 - npm: 11.15.0 + npm: 11.16.0 rc: 1.2.8 read-pkg: 10.1.0 registry-auth-token: 5.1.1 @@ -8233,20 +7974,18 @@ snapshots: '@swc/core': 1.15.40 '@swc/types': 0.1.26 - '@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3)': + '@swc-node/register@1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3)': dependencies: '@swc-node/core': 1.14.1(@swc/core@1.15.40)(@swc/types@0.1.26) '@swc-node/sourcemap-support': 0.6.1 '@swc/core': 1.15.40 colorette: 2.0.20 debug: 4.4.3 - oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-resolver: 11.20.0 pirates: 4.0.7 tslib: 2.8.1 typescript: 6.0.3 transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - '@swc/types' - supports-color @@ -8592,17 +8331,17 @@ snapshots: '@vitest/coverage-istanbul@4.1.7(vitest@4.1.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@istanbuljs/schema': 0.1.6 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - magicast: 0.5.2 + magicast: 0.5.3 obug: 2.1.1 tinyrainbow: 3.1.0 - vitest: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -8615,13 +8354,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.7': dependencies: @@ -8773,7 +8512,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.20: {} + baseline-browser-mapping@2.10.32: {} bcrypt-pbkdf@1.0.2: dependencies: @@ -8826,10 +8565,10 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.20 - caniuse-lite: 1.0.30001790 - electron-to-chromium: 1.5.343 - node-releases: 2.0.38 + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.363 + node-releases: 2.0.46 update-browserslist-db: 1.2.3(browserslist@4.28.2) buffer-equal-constant-time@1.0.1: {} @@ -8876,7 +8615,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001790: {} + caniuse-lite@1.0.30001793: {} caseless@0.12.0: {} @@ -8985,7 +8724,7 @@ snapshots: clsx@2.1.1: {} - cluster-key-slot@1.1.2: {} + cluster-key-slot@1.1.1: {} code-excerpt@4.0.0: dependencies: @@ -9211,7 +8950,7 @@ snapshots: find-my-way-ts: 0.1.6 ini: 7.0.0 kubernetes-types: 1.30.0 - msgpackr: 2.0.1 + msgpackr: 2.0.2 multipasta: 0.2.7 toml: 4.1.1 uuid: 14.0.0 @@ -9219,7 +8958,7 @@ snapshots: ejs@5.0.1: {} - electron-to-chromium@1.5.343: {} + electron-to-chromium@1.5.363: {} emoji-regex@10.6.0: {} @@ -9258,7 +8997,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.0.0: {} + es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: dependencies: @@ -9287,36 +9026,6 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 - esbuild@0.27.4: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 - optional: true - esbuild@0.28.0: optionalDependencies: '@esbuild/aix-ppc64': 0.28.0 @@ -9478,7 +9187,7 @@ snapshots: fast-check@4.8.0: dependencies: - pure-rand: 8.3.0 + pure-rand: 8.4.0 fast-deep-equal@3.1.3: {} @@ -9500,7 +9209,7 @@ snapshots: fast-uri@3.1.2: {} - fast-wrap-ansi@0.2.0: + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 @@ -9601,7 +9310,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.9.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3): + fumadocs-core@16.9.2(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -9626,7 +9335,7 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/react': 19.2.15 - lucide-react: 1.16.0(react@19.2.6) + lucide-react: 1.17.0(react@19.2.6) next: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -9634,14 +9343,14 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-mdx@15.0.9(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)): + fumadocs-mdx@15.0.9(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.2(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.0 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.9.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) + fumadocs-core: 16.9.2(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) js-yaml: 4.1.1 mdast-util-mdx: 3.0.0 picocolors: 1.1.1 @@ -9659,11 +9368,12 @@ snapshots: '@types/react': 19.2.15 next: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 - vite: 8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0) + rolldown: 1.0.2 + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - fumadocs-ui@16.9.1(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + fumadocs-ui@16.9.2(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.2(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@fumadocs/tailwind': 0.0.5 '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -9677,8 +9387,8 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.15)(react@19.2.6) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: 0.7.1 - fumadocs-core: 16.9.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) - lucide-react: 1.16.0(react@19.2.6) + fumadocs-core: 16.9.2(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) + lucide-react: 1.17.0(react@19.2.6) motion: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 @@ -9947,7 +9657,7 @@ snapshots: hosted-git-info@9.0.3: dependencies: - lru-cache: 11.5.0 + lru-cache: 11.5.1 html-escaper@2.0.2: {} @@ -10080,14 +9790,12 @@ snapshots: inline-style-parser@0.2.7: {} - ioredis@5.10.1: + ioredis@5.11.0: dependencies: - '@ioredis/commands': 1.5.1 - cluster-key-slot: 1.1.2 + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 debug: 4.4.3 denque: 2.1.0 - lodash.defaults: 4.2.0 - lodash.isarguments: 3.1.0 redis-errors: 1.2.0 redis-parser: 3.0.0 standard-as-callback: 2.1.0 @@ -10255,7 +9963,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - knip@6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + knip@6.14.2: dependencies: fdir: 6.5.0(picomatch@4.0.4) formatly: 0.3.0 @@ -10263,7 +9971,7 @@ snapshots: jiti: 2.7.0 minimist: 1.2.8 oxc-parser: 0.130.0 - oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-resolver: 11.20.0 picomatch: 4.0.4 smol-toml: 1.6.1 strip-json-comments: 5.0.3 @@ -10271,9 +9979,6 @@ snapshots: unbash: 3.0.0 yaml: 2.9.0 zod: 4.4.3 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' kubernetes-types@1.30.0: {} @@ -10350,14 +10055,10 @@ snapshots: lodash.capitalize@4.2.1: {} - lodash.defaults@4.2.0: {} - lodash.escaperegexp@4.1.2: {} lodash.includes@4.3.0: {} - lodash.isarguments@3.1.0: {} - lodash.isboolean@3.0.3: {} lodash.isinteger@4.0.4: {} @@ -10393,7 +10094,7 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.5.0: {} + lru-cache@11.5.1: {} lru-cache@5.1.1: dependencies: @@ -10401,7 +10102,7 @@ snapshots: lru-cache@7.18.3: {} - lucide-react@1.16.0(react@19.2.6): + lucide-react@1.17.0(react@19.2.6): dependencies: react: 19.2.6 @@ -10409,10 +10110,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.2: + magicast@0.5.3: dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 make-asynchronous@1.1.0: @@ -10940,21 +10641,21 @@ snapshots: ms@2.1.3: {} - msgpackr-extract@3.0.3: + msgpackr-extract@3.0.4: dependencies: node-gyp-build-optional-packages: 5.2.2 optionalDependencies: - '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 optional: true - msgpackr@2.0.1: + msgpackr@2.0.2: optionalDependencies: - msgpackr-extract: 3.0.3 + msgpackr-extract: 3.0.4 multipasta@0.2.7: {} @@ -10964,8 +10665,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.11: {} - nanoid@3.3.12: {} negotiator@0.6.3: {} @@ -10985,8 +10684,8 @@ snapshots: dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.20 - caniuse-lite: 1.0.30001790 + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 postcss: 8.4.31 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -11023,7 +10722,7 @@ snapshots: detect-libc: 2.1.2 optional: true - node-releases@2.0.38: {} + node-releases@2.0.46: {} normalize-package-data@6.0.2: dependencies: @@ -11054,9 +10753,9 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 - npm@11.15.0: {} + npm@11.16.0: {} - nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40): + nx@22.7.5(@swc-node/register@1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40): dependencies: '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.4.5 @@ -11179,7 +10878,7 @@ snapshots: '@nx/nx-linux-x64-musl': 22.7.5 '@nx/nx-win32-arm64-msvc': 22.7.5 '@nx/nx-win32-x64-msvc': 22.7.5 - '@swc-node/register': 1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3) + '@swc-node/register': 1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3) '@swc/core': 1.15.40 transitivePeerDependencies: - debug @@ -11260,31 +10959,27 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.130.0 '@oxc-parser/binding-win32-x64-msvc': 0.130.0 - oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + oxc-resolver@11.20.0: optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.19.1 - '@oxc-resolver/binding-android-arm64': 11.19.1 - '@oxc-resolver/binding-darwin-arm64': 11.19.1 - '@oxc-resolver/binding-darwin-x64': 11.19.1 - '@oxc-resolver/binding-freebsd-x64': 11.19.1 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1 - '@oxc-resolver/binding-linux-arm64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-arm64-musl': 11.19.1 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-riscv64-musl': 11.19.1 - '@oxc-resolver/binding-linux-s390x-gnu': 11.19.1 - '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 - '@oxc-resolver/binding-linux-x64-musl': 11.19.1 - '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 - '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 - '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@oxc-resolver/binding-android-arm-eabi': 11.20.0 + '@oxc-resolver/binding-android-arm64': 11.20.0 + '@oxc-resolver/binding-darwin-arm64': 11.20.0 + '@oxc-resolver/binding-darwin-x64': 11.20.0 + '@oxc-resolver/binding-freebsd-x64': 11.20.0 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0 + '@oxc-resolver/binding-linux-arm64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-arm64-musl': 11.20.0 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-riscv64-musl': 11.20.0 + '@oxc-resolver/binding-linux-s390x-gnu': 11.20.0 + '@oxc-resolver/binding-linux-x64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-x64-musl': 11.20.0 + '@oxc-resolver/binding-openharmony-arm64': 11.20.0 + '@oxc-resolver/binding-wasm32-wasi': 11.20.0 + '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 + '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 oxfmt@0.52.0: dependencies: @@ -11484,7 +11179,7 @@ snapshots: postcss@8.4.31: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -11537,7 +11232,7 @@ snapshots: inherits: 2.0.4 pump: 2.0.1 - pure-rand@8.3.0: {} + pure-rand@8.4.0: {} qs@6.14.2: dependencies: @@ -11571,8 +11266,8 @@ snapshots: react-devtools-core@7.0.1: dependencies: - shell-quote: 1.8.3 - ws: 7.5.10 + shell-quote: 1.8.4 + ws: 7.5.11 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -11815,29 +11510,26 @@ snapshots: reusify@1.1.0: {} - rolldown@1.0.0-rc.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + rolldown@1.0.2: dependencies: - '@oxc-project/types': 0.122.0 - '@rolldown/pluginutils': 1.0.0-rc.11 + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.11 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.11 - '@rolldown/binding-darwin-x64': 1.0.0-rc.11 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.11 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.11 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.11 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.11 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.11 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.11 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.11 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.11 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.11 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.11 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.11 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 run-parallel@1.2.0: dependencies: @@ -11972,7 +11664,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.3: {} + shell-quote@1.8.4: {} shiki@4.1.0: dependencies: @@ -12118,7 +11810,7 @@ snapshots: stream-shift@1.0.3: {} - streamx@2.25.0: + streamx@2.26.0: dependencies: events-universal: 1.0.1 fast-fifo: 1.3.2 @@ -12223,7 +11915,7 @@ snapshots: dependencies: b4a: 1.8.1 fast-fifo: 1.3.2 - streamx: 2.25.0 + streamx: 2.26.0 transitivePeerDependencies: - bare-abort-controller - react-native-b4a @@ -12551,33 +12243,30 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0): + vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 postcss: 8.5.15 - rolldown: 1.0.0-rc.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + rolldown: 1.0.2 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.9.1 - esbuild: 0.27.4 + esbuild: 0.28.0 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.9.0 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - vitest@4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.7(@types/node@25.9.1)(@vitest/coverage-istanbul@4.1.7)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 '@vitest/spy': 4.1.7 '@vitest/utils': 4.1.7 - es-module-lexer: 2.0.0 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 @@ -12588,7 +12277,7 @@ snapshots: tinyexec: 1.2.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 @@ -12648,7 +12337,7 @@ snapshots: wrappy@1.0.2: {} - ws@7.5.10: {} + ws@7.5.11: {} ws@8.21.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3b6a7b2aef..a2bb103a40 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - "tools/*" allowBuilds: + '@parcel/watcher': true "@swc/core": true esbuild: true msgpackr-extract: true @@ -30,6 +31,10 @@ catalog: "oxlint-tsgolint": "^0.23.0" "vitest": "^4.1.7" +blockExoticSubdeps: true + +minimumReleaseAge: 0 + supportedArchitectures: cpu: - current From 8768e0e88b65de960e1b17affd1b5d2c197d8b8a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 1 Jun 2026 10:38:28 +0200 Subject: [PATCH 16/16] fix(cli): decode Go keyring tokens (#5406) ## What changed Decode `go-keyring-base64:` wrapped access tokens when reading from the OS keyring in both the legacy and next CLI credential layers. ## Why The Go keyring backend can store the real `sbp_...` access token inside a `go-keyring-base64:` wrapper. The TypeScript credential readers were validating and using the raw wrapped value, so legacy Management API commands such as `supabase secrets set` failed with an invalid access token format error even though the decoded token was valid. ## Notes for reviewers The normalization happens at the keyring-read boundary so existing validation continues to run against the actual token value, not the storage representation. --- apps/cli/src/legacy/auth/legacy-credentials.layer.ts | 5 ++++- .../legacy/auth/legacy-credentials.layer.unit.test.ts | 11 +++++++++++ apps/cli/src/next/auth/credentials.layer.ts | 5 +++-- apps/cli/src/next/auth/credentials.layer.unit.test.ts | 11 +++++++++++ apps/cli/src/shared/auth/keyring-token.ts | 9 +++++++++ 5 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/shared/auth/keyring-token.ts diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index fca5e4c84c..725d07db12 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -1,6 +1,7 @@ import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { normalizeKeyringToken } from "../../shared/auth/keyring-token.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; @@ -33,7 +34,9 @@ const tryKeyringRead = ( try: () => { const entry = new module.Entry(KEYRING_SERVICE, account); const value = entry.getPassword(); - return value && value.length > 0 ? Option.some(value) : Option.none(); + return value && value.length > 0 + ? Option.some(normalizeKeyringToken(value)) + : Option.none(); }, catch: () => Option.none(), }).pipe(Effect.orElseSucceed(() => Option.none())); diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 13b1a02866..2069c9c141 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -88,6 +88,8 @@ afterEach(() => { const VALID_TOKEN = "sbp_" + "a".repeat(40); const VALID_OAUTH_TOKEN = "sbp_oauth_" + "b".repeat(40); +const encodeGoKeyringBase64 = (token: string) => + `go-keyring-base64:${Buffer.from(token).toString("base64")}`; const expectSomeToken = (token: Option.Option>, expected: string) => { expect(Option.isSome(token)).toBe(true); @@ -115,6 +117,15 @@ describe("legacyCredentialsLayer.getAccessToken", () => { }).pipe(Effect.provide(makeLayer())); }); + it.effect("decodes Go keyring base64 values from the keyring profile account", () => { + passwords.set("Supabase CLI/supabase", encodeGoKeyringBase64(VALID_TOKEN)); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); + it.effect("falls through to the legacy access-token keyring entry", () => { passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); return Effect.gen(function* () { diff --git a/apps/cli/src/next/auth/credentials.layer.ts b/apps/cli/src/next/auth/credentials.layer.ts index dd99c1fe69..1f29ad54ca 100644 --- a/apps/cli/src/next/auth/credentials.layer.ts +++ b/apps/cli/src/next/auth/credentials.layer.ts @@ -1,5 +1,6 @@ import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { normalizeKeyringToken } from "../../shared/auth/keyring-token.ts"; import { CliConfig } from "../config/cli-config.service.ts"; import { Credentials } from "./credentials.service.ts"; @@ -32,7 +33,7 @@ const makeCredentials = Effect.gen(function* () { try { const entry = new keyringModule.value.Entry(SERVICE, ACCOUNT); const token = entry.getPassword(); - if (token) return Option.some(Redacted.make(token)); + if (token) return Option.some(Redacted.make(normalizeKeyringToken(token))); } catch { /* fall through */ } @@ -40,7 +41,7 @@ const makeCredentials = Effect.gen(function* () { try { const entry = new keyringModule.value.Entry(SERVICE, LEGACY_ACCOUNT); const token = entry.getPassword(); - if (token) return Option.some(Redacted.make(token)); + if (token) return Option.some(Redacted.make(normalizeKeyringToken(token))); } catch { /* fall through */ } diff --git a/apps/cli/src/next/auth/credentials.layer.unit.test.ts b/apps/cli/src/next/auth/credentials.layer.unit.test.ts index e65de600bd..ad62f6cf59 100644 --- a/apps/cli/src/next/auth/credentials.layer.unit.test.ts +++ b/apps/cli/src/next/auth/credentials.layer.unit.test.ts @@ -20,6 +20,8 @@ let throwOnSetPassword = false; const throwOnGetPasswordAccounts = new Set(); const returnNullForAccounts = new Set(); const throwOnDeletePasswordAccounts = new Set(); +const encodeGoKeyringBase64 = (token: string) => + `go-keyring-base64:${Buffer.from(token).toString("base64")}`; vi.mock("@napi-rs/keyring", () => ({ Entry: class Entry { @@ -108,6 +110,15 @@ describe("Credentials", () => { }).pipe(Effect.provide(makeLayer(tempHome))); }); + it.effect("decodes Go keyring base64 values from current account", () => { + passwords.set("Supabase CLI/access-token", encodeGoKeyringBase64("current-token")); + return Effect.gen(function* () { + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expectSomeToken(token, "current-token"); + }).pipe(Effect.provide(makeLayer(tempHome))); + }); + it.effect("falls back to legacy account when current is missing", () => { passwords.set("Supabase CLI/supabase", "legacy-token"); return Effect.gen(function* () { diff --git a/apps/cli/src/shared/auth/keyring-token.ts b/apps/cli/src/shared/auth/keyring-token.ts new file mode 100644 index 0000000000..42dbbdebfb --- /dev/null +++ b/apps/cli/src/shared/auth/keyring-token.ts @@ -0,0 +1,9 @@ +const GO_KEYRING_BASE64_PREFIX = "go-keyring-base64:"; + +export function normalizeKeyringToken(value: string): string { + if (!value.startsWith(GO_KEYRING_BASE64_PREFIX)) { + return value; + } + + return Buffer.from(value.slice(GO_KEYRING_BASE64_PREFIX.length), "base64").toString("utf8"); +}