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
9 changes: 6 additions & 3 deletions apps/cli-e2e/src/tests/telemetry.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { chmodSync, writeFileSync } from "node:fs";
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { testBehaviour, testParity } from "./test-context.ts";
Expand Down Expand Up @@ -58,9 +58,12 @@ describe("telemetry", () => {
});

testBehaviour("handles corrupted config gracefully", async ({ run, workspace }) => {
writeFileSync(join(workspace.path, "telemetry.json"), "{{not valid json}}");
const telemetryPath = join(workspace.path, "telemetry.json");
writeFileSync(telemetryPath, "{{not valid json}}");
const result = await run(["telemetry", "status"]);
expect(result.exitCode).not.toBe(0);
expect(result.exitCode).toBe(0);
expect(result.stdout).toMatch(/Telemetry is (enabled|disabled)\./);
expect(() => JSON.parse(readFileSync(telemetryPath, "utf8"))).not.toThrow();
});

testParity(["telemetry", "status"]);
Expand Down
162 changes: 89 additions & 73 deletions apps/cli-go/go.mod

Large diffs are not rendered by default.

382 changes: 209 additions & 173 deletions apps/cli-go/go.sum

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions apps/cli-go/internal/status/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,20 @@ func (c *CustomName) toValues(exclude ...string) map[string]string {
c.DbURL: fmt.Sprintf("postgresql://%s@%s:%d/postgres", url.UserPassword("postgres", utils.Config.Db.Password), utils.Config.Hostname, utils.Config.Db.Port),
}

apiEnabled := utils.Config.Api.Enabled && !slices.Contains(exclude, utils.RestId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Api.Image))
kongEnabled := utils.Config.Api.Enabled && !slices.Contains(exclude, utils.KongId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Api.KongImage))
postgrestEnabled := kongEnabled && !slices.Contains(exclude, utils.RestId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Api.Image))
studioEnabled := utils.Config.Studio.Enabled && !slices.Contains(exclude, utils.StudioId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Studio.Image))
authEnabled := utils.Config.Auth.Enabled && !slices.Contains(exclude, utils.GotrueId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Auth.Image))
inbucketEnabled := utils.Config.Inbucket.Enabled && !slices.Contains(exclude, utils.InbucketId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Inbucket.Image))
storageEnabled := utils.Config.Storage.Enabled && !slices.Contains(exclude, utils.StorageId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Storage.Image))
functionsEnabled := utils.Config.EdgeRuntime.Enabled && !slices.Contains(exclude, utils.EdgeRuntimeId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.EdgeRuntime.Image))

