This file applies to the apps/cli workspace. Read it fully before touching any code in this package.
There are three source trees under src/:
src/
├── next/ # New CLI experience (v3 / alpha channel) — do not modify when porting legacy commands
├── legacy/ # Strict 1:1 TypeScript port of the Go CLI (stable channel)
└── shared/ # Cross-cutting primitives used by both shells
next/andlegacy/cannot import each other. Command trees are fully isolated.- Both shells import freely from
shared/. - All exported tokens from
legacy/must be prefixed withLegacyorlegacy(no exceptions — see naming section below). This prevents IDE auto-complete from suggesting legacy-only exports when working innext/and removes ambiguity at import sites.
Each shell has its own entry chain:
src/legacy/main.ts → legacy/cli/root.ts → legacy/commands/…
src/next/main.ts → next/cli/root.ts → next/commands/…
Both call runCli(root) from shared/cli/run.ts.
This project uses Effect V4. The full source code for the effect library is in .repos/effect/.
Use this for learning more about the library, rather than browsing the code in
node_modules/. See .repos/effect/MIGRATION.md for V3 → V4 changes.
Use Effect.fn for top-level exported command handlers — tracing is desired. In the legacy shell, prefix the trace name with legacy. to distinguish legacy spans from next/ spans in traces:
// next/ handler
export const create = Effect.fn("branches.create")(function* (flags: CreateFlags) {
// ...
});
// legacy/ handler — note the legacy. prefix in the trace name
export const legacyCreate = Effect.fn("legacy.branches.create")(function* (
flags: LegacyCreateFlags,
) {
// ...
});Use Effect.fnUntraced for small internal helpers that don't need individual trace spans:
const resolveToken = Effect.fnUntraced(function* (flag: Option.Option<string>) {
// ...
});Do not use as casts to paper over Effect or CLI typing issues. Fix the type relationships directly, or restructure the code until the compiler is satisfied without assertions.
Always check src/shared/ before writing new infrastructure. Do not duplicate what already exists there or in next/.
| Path | What it provides |
|---|---|
shared/cli/run.ts |
runCli() — CLI execution harness |
shared/cli/global-flags.ts |
OutputFormatFlag — --output-format global flag |
shared/output/output.service.ts |
Output service interface |
shared/output/output.layer.ts |
outputLayerFor(format) — three implementations: text, json, stream-json |
shared/output/table.ts |
outputTable(), formatTableRow() |
shared/output/time.ts |
formatUtcDate(), formatUtcTime() |
shared/output/json-error-handling.ts |
withJsonErrorHandling middleware |
shared/output/errors.ts |
NonInteractiveError |
shared/runtime/ |
Browser, Stdin, Tty, ProcessControl, RuntimeInfo services + layers |
shared/telemetry/ |
withCommandInstrumentation, Analytics, tracing |
Also check the following legacy/ infrastructure before writing equivalent helpers from scratch:
| Path | What it provides |
|---|---|
legacy/config/legacy-cli-config.layer.ts |
LegacyCliConfig — resolves SUPABASE_PROFILE (built-in name or YAML file path), --workdir, --experimental, project-id from supabase/config.toml |
legacy/config/legacy-project-ref.layer.ts |
LegacyProjectRefResolver — --project-ref flag → env → linked-project.json → config fallback chain; matches Go's resolver order |
legacy/telemetry/legacy-telemetry-state.layer.ts |
LegacyTelemetryState.flush — writes ~/.supabase/telemetry.json, runs in every command's Effect.ensuring |
legacy/telemetry/legacy-linked-project-cache.layer.ts |
LegacyLinkedProjectCache.cache(ref) — writes ~/.supabase/<workdir-hash>/linked-project.json after --project-ref resolves; bypasses generated schema validation (uses raw HTTP client) |
legacy/auth/legacy-http-debug.layer.ts |
legacyHttpClientLayer — wraps the HTTP transport with a --debug stderr logger in Go's log.LstdFlags format |
legacy/output/legacy-glamour-table.ts |
renderGlamourTable(headers, rows) — byte-exact ASCII match for Go's glamour.RenderTable(..., AsciiStyle) |
Before any command is natively implemented in TypeScript, the first step for each command is to wrap it: define the command in the TS command tree and proxy all invocations to the bundled Go binary via subprocess.
A proxy handler passes argv through to the Go binary, forwarding stdin/stdout/stderr and propagating the exit code. Use the shared LegacyGoProxy service:
// src/legacy/commands/orgs/list/list.handler.ts (Phase 0 proxy)
export const legacyOrgsList = Effect.fn("legacy.orgs.list")(function* (
_flags: LegacyOrgsListFlags,
) {
const proxy = yield* LegacyGoProxy;
yield* proxy.exec(["orgs", "list"]);
});For each command added to the Phase 0 wrapper, complete all three steps:
- Reconstruct the command definition — flags, subcommands, and argument types must exactly match the Go CLI (use
apps/cli-go/as the reference). - Write a proxy handler — forward invocations to the Go binary via
LegacyGoProxy. - Update
docs/go-cli-porting-status.md— mark the command aswrapped.
When replacing a proxy handler with a native TS implementation:
- Implement the business logic in
<command>.handler.tsusing Effect services (see Legacy Port sections below). - Update
docs/go-cli-porting-status.md— mark the command asported.
One directory per top-level command under src/legacy/commands/:
src/legacy/commands/<command>/
<command>.command.ts # Effect CLI Command definition, flag wiring, layer provision
<command>.handler.ts # Phase 0: proxy handler. Phase 1+: native Effect implementation
<command>.errors.ts # Domain error types (Data.TaggedError) — add when porting
SIDE_EFFECTS.md # Required for every legacy command — see section below
When a command grows beyond a single handler file, follow the optional helper-file shape that emerged from the backups port:
src/legacy/commands/<command>/
<command>.command.ts # Effect CLI Command + flag wiring + layer provide
<command>.handler.ts # native Effect handler
<command>.errors.ts # Data.TaggedError types
<command>.layers.ts # runtime layer composition for the command family
<command>.format.ts # text formatters (timestamps, regions, booleans)
<command>.encoders.ts # Go-compatible JSON / YAML / TOML / env encoders
SIDE_EFFECTS.md
The .format.ts and .encoders.ts files should be pure functions with no Effect or service dependencies — that keeps them unit-testable and makes Go-parity rules explicit (e.g. JSON key sort order, env-var SCREAMING_SNAKE_CASE flattening, empty arrays coerced to null).
Commands with subcommands use nested directories:
src/legacy/commands/branches/
branches.command.ts # Group command (Command.withSubcommands)
create/
create.command.ts
create.handler.ts
…
list/
…
Register every command in src/legacy/cli/root.ts:
import { legacyBranchesCommand } from "../commands/branches/branches.command.ts";
export const legacyRoot = Command.make("supabase").pipe(
Command.withSubcommands([
helloLegacyCommand,
legacyBranchesCommand, // ← add here
]),
// ...
);Every exported token from a legacy/ file must carry the Legacy (PascalCase) or legacy (camelCase/kebab) prefix — no exceptions, even for symbols that are only used within legacy/. This makes the constraint unconditional and prevents auto-complete pollution in next/:
| Export kind | Convention |
|---|---|
| Command constant | export const legacyBranchesCommand |
| Handler function | export const legacyCreate |
| Error class | export class LegacyBranchAlreadyExistsError |
| Service class | export class LegacyProjectState |
| Layer | export const legacyCredentialsLayer |
| Integration test setup helpers | function setupLegacyTty(), function setupLegacyNonTty() |
| Type aliases | export type LegacyCreateFlags |
Do not export a bare create or branchesCommand from a legacy/ file.
Many Management API commands in next/commands/ have already been implemented. The handler logic is Effect-based and shell-agnostic. Check next/commands/ before writing a handler from scratch. You can often copy a handler file verbatim and:
- Rename the exported function (add
legacyprefix) - Adjust the trace name to
legacy.<command>.<subcommand> - Fix import paths (
../../shared/→../../../shared/, etc.)
Before writing handler code for a new port, scan the already-ported commands for overlapping logic. If two commands need the same helper (HTTP-error mapping, output encoder, formatter, runtime layer composition), hoist it instead of inlining a copy.
Decision rule:
- Used by one command only → keep it in the command's own directory (e.g.
backups/backups.errors.ts). - Used by ≥2 commands in the same command family → keep it in the family root (e.g.
backups/backups.encoders.tsis shared bylistandrestore). - Used by ≥2 commands across families → hoist to
src/legacy/shared/(create the directory if it doesn't exist) and refactor the existing call sites in the same change. Do not leave the older command using its inlined copy while the new command uses the hoisted version.
Concrete examples worth watching for as more commands land:
- HTTP-error → tagged-error mapping (
backups.errors.ts:mapLegacyBackupHttpError) — almost every Management API command will need this shape. - Go-compatible JSON / YAML / TOML / env encoders (
backups.encoders.ts) — the flag--output {json,yaml,toml,env}is supported by many Go subcommands. - Glamour-table rendering helpers and column padding — currently in
legacy/output/legacy-glamour-table.ts, already correctly hoisted. - Timestamp / region / boolean formatters (
backups.format.ts) — likely shared the moment a second command renders a backup/project/region field.
This rule is consistent with the repo-wide Refactoring Policy ("delete obsolete helpers, shims, and parallel code paths as part of the refactor") — it just makes the policy concrete for the legacy-port workflow.
The legacy shell is a strict 1:1 port — not a redesign. The compatibility contract covers:
- Same command paths and flag names
- Same stdout/stderr text, including spacing, casing, and newlines
- Same filesystem side effects (files read and written)
- Same API routes and request shapes
- Same exit codes
When in doubt about expected output or behavior, run the equivalent command against the Go CLI reference at apps/cli-go/ and match it exactly.
When porting a Management-API-style command, verify each item before marking the command as ported:
-
Telemetry + linked-project writes run on every invocation — Go uses
PersistentPostRun(seeapps/cli-go/cmd/root.go:176). Wrap the handler body in.pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush))so both files are written on success and failure. Seebackups/list/list.handler.ts:74-114as the canonical pattern. -
Errors go to stderr in text mode, byte-matching Go's template —
Output.failnow writes a frame-free message to stderr followed by the "Try rerunning the command with --debug to get more details." suggestion when--debugis unset. Don't reintroduce clack's■ … │frame. Reference: commitsee041834,cf4f574b. -
--debuglogs every HTTP request on stderr — Format"HTTP YYYY/MM/DD HH:MM:SS <METHOD>: <URL>\n"(Go'slog.LstdFlags|log.Lmsgprefix). Provided automatically bylegacyHttpClientLayer; ensure that layer (not the rawHttpClient.layer) is what every legacy command's runtime composes. Reference: commit39cfec20. -
SUPABASE_PROFILEis dual-mode — accept either a built-in name (supabase,supabase-staging,supabase-local) or a filesystem path to a YAML file withapi_url:/gotrue_url:/db_url:keys. cli-e2e harness relies on the file-path mode. Reference: commit288c2937. -
Layer.providedoes not share to siblings insideLayer.mergeAll— if two sibling layers each requireLegacyCliConfig, provide it to both explicitly. Smoke-test the bundled binary (bun run build && ./dist/supabase-legacy …) when changing production layer wiring; in-process tests don't always catch the missing-service panic. Reference: commita816b12e,backups.layers.ts:32-46. -
Both
--output(Go) and--output-format(TS) must be honored — Go's--output(pretty|json|yaml|toml|env) takes priority when set. Pattern inbackups/list/list.handler.ts:85-113: branch ongoOutputFlagfirst, then fall through to TS--output-formattext/json/stream-json. -
PostHog telemetry payload matches Go 1:1 — see the next section.
The legacy shell sends the same PostHog events to the same product analytics pipeline as the Go CLI. Drift is silent (no test will catch it) and breaks dashboards. The rules:
-
The canonical catalog is
shared/telemetry/event-catalog.ts— a 1:1 mirror ofapps/cli-go/internal/telemetry/events.go. Reference its exported constants (EventCommandExecuted,PropFlags,EnvSignalPresenceKeys, …) instead of writing bare strings. When the Go catalog changes, update the TS catalog in the same PR. -
Native legacy commands wrap with
withLegacyCommandInstrumentation(fromlegacy/telemetry/legacy-command-instrumentation.ts) — not the sharedwithCommandInstrumentation. The legacy variant emits Go-shape properties: a singleflagsmap (vsflags_used/flag_values),is_agent: boolean(vsai_tool: string), andenv_signals. -
Pass
flagsto the wrapper so boolean flag values can be detected and logged verbatim:handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...). Sensitive values become the literal string"<redacted>"to match Go. -
Use
safeFlags: ["flag-name"]to whitelist flags that Go marks withmarkFlagTelemetrySafe(grepapps/cli-go/cmd/*.go). Today these are--project-ref(sso, branches, link, functions, projects/api-keys),--project-id(gen/types),--org-id(projects/create), and--version(migration/squash). -
Proxy handlers (
LegacyGoProxy.exec) must NOT wrap with any instrumentation. The Go subprocess fires its own telemetry; a TS wrapper would double-countcli_command_executed. -
When promoting a command from proxy to native, reproduce every
phtelemetry.*call in the Go counterpart. Grepapps/cli-go/internal/<command>/forservice.Capture,service.Alias,service.Identify,service.GroupIdentify, andTrackUpgradeSuggested. The current Go custom events that legacy ports must reproduce when natively ported:Command Event Identity / groups Go source logincli_login_completedanalytics.alias(gotrueId, deviceId)+analytics.identify(gotrueId)after token persistsinternal/login/login.go:283-296linkcli_project_linkedanalytics.groupIdentify("organization", slug, …)+analytics.groupIdentify("project", ref, …)after link writeinternal/link/link.go:60startcli_stack_startednone — fired after stack health check passes internal/start/start.go:1245sso/{list,create,update,remove},branches/{create,update}cli_upgrade_suggestednone — payload is {feature_key, org_slug}, fired inside billing-gate error branch7 call-sites under internal/{sso,branches}/Reference pattern for login:
next/commands/login/login.handler.ts:38-62. -
Tracing layer is local-only observability, not PostHog. Span names (
legacy.<command>.<sub>) and the NDJSON exporter never leave the user's machine. No parity implication.
The legacy shell bridges two worlds: it must behave exactly like the Go CLI for existing users, and it must lay the groundwork for a seamless upgrade to the next shell.
Dual write requirement: Where a legacy command writes state to disk, it must write to both:
- The Go CLI paths — the exact file locations the Go CLI already uses, so existing scripts, dotfiles, and tooling that depend on those paths continue to work.
- The
next/paths — the file locations thatnext/services and layers expect to read, so a user who upgrades to the next experience finds their state already in place.
When these two sets of paths are the same (they often are via shared services), no extra work is needed. When they differ, the legacy handler must write to both.
Corollary: When a next/ service or layer changes where or how it reads or writes a file, the author must verify that the corresponding legacy command still produces files at the updated location and update it if necessary before merging. This check is required even when file I/O goes through a shared service — confirm the shared service covers both paths.
SIDE_EFFECTS.md is a legacy-only artifact. Do not create these files in next/.
Every legacy command port must include a SIDE_EFFECTS.md in its command directory covering:
- Files read and written — exact paths (with
~/or CWD-relative notation), format, when - API routes called — method, path, request body shape, response shape
- Environment variables consumed
- Exit codes — including error conditions
Use the template at src/legacy/SIDE_EFFECTS_TEMPLATE.md. This document is the compatibility checklist for the port and the primary input to the E2E test suite.
The --output-format global flag is defined in shared/cli/global-flags.ts (OutputFormatFlag) and is already wired into legacy/cli/root.ts. It accepts three values:
| Value | Description |
|---|---|
text (default) |
Human-readable terminal output with spinners, tables, prompts |
json |
Single JSON object emitted to stdout on completion |
stream-json |
NDJSON events streamed to stdout (log, progress, result, error) |
Every legacy command handler must handle all three formats. The json and stream-json modes provide machine-readable output for scripted workflows and AI agents.
if (output.format !== "text") {
// json / stream-json — emit structured result
yield * output.success("Branch created", { ...branch });
return;
}
// text — human-readable table + outro
yield * outputTable(BRANCH_HEADERS, [branch], formatRow);
yield * output.outro(`Branch "${branch.name}" created.`);Wrap every async API call in output.task so the terminal does not appear to hang in text mode. In json/stream-json mode the task is a no-op — the spinner is suppressed automatically:
const creating = yield * output.task("Creating branch...");
const branch = yield * api.createBranch(params).pipe(Effect.tapError(() => creating.fail()));
yield * creating.clear(); // dismiss without a message
// OR
yield * creating.succeed("Branch created");The Go-compat -o/--output flag (LegacyOutputFlag, values env|pretty|json|toml|yaml) is independent of --output-format. It does not change output.format, so a command run with -o json (and no --output-format) keeps output.format === "text" and the spinner gate output.format === "text" stays true. If the plain textOutputLayer is active, clack writes spinner ANSI (e.g. the hide-cursor \x1b[?25l) to stdout and corrupts the machine payload the handler emits via output.raw — exactly the CLI-1546 regression (branches list -o json → broken JSON.parse).
legacy/cli/root.ts therefore selects legacyQuietProgressTextOutputLayer (in legacy/output/) for any Go machine format (json|yaml|toml|env). It is a legacy-only wrapper over the shared textOutputLayer that no-ops only task and progress; everything else — format: "text", raw, logs, and error rendering (red text on stderr) — delegates unchanged, so Go output parity is preserved exactly.
Rules:
- stdout is payload-only whenever a machine format is requested (
-o json|yaml|toml|envor--output-format json|stream-json). All progress/diagnostic output goes to stderr. - Do not fix spinner-on-stdout by routing the shared spinner to stderr or otherwise editing
shared/output/output.layer.ts— that changesnext/text rendering. Keep the fix legacy-scoped. - A handler reaching this path still emits its machine payload through the Go encoder (
output.raw(encodeGoJson(...))etc.), checked before theoutput.formatbranch, so output stays byte-identical to before — minus the spinner.
Use bun run test (not bun test) to run tests. The package.json test script runs all Vitest projects with coverage enabled for the core project.
Use bun run test:core for the main in-process suite, and bun run test:e2e for the sequential subprocess suite.
Always run the relevant unit and integration tests automatically for the command or workspace you changed.
Do not run the full e2e suite automatically. Only run e2e when the user asks, or when you need extra confidence for the command you touched.
When running e2e automatically, run only the targeted *.e2e.test.ts file(s) for the command you changed.
When running the CLI from source, always invoke it as bun src/supabase.ts ... directly. Do not use bun run src/supabase.ts because of Bun bug #11400.
Command handler integration tests must achieve 100% branch coverage.
Read https://www.effect.solutions/testing for Effect testing patterns. Note that the guide targets Effect V3 — adapt to V4 APIs using the source code in .repos/effect/packages/effect/ and .repos/effect/packages/vitest/.
*.unit.test.tsbelongs to theunitVitest project and is the default for unit-style and other fast in-process tests.*.integration.test.tsbelongs to theintegrationproject and is for in-process integration tests that exercise real handler or service behavior with layered dependency replacement.*.e2e.test.tsbelongs to thee2eVitest project and is for black-box CLI subprocess tests.
- Prefer integration tests over unit tests for command behavior.
- New command behavior should usually be covered in
*.integration.test.tsfirst. - Prefer the highest-level in-process test that exercises the real behavior with stable, local feedback.
- Use
*.unit.test.tsfor pure logic, parsing, formatting, small state machines, and narrow edge cases that are awkward or noisy to cover through handlers. - Unit-style tests should prefer real collaborators and avoid mocking by default.
- Small fakes are acceptable only at true boundaries such as filesystem, env, clock, TTY, process, browser, or network.
- If a test needs multiple service replacements or
Layer.mergeAll(...), it likely belongs in*.integration.test.ts. - Prefer assertions on outputs and accumulated state over spy-heavy interaction tests.
- Keep
*.e2e.test.tsfocused on golden paths, CLI surface behavior, and subprocess correctness, not branch-by-branch coverage. - Forbidden pattern (do not add): spawning the CLI to assert that
--helprenders a flag. Help text is dynamic over flag wiring and is exercised by the integration test's flag parser. The two backups e2e files removed alongside this guidance update are the canonical example of what not to write.
When you add or change CLI commands, subcommands, flags, or parameters in the legacy shell, always update docs/go-cli-porting-status.md.
- Update status when a Go leaf command moves between
missing,partial, andported. - Update missing or extra flag/parameter notes when the command surface changes — including when you add or remove a flag on an already-ported TS command.
- Keep the tracker focused on final leaf commands, not command groups.
- If you add a TS-native command with no direct Go equivalent (for example
dev), record it in the TS-only section instead of marking a Go command as ported.
After finishing any task or refactor, always run all quality checks before considering the work done:
bun run test
bun run --parallel "*:check"lalph is a CLI written by Tim Smart, a core maintainer of Effect, using Effect V4. Study its source code to determine good practices and patterns when building CLI applications with Effect.
effect-patterns contains practical patterns for structuring Effect services, layers, and error handling. Note that the code targets Effect V3 — adapt the idioms to V4 APIs using .repos/effect/MIGRATION.md and the V4 source code.
The old Supabase CLI written in Go. When porting a command to the legacy shell, use this as the authoritative source for expected output, flags, and behavior. Match it exactly.