Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/cli/docs/go-cli-porting-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
42 changes: 4 additions & 38 deletions apps/cli/src/legacy/commands/branches/branches.format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;

/**
Expand All @@ -72,8 +54,8 @@ export function renderBranchesListTable(branches: ReadonlyArray<Branch>): 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);
}
Expand Down Expand Up @@ -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_<NAME>_KEY`, fall back to `"******"`
* when the api_key value is nullable-null.
*/
export function apiKeysToEnv(keys: ReadonlyArray<ApiKey>): Record<string, string> {
const envs: Record<string, string> = {};
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<string, string>;
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,14 @@
import { describe, expect, it } from "vitest";

import { apiKeysToEnv } from "../../shared/legacy-api-keys.format.ts";
import {
apiKeysToEnv,
formatUtcDateTime,
parsePoolerConnectionString,
renderBranchGetTable,
renderBranchesListTable,
toPostgresUrl,
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([
Expand Down
98 changes: 98 additions & 0 deletions apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
@@ -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 |
| `<workdir>/supabase/.temp/project-ref` | plain text (ref string) | `list` (linked marker), `api-keys` (ref source), `delete` (unlink match) |

## Files Written / Removed

| Path | Action | When |
| --------------------------- | ------- | ---------------------------------------------------------------- |
| `<workdir>/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 <dashboard>/project/<id>` 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: <name>`; 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_<NAME>_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`.
48 changes: 31 additions & 17 deletions apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

## Files Read

| Path | Format | When |
| --------------------------------- | ------------------------- | ------------------------------------------------------------------------ |
| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable |
| `<workdir>/.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 |
| `<workdir>/supabase/.temp/project-ref` | plain text (ref string) | when `--project-ref` is not provided, to resolve the linked project ref |

## Files Written

Expand Down Expand Up @@ -42,36 +42,48 @@
| `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_<NAME>_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`

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:
Expand All @@ -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.
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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"])),
);
Loading
Loading