if apiEnabled {
if kongEnabled {
values[c.ApiURL] = utils.Config.Api.ExternalUrl
values[c.RestURL] = utils.GetApiUrl("/rest/v1")
values[c.GraphqlURL] = utils.GetApiUrl("/graphql/v1")
if postgrestEnabled {
values[c.RestURL] = utils.GetApiUrl("/rest/v1")
values[c.GraphqlURL] = utils.GetApiUrl("/graphql/v1")
}
if functionsEnabled {
values[c.FunctionsURL] = utils.GetApiUrl("/functions/v1")
}
Expand Down
5 changes: 3 additions & 2 deletions apps/cli-go/internal/utils/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ func GetGitBranchOrDefault(def string, fsys afero.Fs) string {
if len(head) > 0 {
return head
}
opts := &git.PlainOpenOptions{DetectDotGit: true}
if repo, err := git.PlainOpenWithOptions(".", opts); err == nil {
if root, err := findGitRoot("."); err != nil {
return def
} else if repo, err := git.PlainOpen(root); err == nil {
if ref, err := repo.Head(); err == nil {
return ref.Name().Short()
}
Expand Down
29 changes: 25 additions & 4 deletions apps/cli-go/internal/utils/misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,16 @@ func AssertServiceIsRunning(ctx context.Context, containerId string) error {
}

func IsGitRepo() bool {
opts := &git.PlainOpenOptions{DetectDotGit: true}
_, err := git.PlainOpenWithOptions(".", opts)
_, err := findGitRoot(".")
return err == nil
}

func IsGitIgnored(fp ...string) (bool, error) {
opts := &git.PlainOpenOptions{DetectDotGit: true}
repo, err := git.PlainOpenWithOptions(".", opts)
root, err := findGitRoot(".")
if err != nil {
return false, err
}
repo, err := git.PlainOpen(root)
if err != nil {
return false, err
}
Expand All @@ -182,6 +184,25 @@ func IsGitIgnored(fp ...string) (bool, error) {
return m.Match(fp, false), nil
}

func findGitRoot(path string) (string, error) {
cwd, err := filepath.Abs(path)
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(cwd, git.GitDirName)); err == nil {
return cwd, nil
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
if parent := filepath.Dir(cwd); parent != cwd {
cwd = parent
} else {
return "", git.ErrRepositoryNotExists
}
}
}

// If the `os.Getwd()` is within a supabase project, this will return
// the root of the given project as the current working directory.
// Otherwise, the `os.Getwd()` is kept as is.
Expand Down
12 changes: 12 additions & 0 deletions apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,18 @@ yield * creating.clear(); // dismiss without a message
yield * creating.succeed("Branch created");
```

### Invariant: `-o json|yaml|toml|env` must suppress the spinner (CLI-1546)

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|env` or `--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 changes `next/` 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** the `output.format` branch, so output stays byte-identical to before — minus the spinner.

---

## Testing
Expand Down
10 changes: 5 additions & 5 deletions apps/cli/docs/go-cli-porting-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,13 @@ Legend:
| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) |
| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) |
| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) |
| `encryption get-root-key` | `wrapped` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) |
| `encryption update-root-key` | `wrapped` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) |
| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) |
| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) |
| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) |
| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) |
| `postgres-config get` | `wrapped` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) |
| `postgres-config update` | `wrapped` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) |
| `postgres-config delete` | `wrapped` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) |
| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) |
| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) |
| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) |
| `login` | `wrapped` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) |
| `logout` | `wrapped` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) |
| `link` | `wrapped` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) |
Expand Down
8 changes: 6 additions & 2 deletions apps/cli/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ const root = path.resolve(import.meta.dir, "../../..");
const entrypoint = path.join(root, "apps/cli/src", shell, "main.ts");
const distDir = path.join(root, "dist");
const goSource = path.resolve(root, "apps/cli-go");
const posthogBuildDefines = [
`--define=process.env.SUPABASE_CLI_POSTHOG_KEY=${JSON.stringify(process.env.POSTHOG_API_KEY ?? "")}`,
`--define=process.env.SUPABASE_CLI_POSTHOG_HOST=${JSON.stringify(process.env.POSTHOG_ENDPOINT ?? "")}`,
] as const;

type BunTarget = (typeof TARGETS)[number]["bunTarget"];

Expand Down Expand Up @@ -113,7 +117,7 @@ async function buildTarget(target: (typeof TARGETS)[number]) {
const libc = libcForBunTarget(target.bunTarget);

console.log(`[${target.pkg}] Compiling Bun CLI...`);
await $`bun build ${entrypoint} --compile --minify --target=${target.bunTarget} --define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)} --define=SUPABASE_LIBC=${JSON.stringify(libc)} --outfile=${outfile}`;
await $`bun build ${entrypoint} --compile --minify --target=${target.bunTarget} --define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)} --define=SUPABASE_LIBC=${JSON.stringify(libc)} ${posthogBuildDefines} --outfile=${outfile}`;
console.log(`[${target.pkg}] Done.`);
}

Expand Down Expand Up @@ -184,7 +188,7 @@ async function buildMuslBinaries() {
const outfile = path.join(binDir, "supabase");
const libc = libcForBunTarget(target.bunTarget);
console.log(`[${target.pkg}] Compiling Bun CLI (musl)...`);
await $`bun build ${entrypoint} --compile --minify --target=${target.bunTarget} --define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)} --define=SUPABASE_LIBC=${JSON.stringify(libc)} --outfile=${outfile}`;
await $`bun build ${entrypoint} --compile --minify --target=${target.bunTarget} --define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)} --define=SUPABASE_LIBC=${JSON.stringify(libc)} ${posthogBuildDefines} --outfile=${outfile}`;

if (shell === "legacy") {
// Go binary is CGO_ENABLED=0 (fully static), so the glibc Linux build works on
Expand Down
14 changes: 13 additions & 1 deletion apps/cli/src/legacy/cli/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { legacyUnlinkCommand } from "../commands/unlink/unlink.command.ts";
import { legacyVanitySubdomainsCommand } from "../commands/vanity-subdomains/vanity-subdomains.command.ts";
import { OutputFormatFlag } from "../../shared/cli/global-flags.ts";
import { outputLayerFor } from "../../shared/output/output.layer.ts";
import { legacyQuietProgressTextOutputLayer } from "../output/legacy-quiet-progress-text-output.layer.ts";
import { makeGoProxyLayer } from "../../shared/legacy/go-proxy.layer.ts";
import {
LEGACY_GLOBAL_FLAGS,
Expand Down Expand Up @@ -125,7 +126,18 @@ export const legacyRoot = Command.make("supabase").pipe(
if (createTicket) globalArgs.push("--create-ticket");
if (agent !== "auto") globalArgs.push("--agent", agent);

return Layer.mergeAll(outputLayerFor(outputFormat), makeGoProxyLayer({ globalArgs }));
// Go's `-o {json,yaml,toml,env}` selects a machine encoder the handler
// writes via `output.raw`. Keep the text layer (so errors still render
// as red text on stderr, matching Go), but suppress its progress spinner
// — otherwise clack writes ANSI to stdout and corrupts the payload
// (CLI-1546). `-o pretty` / no `-o` keep the normal text/json layers.
const goFmt = Option.getOrUndefined(goOutput);
const isGoMachineFormat = goFmt !== undefined && goFmt !== "pretty";
const outputLayer = isGoMachineFormat
? legacyQuietProgressTextOutputLayer
: outputLayerFor(outputFormat);

return Layer.mergeAll(outputLayer, makeGoProxyLayer({ globalArgs }));
}),
),
),
Expand Down
88 changes: 88 additions & 0 deletions apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# `supabase encryption [get-root-key|update-root-key]`

Manage a project's pgsodium root encryption key. Each subcommand resolves a
project ref and calls one Management API endpoint. `update-root-key`
additionally reads the new key from stdin.

## Files Read

| Path | Format | When |
| ------------------------------------------------ | ------------------------- | ------------------------------------------------------------------ |
| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable |
| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | when `--project-ref` / `PROJECT_ID` unset, to resolve linked ref |
| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) |

## Files Written

| Path | Format | When |
| ------------------------------------------------ | ------ | ------------------------------------------------- |
| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | PersistentPostRun, after the project ref resolves |
| `~/.supabase/telemetry.json` | JSON | PersistentPostRun, on success or failure |

## API Routes

| Method | Path | Auth | Request body | Response (used fields) |
| ------ | ----------------------------- | ------------ | ------------ | ---------------------- |
| `GET` | `/v1/projects/{ref}/pgsodium` | Bearer token | none | `{root_key}` |
| `PUT` | `/v1/projects/{ref}/pgsodium` | Bearer token | `{root_key}` | `{root_key}` |

`get-root-key` calls `GET`; `update-root-key` calls `PUT`.

## 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_ID` / `PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) |
| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) |
| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (defaults to `supabase`) |

## Exit Codes

| Code | Condition |
| ---- | ----------------------------------------- |
| `0` | success |
| `1` | project ref unresolved / malformed |
| `1` | network / connection failure |
| `1` | non-200 status from the pgsodium endpoint |

## 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` redacted — not telemetry-safe) |

