Skip to content
Merged
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
4 changes: 2 additions & 2 deletions apps/cli/docs/go-cli-porting-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,8 @@ Legend:

| Command | Legacy status | Legacy command path |
| -------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `orgs list` | `wrapped` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) |
| `orgs create` | `wrapped` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) |
| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) |
| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) |
| `projects list` | `wrapped` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) |
| `projects create` | `wrapped` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) |
| `projects delete` | `wrapped` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) |
Expand Down
132 changes: 88 additions & 44 deletions apps/cli/src/legacy/commands/orgs/create/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,77 +2,121 @@

## Files Read

| Path | Format | When |
| -------------------------- | ------------------------- | ---------------------------------------------------------- |
| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable |
| Path | Format | When |
| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- |
| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` |
| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses |
| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss |

## Files Written

| Path | Format | When |
| ---- | ------ | ---- |
| — | — | — |
| Path | Format | When |
| ---------------------------- | ------ | ----------------------------------------------------------- |
| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity |

`orgs create` is a user-level command — it does not resolve a `--project-ref`, so the legacy
linked-project cache is never written.

## Positional Arguments

| Argument | Required? | Description |
| -------- | --------- | ---------------------------- |
| `name` | yes | Name of the new organization |

## API Routes

| Method | Path | Auth | Request body | Response (used fields) |
| ------ | ------------------- | ------------ | ---------------- | ---------------------------- |
| `POST` | `/v1/organizations` | Bearer token | `{name: string}` | `{id: string, name: string}` |
| Method | Path | Auth | Request body | Response (used fields) |
| ------ | ------------------- | ------------ | ---------------- | ------------------------------------------ |
| `POST` | `/v1/organizations` | Bearer token | `{name: string}` | `{id: string, slug: string, name: string}` |

## Environment Variables

| Variable | Purpose | Required? |
| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- |
| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) |
| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) |
| Variable | Purpose | Required? |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) |
| `SUPABASE_PROFILE` | selects API base URL (`supabase`, `supabase-staging`, `supabase-local`), or a filesystem path to a YAML profile (Go parity — used by the cli-e2e harness) | no (defaults to `supabase`) |

## Exit Codes

| Code | Condition |
| ---- | ----------------------------------------------------- |
| `0` | success — organization created |
| `1` | authentication error — no valid token found |
| `1` | API error — non-2xx response from `/v1/organizations` |
| `1` | network / connection failure |
| Code | Condition |
| ---- | ------------------------------------------------------------------------------- |
| `0` | success — organization created |
| `1` | `LegacyPlatformAuthRequiredError` — no token in env/keyring/file |
| `1` | `LegacyOrgsCreateUnexpectedStatusError` — non-201 response from create endpoint |
| `1` | `LegacyOrgsCreateNetworkError` — transport-level network failure |

Unlike `orgs list`, there is no env-not-supported branch — Go's `EncodeOutput` happily
flattens a single object into `ID=… NAME=… SLUG=…` env lines.

## Telemetry Events Fired

| Event | When | Notable properties / groups |
| ---------------------- | ------------------------------------------ | ----------------------------------- |
| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` |

Matches `apps/cli-go/internal/orgs/create/`. Go does not fire any custom telemetry event for
this command.

## Output

### `--output-format text` (Go CLI compatible)
Every output mode (Go-compat and TS) starts by printing `Created organization: <id>\n` to
stdout, except `--output-format json` / `stream-json`, which emit a single structured event
instead. `--output` (Go) wins over `--output-format` (TS) when both are supplied.

Prints `Created organization: <id>` followed by a Markdown-style table to stdout.
Table has a header row and one row for the created organization.
Column order: `ID`, `NAME`.
### `--output pretty` (Go default) / `--output-format text`

```
Created organization: combined-fuchsia-lion
ID NAME
combined-fuchsia-lion My Test Org
```
`Created organization: <id>` followed by a Glamour-styled markdown table with columns
`ID`, `NAME` for the created organization. The rendered table always ends with a trailing
newline (Glamour appends one).

### `--output-format json`
### `--output json` (Go-compat)

Single JSON object emitted to stdout on success containing the full organization
object as returned by the Management API.
Preamble line followed by indented JSON of the created `OrganizationResponseV1` object with
alphabetical keys + trailing newline.

```json
{ "id": "combined-fuchsia-lion", "name": "My Test Org" }
```
### `--output yaml`

### `--output-format stream-json`
Preamble line followed by a YAML document of the created organization object.

### `--output toml`

One `result` event on success.
Preamble line followed by a TOML document of the created organization object.

```ndjson
{"type":"result","data":{"id":"combined-fuchsia-lion","name":"My Test Org"}}
```
### `--output env`

On failure, an `error` event is emitted instead:
Preamble line followed by `ID=…`, `NAME=…`, `SLUG=…` env lines — env IS supported here,
unlike on `orgs list`. Matches the Go encoder behavior in
`apps/cli-go/internal/orgs/create/create.go:27`.

### `--output-format json`

Single `Output.success` envelope written as JSON. The envelope carries the message
`"Created organization"` plus the created org spread into `data` (`{id, slug, name}`). No
`Created organization: <id>\n` preamble line — the message is delivered as a structured
field instead of stdout text.

### `--output-format stream-json`

```ndjson
{"type":"error","code":"ApiError","message":"…"}
```
One `result` NDJSON event with the created org as `data` and the same `"Created organization"`
message. No preamble line.

## Notes

- Takes exactly one positional argument: the organization name.
- No `--project-ref` flag. `orgs create` is a user-level command.
- The organization ID in the response is a human-readable slug (e.g. `combined-fuchsia-lion`), not a UUID.
- The organization `id` and `slug` in the response are human-readable slugs (e.g.
`combined-fuchsia-lion`), not UUIDs.
- Sends `User-Agent: SupabaseCLI/<version>` and Bearer auth.

