From 63117b30d5192f0d565e08bbae85b25ab37932f2 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 28 May 2026 14:00:38 +0100 Subject: [PATCH] feat(cli): port orgs commands to native TypeScript Promote `orgs list` and `orgs create` from Phase 0 Go-binary proxies to native TypeScript implementations in the legacy shell. Mirrors the just- landed branches port: shared HTTP error mapper, Go-compatible JSON/YAML/TOML/env encoders, Glamour-rendered pretty table, and the full five-mode `--output` flag with TS `--output-format` deferral. - `orgs list` rejects `--output env` with the byte-exact Go message and wraps TOML output as `[[organizations]]` per Go parity. - `orgs create` emits the `Created organization: ` preamble before every Go-output mode (json/yaml/toml/env supported; the env branch works here unlike on list, matching Go's encoder behaviour). - Telemetry flushes in `Effect.ensuring` on success and failure; no linked-project cache write because no `--project-ref` is resolved. Tests: 3 unit + 32 integration covering every output branch, the non-text-format + API-failure spinner-undefined path, and the `cli_command_executed` telemetry parity rules. Refs CLI-1290. --- apps/cli/docs/go-cli-porting-status.md | 4 +- .../commands/orgs/create/SIDE_EFFECTS.md | 132 ++++++---- .../commands/orgs/create/create.command.ts | 14 +- .../commands/orgs/create/create.handler.ts | 91 ++++++- .../orgs/create/create.integration.test.ts | 217 ++++++++++++++++ .../legacy/commands/orgs/list/SIDE_EFFECTS.md | 123 +++++---- .../legacy/commands/orgs/list/list.command.ts | 9 +- .../legacy/commands/orgs/list/list.handler.ts | 75 +++++- .../orgs/list/list.integration.test.ts | 245 ++++++++++++++++++ .../src/legacy/commands/orgs/orgs.command.ts | 7 +- .../src/legacy/commands/orgs/orgs.errors.ts | 43 +++ .../src/legacy/commands/orgs/orgs.format.ts | 30 +++ .../commands/orgs/orgs.format.unit.test.ts | 27 ++ 13 files changed, 903 insertions(+), 114 deletions(-) create mode 100644 apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/orgs/orgs.errors.ts create mode 100644 apps/cli/src/legacy/commands/orgs/orgs.format.ts create mode 100644 apps/cli/src/legacy/commands/orgs/orgs.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 2d423ec7ae..fccaee0a03 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -213,8 +213,8 @@ Legend: | Command | Legacy status | Legacy command path | | -------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `orgs list` | `wrapped` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | -| `orgs create` | `wrapped` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | +| `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) | diff --git a/apps/cli/src/legacy/commands/orgs/create/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/orgs/create/SIDE_EFFECTS.md index ec7b0c20ab..b6a2dfee84 100644 --- a/apps/cli/src/legacy/commands/orgs/create/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/orgs/create/SIDE_EFFECTS.md @@ -2,77 +2,121 @@ ## 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 | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | ----------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | + +`orgs create` is a user-level command — it does not resolve a `--project-ref`, so the legacy +linked-project cache is never written. + +## Positional Arguments + +| Argument | Required? | Description | +| -------- | --------- | ---------------------------- | +| `name` | yes | Name of the new organization | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------- | ------------ | ---------------- | ---------------------------- | -| `POST` | `/v1/organizations` | Bearer token | `{name: string}` | `{id: string, name: string}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------- | ------------ | ---------------- | ------------------------------------------ | +| `POST` | `/v1/organizations` | Bearer token | `{name: string}` | `{id: string, slug: string, name: 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 → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL (`supabase`, `supabase-staging`, `supabase-local`), or a filesystem path to a YAML profile (Go parity — used by the cli-e2e harness) | no (defaults to `supabase`) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------- | -| `0` | success — organization created | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from `/v1/organizations` | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------- | +| `0` | success — organization created | +| `1` | `LegacyPlatformAuthRequiredError` — no token in env/keyring/file | +| `1` | `LegacyOrgsCreateUnexpectedStatusError` — non-201 response from create endpoint | +| `1` | `LegacyOrgsCreateNetworkError` — transport-level network failure | + +Unlike `orgs list`, there is no env-not-supported branch — Go's `EncodeOutput` happily +flattens a single object into `ID=… NAME=… SLUG=…` env lines. + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +Matches `apps/cli-go/internal/orgs/create/`. Go does not fire any custom telemetry event for +this command. ## Output -### `--output-format text` (Go CLI compatible) +Every output mode (Go-compat and TS) starts by printing `Created organization: \n` to +stdout, except `--output-format json` / `stream-json`, which emit a single structured event +instead. `--output` (Go) wins over `--output-format` (TS) when both are supplied. -Prints `Created organization: ` followed by a Markdown-style table to stdout. -Table has a header row and one row for the created organization. -Column order: `ID`, `NAME`. +### `--output pretty` (Go default) / `--output-format text` -``` -Created organization: combined-fuchsia-lion - ID NAME - combined-fuchsia-lion My Test Org -``` +`Created organization: ` followed by a Glamour-styled markdown table with columns +`ID`, `NAME` for the created organization. The rendered table always ends with a trailing +newline (Glamour appends one). -### `--output-format json` +### `--output json` (Go-compat) -Single JSON object emitted to stdout on success containing the full organization -object as returned by the Management API. +Preamble line followed by indented JSON of the created `OrganizationResponseV1` object with +alphabetical keys + trailing newline. -```json -{ "id": "combined-fuchsia-lion", "name": "My Test Org" } -``` +### `--output yaml` -### `--output-format stream-json` +Preamble line followed by a YAML document of the created organization object. + +### `--output toml` -One `result` event on success. +Preamble line followed by a TOML document of the created organization object. -```ndjson -{"type":"result","data":{"id":"combined-fuchsia-lion","name":"My Test Org"}} -``` +### `--output env` -On failure, an `error` event is emitted instead: +Preamble line followed by `ID=…`, `NAME=…`, `SLUG=…` env lines — env IS supported here, +unlike on `orgs list`. Matches the Go encoder behavior in +`apps/cli-go/internal/orgs/create/create.go:27`. + +### `--output-format json` + +Single `Output.success` envelope written as JSON. The envelope carries the message +`"Created organization"` plus the created org spread into `data` (`{id, slug, name}`). No +`Created organization: \n` preamble line — the message is delivered as a structured +field instead of stdout text. + +### `--output-format stream-json` -```ndjson -{"type":"error","code":"ApiError","message":"…"} -``` +One `result` NDJSON event with the created org as `data` and the same `"Created organization"` +message. No preamble line. ## Notes - Takes exactly one positional argument: the organization name. - No `--project-ref` flag. `orgs create` is a user-level command. -- The organization ID in the response is a human-readable slug (e.g. `combined-fuchsia-lion`), not a UUID. +- The organization `id` and `slug` in the response are human-readable slugs (e.g. + `combined-fuchsia-lion`), not UUIDs. +- Sends `User-Agent: SupabaseCLI/` and Bearer auth. + +## Security Notes + +- The `Created organization: ` preamble and the rendered Glamour table interpolate the + API-supplied `id` and `name` strings without ANSI / control-character sanitization. This + is strict Go parity — Go's `fmt.Println` and `glamour` both pass these through verbatim. + A malicious or compromised Management API could in principle return values containing + terminal escape sequences. If sanitization is added later it should land at the renderer + (and at any shared preamble helper) so both shells inherit the fix. +- `--output env` values are escaped via `encodeEnv` (`\n`, `\r`, `\t` → backslash-escaped), + matching Go's `%q` semantics. ESC (`0x1b`) is not escaped — again Go parity. +- Error response bodies embedded in `LegacyOrgsCreateUnexpectedStatusError` are sanitized + by `mapLegacyHttpError` (control chars stripped, capped at 1024 bytes). diff --git a/apps/cli/src/legacy/commands/orgs/create/create.command.ts b/apps/cli/src/legacy/commands/orgs/create/create.command.ts index fcbdae577c..45fd2cdcce 100644 --- a/apps/cli/src/legacy/commands/orgs/create/create.command.ts +++ b/apps/cli/src/legacy/commands/orgs/create/create.command.ts @@ -1,10 +1,14 @@ 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 { legacyOrgsCreate } from "./create.handler.ts"; const config = { name: Argument.string("name").pipe( - Argument.withDescription("Name of the organization to create."), + Argument.withDescription("Display name for the new organization."), ), }; export type LegacyOrgsCreateFlags = CliCommand.Command.Config.Infer; @@ -12,5 +16,11 @@ export type LegacyOrgsCreateFlags = CliCommand.Command.Config.Infer legacyOrgsCreate(flags)), + Command.withHandler((flags) => + legacyOrgsCreate(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["orgs", "create"])), ); diff --git a/apps/cli/src/legacy/commands/orgs/create/create.handler.ts b/apps/cli/src/legacy/commands/orgs/create/create.handler.ts index 78514e3bd2..f710ff5b9c 100644 --- a/apps/cli/src/legacy/commands/orgs/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/orgs/create/create.handler.ts @@ -1,11 +1,92 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import type { V1CreateAnOrganizationOutput } from "@supabase/api/effect"; +import { Effect, Option } from "effect"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.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 { + LegacyOrgsCreateNetworkError, + LegacyOrgsCreateUnexpectedStatusError, +} from "../orgs.errors.ts"; +import { renderOrgsListTable } from "../orgs.format.ts"; import type { LegacyOrgsCreateFlags } from "./create.command.ts"; +type CreatedOrganization = typeof V1CreateAnOrganizationOutput.Type; + +const mapCreateError = mapLegacyHttpError({ + networkError: LegacyOrgsCreateNetworkError, + statusError: LegacyOrgsCreateUnexpectedStatusError, + networkMessage: (cause) => `failed to create organization: ${cause}`, + statusMessage: (status, body) => `unexpected create organization status ${status}: ${body}`, +}); + export const legacyOrgsCreate = Effect.fn("legacy.orgs.create")(function* ( flags: LegacyOrgsCreateFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["orgs", "create", flags.name]; - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + // Spinner runs only in text mode — it would corrupt machine-readable + // stdout. The output-routing branches below dispatch on `goFmt`, but the + // spinner uses `output.format` because `--output pretty` keeps the format + // as "text" while requiring the table render; both paths need the spinner. + const creating = + output.format === "text" ? yield* output.task("Creating organization...") : undefined; + const created: CreatedOrganization = yield* api.v1 + .createAnOrganization({ name: flags.name }) + .pipe( + Effect.tapError(() => creating?.fail() ?? Effect.void), + Effect.catch(mapCreateError), + ); + yield* creating?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + // Go calls `fmt.Println("Created organization:", id)` once before its + // format switch (`apps/cli-go/internal/orgs/create/create.go:22`). We + // repeat the preamble inside each Go-format branch rather than hoisting + // it, so the TS `--output-format json` / `stream-json` paths (which emit + // a single structured event below) stay preamble-free. + const preamble = `Created organization: ${created.id}\n`; + + if (goFmt === "json") { + yield* output.raw(preamble); + yield* output.raw(encodeGoJson(created)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(preamble); + yield* output.raw(encodeYaml(created)); + return; + } + if (goFmt === "toml") { + yield* output.raw(preamble); + yield* output.raw(encodeToml(created) + "\n"); + return; + } + if (goFmt === "env") { + yield* output.raw(preamble); + yield* output.raw(encodeEnv(created) + "\n"); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("Created organization", { ...created }); + return; + } + + yield* output.raw(preamble); + yield* output.raw(renderOrgsListTable([created])); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts b/apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts new file mode 100644 index 0000000000..5401a18497 --- /dev/null +++ b/apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts @@ -0,0 +1,217 @@ +import type { V1CreateAnOrganizationOutput } 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, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyOrgsCreate } from "./create.handler.ts"; + +type CreatedOrganization = typeof V1CreateAnOrganizationOutput.Type; + +const CREATED: CreatedOrganization = { + id: "combined-fuchsia-lion", + slug: "combined-fuchsia-lion", + name: "Acme", +}; + +const tempRoot = useLegacyTempWorkdir("supabase-orgs-create-int-"); + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly response?: CreatedOrganization; + readonly status?: number; + readonly network?: "fail"; +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 201, body: opts.response ?? CREATED }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + 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 ?? 201, body: opts.response ?? CREATED }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const telemetry = mockLegacyTelemetryStateTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + }); + return { layer, out, api, telemetry }; +} + +describe("legacy orgs create integration", () => { + it.live('prints "Created organization: " then a Glamour table in text mode', () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyOrgsCreate({ name: "Acme" }); + expect(out.stdoutText).toContain("Created organization: combined-fuchsia-lion"); + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).toContain("Acme"); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends POST /v1/organizations with { name } body", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyOrgsCreate({ name: "Acme" }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.method).toBe("POST"); + expect(api.requests[0]?.url).toContain("/v1/organizations"); + expect(api.requests[0]?.body).toEqual({ name: "Acme" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits Go-byte-exact preamble + indented JSON for --output json", () => { + const { layer, out } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyOrgsCreate({ name: "Acme" }); + expect(out.stdoutText).toContain("Created organization: combined-fuchsia-lion\n"); + expect(out.stdoutText).toContain('"name": "Acme"'); + expect(out.stdoutText.endsWith("}\n")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits preamble + YAML object for --output yaml", () => { + const { layer, out } = setup({ goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyOrgsCreate({ name: "Acme" }); + expect(out.stdoutText).toContain("Created organization: combined-fuchsia-lion\n"); + expect(out.stdoutText).toContain("name: Acme"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits preamble + TOML for --output toml", () => { + const { layer, out } = setup({ goOutput: "toml" }); + return Effect.gen(function* () { + yield* legacyOrgsCreate({ name: "Acme" }); + expect(out.stdoutText).toContain("Created organization: combined-fuchsia-lion\n"); + expect(out.stdoutText).toContain('name = "Acme"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits preamble + env vars for --output env (create-only branch)", () => { + const { layer, out } = setup({ goOutput: "env" }); + return Effect.gen(function* () { + yield* legacyOrgsCreate({ name: "Acme" }); + expect(out.stdoutText).toContain("Created organization: combined-fuchsia-lion\n"); + expect(out.stdoutText).toContain("NAME="); + expect(out.stdoutText).toContain("Acme"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with org fields for --output-format=json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyOrgsCreate({ name: "Acme" }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ id: "combined-fuchsia-lion", name: "Acme" }); + }).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* legacyOrgsCreate({ name: "Acme" }); + expect(out.messages.find((m) => m.type === "success")).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("treats --output pretty as identical to text mode (preamble + table)", () => { + const { layer, out } = setup({ goOutput: "pretty" }); + return Effect.gen(function* () { + yield* legacyOrgsCreate({ name: "Acme" }); + expect(out.stdoutText).toContain("Created organization: combined-fuchsia-lion"); + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).toContain("Acme"); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output flag wins over --output-format", () => { + const { layer, out } = setup({ format: "json", goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyOrgsCreate({ name: "Acme" }); + expect(out.stdoutText).toContain("name: Acme"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyOrgsCreateUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyOrgsCreate({ name: "Acme" })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyOrgsCreateUnexpectedStatusError"); + expect(json).toContain("unexpected create organization status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyOrgsCreateNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyOrgsCreate({ name: "Acme" })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyOrgsCreateNetworkError"); + expect(json).toContain("failed to create organization"); + } + }).pipe(Effect.provide(layer)); + }); + + // Exercises the `creating?.fail() ?? Effect.void` undefined branch — when + // --output-format != "text", no spinner exists, so the `??` fallback fires. + it.live("propagates a transport failure when --output-format=json suppresses the spinner", () => { + const { layer } = setup({ format: "json", network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyOrgsCreate({ name: "Acme" })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyOrgsCreateNetworkError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state on success", () => { + const { layer, telemetry } = setupTracked(); + return Effect.gen(function* () { + yield* legacyOrgsCreate({ name: "Acme" }); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state on failure", () => { + const { layer, telemetry } = setupTracked({ status: 503 }); + return Effect.gen(function* () { + yield* Effect.exit(legacyOrgsCreate({ name: "Acme" })); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/orgs/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/orgs/list/SIDE_EFFECTS.md index 22ea61f83f..922c2087ce 100644 --- a/apps/cli/src/legacy/commands/orgs/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/orgs/list/SIDE_EFFECTS.md @@ -2,85 +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 | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | ----------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | + +`orgs list` is a user-level command — it does not resolve a `--project-ref`, so the legacy +linked-project cache (`~/.supabase//linked-project.json`) is never written. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------- | ------------ | ------------ | ------------------------------ | -| `GET` | `/v1/organizations` | Bearer token | none | `[{id: string, name: string}]` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------- | ------------ | ------------ | -------------------------------------------- | +| `GET` | `/v1/organizations` | Bearer token | none | `[{id: string, slug: string, name: 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 → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL (`supabase`, `supabase-staging`, `supabase-local`), or a filesystem path to a YAML profile (Go parity — used by the cli-e2e harness) | no (defaults to `supabase`) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------- | -| `0` | success — organizations printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from `/v1/organizations` | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------- | +| `0` | success — organizations printed to stdout | +| `1` | `LegacyPlatformAuthRequiredError` — no token in env/keyring/file | +| `1` | `LegacyOrgsListUnexpectedStatusError` — non-2xx response from list endpoint | +| `1` | `LegacyOrgsListNetworkError` — transport-level network failure | +| `1` | `LegacyOrgsEnvNotSupportedError` — `--output env` flag is rejected | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +Matches `apps/cli-go/internal/orgs/list/`. Go does not fire any custom telemetry event for this +command. ## Output -### `--output-format text` (Go CLI compatible) +The legacy `--output {pretty,json,yaml,toml,env}` flag (Go-compatible) and the new global +`--output-format {text,json,stream-json}` flag are both honored. `--output` wins when both +are supplied. `pretty` and `text` map to the same Glamour render. -Prints a Markdown-style table to stdout with a header row and one row per organization. -Column order: `ID`, `NAME`. Columns are separated by two spaces and left-aligned. -No trailing newline after the last row (matches Go CLI behavior). +### `--output pretty` (Go default) / `--output-format text` -``` - ID NAME - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx My Org - yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy Another Org -``` +Prints a Glamour-styled markdown table with columns `ID`, `NAME`. Byte-matched against the +Go CLI. The rendered table always ends with a trailing newline (Glamour appends one). -### `--output-format json` +### `--output json` (Go-compat) -Single JSON array emitted to stdout on success. Each element contains the full -organization object as returned by the Management API. +Indented JSON of the `OrganizationResponseV1[]` array with alphabetical keys + trailing newline. -```json -[ - { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "name": "My Org" }, - { "id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", "name": "Another Org" } -] -``` +### `--output yaml` -### `--output-format stream-json` +YAML document of the organizations array. + +### `--output toml` -One `result` event on success. No intermediate `log` events (the request is a single fast -API call with no multi-step progress). +TOML document wrapping the array as `[[organizations]]` (Go parity). -```ndjson -{"type":"result","data":[{"id":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","name":"My Org"}]} -``` +### `--output env` -On failure, an `error` event is emitted instead: +Fails with `LegacyOrgsEnvNotSupportedError("--output env flag is not supported")`. Matches +`apps/cli-go/internal/orgs/list/list.go:32-33`. -```ndjson -{"type":"error","code":"ApiError","message":"…"} -``` +### `--output-format json` + +Single JSON object via `Output.success` with `{organizations: [...]}` data. + +### `--output-format stream-json` + +One `result` NDJSON event with `{organizations: [...]}`. ## Notes -- No `--project-ref` flag. `orgs list` is a user-level command — it lists all organizations - the authenticated user has access to, regardless of any linked project. -- The result set is determined entirely by the access token's scope; no local config is read - beyond resolving the token itself. -- The Go CLI also supports `--output toml` and `--output json` via its own flag. The - TypeScript port uses the global `--output-format` flag instead. The `toml` format is not - reproduced (not part of the compatibility contract for scripted workflows). +- No `--project-ref` flag. The result set is determined entirely by the access token's scope. +- Sends `User-Agent: SupabaseCLI/` and Bearer auth. + +## Security Notes + +- API-supplied `id` and `name` strings are rendered to stdout without ANSI / control-character + sanitization. This is strict Go parity — the Go CLI uses `glamour` which has the same + pass-through behaviour. A malicious or compromised Management API could in principle return + org names containing terminal escape sequences. If sanitization is added later it should + land at the renderer (`legacy-glamour-table.ts`) so both shells inherit the fix. +- Error response bodies embedded in `LegacyOrgsListUnexpectedStatusError` are sanitized by + `mapLegacyHttpError` (control chars stripped, capped at 1024 bytes). diff --git a/apps/cli/src/legacy/commands/orgs/list/list.command.ts b/apps/cli/src/legacy/commands/orgs/list/list.command.ts index 0d5c891c3c..f3f0d29b23 100644 --- a/apps/cli/src/legacy/commands/orgs/list/list.command.ts +++ b/apps/cli/src/legacy/commands/orgs/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 { legacyOrgsList } from "./list.handler.ts"; const config = {}; @@ -8,5 +12,8 @@ export type LegacyOrgsListFlags = CliCommand.Command.Config.Infer export const legacyOrgsListCommand = Command.make("list", config).pipe( Command.withDescription("List all organizations the logged-in user belongs."), Command.withShortDescription("List all organizations"), - Command.withHandler((flags) => legacyOrgsList(flags)), + Command.withHandler((flags) => + legacyOrgsList(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(legacyManagementApiRuntimeLayer(["orgs", "list"])), ); diff --git a/apps/cli/src/legacy/commands/orgs/list/list.handler.ts b/apps/cli/src/legacy/commands/orgs/list/list.handler.ts index 470215e8dc..0cca870961 100644 --- a/apps/cli/src/legacy/commands/orgs/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/orgs/list/list.handler.ts @@ -1,10 +1,77 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import type { V1ListAllOrganizationsOutput } from "@supabase/api/effect"; +import { Effect, Option } from "effect"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.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 { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyOrgsEnvNotSupportedError, + LegacyOrgsListNetworkError, + LegacyOrgsListUnexpectedStatusError, +} from "../orgs.errors.ts"; +import { renderOrgsListTable } from "../orgs.format.ts"; import type { LegacyOrgsListFlags } from "./list.command.ts"; +type Organizations = typeof V1ListAllOrganizationsOutput.Type; + +const mapListError = mapLegacyHttpError({ + networkError: LegacyOrgsListNetworkError, + statusError: LegacyOrgsListUnexpectedStatusError, + networkMessage: (cause) => `failed to list organizations: ${cause}`, + statusMessage: (status, body) => `unexpected list organizations status ${status}: ${body}`, +}); + export const legacyOrgsList = Effect.fn("legacy.orgs.list")(function* ( _flags: LegacyOrgsListFlags, ) { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["orgs", "list"]); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const telemetryState = yield* LegacyTelemetryState; + + yield* Effect.gen(function* () { + // Spinner runs only in text mode — it would corrupt machine-readable + // stdout. The output-routing branches below dispatch on `goFmt`, but the + // spinner uses `output.format` because `--output pretty` keeps the format + // as "text" while requiring the table render; both paths need the spinner. + const fetching = + output.format === "text" ? yield* output.task("Fetching organizations...") : undefined; + const orgs: Organizations = yield* api.v1.listAllOrganizations().pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapListError), + ); + yield* fetching?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "env") { + return yield* new LegacyOrgsEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } + if (goFmt === "json") { + yield* output.raw(encodeGoJson(orgs)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(orgs)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml({ organizations: orgs }) + "\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("", { organizations: orgs }); + return; + } + + yield* output.raw(renderOrgsListTable(orgs)); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts b/apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts new file mode 100644 index 0000000000..431aac47a1 --- /dev/null +++ b/apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts @@ -0,0 +1,245 @@ +import type { V1ListAllOrganizationsOutput } 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, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyOrgsList } from "./list.handler.ts"; + +type Organizations = typeof V1ListAllOrganizationsOutput.Type; + +const SAMPLE_ORG: Organizations[number] = { + id: "combined-fuchsia-lion", + slug: "combined-fuchsia-lion", + name: "Test Org", +}; + +const SAMPLE_ORG_PIPE: Organizations[number] = { + id: "calm-cobalt-emu", + slug: "calm-cobalt-emu", + name: "with|pipe", +}; + +const tempRoot = useLegacyTempWorkdir("supabase-orgs-list-int-"); + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly response?: Organizations; + readonly status?: number; + readonly network?: "fail"; +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: opts.response ?? [SAMPLE_ORG] }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + 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_ORG] }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const telemetry = mockLegacyTelemetryStateTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + }); + return { layer, out, api, telemetry }; +} + +describe("legacy orgs list integration", () => { + it.live("renders a Glamour table with ID and NAME columns in text mode", () => { + const { layer, out } = setup({ response: [SAMPLE_ORG] }); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + expect(out.stdoutText).toContain("ID"); + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).toContain("combined-fuchsia-lion"); + expect(out.stdoutText).toContain("Test Org"); + }).pipe(Effect.provide(layer)); + }); + + it.live("renders an empty table when the API returns []", () => { + const { layer, out } = setup({ response: [] }); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).not.toContain("Test Org"); + }).pipe(Effect.provide(layer)); + }); + + it.live("renders literal | characters in organization names (Go parity)", () => { + const { layer, out } = setup({ response: [SAMPLE_ORG_PIPE] }); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + expect(out.stdoutText).toContain("with|pipe"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with { organizations } for --output-format=json", () => { + const { layer, out } = setup({ format: "json", response: [SAMPLE_ORG] }); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ organizations: [SAMPLE_ORG] }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event for --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json", response: [SAMPLE_ORG] }); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + expect(out.messages.find((m) => m.type === "success")).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits Go-byte-exact indented JSON for --output json", () => { + const { layer, out } = setup({ goOutput: "json", response: [SAMPLE_ORG] }); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + expect(out.stdoutText.startsWith("[\n {\n")).toBe(true); + expect(out.stdoutText.endsWith("]\n")).toBe(true); + expect(out.stdoutText).toContain('"name": "Test Org"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a YAML array for --output yaml", () => { + const { layer, out } = setup({ goOutput: "yaml", response: [SAMPLE_ORG] }); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + expect(out.stdoutText).toContain("name: Test Org"); + }).pipe(Effect.provide(layer)); + }); + + it.live("wraps result as { organizations = [...] } for --output toml", () => { + const { layer, out } = setup({ goOutput: "toml", response: [SAMPLE_ORG] }); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + expect(out.stdoutText).toContain("[[organizations]]"); + expect(out.stdoutText).toContain('name = "Test Org"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyOrgsEnvNotSupportedError for --output env", () => { + const { layer } = setup({ goOutput: "env", response: [SAMPLE_ORG] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyOrgsList({})); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyOrgsEnvNotSupportedError"); + expect(json).toContain("--output env flag is not supported"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("treats --output pretty as identical to text mode (table render)", () => { + const { layer, out } = setup({ goOutput: "pretty", response: [SAMPLE_ORG] }); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).toContain("Test Org"); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output flag wins over --output-format", () => { + const { layer, out } = setup({ + format: "json", + goOutput: "yaml", + response: [SAMPLE_ORG], + }); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + expect(out.stdoutText).toContain("name: Test Org"); + }).pipe(Effect.provide(layer)); + }); + + it.live("calls GET /v1/organizations with no path params", () => { + const { layer, api } = setup({ response: [SAMPLE_ORG] }); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.method).toBe("GET"); + expect(api.requests[0]?.url).toContain("/v1/organizations"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyOrgsListUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503, response: [] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyOrgsList({})); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyOrgsListUnexpectedStatusError"); + expect(json).toContain("unexpected list organizations status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyOrgsListNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyOrgsList({})); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyOrgsListNetworkError"); + expect(json).toContain("failed to list organizations"); + } + }).pipe(Effect.provide(layer)); + }); + + // Exercises the `fetching?.fail() ?? Effect.void` undefined branch — when + // --output-format != "text", no spinner exists, so the `??` fallback fires. + it.live("propagates a transport failure when --output-format=json suppresses the spinner", () => { + const { layer } = setup({ format: "json", network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyOrgsList({})); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyOrgsListNetworkError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state on success", () => { + const { layer, telemetry } = setupTracked(); + return Effect.gen(function* () { + yield* legacyOrgsList({}); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state on failure", () => { + const { layer, telemetry } = setupTracked({ status: 503 }); + return Effect.gen(function* () { + yield* Effect.exit(legacyOrgsList({})); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/orgs/orgs.command.ts b/apps/cli/src/legacy/commands/orgs/orgs.command.ts index 0224346157..94ab69aead 100644 --- a/apps/cli/src/legacy/commands/orgs/orgs.command.ts +++ b/apps/cli/src/legacy/commands/orgs/orgs.command.ts @@ -2,8 +2,11 @@ import { Command } from "effect/unstable/cli"; import { legacyOrgsCreateCommand } from "./create/create.command.ts"; import { legacyOrgsListCommand } from "./list/list.command.ts"; +// Description text matches Go's `cmd/orgs.go:13` exactly — cobra has no Long, +// so the Short string is reused for both the subcommand summary and the +// `supabase orgs --help` long description. No trailing period. export const legacyOrgsCommand = Command.make("orgs").pipe( - Command.withDescription("Manage Supabase organizations."), - Command.withShortDescription("Manage organizations"), + Command.withDescription("Manage Supabase organizations"), + Command.withShortDescription("Manage Supabase organizations"), Command.withSubcommands([legacyOrgsListCommand, legacyOrgsCreateCommand]), ); diff --git a/apps/cli/src/legacy/commands/orgs/orgs.errors.ts b/apps/cli/src/legacy/commands/orgs/orgs.errors.ts new file mode 100644 index 0000000000..494060d003 --- /dev/null +++ b/apps/cli/src/legacy/commands/orgs/orgs.errors.ts @@ -0,0 +1,43 @@ +import { Data } from "effect"; + +// --------------------------------------------------------------------------- +// HTTP-bound errors — one (Network + UnexpectedStatus) pair per Go errorf site +// under `apps/cli-go/internal/orgs/`. Templates byte-match Go's `errors.Errorf`. +// --------------------------------------------------------------------------- + +export class LegacyOrgsListNetworkError extends Data.TaggedError("LegacyOrgsListNetworkError")<{ + readonly message: string; +}> {} + +export class LegacyOrgsListUnexpectedStatusError extends Data.TaggedError( + "LegacyOrgsListUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyOrgsCreateNetworkError extends Data.TaggedError("LegacyOrgsCreateNetworkError")<{ + readonly message: string; +}> {} + +export class LegacyOrgsCreateUnexpectedStatusError extends Data.TaggedError( + "LegacyOrgsCreateUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +// --------------------------------------------------------------------------- +// Pure-path error — `orgs list --output env` is explicitly rejected by the Go +// CLI (`apps/cli-go/internal/orgs/list/list.go:32-33`). `orgs create` does NOT +// have an equivalent branch — the Go `EncodeOutput` env encoder happily +// flattens the single object into `ID=… NAME=… SLUG=…`. +// --------------------------------------------------------------------------- + +export class LegacyOrgsEnvNotSupportedError extends Data.TaggedError( + "LegacyOrgsEnvNotSupportedError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/orgs/orgs.format.ts b/apps/cli/src/legacy/commands/orgs/orgs.format.ts new file mode 100644 index 0000000000..b8deef6172 --- /dev/null +++ b/apps/cli/src/legacy/commands/orgs/orgs.format.ts @@ -0,0 +1,30 @@ +import type { OrganizationResponseV1 } from "@supabase/api/effect"; + +import { renderGlamourTable } from "../../output/legacy-glamour-table.ts"; + +// --------------------------------------------------------------------------- +// Pure formatter — no Effect / no service dependencies, kept unit-testable. +// Reproduces Go's `orgs/list/list.go:ToMarkdown` + glamour pipeline. +// +// Go wraps each cell in backticks and escapes `|` as `\|` in the markdown +// intermediate; glamour decodes the escape and strips the backticks. Our +// `renderGlamourTable` lays out cells directly, so we pass raw values +// (including any literal `|`) and the byte output matches the Go binary. +// +// Note (Go parity): API-supplied `id` / `name` strings are NOT stripped of +// ANSI escape sequences or other terminal control bytes before rendering. +// Go's `glamour` library has identical pass-through behaviour, and the legacy +// shell's contract is strict 1:1 with the Go CLI — diverging here would mean +// scripts grepping table cells see different bytes than they do today. If a +// future security review decides to sanitize, it should land for both shells +// at the renderer (`legacy-glamour-table.ts`), not per-command. +// --------------------------------------------------------------------------- + +const HEADERS = ["ID", "NAME"] as const; + +type Organization = typeof OrganizationResponseV1.Type; + +export function renderOrgsListTable(orgs: ReadonlyArray): string { + const rows = orgs.map((o) => [o.id, o.name]); + return renderGlamourTable(HEADERS, rows); +} diff --git a/apps/cli/src/legacy/commands/orgs/orgs.format.unit.test.ts b/apps/cli/src/legacy/commands/orgs/orgs.format.unit.test.ts new file mode 100644 index 0000000000..2a24a7502c --- /dev/null +++ b/apps/cli/src/legacy/commands/orgs/orgs.format.unit.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { renderOrgsListTable } from "./orgs.format.ts"; + +describe("renderOrgsListTable", () => { + it("renders the header row even when the organization list is empty", () => { + const out = renderOrgsListTable([]); + expect(out).toContain("ID"); + expect(out).toContain("NAME"); + }); + + it("renders one row per organization with id and name columns", () => { + const out = renderOrgsListTable([ + { id: "combined-fuchsia-lion", slug: "combined-fuchsia-lion", name: "My Org" }, + { id: "calm-cobalt-emu", slug: "calm-cobalt-emu", name: "Another Org" }, + ]); + expect(out).toContain("combined-fuchsia-lion"); + expect(out).toContain("My Org"); + expect(out).toContain("calm-cobalt-emu"); + expect(out).toContain("Another Org"); + }); + + it("preserves literal `|` characters in organization names (Glamour does not double-escape)", () => { + const out = renderOrgsListTable([{ id: "id", slug: "id", name: "with|pipe" }]); + expect(out).toContain("with|pipe"); + }); +});