No custom `phtelemetry.*` events in `internal/encryption/`.

## Output

### `--output-format text` (Go CLI compatible)

- `get-root-key`: the bare root key followed by a newline, to **stdout** (Go `fmt.Println`).
- `update-root-key`: `Finished supabase root-key update.` followed by a newline, to **stderr**
(Go's `utils.Aqua` color rendered as plain text per the legacy-port convention).

### `--output-format json`

A single JSON object emitted to stdout: `{"root_key":"…"}` (both subcommands).

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

One `result` event carrying `{root_key}` (both subcommands).

```ndjson
{"type":"result","data":{"root_key":"…"}}
```

## Notes

- Requires `--project-ref`, `SUPABASE_PROJECT_ID`/`PROJECT_ID`, or a linked project.
- `update-root-key` reads the key from stdin: a real TTY is read with a masked
prompt; piped stdin is decoded as UTF-8 and whitespace-trimmed. An empty or
whitespace-only key sends an empty `root_key`, matching Go's `io.Copy` +
`strings.TrimSpace` behavior. (The TTY masked prompt also trims, matching Go.)
- **Known divergence:** Go writes the bare prompt `Enter a new root key: ` to
stderr and reads via `term.ReadPassword`. The port uses a clack masked prompt
with the same label text, so the rendered TTY prompt is not byte-identical to
Go (clack adds its own framing). Piped (non-TTY) mode does not print the prompt
at all — it reads stdin directly, as Go's `io.Copy` branch does.
Loading
Loading