## Security Notes

- The `Created organization: <id>` preamble and the rendered Glamour table interpolate the
API-supplied `id` and `name` strings without ANSI / control-character sanitization. This
is strict Go parity — Go's `fmt.Println` and `glamour` both pass these through verbatim.
A malicious or compromised Management API could in principle return values containing
terminal escape sequences. If sanitization is added later it should land at the renderer
(and at any shared preamble helper) so both shells inherit the fix.
- `--output env` values are escaped via `encodeEnv` (`\n`, `\r`, `\t` → backslash-escaped),
matching Go's `%q` semantics. ESC (`0x1b`) is not escaped — again Go parity.
- Error response bodies embedded in `LegacyOrgsCreateUnexpectedStatusError` are sanitized
by `mapLegacyHttpError` (control chars stripped, capped at 1024 bytes).
14 changes: 12 additions & 2 deletions apps/cli/src/legacy/commands/orgs/create/create.command.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { Argument, Command } from "effect/unstable/cli";
import type * as CliCommand from "effect/unstable/cli/Command";

import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts";
import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts";
import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts";
import { legacyOrgsCreate } from "./create.handler.ts";

const config = {
name: Argument.string("name").pipe(
Argument.withDescription("Name of the organization to create."),
Argument.withDescription("Display name for the new organization."),
),
};
export type LegacyOrgsCreateFlags = CliCommand.Command.Config.Infer<typeof config>;

export const legacyOrgsCreateCommand = Command.make("create", config).pipe(
Command.withDescription("Create an organization for the logged-in user."),
Command.withShortDescription("Create an organization"),
Command.withHandler((flags) => legacyOrgsCreate(flags)),
Command.withHandler((flags) =>
legacyOrgsCreate(flags).pipe(
withLegacyCommandInstrumentation({ flags }),
withJsonErrorHandling,
),
),
Command.provide(legacyManagementApiRuntimeLayer(["orgs", "create"])),
);
91 changes: 86 additions & 5 deletions apps/cli/src/legacy/commands/orgs/create/create.handler.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,92 @@
import { Effect } from "effect";
import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts";
import type { V1CreateAnOrganizationOutput } from "@supabase/api/effect";
import { Effect, Option } from "effect";

import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts";
import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts";
import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts";
import { Output } from "../../../../shared/output/output.service.ts";
import {
encodeEnv,
encodeGoJson,
encodeToml,
encodeYaml,
} from "../../../shared/legacy-go-output.encoders.ts";
import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts";
import {
LegacyOrgsCreateNetworkError,
LegacyOrgsCreateUnexpectedStatusError,
} from "../orgs.errors.ts";
import { renderOrgsListTable } from "../orgs.format.ts";
import type { LegacyOrgsCreateFlags } from "./create.command.ts";

type CreatedOrganization = typeof V1CreateAnOrganizationOutput.Type;

const mapCreateError = mapLegacyHttpError({
networkError: LegacyOrgsCreateNetworkError,
statusError: LegacyOrgsCreateUnexpectedStatusError,
networkMessage: (cause) => `failed to create organization: ${cause}`,
statusMessage: (status, body) => `unexpected create organization status ${status}: ${body}`,
});

export const legacyOrgsCreate = Effect.fn("legacy.orgs.create")(function* (
flags: LegacyOrgsCreateFlags,
) {
const proxy = yield* LegacyGoProxy;
const args: string[] = ["orgs", "create", flags.name];
yield* proxy.exec(args);
const output = yield* Output;
const goOutputFlag = yield* LegacyOutputFlag;
const api = yield* LegacyPlatformApi;
const telemetryState = yield* LegacyTelemetryState;

yield* Effect.gen(function* () {
// Spinner runs only in text mode — it would corrupt machine-readable
// stdout. The output-routing branches below dispatch on `goFmt`, but the
// spinner uses `output.format` because `--output pretty` keeps the format
// as "text" while requiring the table render; both paths need the spinner.
const creating =
output.format === "text" ? yield* output.task("Creating organization...") : undefined;
const created: CreatedOrganization = yield* api.v1
.createAnOrganization({ name: flags.name })
.pipe(
Effect.tapError(() => creating?.fail() ?? Effect.void),
Effect.catch(mapCreateError),
);
yield* creating?.clear() ?? Effect.void;

const goFmt = Option.getOrUndefined(goOutputFlag);

// Go calls `fmt.Println("Created organization:", id)` once before its
// format switch (`apps/cli-go/internal/orgs/create/create.go:22`). We
// repeat the preamble inside each Go-format branch rather than hoisting
// it, so the TS `--output-format json` / `stream-json` paths (which emit
// a single structured event below) stay preamble-free.
const preamble = `Created organization: ${created.id}\n`;

if (goFmt === "json") {
yield* output.raw(preamble);
yield* output.raw(encodeGoJson(created));
return;
}
if (goFmt === "yaml") {
yield* output.raw(preamble);
yield* output.raw(encodeYaml(created));
return;
}
if (goFmt === "toml") {
yield* output.raw(preamble);
yield* output.raw(encodeToml(created) + "\n");
return;
}
if (goFmt === "env") {
yield* output.raw(preamble);
yield* output.raw(encodeEnv(created) + "\n");
return;
}

if (output.format === "json" || output.format === "stream-json") {
yield* output.success("Created organization", { ...created });
return;
}

yield* output.raw(preamble);
yield* output.raw(renderOrgsListTable([created]));
}).pipe(Effect.ensuring(telemetryState.flush));
});
Loading
Loading