diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 7e8592cb43..08dbfbf5d4 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 5bbef78478..04485dc685 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"; @@ -171,11 +171,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)); }); @@ -225,4 +221,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 548fd6c033..19f5af9c09 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -359,7 +359,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